DEV Community

SEN LLC
SEN LLC

Posted on

A Stopwatch and Timer With Lap Tracking and Timestamp-Based Accuracy

A Stopwatch and Timer With Lap Tracking and Timestamp-Based Accuracy

Browser timers accumulated through setInterval drift when tabs are backgrounded. Timestamp-based timing doesn't. Track startedAt when the stopwatch starts, compute elapsed as now - startedAt + accumulatedPauses on each render. The stopwatch is accurate whether you're watching it or not.

Stopwatches and timers should be boring and dependable. They usually aren't β€” most web-based ones drift badly when you tab away. Fixing this is a matter of not counting intervals and instead computing from timestamps.

πŸ”— Live demo: https://sen.ltd/portfolio/stopwatch-timer/
πŸ“¦ GitHub: https://github.com/sen-ltd/stopwatch-timer

Screenshot

Features:

  • Two modes: stopwatch (up) + timer (down)
  • Lap tracking with best/worst highlighting
  • Millisecond precision
  • Keyboard: Space (start/stop), R (reset), L (lap)
  • Audio beep + browser notification on timer end
  • Pause/resume with correct accumulation
  • Japanese / English UI
  • Zero dependencies, 64 tests

The accumulation pattern

Stopwatch state:

{
  status: 'idle' | 'running' | 'paused',
  startedAt: number | null,  // timestamp of current run segment
  accumulated: number,        // ms from previous runs
  laps: [...],
}
Enter fullscreen mode Exit fullscreen mode

The key invariant: accumulated holds the total time from all previous start→pause segments. startedAt marks the current segment (null if not running). Total elapsed is accumulated + (now - startedAt) while running, or just accumulated while paused.

export function startStopwatch(state, now) {
  if (state.status === 'running') return state;
  return { ...state, status: 'running', startedAt: now };
}

export function pauseStopwatch(state, now) {
  if (state.status !== 'running') return state;
  return {
    ...state,
    status: 'paused',
    startedAt: null,
    accumulated: state.accumulated + (now - state.startedAt),
  };
}

export function getElapsed(state, now) {
  if (state.status === 'running') {
    return state.accumulated + (now - state.startedAt);
  }
  return state.accumulated;
}
Enter fullscreen mode Exit fullscreen mode

This pattern works regardless of how often the UI renders. Render once per second? Accurate. Render 60 times per second? Same accuracy. Render only on button clicks? Also accurate.

Lap mechanics

Each lap captures the current total elapsed and can derive the per-lap split:

export function addLap(state, now) {
  if (state.status !== 'running') return state;
  const cumulative = getElapsed(state, now);
  const previous = state.laps[state.laps.length - 1]?.cumulative || 0;
  const time = cumulative - previous;
  return { ...state, laps: [...state.laps, { time, cumulative }] };
}
Enter fullscreen mode Exit fullscreen mode

The first lap's time equals its cumulative (no previous). Subsequent laps subtract the previous cumulative. This gives "this lap took 12.4 seconds" alongside "total at this point: 47.1 seconds".

Best and worst laps

export function getBestLapIndex(laps) {
  if (laps.length === 0) return -1;
  let best = 0;
  for (let i = 1; i < laps.length; i++) {
    if (laps[i].time < laps[best].time) best = i;
  }
  return best;
}
Enter fullscreen mode Exit fullscreen mode

The UI highlights the best lap green and the worst lap red. Useful for interval training or tracking consistency in repeated tasks.

Timer parseTimeString

The timer takes human-friendly input: 1:30:45, 5m, 90s, 1h30m, or bare seconds:

export function parseTimeString(str) {
  str = str.trim();

  // HH:MM:SS or MM:SS
  if (/^\d+(:\d+){1,2}$/.test(str)) {
    const parts = str.split(':').map(Number);
    if (parts.length === 2) return (parts[0] * 60 + parts[1]) * 1000;
    if (parts.length === 3) return (parts[0] * 3600 + parts[1] * 60 + parts[2]) * 1000;
  }

  // 1h30m5s style
  const match = str.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/);
  if (match && match[0]) {
    const h = parseInt(match[1] || '0');
    const m = parseInt(match[2] || '0');
    const s = parseInt(match[3] || '0');
    return ((h * 60 + m) * 60 + s) * 1000;
  }

  // Bare number = seconds
  if (/^\d+$/.test(str)) return parseInt(str) * 1000;

  return null;
}
Enter fullscreen mode Exit fullscreen mode

Accepts the formats users actually type. No "please enter exactly HH:MM:SS" error messages β€” if the user's intent is parseable, parse it.

Timer completion beep

function playBeep() {
  const ctx = new AudioContext();
  const osc = ctx.createOscillator();
  const gain = ctx.createGain();
  osc.frequency.value = 880;
  gain.gain.setValueAtTime(0.3, ctx.currentTime);
  gain.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.5);
  osc.connect(gain).connect(ctx.destination);
  osc.start();
  osc.stop(ctx.currentTime + 0.5);
}
Enter fullscreen mode Exit fullscreen mode

Short A5 sine wave with a 0.5-second decay. Noticeable but not annoying. Browser notification fires in parallel so you get alerted even when tabs are hidden.

Series

This is entry #90 in my 100+ public portfolio series.

Top comments (0)