How it’s built
The Pomodoro Timer is a state machine on the server
Most timer apps keep the countdown in the browser tab — close it and the session is gone. Ours is a finite state machine that lives in the backend domain model, so a session survives reloads, works across devices, and can never enter an invalid state.
The states
A session has two orthogonal dimensions: its status (is the machine alive?) and, while active, its phase (what should you be doing?).
status: running ⇄ paused phase: focus ──▶ short_break ──▶ focus …
│ │ (every Nth break
├──▶ completed (terminal) └──────────▶ long_break)
└──▶ abandoned (terminal)
After every cyclesBeforeLongBreak focus phases the short break is replaced by a long one, and finishing the final focus phase completes the whole session. All of that is configuration — the machine itself never changes, only its parameters do.
The transitions
Every transition is a named domain operation exposed as one API endpoint. Anything not in this table — resuming a completed session, pausing twice — is rejected by the entity before it can touch the database.
| From | Event | To | What happens |
|---|---|---|---|
| running | pause | paused | Elapsed time is banked into an accumulator. |
| paused | resume | running | A new wall-clock segment starts; the bank is kept. |
| running / paused | complete-phase | running (next phase) | focus → break → focus…; the break kind depends on completed cycles. |
| running / paused | complete-phase (last focus) | completed | Terminal — the session leaves the timer and enters your history. |
| running / paused | abandon | abandoned | Terminal — focus phases already finished still count toward stats. |
Pause-safe timing without a clock
The server never runs a timer. Each phase stores an accumulator of milliseconds already spent plus the wall-clock instant the current running segment started. Pausing banks the elapsed segment into the accumulator; resuming starts a new segment. Remaining time is derived on demand:
elapsed(now) = accumulatedMs + (running ? now - phaseStartedAt : 0) remaining(now) = phaseDuration - elapsed(now)
That makes pauses free to model, keeps the API stateless between requests, and lets the browser extrapolate the countdown locally between syncs — even when its clock disagrees with the server’s.
The invariant the database enforces
“One active session per user” is a business rule, so it isn’t left to application code alone — a partial unique index makes the database reject a second active session even under concurrent requests:
CREATE UNIQUE INDEX idx_pomodoro_sessions_one_active_per_user
ON pomodoro_sessions (user_id)
WHERE status IN ('running', 'paused');The same pattern in the browser
The timer screen mirrors the server: its view state is a TypeScript discriminated union — one variant per screen state, each carrying exactly the data that exists in that state. Four independent flags (“loading”, “busy”, “error”, “session”) would allow 2n combinations, most of them nonsense; the union allows exactly four — each tagged with where the session lives, an account on the server or just this device.
type SessionHome = 'account' | 'device';
type PomodoroViewState =
| { type: 'CHECKING_SESSION' }
| { type: 'SETUP'; home: SessionHome;
pending: boolean; error: string | null }
| { type: 'ACTIVE'; home: SessionHome;
session: Session; syncedAt: number;
pending: boolean; error: string | null }
| { type: 'CELEBRATING'; home: SessionHome; session: Session };Rendering is an exhaustive switch with an assertNever default — add a fifth state and the compiler lists every screen that hasn't handled it. Illegal states aren't checked for; they're unrepresentable.
Why this matters beyond timers
The same archetype — explicit states, named transitions, invalid moves rejected at the domain layer, invariants backed by the storage engine — is how we model order lifecycles, approval workflows, and subscription billing for client systems. A Pomodoro timer is simply the smallest honest demo of it: the domain entity is a plain TypeScript class with unit tests around every transition, the HTTP layer is a thin adapter, and the UI is a projection of server state.