25 + 5 Clock

Intro

This is the fifth project in the freeCodeCamp Front End Development Libraries Certification. The objective is to build a 25 +5 clock, aka pomodoro. The suggested method is to use a front end library like React. Read more about the project at Build a 25 + 5 Clock

Check out the end product at https://pomodoro.projects.yasakdogra.com

Planning

We will use React, Redux and Tailwind CSS for this project. Check out how to setup a basic project at: React with Redux, Tailwind and More

We will take a top down approach for this project. Our app will have title, settings and timer. We will set the session and break time duration in settings using our time input component which will have increment and decrement buttons. The timer will show a countdown, play/play button and reset button. All the buttons will be similar, so we will create a common component for them and pass in different props.

AppTSTieittmlteeirngsT(T(TS(S(isibiVrVpmemrmGeGleseeesasarBeByIiIkutu/non)Dt)tppnpittau)usoouttpnnslea)yS(S(ViVdGnGeccBrBrueuetmtmteteononntnt))FontAwesomeIcon

We only need to save the timer values and the current mode in the state. We can create the timers and manage the audio clip playback in a component.

State

Create src/state/defaults.ts

export const INITIAL_SESSION_LENGTH = 25;	// in minutes
export const INITIAL_BREAK_LENGTH = 5;		// in minutes

Create src/state/pomodoroSlice and import redux and our defaults

import { createSlice } from "@reduxjs/toolkit";
import { INITIAL_BREAK_LENGTH, INITIAL_SESSION_LENGTH } from "./defaults";

Create the type and initial state

interface PomodoroState {
    breakLength: number;    // in minutes
    sessionLength: number;  // in minutes
    active: "break" | "session";
    status: "idle" | "running" | "paused";
    timeLeft: number;   // in seconds
};

const initialState: PomodoroState = {
    breakLength: INITIAL_BREAK_LENGTH,
    sessionLength: INITIAL_SESSION_LENGTH,
    active: 'session',
    status: 'idle',
    timeLeft: INITIAL_SESSION_LENGTH * 60,
};

Create validation functions for settings and time left

function validateLength(length: number): number {
    if (length < 1) {
        return 1;
    } else if (length > 60) {
        return 60;
    } else {
        return length;
    }
}

function validateTimeLeft(timeLeft: number): number {
    if (timeLeft < 0) {
        return 0;
    } else if (timeLeft > 3600) {
        return 3600;
    } else {
        return timeLeft;
    }
}

Create and export action creators and reducer

const pomodoroSlice = createSlice({
    name: 'pomodoro',
    initialState,
    reducers: {
        incrementLength(state, action) {...},
        decrementLength(state, action) {...},
        reset(state) {...},
        tick(state) {...},
        start(state) {...},
        pause(state) {...},
        resume(state) {...}
    },
});
                      
export const { incrementLength, decrementLength, reset, tick, start, pause, resume } = pomodoroSlice.actions;
export default pomodoroSlice.reducer;

The incrementLength and decrementLength actions are very similar

incrementLength(state, action) {
    if(state.status === 'running' ) return;
    if (action.payload === 'break') {
        state.breakLength = validateLength(state.breakLength+1);
        if(state.active === 'break') {
            state.timeLeft = validateTimeLeft(state.breakLength * 60);
        }
    } else if (action.payload === 'session') {
        state.sessionLength = validateLength(state.sessionLength+1);
        if(state.active === 'session') {
            state.timeLeft = validateTimeLeft(state.sessionLength * 60);
        }
    }
}

The tick action counts down the time left on the current mode (break or session), switches to the next mode when time left is zero and resets the timer for the next mode.

tick(state) {
    if (state.timeLeft > 0) {
        state.timeLeft -= 1;
    } else {
        state.active = state.active === 'session' ? 'break' : 'session';
        state.timeLeft = state.active === 'session' ? state.sessionLength * 60 : state.breakLength * 60;
    }
}

The start, pause, resume change the status in the state and reset resets the entire state to the initial state values.

Create **src/state/store.js **and add the pomodoro reducer we created

import { configureStore } from "@reduxjs/toolkit";
import pomodoroReducer from "./pomodoroSlice";

