Engineering notes
KodaPomo
Pomodoro Dashboard
These notes are the long-form version of the KodaPomo case study: what the product started as, what it became, how it is composed, and the decisions behind the database, the sync engine and the AI layer. Everything here is implemented in the running product at kodapomo.com โ nothing is aspirational unless it is written in the open-work section at the end.
1. Where it started
The original brief was small and honest: an online Pomodoro app. Not a task platform, not a habit tracker โ a place where the Pomodoro technique is literal. Twenty-five minutes of focus, five minutes of rest, a longer break after four sessions. The point was compartmentalisation: a user should be able to take one piece of work, break it into sessions they can actually finish, and see their effort managed instead of guessed at.
The first version was a generated Next.js timer: one mode, one countdown, no persistence. It worked, and it was not a product. From there the scope moved deliberately: stores instead of one component, tasks bound to sessions, ambient audio, analytics, then an AI planner and AI insights, then accounts, sync and billing. Each step was added because the previous one exposed a gap, not because the feature list looked short.
- v0.1.0 โ generated Pomodoro UI, no persistence, no tasks.
- v0.2.0 โ architecture overhaul: composition root plus independent stores for timer, tasks, settings, statistics and interface.
- Tasks โ estimates, an active task bound to each session, per-session attribution, soft-delete archive.
- Audio โ five mixable ambient channels, later a licensed music library served from private storage.
- Analytics โ streaks, weekly minutes, peak hours, estimate accuracy, and a deterministic rule-based coaching layer.
- AI โ planner (goal โ preflight โ blocks โ tasks โ subtasks) and insights over completed sessions.
- Platform โ Supabase auth, per-identity sync, Stripe on web, RevenueCat on device, Capacitor wrapper for Android.
The 25/5 baseline is still the default. Discipline presets re-pace it โ programming runs 50/10/20 โ but the model behind it is identical, so a different rhythm never means a different code path.
2. Stack and why
3. How it is composed
- app/page.tsx is the composition root: it wires stores to components and owns the timer lifecycle.
- features/ holds vertical slices โ the timer (store, hooks, notifications) and the AI feature (prompts, schemas, validators, rate limiting).
- store/ holds the seven Zustand stores, each with its own persistence key, version and migration path.
- lib/ holds cross-cutting concerns: audio, sync, Supabase clients, auth guards, origin checks, insights rules and the AI transport helpers.
- app/api/ is the entire server surface โ AI routes, Stripe and RevenueCat webhooks, subscription status, account deletion, signed track delivery.
- supabase/migrations/ holds nine append-only migrations; every user table has RLS enabled and forced.
4. The timer: correctness over cleverness
A Pomodoro timer is a correctness problem disguised as a UI problem. Browsers throttle background tabs, phones suspend WebViews, and users switch tabs for twenty minutes. Any timer that counts intervals will drift, and a productivity tool that lies about time is worse than no tool at all.
- Starting a session records an absolute end time; every repaint re-derives the remaining time from the wall clock instead of decrementing a counter.
- A 250 ms interval is only a repaint cadence โ dropping frames or throttling changes nothing about the result.
- A visibility listener forces an immediate reconcile when the tab returns, and rehydration recomputes from the recorded start time so a reload cannot shorten or lengthen a session.
- Completion fires once, through a one-shot signal keyed by mode and start time, so React re-renders and Strict Mode cannot double-record.
- A broadcast channel elects a leader tab; follower tabs stand down instead of running competing timers.
- On Android, a native local notification is scheduled on the system clock when the session starts, so completion survives the WebView being suspended.
5. Data: what is stored, where, and why
The database question was the one that mattered most: a productivity app can accumulate an unbounded amount of trivia, and every table added is a migration, an index and a sync path to maintain. The rule I settled on is simple โ persist what a user would be angry to lose, what analytics genuinely need, and nothing else. Everything else stays in browser storage until a query proves it belongs in a table.
Deliberately not in Postgres: live timer state (device-local, and sessionStorage when anonymous), the AI insights cache (cheap to recompute), planner drafts (still changing shape), task extras such as notes and subtasks (not yet queried remotely), and every ephemeral UI flag โ persisting 'is generating' produced stuck spinners after reload, so it is excluded on purpose.
6. Sync: two writers, one truth
Local storage is the source of truth while the app is open; Postgres is the mirror that makes the product portable. A singleton engine debounces pushes, pulls on start, and guards against feedback loops while a merge is in flight.
- Tasks sync by diff: changed rows are upserted, deletions are tombstones, and cloud-assigned ids are written back locally so a later update targets the right row.
- Sessions and settings sync as a full replace โ append-mostly data where a diff adds complexity without adding safety.
- A five-day soft-delete window on tasks makes an accidental delete recoverable, with a scheduled purge afterwards.
- Open work: the session and settings replace is not atomic, so a failed push is not yet non-destructive. Making it transactional is the next data-layer task.
7. Audio, isolated on purpose
Sound is the feature most likely to break the timer. It is implemented as a non-React singleton wrapped around Howler: five ambient channels with independent volume, a separate music path, and playback state that lives nowhere in the stores. The stores hold declarative settings; a hook mirrors them into the engine. The engine is imported dynamically on the first user gesture, so it never blocks first paint, and again at completion time, so an alert sound can never be delayed by a missing chunk. Ambient audio and music are mutually exclusive by design, and licensed tracks are served through an authenticated route backed by private storage rather than a public bucket.
8. AI engineering: from unsustainable to bounded
The first AI implementation worked and could not be afforded. It consumed credits faster than the product justified, its answers were generic, it recommended paid resources as if everything were free, and it would happily be called with a blocked, dangerous or nonsensical prompt because nothing inspected the request before it reached the model. The rewrite rested on three ideas: bound the spend, bound the input, and make the context structured.
Bound the spend
Per-route output caps from 512 to 16,384 tokens against a hard ceiling; an input budget of roughly 32K enforced as a 28,000-token clamp that trims rather than rejects; per-field clamps on every user-supplied string; Redis quotas with true rolling windows (3 a day and 10 a week on the free tier, 30 a day and 120 a week on premium, plus a per-IP ceiling) that fail closed with 503 when the limiter is unavailable. One endpoint โ whole-curriculum refinement, around 17K input tokens per call โ was deleted rather than optimised.
Bound the input
Every request is validated before a token is spent: request schemas clamp and type each field; goal validation rejects gibberish, repetition and prompt-injection patterns, and blocks a trilingual safety bank; feedback passes two-layer moderation (a zero-cost regex pass plus the free moderation endpoint) and is fenced in tagged blocks the model is instructed never to obey. Blocked input returns 422, borderline input returns 400 with a message the user can act on.
Make the context structured
Context presets per discipline set the pacing and persona; a computed focus history (sessions, streak, peak hours, completion rate) tailors difficulty and task sizing; answers must come back as JSON validated against schemas after a lenient normalisation pass, with a deterministic estimation table when the model omits or misjudges effort; and every suggested link is checked for reachability against a paywall domain list before it reaches the user.
Handle failure in the interface, not just the API
One error taxonomy maps each failure mode โ not configured, goal rejected, timeout, empty response, schema validation, unavailable โ to a status code, a retryability flag and a localised message. Blocked and policy failures surface as warning toasts, transient failures as inline retryable errors, and long generations stream a heartbeat so Safari does not kill an idle request. Generated plans and insights are cached locally so a paid result is never bought twice.
- Models are configuration, not code: one OpenAI-compatible client, with the model and base URL as environment values and presets for several providers plus local inference.
- Insights are evidence-gated: confidence is forced low under ten sessions, chronotype and causation claims are forbidden, and the 21-day habit myth is explicitly banned.
- User-supplied URLs pass an SSRF guard: HTTP(S) only, ports 80 and 443, private and cloud-metadata hosts rejected.
- Every AI route checks origin, authentication, configuration and quota before a model is called.
9. Analytics: free first, AI second
Most insight in the product costs nothing to produce. Sessions are bucketed locally into peak hours, streaks, weekly minutes, estimate accuracy and break behaviour, then run through a deterministic rule engine with small-sample guards โ a rule that fires on three sessions is noise, so it does not. The AI analysis sits above that layer, requires a minimum amount of history, and is cached until the user asks for a fresh one.
10. Quality gates
- Unit and integration tests over stores, timer logic, sync, AI schemas, normalisation and the security surface.
- End-to-end browser flows across Chromium, Firefox and WebKit, each starting from a clean storage state.
- Security tests for identity isolation, origin checks, SSRF, rate-limit bypass and input size limits.
- Continuous integration runs the linter, a strict type check, a Capacitor configuration guard and the build on every push to main.
- A static quality gate asserts design-system rules, and the performance fixes found in the Lighthouse audit are asserted as tests so they cannot regress silently.
11. Running it locally
pnpm install
cp .env.example .env.local # Supabase, AI provider, Redis, Stripe are all optional to start
pnpm dev # runs offline-first as a local user without any credentials
pnpm test # unit, integration and security suites
pnpm lint && pnpm exec tsc --noEmitThe product runs fully offline as a local user when no Supabase credentials are present โ the account layer is an addition, never a precondition.
12. Known trade-offs and open work
- Session and settings sync is a full replace, not a diff, so a failed push is not yet non-destructive.
- Planner data and task extras are still device-local until the sync engine covers them.
- The Android wrapper is gated behind device and store checks: release signing, closed testing and the store listing are open work.
- Some pricing copy still advertises 200 weekly AI generations while the enforced premium quota is 120 โ the copy, not the limit, is what needs fixing.
- Local Lighthouse performance is still below the production target; the fixes are known and partly asserted as tests.
The short version: the interesting engineering in KodaPomo was never the countdown. It was deciding what deserves a table, what belongs on the device, what a model is allowed to see, and what a single generation is allowed to cost โ and then making each of those decisions enforceable in code rather than in intention.