Once Upon a Timer

Written by Zackary Frazier, posted on 2024-08-22

  • NEXT.JS

Once upon a time, I had to create a task timer in JavaScript, which intuitively sounds easy.

Honestly, this sounds like a leetcode question or something someone might ask in an interview to figure out if the candidate is a complete moron.

It can't be much simpler than this, right?

"use client"
import React, { useState, useEffect } from "react"
const Timer = () => {
    const [time, setTime] = useState(0)
    useEffect(() => {
        setInterval(() => {
            setTime(time + 1)
        }, 1000)
    })
    return (
        <div>
            <h1>{time}</h1>
        </div >
    )
}

export default Timer

Boyyy was I wrong.

So lets begin by defining what a task timer is.

  • start button
    • starts the timer from the last stop point
  • pause button
    • pauses the current state of the timer
  • save button
    • commits the timer entry to a database

The timer also requires a description section. And on save the current date needs to be captured.

Anything less than this and you don't really have a useful task timer.

Building an Accurate Clock

So the first difficulty here is, setInterval is not and accurate counter. If you tell it to run once a second, it will run roughly every second, most of the time, except when your page is minimized, then it might run only two minutes as the thread is put to sleep by your browser. Yay.

So what I wound up doing is defining the timer as a React hook. This allowed me to abstract a bit and keep the logic out of the JSX component. The logic is straightforward.

With useEffect, set an interval that runs one-tenth of a seconds. We can't depend on setInterval to run every second, so we need to ensure it runs more frequently than that.

If the timer has been paused, it will clear the interval. Otherwise, on each interval, our function will capture the current timestamp. It then will compare the timestamp against the prior timestamp. If more than a second as passed, it updates the time and timestamp.

However, to account for the fact that the browser loves to put the timer to sleep when the page in minimized, we use a delta variable. This is the amount of time that has passed since the time was last updated. We then apply some simple math, Math.floor(delta / 1000), to work out exactly how much time we need to increment our timer.

Unfortunately, as I eventually figured out, setting a state variable in useEffect will trigger the page to re-render. On each re-render useEffect will fire again. When it fires again, it will sets a new interval on the page window, which will also set state variables. Exponential state setting. In other words, the page will spazz out after about two seconds.

The solution here was to have useEffect return a function that clears the interval so we don't spam the window with new intervals.

"use client"
import { useEffect, useState } from 'react'
const useTimer = (isPaused: boolean): [number, () => void] => {
    const [time, setTime] = useState(0)
    const [lastTimeStamp, setLastTimeStamp] = useState(now())
    useEffect(() => {
        const handle = setInterval(() => {
            if (isPaused) {
                clearInterval(handle)
            }
            const currTimeStamp = now()
            const delta = currTimeStamp - lastTimeStamp
            if (delta > 1000) {
                setTime(time + Math.floor(delta / 1000))
                setLastTimeStamp(currTimeStamp)
            }
        }, 100)
        return () => {
            if (handle) {
                clearInterval(handle)
            }
        }
    }, [time, lastTimeStamp, isPaused])
    const resetTimer = () => {
        setTime(0)
        setLastTimeStamp(now())
    }
    return [time, resetTimer]
}

Do You Have the Time?

The other issue is Date.now() isn't always 100% accurate either. Fortunately this problem is solved by the performance browser API. However we have to be mindful of the fact that not every browser has the performance API.

So what we can do is go through all possible browser-specific versions of the performance API. If we find none, then we have to default to Date.now().

Stolen from this Stack Overflow post (adjusted for TypeScript):

interface Performance {
    now?: () => number,
    mozNow?: () => number,
    msNow?: () => number,
    oNow?: () => number,
    webkitNow?: () => number
}

const now = () => {
    const perf = performance as Performance
    if (globalThis.performance.now) {
        return globalThis.performance.now()
    } else if (perf.mozNow) {
        return perf.mozNow()
    } else if (perf.msNow) {
        return perf.msNow()
    } else if (perf.oNow) {
        return perf.oNow()
    } else if (perf.webkitNow) {
        return perf.webkitNow()
    }
    return Date.now()
}

Cool! We have a reliable way to get the time now! 😎

So what next? We need a pause and save button.

The pause functionality was straightforward. Just sets a boolean flag and pass it into the hook!

const [isPaused, setIsPaused] = useState(false)
const [time, resetTimer] = useTimer(isPaused)
const toggleTimer = () => {
        setIsPaused(!isPaused)
}

The save button was also pretty simple. I just created a button that makes a callout to the back-end to save the data in our database. It passes the description, the full name of the person using the timer, and the time tracked to the back-end.

Let's Go, Baby!

I'll leave the details of the back-end alone, but for my demo of this app, I used a Go back-end that persisted the timer data to a SQLite database.

A toy version of this project can be found on my GitHub. It includes a nice little dashboard to go with the timer that sums the timers entries per person.