Before this lesson

Understand event handling, timestamps, setInterval and page visibility. This project focuses on timer state.

What you will be able to do

Implement start, pause, resume and reset using a deadline instead of counting timer callbacks.

Understand the mechanism

Define ready, running, paused and completed states. remaining stores milliseconds and deadline represents the running end time. Derive button state from running and guard handlers so repeated activation cannot create multiple intervals.

Intervals are scheduling callbacks, not precise clocks. Busy or background pages delay them, so decrementing a counter drifts. Recompute from deadline on every update and when visibility changes, rounding display upward and clamping to zero.

Pause computes the current remainder before clearing the timer; resume establishes a new deadline. Reset clears scheduling and uses validated whole minutes from one to 120.

Avoid announcing the clock every second. Update the live status only on meaningful transitions. No notification permission is requested, and reloading resets the timer; a frozen browser cannot guarantee an on-time alert.

Runnable example

Run HTML examples as separate .html files in a modern browser. Prepare resource files and multi-file modules as described in the lesson.

<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Focus timer</title><style>
*{box-sizing:border-box}body{max-width:850px;margin:auto;padding:24px;font:17px/1.6 system-ui;color:#20362c;background:#f7faf4}button,input,select{font:inherit;padding:8px;max-width:100%}button{cursor:pointer}button:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid #176343;outline-offset:3px}form{display:flex;flex-wrap:wrap;gap:12px;align-items:end}label{display:block}input,select{display:block}li{margin:12px 0;padding:12px;background:white;border:1px solid #d4dfd2;border-radius:8px;overflow-wrap:anywhere}li button{margin:4px}progress{width:100%}.row{display:flex;gap:12px;flex-wrap:wrap}output{font-size:2rem;font-variant-numeric:tabular-nums}small{display:block}table{border-collapse:collapse;width:100%}td,th{padding:10px;text-align:left;border-bottom:1px solid #ccc}.table-wrap{overflow:auto}h1{font-size:clamp(26px,5vw,38px)}
</style></head><body><main><h1>Focus timer</h1><p>A local practice timer. No notification permission or background service is required.</p>
<label for="minutes">Duration in minutes</label><input id="minutes" type="number" min="1" max="120" value="25">
<p><output id="clock" aria-label="Time remaining">25:00</output></p><div class="row"><button id="start">Start</button><button id="pause" disabled>Pause</button><button id="reset">Reset</button></div><p id="status" role="status">Ready</p></main><script>
let remaining=25*60*1000,deadline=0,timer=null,running=false;
const duration=document.querySelector('#minutes'),clock=document.querySelector('#clock'),status=document.querySelector('#status'),start=document.querySelector('#start'),pause=document.querySelector('#pause');
function paint(){const seconds=Math.ceil(remaining/1000);clock.textContent=String(Math.floor(seconds/60)).padStart(2,'0')+':'+String(seconds%60).padStart(2,'0');start.disabled=running;pause.disabled=!running;duration.disabled=running;}
function tick(){if(running){remaining=Math.max(0,deadline-Date.now());if(remaining===0){running=false;clearInterval(timer);timer=null;status.textContent='Session complete. Take a break.';}}paint();}
function readDuration(){const value=Number(duration.value);if(!Number.isInteger(value)||value<1||value>120){status.textContent='Enter a whole number from 1 to 120.';return null;}return value*60000;}
function reset(){const value=readDuration();if(value===null)return;clearInterval(timer);timer=null;running=false;remaining=value;status.textContent='Ready';paint();}
start.addEventListener('click',()=>{if(running)return;const value=readDuration();if(value===null)return;if(remaining===0){remaining=value;}deadline=Date.now()+remaining;running=true;status.textContent='Focusing';timer=setInterval(tick,200);paint();});
pause.addEventListener('click',()=>{if(!running)return;remaining=Math.max(0,deadline-Date.now());running=false;clearInterval(timer);timer=null;status.textContent=remaining===0?'Session complete.':'Paused';paint();});
document.querySelector('#reset').addEventListener('click',reset);duration.addEventListener('change',reset);document.addEventListener('visibilitychange',tick);paint();
</script></body></html>

Trace and verify

Use one minute, pause and verify the display remains stable, then resume. Repeated start cannot accelerate time. Return from a background tab and compare to the deadline. Test invalid durations and completion at zero with a restart available.

Common mistakes and boundaries

Date.now is affected by clock adjustments; this wall-clock approach is not precision measurement. A stopwatch might use a monotonic clock, while persistence needs separate design. Timer behavior is not evidence of user attention.

Practice challenge

Add 25-minute focus and five-minute break presets. Define whether switching while running resets or is blocked, without silently changing the deadline.

Reference solution and reasoning

Map modes to durations and reuse reset rather than duplicating timers. Completion can suggest the next mode; automatic start requires a clear setting.

Project lab: derive remaining time from a deadline

setInterval is not a precise clock. Background throttling or a busy main thread can delay callbacks, so subtracting one each time accumulates drift. Keep a deadline and let callbacks refresh its difference from now. Injected timestamps let you test this without waiting or changing the system clock.

Run this separate experiment in a modern browser Console; it is not a replacement for the main example above.

function secondsLeft(deadline, now) {
  return Math.max(0, Math.ceil((deadline - now) / 1000));
}
const start = 100000;
let deadline = start + 60000;
console.log(secondsLeft(deadline, start));
console.log(secondsLeft(deadline, start + 20400));
const pausedMilliseconds = Math.max(0, deadline - (start + 20400));
const resumedAt = start + 120000;
deadline = resumedAt + pausedMilliseconds;
console.log(secondsLeft(deadline, resumedAt));
console.log(secondsLeft(deadline, resumedAt + 40000));

Expected observations

Output is 60, 40, 40 and 0. Pausing preserves 39,600 milliseconds rather than the rounded display of 40 seconds, avoiding time gains from repeated pauses.

Why this happens and where it stops applying

performance.now is useful for monotonic elapsed time within a page session. Persistence across reloads generally needs a recoverable wall-clock timestamp, which can be affected by clock adjustments. Never subtract values from different clock systems. Trigger completion once on the running-to-finished transition, not every time a callback sees zero. Resetting should stop old callbacks before establishing new state.

Extension challenge

After twenty seconds without callbacks, should the page replay twenty decrement operations?

Reasoned solution

No. On visibility recovery, read the current time and recompute once. Test delayed callbacks, pause/resume, overdue deadlines and repeated Start clicks. Verify state and display rather than counting timer invocations.

Further reading

Complete source and acceptance

Download the complete project source (.html.txt)

Save the downloaded text as a .html file in a separate practice directory and open it in your browser. It contains the main project above; the later labs are separate exercises. Data stays in that browser origin, not a server account.