I built this Pomodoro timer for the moment when a simple countdown stops being enough. A useful session timer has to know whether it is work, a short break, or a long break, remember which task was completed, and survive the user switching tabs. The interesting part is keeping those concerns separate. The reader I had in mind was someone implementing a browser timer who has already discovered that setInterval is not a clock.
One state machine drives every phase
The component stores the current phase and the number of completed work sessions. Duration is derived from the phase, while the display only deals with remaining seconds:
const phase = ref("work"); // work | shortBreak | longBreak
const remainingSeconds = ref(25 * 60);
const completedWorkCount = ref(0);
const durationForPhase = (p) => {
if (p === "work") return (Number(settings.workMin) || 1) * 60;
if (p === "shortBreak") return (Number(settings.shortBreakMin) || 1) * 60;
return (Number(settings.longBreakMin) || 1) * 60;
};
That small function is doing more than formatting. It gives every phase one source of truth, including the reset path and the settings watcher. Invalid or empty durations fall back to one minute, so a half-edited input cannot create a zero-length loop. The UI can still let people choose work, short-break, long-break, and rounds independently without duplicating conversion logic in several click handlers.
When a phase ends, the code increments the work count only for a completed work phase, then chooses a long break when the configured round count is reached. Breaks do not increment the count. That avoids the common bug where counting every transition makes the long break arrive early. After the long break, the count resets so the next work block starts at round one. Pausing clears the deadline but keeps the phase and remaining seconds, which makes Resume behave differently from Reset in a predictable way.
Measuring the deadline instead of trusting interval ticks
The timer keeps a phaseEndAt timestamp and calculates the remaining value from Date.now() on each tick. The interval is for refreshing the screen, not for measuring time:
const tick = () => {
if (!isRunning.value || phaseEndAt === null) return;
const rem = Math.max(0, Math.round((phaseEndAt - Date.now()) / 1000));
remainingSeconds.value = rem;
if (rem <= 0) completePhase();
};
const start = () => {
if (isRunning.value) return;
ensureAudioContext();
isRunning.value = true;
phaseEndAt = Date.now() + Math.max(0, remainingSeconds.value) * 1000;
stopInterval();
timerHandle = setInterval(tick, 250);
};
The interval runs every 250 milliseconds only to make the display feel responsive. If the browser throttles a background tab, callbacks may arrive late, but the next callback subtracts the current wall-clock time from the saved end time. A tick is not allowed to “owe” exactly one second simply because one callback happened. There is still a wall-clock caveat: a large system clock adjustment can affect Date.now(). For this productivity timer that trade-off is acceptable, and the deadline is much more robust than accumulating interval deltas.
The progress bar is derived from the same phase duration rather than from a second counter maintained by the template:
const progressPercent = computed(() => {
const total = durationForPhase(phase.value);
if (!total) return 0;
const pct = (1 - remainingSeconds.value / total) * 100;
return Math.min(100, Math.max(0, Math.round(pct)));
});
At a transition the component pauses, records a work phase if appropriate, plays a short Web Audio beep, sends a browser Notification when permission is available, applies the next phase, and starts again. The beep and notification are side effects; a permission denial must never prevent the state machine from moving on.
The log is a local product feature, not a server database
A completed work phase stores the trimmed task name, an ISO timestamp, and a date key in localStorage. The UI derives today's count, a seven-day trend, and recent records from that array. Settings and the last task are stored separately, so clearing history does not unexpectedly reset a user's preferred durations. A task name is captured when the phase finishes, not when the user merely presses Start, which keeps abandoned sessions out of the log.
Try a concrete setup: set work to 25 minutes, short break to 5, long break to 15, and four rounds. After the fourth work phase, the counter reaches four and the next phase is long break. If you change the durations while paused, the watcher recalculates the visible remaining seconds for the current phase; while running, it does not rewrite the active deadline underneath you.
That separation also makes the behavior testable: phase transitions can be checked with a fake clock, while local history and notification permissions can be tested as independent browser concerns. The timer does not need a server or a login to be useful.
The honest limitation is that local storage is per browser profile. Clearing site data, using private browsing, or changing devices removes the history. Notifications and audio also require browser permission and can be unavailable in some environments. I turned this implementation into a small free tool: Pomodoro Timer.
Top comments (0)