jitomate: a Pomodoro clock of sorts
2021.09.14 - Jan Reggie Dela Cruz - ~16 Minutes
jitomate is a “25+5 application”, or a Pomodoro clock, built using React and TypeScript, which is another project of five in the “Front End Libraries” certification in freeCodeCamp.
Using the provided user stories, the following should hold true for the application:
- There should be a session and a break length that are adjustable
- There should be a timer that counts down and switches between session and break lengths
- There should be a way to start, pause, and reset the timer
- An audio cue should be available when the timer counts to zero
This reader is assumed to be already familiar with writing React applications and how they are structured. Here are some notes with regards to the code used in this post:
- The code isn’t complete, and I have ommitted some of the
import
andexport
directives, as well as theid='...'
parameters in the JSX. I don’t want them to clutter the reader’s view, as they aren’t really the center of the article. - The JSX listed have
className='...'
attributes. Most, if not all, classes are there for visual purposes, and do not necessarily affect the behavior of the application. Most are Bootstrap adjectives. - I didn’t strictly write this application from start to finish as I have mentioned in the bullet points above. I wanted this post to convey a story, hence the order.
The entire source is available in janreggie/jitomate . With all that being said, let’s get started.
First steps
As usual, create-react-app
has been very useful.
I decided to run this project in TypeScript from the get-go,
so I ran the following:
npx create-react-app jitomate --template typescript
Afterwards, I had to remove some unnecessary files that come in default, change a few values here and there, and install the freeCodeCamp test suite and eslint to aid me with the project.
Test suite
The freeCodeCamp project comes with a test suite, so that programmers may check if the project “works”.
That can be done in the /public/index.html
:
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!-- Some comments here -->
<script src='https://cdn.freecodecamp.org/testable-projects-fcc/v1/bundle.js'></script>
</body>
This installs a hamburger in the upper-right part of the screen.
eslint
I wanted to set up eslint to make my code look more consistent.
yarn add --dev eslint
yarn esint init
Then I answered the questions that followed,
saying that I want to create a React app on a browser that uses TypeScript.
I also went along with the standard
style guide, being a lot more comfortable with it.
Because setting standard
as my style guide caused eslint to get some packages with npm
,
I had to run yarn
and remove my package-lock.json
.
See Migrating from npm
for more details.
I added the following rules to prevent eslint from complaining about an unused React
whenever I import React from 'react'
,
because removing that line will cause an error
saying that I “can’t use JSX without importing React
”.
Note that I set my eslint output to be a YAML instead of a JSON.
# some items above...
rules:
"no-use-before-define": "off"
"@typescript-eslint/no-use-before-define": ["error"]
Afterwards, I ran the following command which formats the files already present:
yarn eslint 'src/*' --fix
Note that this will cause errors to appear when trying to format CSS or SCSS files. I’ll think about how to exclude those in another time.
Installing Bootstrap 5
I included Bootstrap 5 for aesthetic reasons.
What I did was run yarn add bootstrap
and add the following to my /src/index.tsx
:
import 'bootstrap/dist/css/bootstrap.css'
import React from 'react'
import ReactDOM from 'react-dom'
import './index.css'
import App from './App'
import reportWebVitals from './reportWebVitals'
// More lines follow
Program layout and some formatting
The application proper is in a standard Header-Main-Footer layout:
import Header from './components/Header'
import Footer from './components/Footer'
import Main from './components/Main'
function App () {
return (
<div className='App'>
<Header />
<Main />
<Footer />
</div>
)
}
The folder layout is as follows,
where ...
means “files which we’ll be concerned of later or not at all”.
public
├── index.html
└── ...
src
├── App.scss
├── App.tsx
├── components
│ ├── Footer.tsx
│ ├── Header.tsx
│ ├── Main.tsx
│ └── ...
├── config.ts
├── index.tsx
└── ...
Header and footer components
There isn’t much to say about these, but here they are.
function Header () {
return (
<header id='header'>
<nav className='navbar navbar-expand-lg navbar-light bg-light'>
<div className='container-fluid'>
<a></a>{/* Empty to make sure navbar-brand is aligned to the right */}
<a className='navbar-brand d-flex'>jitomate 🍅</a>
</div>
</nav>
</header>
)
}
function Footer () {
return (
<footer className='footer mt-auto py-3 bg-light' id='footer'>
<div className='container text-muted'>
{/* Copyright statement here... */}
</div>
</footer>
)
}
And some relevant SCSS to make sure that they are aligned properly:
main {
padding: 1em 1em 0;
width: 95%;
max-width: 60em;
margin: 1em auto;
}
.App {
min-height: 100vh;
display: grid;
grid-template-rows: auto 1fr auto;
}
footer {
width: 100%;
padding-top: 1rem;
padding-bottom: 1rem;
a {
color: inherit;
text-decoration: underline;
}
}
Program logic
Here lies the meat of our application. This section is laid out using the bullet points that I have mentioned at the start of the article.
Setting the session and break lengths
This is probably the simplest part of the application.
Let us initialize our Main
component with some states.
Remember that all components will be at /src/Components
:
function Main () {
const [sessionLength, setSessionLength] = useState(DefaultSessionLength)
const [breakLength, setBreakLength] = useState(DefaultBreakLength)
return (
<main className='border text-center'>
{/* TODO: Create a way to control session and break lengths */}
</main>
)
}
And the next thing we would want to do is to create a “Knob” of sorts:
export type knobParameters = {
name: string // for creating IDs of components (unused here for readability)
label: string
value: number
setValue: ((n: number) => void)
stepSize: number
maximum: number
}
function Knob (params: knobParameters) {
const increment = () => {
if (params.value + params.stepSize > params.maximum) {
return
}
params.setValue(params.value + params.stepSize)
}
const decrement = () => {
if (params.value <= params.stepSize) {
return
}
params.setValue(params.value - params.stepSize)
}
return (
<div className='p-2 bg-light border knob'>
<div>{params.label}</div>
<div className='btn-group'>
<button className='btn btn-secondary' type='button' onClick={decrement}>-</button>
<button className='btn' disabled={true}>{params.value}</button>
<button className='btn btn-secondary' type='button' onClick={increment}>+</button>
</div>
</div>
)
}
The Knob
component gives us a way to control a value using (+) and (-) operators.
Note the value
, setValue
, stepSize
, and maximum
parameters:
the buttons allow value
to be increased or decreased by stepSize
such that it does not go to zero, and it does not go above maximum.
Let us create two Knob
s in our LengthControls
:
import Knob from './Knob'
type lengthControlsParameters = {
sessionLength: number
setSessionLength: ((n : number) => void)
breakLength: number
setBreakLength: ((n : number) => void)
}
function LengthControls (params : lengthControlsParameters) {
return (
<div className='row' id='length-controls'>
<div className='col'>
<Knob
name='session'
label='Session Length'
value={params.sessionLength}
setValue={params.setSessionLength}
stepSize={1}
maximum={60} />
</div>
<div className='col'>
<Knob
name='break'
label='Break Length'
value={params.breakLength}
setValue={params.setBreakLength}
stepSize={1}
maximum={60} />
</div>
</div>
)
}
Finally, let us include LengthControls
in our Main
component:
import { DefaultBreakLength, DefaultSessionLength } from '../config'
import LengthControls from './LengthControls'
function Main () {
const [sessionLength, setSessionLength] = useState(DefaultSessionLength)
const [breakLength, setBreakLength] = useState(DefaultBreakLength)
return (
<main className='border text-center'>
{/* TODO: Implement timer */}
<LengthControls
sessionLength={sessionLength}
setSessionLength={setSessionLength}
breakLength={breakLength}
setBreakLength={setBreakLength} />
</main>
)
}
Okay, we’ve already created a way to set and get our lengths. Now, let’s use those values to create a timer.
Counting down
Let us create our Timer
component
that takes in the provided sessionLength
and breakLength
earlier.
Let us place this in /src/components/Timer/index.tsx
since our Timer
will have several sub-components:
import React, { useEffect, useState } from 'react'
import RemainingTimeDisplay from './RemainingTimeDisplay'
export type timerParameters = {
sessionLength: number
breakLength: number
}
function Timer (params : timerParameters) {
const [remainingTime, setRemainingTime] = useState(params.sessionLength * 60) // remaining time that is visible
const [isOnBreak, setIsOnBreak] = useState(false) // session or break mode?
// To countdown remaining time
useEffect(() => {
setTimeout(() => {
setRemainingTime(remainingTime - 1)
}, 1000)
}, [remainingTime])
// To trigger at 00:00
useEffect(() => {
if (remainingTime > 0) { return }
if (isOnBreak) {
setRemainingTime(params.sessionLength * 60)
} else {
setRemainingTime(params.breakLength * 60)
}
setIsOnBreak(!isOnBreak)
}, [remainingTime])
return (
<div className='p-2' id='timer'>
<RemainingTimeDisplay remainingTime={remainingTime} />
</div>
)
}
Note that we have two useEffect
hooks.
See the official documentation on the Effect hook
for more details on how they’re used.
Let’s talk about them in more detail:
// To countdown remaining time
useEffect(() => {
setTimeout(() => {
setRemainingTime(remainingTime - 1)
}, 1000)
}, [remainingTime])
The above hook should be self-explanatory.
It subtracts remainingTime
after every 1000 ms,
and it runs every time remainingTime
changes.
There aren’t any problems there.
// To trigger at 00:00
useEffect(() => {
if (remainingTime > 0) { return }
if (isOnBreak) {
setRemainingTime(params.sessionLength * 60)
} else {
setRemainingTime(params.breakLength * 60)
}
setIsOnBreak(!isOnBreak)
}, [remainingTime])
The above hook makes sure to only run
when remainingTime <= 0
.
It then sets the remaining time to either the session or break length,
depending on the previous state,
and then sets isOnBreak
.
Alternatively, we could have written it as follows:
useEffect(() => {
if (remainingTime > 0) { return }
setIsOnBreak(!isOnBreak)
if (isOnBreak) {
setRemainingTime(params.breakLength * 60)
} else {
setRemainingTime(params.sessionLength * 60)
}
}, [remainingTime])
But we didn’t.
useState
is async
,
hence when the if
block runs,
isOnBreak
might not have even been set to the appropriate value.
RemainingTimeDisplay
is a way for me to display the number of seconds
into a MM:SS format:
function RemainingTimeDisplay ({ remainingTime } : { remainingTime : number}) {
const pad = (n : number) => n.toString().padStart(2, '0')
const formatTime = (seconds : number) =>
pad(Math.floor(seconds / 60)) + ':' + pad(seconds % 60)
return (
<div id='time-left'>{formatTime(remainingTime)}</div>
)
}
Including the Timer
component in our code would be simple:
function Main () {
const [sessionLength, setSessionLength] = useState(DefaultSessionLength)
const [breakLength, setBreakLength] = useState(DefaultBreakLength)
return (
<main className='border text-center'>
<Timer
sessionLength={sessionLength}
breakLength={breakLength}
/>
<LengthControls
sessionLength={sessionLength}
setSessionLength={setSessionLength}
breakLength={breakLength}
setBreakLength={setBreakLength} />
</main>
)
}
Well, this works. However, there are a few issues here:
- There is no way to control the state of the program. The timer is always counting down, switching between “session time” and “break time”.
- Because
useState(params.sessionLength*60)
is run at the start of the program, the session length cannot be controlled, unless during the middle of a Pomodoro session, that is, in the seconduseEffect
hook.
Start, pause, and reset
Let us consider the three states in our program:
- The timer has yet to be started, and we’re just setting up our lengths.
- The inital value of these lengths should be the default ones.
- Setting the lengths should change the remaining time.
- The timer has already started, and is actively counting down.
- Every second, the remaining time should count down.
- When the remaining time reaches zero,
isOnBreak = !isOnBreak
.
- The timer has already started, but the user decided to pause the timer.
- The remaining time should stay constant.
The program should start at State 1, but the program is “perpetually” at State 2 as shown in the previous section.
Now, let us consider a Start/Pause button and a Reset button to switch behind these states:
- Start/Pause will trigger 1 to 2, 2 to 3, and 3 to 2.
- Reset will trigger from any state to 1.
First, let us introduce the code, and let’s talk about it afterwards:
|
|
Quite a lot to take in, but okay.
The first thing that I did is introduce two state hooks,
hasStarted
and isRunning
,
such that they reflect the three states mentioned earlier:
state | hasStarted | isRunning |
---|---|---|
1 | false | false |
2 | true | true |
3 | true | false |
A certain resetLengths
is added in our timerParameters
.
Let us look at how it is used in Main
:
|
|
This is so that
instead of the “default values” of these lengths
getting passed down to Timer
,
and delegating the responsibility of setting their values to it,
only a callback to reset is necessary.
We also see two functions defined: atReset
and atToggleStartStop
.
They are passed to the buttons to switch between stages at will.
Let us inspect the useEffect
hooks again:
|
|
First, a new hook is created.
This only runs if the program has yet to be started,
so that it is possible to set sessionLength
and have its value “attached” to remainingTime
and consequently to RemainingTimeDisplay
.
The second change is obvious. If it isn’t running, then the timer shouldn’t count down.
The third and fourth changes show something a bit more interesting.
The useEffect
callback now returns a function,
which clears out the timer that we just set.
This function is called whenever the useEffect
hook is called:
that is, when either remainingTime
or isRunning
change,
or when the component is “unmounted”:
When
remainingTime
changes, nothing happens.clearTimeout
will still run, but it would’ve been too late, since the reason whyremainingTime
changed is exactly because of that timeout.When the component is unmounted, there’s nothing to worry about.
setTimeout
will just occur once.But, when
isRunning
turns fromtrue
tofalse
, andremainingTime
has yet to be changed by the timer, then it is cancelled. This prevents behavior such as when the user toggles the timer but the timer ticks down afterwards.
Let us go to the other components:
export type timerLabelParameters = {
hasStarted : boolean
isRunning : boolean
isOnBreak : boolean
}
function TimerLabel (params : timerLabelParameters) {
const labelText = () => {
if (!params.hasStarted) { return 'press start to start timer' }
if (!params.isRunning) { return 'press start to continue timer' }
if (params.isOnBreak) {
return 'break started'
}
return 'session started'
}
return (
<div id='timer-label'>{ labelText() }</div>
)
}
TimerLabel
just adds a label at the top of the timer.
It is an indicator so that the user may know
at which stage is the application in.
export type startResetButtonsParameters = {
isRunning : boolean
atToggleStartStop : (() => void)
atReset : (() => void)
}
function StartResetButtons (params : startResetButtonsParameters) {
return (
<div className='row' id='start-reset-buttons'>
<button className='btn btn-primary m-2 col'
type='button'
onClick={params.atToggleStartStop}
id='start_stop'>
{params.isRunning ? 'stop' : 'start'}
</button>
<button className='btn btn-danger m-2 col'
type='button'
onClick={params.atReset}
id='reset'>
reset
</button>
</div>
)
}
These are just buttons. The Toggle button changes its label depending whether the application is running.
Adding audio
At this point, most of the tests that I need work. However, I still needed to implement a beep that rings when the timer gets to zero. So I downloaded a single second beep and went with it.
Because the user stories specifically require
that I use an <audio>
element,
I had to insert the audio file in /public
and update index.html
:
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<audio src='%PUBLIC_URL%/beep.m4a' id='beep'>This browser does not support audio playback.</audio>
<!-- Comments here -->
<script src='https://cdn.freecodecamp.org/testable-projects-fcc/v1/bundle.js'></script>
</body>
Great, but now what?
Let’s create an audioElement
that points to this specific <audio>
node:
// For audio (see https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Using_Web_Audio_API).
// @ts-ignore webkitAudioContext may exist in other browsers
const AudioContext = window.AudioContext || window.webkitAudioContext
const audioContext = new AudioContext()
const audioElement = document.querySelector('audio')! // Exclamation pt b/c YES, IT EXISTS! Check /index.html
const track = audioContext.createMediaElementSource(audioElement)
track.connect(audioContext.destination)
export default audioElement
I had to add a @ts-ignore
directive
because window.webkitAudioContext
is treated as an error.
Now, let us use this in our Timer
:
|
|
The third change is the most relevant here: at the instant of zero time, play the audio cue.
Testing and hosting
I didn’t write the tests myself, partly because I have yet to learn how to write tests in React. Instead, I used the freeCodeCamp test suite for this.
Okay. Now, let’s upload this to GitHub Pages.
I created the following GitHub Action
that I copied somewhere from the Internet,
as one does in our industry.
It’s placed in /.github/workflows/deploy.yml
:
name: Deployment
on:
push:
branches:
- master
jobs:
deploy:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [16.x]
steps:
- uses: actions/checkout@v1
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- name: Install Packages
run: yarn install
- name: Build page
run: yarn build
- name: Deploy to gh-pages
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./build
And I made sure to edit my /package.json
:
{
"name": "jitomate",
"homepage": "https://janreggie.github.io/jitomate/",
"version": "0.1.0",
"private": true,
"license": "MIT",
"_comment": "More lines here..."
}
The highlighted line lets the builder know
to move paths relative to /jitomate
.
Future plans
Probably none. I’ll let jitomate be there as-is. It’s a fun project, that’s for sure.
Except maybe one thing: test React applications on your own. And that’s something I’ll learn in the future.