export const store = configureStore({
    reducer: {
        pomodoro: pomodoroReducer,
    }
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

Components

App Component

Add Title, Settings and Timer to App component

function App() {
  return (
    <>
    <div className='flex flex-wrap flex-col justify-center p-4'>
      <Title />
      <Settings />
      <Timer />
    </div>
    </>
  );
}

Title Component

This is the title for our page and just needs text with some styling using Tailwind classes.

Create src/components/Title.tsx

export default function Title() {
    return (
        <div className='flex flex-wrap justify-center'>
        <span className='text-white text-3xl'>25 + 5 Clock</span>
        </div>
    );
}

Settings Component

Add two TimeInput in this component, one for break and session each. We can pass in the kind and label props to make them render differently.

import TimeInput from "./TimeInput"

export default function Settings() {
  return (
    <div className="flex flex-row justify-center gap-8 mt-8">
      <TimeInput kind={'break'} label='Break Length' />
      <TimeInput kind={'session'} label='Session Length' />
    </div>
  )
}

TimeInput Component

This component will show the setting for the time duration and provide buttons to increment and decrement it.

Create src/components/TimeInput.tsx

import { useDispatch, useSelector } from "react-redux";
import { AppDispatch, RootState } from "../store/store";
import { decrementLength, incrementLength } from "../store/pomodoroSlice";
import { faAngleUp, faAngleDown } from "@fortawesome/free-solid-svg-icons";
import SvgButton from "./SvgButton";

Add the component

type TimeInputProps = {
    kind: string;
    label: string;
};


export default function TimeInput({ kind, label }: TimeInputProps) {...};

First we need to determine whether to render it for session or break duration. We can pick the right state value by checking the kind prop.

Inside the component function, add

const inputValue = useSelector((state: RootState) =>
    kind === "break"
        ? state.pomodoro.breakLength
        : state.pomodoro.sessionLength
);

Similarly, we can also dispatch actions using kind prop as payload

const dispatch = useDispatch<AppDispatch>();

const handleIncrement = () => {
    dispatch(incrementLength(kind));
};
const handleDecrement = () => {
    dispatch(decrementLength(kind));
};

Now the return function can just use these values and functions

return (
    <div>
        <div id={`${kind}-label`} className="flex flex-row justify-center text-center text-white text-2xl p-2">
            {label}
        </div>
        <div className="flex flex-row justify-center">
            <div>
                <SvgButton id={`${kind}-increment`}
                    icon={faAngleUp}
                    onClick={handleIncrement}
                />
            </div>
            <div id={`${kind}-length`} className="text-4xl text-white px-2">
                {inputValue}
            </div>
            <div>
                <SvgButton id={`${kind}-decrement`}
                    icon={faAngleDown}
                    onClick={handleDecrement}
                />
            </div>
        </div>
    </div>
);

SvgButton Component

We will add a button component that we can use for all the buttons on the page. We can pass in the icon, click handler function and optionally the width and id for the button.

Create src/components/SvgButton.tsx

import { IconDefinition } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";

type SvgButtonProps = {
    icon: IconDefinition;
    onClick: () => void;
    width?: number;
    id?: string;
};

export default function SvgButton({ icon, onClick, width, id }: SvgButtonProps) {
    return (
        <button
            id={id}
            onClick={onClick}
            className={`text-black bg-slate-100 font-bold px-4 text-xl 
             hover:bg-slate-200 h-10`}
            style={{minWidth: `${width ? `${width}rem` : `3rem`} `}}
        >
            <FontAwesomeIcon icon={icon} />
        </button>
    );
};

Timer Component

This component is a bit more complex than the others. It will be responsible for creating a timer using setInterval to count down the time left for session and break segment. It will show the time left on the screen. When the timer switches from break to session or session to break mode, it will play a sound clip to notify the user. It will also have a pause/play button and reset button.

NOTE: Remember to add a sound clip in the assets folder and use the same name in the import

Create src/components/Timer.tsx and import all the things we need

import { useEffect, useRef } from "react";
import { useDispatch, useSelector } from "react-redux";
import { AppDispatch, RootState } from "../store/store";
import { faPause, faPlay, faRefresh } from "@fortawesome/free-solid-svg-icons";
import { pause, reset, resume, start, tick } from "../store/pomodoroSlice";
import TimerDisplay from "./TimerDisplay";
import SvgButton from "./SvgButton";
import cuckoo from "../assets/cuckoo.mp3";

Add the component function and get the state


export default function Timer() {
    const dispatch = useDispatch<AppDispatch>();
    const status = useSelector((state: RootState) => state.pomodoro.status);
    const timeLeft = useSelector((state: RootState) => state.pomodoro.timeLeft);
    const active = useSelector((state: RootState) => state.pomodoro.active);
    const sessionLength = useSelector((state: RootState) => state.pomodoro.sessionLength);
    const breakLength = useSelector((state: RootState) => state.pomodoro.breakLength);
};

We need one more thing here. We need to store the interval ID returned by setInterval so we can use them in our event handlers. We cannot save this ID in the state because the state will not be available inside our event handlers. We will use useRef to save the ID since it’s not needed for rendering and still accessible wherever we want.

Add intervalRef with type undefined | number and initialize it to undefined.

let intervalRef = useRef<undefined | number>(undefined);

Add the play/pause button click handler. When clicked on idle or paused status, we will create a repeated timer for one second. When clicked on running status, we will clear the timer. We will also send an action on every click to save the status change. When the timer expires, it will dispatch a tick action.

const handlePlayPause = () => {
    if(status === 'idle') {
        dispatch(start());
        intervalRef.current = setInterval(() => {
            dispatch(tick());
        }, 1000);
    } else if(status === 'running') {
        dispatch(pause());
        clearInterval(intervalRef.current);
    } else if(status === 'paused') {
        dispatch(resume());
        intervalRef.current = setInterval(() => {
            dispatch(tick());
        }, 1000);
    }
};

We can create a similar function for reset button click. This button will clear the timer, stop playing the sound clip and dispatch a reset action

const handleReset = () => {
    clearInterval(intervalRef.current);
    let ae = document.getElementById('beep') as HTMLAudioElement;
    ae.pause();
    ae.currentTime = 0;
    dispatch(reset());
};

We need to play the sound clip when the mode switches from session to break or break to session. We can do this by checking if the time left for the current mode is zero. We will use reach hook useEffect and use timeLeft as a dependency so it runs every time timeLeft is changed. We will have access to timeLeft inside the hook

useEffect(()=>{
    if(timeLeft === 0) {
        const ae = document.getElementById("beep") as HTMLAudioElement;
        ae.currentTime = 0;
        ae.play();
    }
},[timeLeft]);

Now we just add the return function which uses the TimerDisplay component in addition to state and event handlers we created above

return (
    <div className="flex flex-col justify-center mt-8">
        <span id="timer-label" className="text-center text-white text-3xl p-2">
            {active === "session" ? "Session" : "Break"}
        </span>
        <audio id="beep" src={cuckoo} />
        <TimerDisplay
            total={active === "session" ? sessionLength : breakLength}
            remaining={timeLeft}
        />
        <div className="flex flex-wrap justify-center gap-4">
            <SvgButton id="start_stop" width={4} icon={status === 'running' ? faPause : faPlay} onClick={handlePlayPause} />
            <SvgButton id="reset" width={4} icon={faRefresh} onClick={handleReset} />
        </div>
    </div>
);

TimerDisplay

In this component, we can create an SVG with two circles, one for the track, other for the current time remaining. In the middle of the SVG we can add a text displaying the time left in minutes and seconds

type TimerDisplayProps = {
    total: number;
    remaining: number;
};

export default function TimerDisplay({ total, remaining }: TimerDisplayProps) {
    return (
        <div className="flex items-center justify-center">
            <svg className="transform -rotate-90 w-72 h-72">
                <circle
                    cx="145" cy="145" r="120"
                    stroke="currentColor" strokeWidth="10" fill="transparent"
                    className="text-gray-700"
                />

                <circle
                    cx="145" cy="145" r="120"
                    stroke="currentColor" strokeWidth="10" fill="transparent"
                    pathLength={total * 60} strokeDasharray={`${remaining} ${total * 60}`}
                    className="text-blue-500 "
                />
            </svg>
            <span id="time-left" className="absolute text-5xl text-white">
                {`${Math.floor(remaining / 60).toString().padStart(2, '0')}:${(remaining % 60).toString().padStart(2, '0')}`}
            </span>
        </div>
    );
};

Thank you for reading. You can also check out my other projects for this series below.