From bbf2509a594f850413b7c93f850649039fe2caf6 Mon Sep 17 00:00:00 2001 From: "Bryan \"Beege\" Berry" Date: Sat, 30 May 2026 19:39:11 +0900 Subject: [PATCH] feat(pomodoro): implement zero-dependency vanilla Pomodoro timer Delivers the full Pomodoro timer web app per spec 2026-05-30-pomodoro-app.md. Three static files (index.html, styles.css, app.js) work via file:// and any static server with no build step, no CDN, and no runtime dependencies. Key behaviors implemented: - 25/5/15-minute Work/Short-Break/Long-Break cycle with Long Break every 4th work - Start/Pause/Resume toggle, Reset (no cycle advance), Skip (no Pomodoro credit) - Wall-clock-derived countdown (endAtMs reference) prevents cumulative drift - End-of-interval: synthesized Web Audio beep + CSS flash animation; visual cue fires independently of audio so browser autoplay suppression is handled safely - Completed-Pomodoro counter; no auto-start after transition (waits for Start) - ARIA live region announces transitions; role="timer" on countdown; no color-only interval signaling; visible :focus-visible ring on all buttons - No localStorage, no Notification API, no network requests ADRs added: - adr/2026-05-30-vanilla-single-page-no-build.md (Accepted) - adr/2026-05-30-walltime-drift-free-countdown.md (Accepted) Co-Authored-By: Claude Sonnet 4.6 --- ...2026-05-30-vanilla-single-page-no-build.md | 39 ++ ...026-05-30-walltime-drift-free-countdown.md | 35 ++ adr/README.md | 3 +- app.js | 286 +++++++++++++ index.html | 49 +++ plans/2026-05-30-pomodoro-app.md | 392 ++++++++++++++++++ specs/2026-05-30-pomodoro-app.md | 172 ++++++++ styles.css | 145 +++++++ 8 files changed, 1120 insertions(+), 1 deletion(-) create mode 100644 adr/2026-05-30-vanilla-single-page-no-build.md create mode 100644 adr/2026-05-30-walltime-drift-free-countdown.md create mode 100644 app.js create mode 100644 index.html create mode 100644 plans/2026-05-30-pomodoro-app.md create mode 100644 specs/2026-05-30-pomodoro-app.md create mode 100644 styles.css diff --git a/adr/2026-05-30-vanilla-single-page-no-build.md b/adr/2026-05-30-vanilla-single-page-no-build.md new file mode 100644 index 0000000..5245016 --- /dev/null +++ b/adr/2026-05-30-vanilla-single-page-no-build.md @@ -0,0 +1,39 @@ +--- +title: Vanilla single-page app, no build step, file:// compatible +date: 2026-05-30 +status: Accepted +supersedes: +superseded-by: +--- + +# 2026-05-30 — Vanilla single-page app, no build step, file:// compatible + +## Context +The Pomodoro app spec (specs/2026-05-30-pomodoro-app.md) mandates zero runtime +dependencies, zero build tooling, no CDN/external assets, and a hard requirement that the +app open and fully function via `file://` as well as from any static server. Browsers +block ES-module fetches and many cross-origin/`file://` fetches, which constrains how code +can be split across files. + +## Decision +We will build the app as three sibling static files — `index.html`, `styles.css`, +`app.js` — loaded with relative paths via a same-origin `` and a single **classic +(non-module) ` + + diff --git a/plans/2026-05-30-pomodoro-app.md b/plans/2026-05-30-pomodoro-app.md new file mode 100644 index 0000000..b27be03 --- /dev/null +++ b/plans/2026-05-30-pomodoro-app.md @@ -0,0 +1,392 @@ +--- +title: Implementation plan — Pomodoro timer web app (zero-dependency, vanilla) +date: 2026-05-30 +spec: specs/2026-05-30-pomodoro-app.md +branch: feat/pomodoro-app +--- + +# 2026-05-30 — Pomodoro timer web app (implementation plan) + +## Overview + +Build a self-contained Pomodoro timer that runs by opening a single HTML file via +`file://` or any static server. Vanilla HTML/CSS/JS, **zero build tooling, zero runtime +dependencies, no CDN/external assets**. The app cycles Work (25:00) → Short Break (5:00) / +Long Break (15:00, every 4th work), with Start/Pause/Resume, Reset, and Skip controls, a +completed-Pomodoro counter, a drift-free wall-clock countdown, and an end-of-interval cue +(synthesized Web Audio beep + visual flash). No persistence, no Notification API, no +network requests at runtime. + +**In scope:** the full feature set in the spec's functional requirements 1–14 and all +acceptance criteria. + +**Out of scope:** settings/customization UI, persistence, theming, i18n, mobile-specific +layout work beyond basic responsiveness, browser-tab-title countdown (low-impact nice-to-have). + +**Branch:** `feat/pomodoro-app` + +## Architecture & Design + +### File layout + +The `file://` hard requirement rules out cross-file ES-module `import` (browsers block +module fetches under `file://` via CORS). To keep concerns separated while staying within +that constraint, ship **three sibling files loaded with relative paths** — a same-origin +`` and a **classic (non-module) `` + (classic script, **no `type="module"`**). +- `styles.css` — all styling; interval-type theming via a `data-interval` attribute on a + container element (no color-only signaling — also a text label). +- `app.js` — all behavior in one classic script using an IIFE (or top-level `const`s) to + avoid leaking globals. No `import`/`export`. + +No `package.json`, no `node_modules`, no bundler config. (A tiny `package.json` with only +a `scripts` block and **no `dependencies`/`devDependencies`** MAY be added solely to wire +the lint step in the quality gate; if added it MUST declare zero dependencies. Default: +omit it and run the linter via `npx` one-off so the project tree stays dependency-free — +see Implementation Steps step 7.) + +### Core model (in `app.js`) + +A single in-memory state object drives render; no framework. Shape: + +``` +state = { + intervalType: 'work' | 'shortBreak' | 'longBreak', + status: 'ready' | 'running' | 'paused', // 'ready' = full duration, not started + remainingMs: number, // ms left in current interval + endAtMs: number | null, // wall-clock target while running (Date.now()+remainingMs) + completedWorkCount: number, // total completed Pomodoros this session + workSinceLongBreak: number, // 0..3, resets to 0 when a long break STARTS + rafId / tickId: number | null, // active ticker handle +} +``` + +Fixed config constant: + +``` +const DURATIONS_MS = { work: 25*60*1000, shortBreak: 5*60*1000, longBreak: 15*60*1000 }; +const WORK_INTERVALS_PER_LONG_BREAK = 4; +``` + +### Drift-free countdown (FR timing-accuracy, edge case "no cumulative drift") + +Do **not** decrement a counter by 1000 each tick. On Start/Resume, compute +`endAtMs = Date.now() + remainingMs`. The ticker (use `setInterval` at ~250 ms, or +`requestAnimationFrame`) each tick computes `remainingMs = max(0, endAtMs - Date.now())` +and re-renders `MM:SS` derived from that. Display value = `Math.ceil(remainingMs/1000)` so +it shows `25:00` at start and only reaches `00:00` at true zero. When `remainingMs <= 0`, +fire `completeInterval()`. This keeps accuracy even when background tabs throttle the +timer, because the value is recomputed from wall clock, not accumulated. + +On Pause: clear ticker, set `remainingMs = max(0, endAtMs - Date.now())`, set +`endAtMs = null`, `status = 'paused'`. Resume re-derives `endAtMs`. + +### Control semantics (functions in `app.js`) + +- `start()` — only acts when `status !== 'running'`; sets `endAtMs`, starts ticker, + `status='running'`. Idempotent guard satisfies "Start while running = no-op". Also + resumes a suspended `AudioContext` here (user-gesture audio unlock). +- `pause()` — only acts when `status === 'running'`; freezes time. No-op otherwise. +- `togglePlay()` — the single Start/Pause/Resume button calls `start()` or `pause()` + based on current `status`. (FR3 allows one toggle.) +- `reset()` — stops ticker, `remainingMs = DURATIONS_MS[intervalType]`, `status='ready'`, + `endAtMs=null`. Does **not** change `completedWorkCount` or `workSinceLongBreak`, does + **not** advance interval (FR4, acceptance: count unchanged). +- `skip()` — stops ticker, advances cycle via `advance({ countCompletion:false })`. + Skipping work does NOT increment `completedWorkCount` (edge case + acceptance). +- `completeInterval()` — fires the end-of-interval cue (beep + visual flash), then + `advance({ countCompletion: true })`. Only this path (countdown reaching 0) increments + the Pomodoro count when the completed interval was work. +- `advance({ countCompletion })` — pure-ish transition that: + 1. If leaving a `work` interval and `countCompletion`, increment `completedWorkCount` + and increment `workSinceLongBreak`. + 2. Choose next interval: + - leaving work → if `workSinceLongBreak >= WORK_INTERVALS_PER_LONG_BREAK` → + `longBreak` (and on the long break *starting*, reset `workSinceLongBreak = 0`); + else `shortBreak`. + - leaving any break → `work`. + - **Skip nuance:** when skipping a work interval (`countCompletion=false`), + `workSinceLongBreak` is NOT incremented, so a skipped work does not push toward the + long break (consistent with "only completed work counts"). Document this explicitly + in code comments so the long-break-cadence wrap (edge case 3) is unambiguous. + 3. Set `remainingMs = DURATIONS_MS[next]`, `status='ready'`, `endAtMs=null`, + `intervalType=next`. **Does not auto-start** (FR11): next interval waits for Start. + + Note on `workSinceLongBreak` reset timing: reset it to 0 at the moment a long break is + selected as the next interval, so that the four-work counter restarts cleanly and the + cycle wraps correctly (edge case 3: Long Break → Work resets the toward-long-break + counter appropriately). + +- `render()` — single function that writes derived UI from `state`: `MM:SS` text, interval + label ("Work" / "Short Break" / "Long Break"), `data-interval` attribute for theming, + completed count, and button label/`aria-pressed` (Start vs Pause). Called after every + state mutation and on each tick. + +### Audio (FR8, edge case: blocked autoplay) + +Lazily create one `AudioContext` on first user gesture (inside `start()` / any control +handler). `beep()` creates an `OscillatorNode` + `GainNode`, plays a short tone +(~440–880 Hz, ~150–250 ms) with a quick gain ramp to avoid clicks, then stops. Wrap the +whole audio path in `try/catch` and guard on `AudioContext` existence so that if audio is +unavailable or suppressed, **the visual cue still fires** (edge case). `beep()` is called +only from `completeInterval()`, never from skip/reset. + +### Visual end-of-interval cue (FR8) + +Add/remove a CSS class (e.g. `flash`) on a container for a brief animation, and update the +ARIA live region text so assistive tech announces the transition. Use a timeout to remove +the class. The visual cue is independent of audio success. + +### Accessibility (NFR) + +- All controls are real `