Skip to the code
cf.

Countdown timer with pause and resume

MediumTiming

Write createCountdown(seconds, { onTick, onDone }). It starts counting down from seconds right away and returns an object with pause(), resume(), and remaining().

Each time another whole second has been counted, it calls onTick(secondsLeft). When it reaches zero, it calls onDone() instead, once, and stops, leaving no timers running. A 3 second countdown calls onTick(2) after 1 s, onTick(1) after 2 s, and onDone() after 3 s.

remaining() returns the milliseconds left, exact to the millisecond: 1750 when 1.25 s of a 3 s countdown have passed, and 0 once it's done.

pause() freezes the countdown, keeping the time left, including the part of the current second. resume() continues from exactly there: paused 1.5 s into a 3 s countdown, the next onTick(1) comes 0.5 s after resume(). Pausing while paused, or resuming while running, does nothing.

const countdown = createCountdown(3, {
  onTick: (secondsLeft) => console.log(secondsLeft),
  onDone: () => console.log("go!"),
});
// after 1 s: logs 2. At 1.5 s:
countdown.pause();
countdown.remaining(); // 1500
countdown.resume();
// 0.5 s later: logs 1. 1 s after that: logs "go!"

What the tests check

  1. calls onTick every second with the seconds left
    const clock = useFakeTimers();
    const onTick = spy();
    createCountdown(3, { onTick, onDone: spy() });
    clock.tick(999);
    expect(onTick).toHaveBeenCalledTimes(0);
    clock.tick(1);
    expect(onTick).toHaveBeenLastCalledWith(2);
    clock.tick(1000);
    expect(onTick.calls.map((call) => call.args[0])).toEqual([2, 1]);
    
  2. calls onDone once at zero, then stops
    const clock = useFakeTimers();
    const onTick = spy();
    const onDone = spy();
    createCountdown(3, { onTick, onDone });
    clock.tick(2999);
    expect(onDone).toHaveBeenCalledTimes(0);
    clock.tick(1);
    expect(onDone).toHaveBeenCalledTimes(1);
    clock.tick(5000);
    expect(onDone).toHaveBeenCalledTimes(1);
    expect(onTick).toHaveBeenCalledTimes(2);
    expect(clock.pending()).toBe(0);
    
  3. remaining() returns the exact milliseconds left
    const clock = useFakeTimers();
    const countdown = createCountdown(3, { onTick: spy(), onDone: spy() });
    expect(countdown.remaining()).toBe(3000);
    clock.tick(1250);
    expect(countdown.remaining()).toBe(1750);
    clock.tick(5000);
    expect(countdown.remaining()).toBe(0);
    
  4. pause() freezes the countdown
    const clock = useFakeTimers();
    const onTick = spy();
    const onDone = spy();
    const countdown = createCountdown(3, { onTick, onDone });
    clock.tick(1500);
    countdown.pause();
    clock.tick(10000);
    expect(countdown.remaining()).toBe(1500);
    expect(onTick).toHaveBeenCalledTimes(1);
    expect(onDone).toHaveBeenCalledTimes(0);
    
  5. resume() continues mid-second, from where it paused
    const clock = useFakeTimers();
    const onTick = spy();
    const onDone = spy();
    const countdown = createCountdown(3, { onTick, onDone });
    clock.tick(1500);
    countdown.pause();
    clock.tick(10000);
    countdown.resume();
    clock.tick(499);
    expect(onTick).toHaveBeenCalledTimes(1);
    clock.tick(1);
    expect(onTick).toHaveBeenCalledTimes(2);
    expect(onTick).toHaveBeenLastCalledWith(1);
    clock.tick(999);
    expect(onDone).toHaveBeenCalledTimes(0);
    clock.tick(1);
    expect(onDone).toHaveBeenCalledTimes(1);
    
  6. extra pause() and resume() calls change nothing
    const clock = useFakeTimers();
    const onTick = spy();
    const onDone = spy();
    const countdown = createCountdown(3, { onTick, onDone });
    clock.tick(1500);
    countdown.pause();
    countdown.pause();
    clock.tick(1000);
    expect(countdown.remaining()).toBe(1500);
    countdown.resume();
    countdown.resume();
    clock.tick(500);
    expect(onTick.calls.map((call) => call.args[0])).toEqual([2, 1]);
    clock.tick(1000);
    expect(onDone).toHaveBeenCalledTimes(1);
    expect(onTick).toHaveBeenCalledTimes(2);
    

Learn more

solution.jsSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).