jitomate: a Pomodoro clock of sorts

jitomate: a Pomodoro clock of sorts

 2021.09.14 -  Jan Reggie Dela Cruz -  ~16 Minutes

 jitomate

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 and export directives, as well as the id='...' 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
└── ...

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 Knobs 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 second useEffect hook.

Start, pause, and reset

Let us consider the three states in our program:

  1. 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.
  2. 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.
  3. 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
import RemainingTimeDisplay from './RemainingTimeDisplay'
import StartResetButtons from './StartResetButtons'
import TimerLabel from './TimerLabel'

export type timerParameters = {
  sessionLength: number
  breakLength: number
  resetLengths: (() => void)
}

function Timer (params : timerParameters) {
  const [remainingTime, setRemainingTime] = useState(params.sessionLength * 60) // remaining time that is visible
  const [hasStarted, setHasStarted] = useState(false) // has start been pressed once?
  const [isOnBreak, setIsOnBreak] = useState(false) // session or break mode?
  const [isRunning, setIsRunning] = useState(false) // is it not paused?

  /** atReset reverts the program to its "initial" state */
  const atReset = () => {
    setHasStarted(false)
    setIsRunning(false)
    setIsOnBreak(false)
    params.resetLengths()
    setRemainingTime(params.sessionLength * 60)
  }

  /** atToggleStartStop runs when the start/stop button is pressed */
  const atToggleStartStop = () => {
    setHasStarted(true)
    setIsRunning(!isRunning)
  }

  // To make sure that remainingTime is "linked" to sessionLength until start button has been pressed
  useEffect(() => {
    if (hasStarted) { return }
    setRemainingTime(params.sessionLength * 60)
  }, [hasStarted, params.sessionLength])

  // To countdown remaining time
  useEffect(() => {
    if (!isRunning) { return }

    const intervalID = setTimeout(() => {
      setRemainingTime(remainingTime - 1)
    }, 1000)
    return () => { clearTimeout(intervalID) } // if isRunning gets toggled, make sure to cancel it
  }, [remainingTime, isRunning])

  // 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'>
      <TimerLabel hasStarted={hasStarted} isRunning={isRunning} isOnBreak={isOnBreak} />
      <RemainingTimeDisplay remainingTime={remainingTime} />
      <StartResetButtons isRunning={isRunning} atToggleStartStop={atToggleStartStop} atReset={atReset} />
    </div>
  )
}

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:

statehasStartedisRunning
1falsefalse
2truetrue
3truefalse

A certain resetLengths is added in our timerParameters. Let us look at how it is used in Main:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
function Main () {
  const [sessionLength, setSessionLength] = useState(DefaultSessionLength)
  const [breakLength, setBreakLength] = useState(DefaultBreakLength)

  return (
    <main className='border text-center'>
      <Timer
        sessionLength={sessionLength}
        breakLength={breakLength}
        resetLengths={() => { setSessionLength(DefaultSessionLength); setBreakLength(DefaultBreakLength) }}
        />
      <LengthControls
        sessionLength={sessionLength}
        setSessionLength={setSessionLength}
        breakLength={breakLength}
        setBreakLength={setBreakLength} />
    </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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// To make sure that remainingTime is "linked" to sessionLength until start button has been pressed
useEffect(() => {
  if (hasStarted) { return }
  setRemainingTime(params.sessionLength * 60)
}, [hasStarted, params.sessionLength])

// To countdown remaining time
useEffect(() => {
  if (!isRunning) { return }

  const intervalID = setTimeout(() => {
    setRemainingTime(remainingTime - 1)
  }, 1000)
  return () => { clearTimeout(intervalID) } // if isRunning gets toggled, make sure to cancel it
}, [remainingTime, isRunning])

// To trigger at 00:00
// useEffect hook unchanged so omitted here.

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 why remainingTime 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 from true to false, and remainingTime 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import audioElement from './audioElement'
import RemainingTimeDisplay from './RemainingTimeDisplay'
import StartResetButtons from './StartResetButtons'
import TimerLabel from './TimerLabel'

// ...

function Timer(params : timerParameters) {
  // ...

  /** atReset reverts the program to its "initial" state */
  const atReset = () => {
    setHasStarted(false)
    setIsRunning(false)
    setIsOnBreak(false)
    params.resetLengths()
    audioElement.pause()
    audioElement.currentTime = 0
    setRemainingTime(params.sessionLength * 60) // While this may seem unnecessary due to useEffect, Test 11 screws up if this isn't present...
  }

  // ...

  // To trigger at 00:00
  useEffect(() => {
    if (remainingTime > 0) { return }

    if (isOnBreak) {
      setRemainingTime(params.sessionLength * 60)
    } else {
      setRemainingTime(params.breakLength * 60)
    }
    setIsOnBreak(!isOnBreak)
    audioElement.play()
  }, [remainingTime])

  // ...
}

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.

A perfect score.

A perfect score.

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.