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.

FromEventToWhat happens
runningpausepausedElapsed time is banked into an accumulator.
pausedresumerunningA new wall-clock segment starts; the bank is kept.
running / pausedcomplete-phaserunning (next phase)focus → break → focus…; the break kind depends on completed cycles.
running / pausedcomplete-phase (last focus)completedTerminal — the session leaves the timer and enters your history.
running / pausedabandonabandonedTerminal — 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.

How It’s Built: State Machine — stop.procrastin.ar