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.
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.