Own product · Next.js and Supabase
A personal tracking app, and the reason behind every decision
It closes the day —habits, body, sleep, food, spending— in one form and one button, then hands it back read instead of raw. There is an open demo with the five real screens and the data of someone who does not exist.
Private repository · the demo is the public surface
Public demo and app in production · account sign-up is written and still closed
- 5real screens in the demo, not mockups
the app's own, running on data from someone invented
- 0database queries from the demo
measured in the browser: zero requests and zero submissions across six routes
- 526automated tests across eight CI gates
types, format, lint, tests, build, bundle leak, dependencies and row security
- 34tables with a per-user row policy
and the primary keys rebuilt, which is what was really in the way
The demo
The five real screens, on data from someone who does not exist
An app whose data is one person's finances and body cannot be shown with screenshots. The demo runs the same five screens —not copies— and the same calculation functions over a hundred and twenty days generated from a seed: the same figures on every reload, so a screenshot never goes stale. Those hundred and twenty days end today, so the demo does not age either.
The demo never queries the database. Not once.
This is not a promise written in a document: a test walks the import graph that exists at runtime from the demo module and fails if any reachable module touches the database client. It is safe by construction rather than by permission — even if the route guard fell, there would be no query to serve. Confirmed in the browser too, across all six routes: zero requests to the database and zero submissions.
What you see
Captured from the demo itself, with its own banner printed on.




What the demo person has, and why
- 1Three days paused for a trip, to show that a declared gap is not a failure.
- 2A graduated habit with its note: the explicit exit for a metric that has done its job.
- 3Weight and waist measured three times a week, so the series carry gaps nobody interpolates.
- 4A purchase in twelve instalments and a receivable in five, which is what separates current from deferred spending.
- 5Someone else's pocket, labelled as third-party custody and kept out of every net-worth total.
Decisions
Six decisions, each against a specific way of quitting
Half of people abandon a tracking app within the first month, and one in eight who quit do so because they already learned what they needed. Those two figures are the starting point: every decision answers a documented way of failing, not an aesthetic preference.
A gap and a failure are not the same thing
An unmarked day leaves the denominator; one marked “no” counts as zero; and a day declared paused —travel, illness— counts neither for nor against. Without that distinction, two weeks of ordinary life read as fourteen failures.
Graduating is a happy ending
A habit can be graduated with a note: it stops being asked daily, stays in the history, and no longer drags down everyone else's adherence. It leaves the denominator only from the graduation date onward, because the earlier days already counted and rewriting them would change a history that was true.
The reference is yours, not the average
Each spending category is compared against its budget when it has one and against your own mean when it does not, and the screen always says which. The expected-pace curve is not a straight line: it comes from the shape your own closed months had, because rent is not paid in daily instalments.
Weight is not a goal
There is no progress bar toward a target weight anywhere in the app. Waist has one, because it measures what was actually meant to change. That is the difference between an app that keeps you company and one that watches you.
Never invent a value
An empty field shows empty and series are not interpolated: a straight line between two weigh-ins two weeks apart is not a trend. When there are not enough days to claim something, the app says so instead of claiming it anyway.
The colors were measured, not picked
Every color went through contrast validation in both themes, and the six habit colors carry stepped luminance so they stay apart in greyscale or with color blindness. Calendar states are separated by shape —fill, hatch, outline— which is the only thing that survives a screen in sunlight.
Data
Postgres with row-level security, and the keys multi-user exposed
A policy per row, not per screen
Thirty-three tables and twenty-two views in Postgres, each with its policy and the views in invoker mode. The anonymous role reads nothing: a smoke test walks all fifty-five through the same API the app uses and requires all fifty-five to deny.
What blocked multi-user was not the security layer
It was the primary keys. Twenty-two tables identified rows without the user: measurements by date, habits by date and name, accounts by name. The second person to weigh themselves on a Tuesday got a duplicate-key error, and only one person in the whole system could have an account named after their bank.
Foreign keys carry the user inside
Without it, a transaction could point at someone else's account and row security would not stop it, because a foreign key is validated inside the engine and never passes through the policy.
A zero is not a value
Empty is stored as null, never as zero, and a blank field does not erase what was already measured that day: null keys are stripped from the payload before writing.
The numbers module
A browser number field rejects the decimal comma and reads 10.500 as ten and a half, so an expense was being stored divided by a thousand with nothing to warn you. Every field is text and goes through a purpose-built reader with its rules written and tested; under each one, the exact value about to be stored is shown, already trimmed to the column's decimals.
The charts are hand-written SVG
Thirty lines of arithmetic instead of a charting library: they are tested with the rest of the code without spinning up a browser, and the phone does not download twenty kilobytes to draw six lines.
Process
Eight gates before a change reaches production
Every push and every pull request goes through the same chain, and each gate is judged by its exit code rather than by what it prints. The full chain, run against the main branch:
- 1Typesstrict TypeScript, not one error
- 2FormatPrettier in check mode
- 3LintESLint with the Next config
- 4Tests526 tests across 38 files
- 5Buildfull production compile
- 6Bundle leak12 personal terms, none in the 28 files served
- 7Dependenciesaudit with no high-severity findings
- 8Row security55 of 55 checks deny the anonymous role
Four guards against one specific regression each
Demo isolation
Walks the value-import graph from the demo module and fails if any reachable module gets to the database client.
Demo writes
Walks the client components and requires every one that imports a server action to ask whether it is in demo mode, or be exempt with its reason written down.
Conflict keys
Every write with conflict resolution has to start with the user. Without that guard, the move to multi-user broke eleven saves in silence: an immaculate database and an unusable app.
Personal data in the artifact
Runs after the build and against what is served, not the source: the bundler decides what travels to the browser, not the import. It is proven to catch a planted leak.
Security
Two findings that only appear once a door is opened
The bundle is a surface too
Opening the demo exposes more than what the demo paints: it exposes the screens' JavaScript files, because they are the same ones. The real habit list lived in a shared constants module and the bundler shipped the whole module to the browser; two of those names are special-category data. They moved to a server-only module, with two guards behind them. It never became exposed: it was fixed before merging.
A demo write could reach real data, and not over the network
The form draft lives in the phone's own storage keyed by date, and the demo uses today's date. Anyone opening the demo in the same browser they log with would overwrite their real draft for the day, with no visible error. In demo mode the draft no longer reads, saves, clears or queues.
No third parties, and a per-request content policy
No external fonts, no analytics, nobody's scripts. The content security policy is issued with a single-use number on every request, and the offline cache stores no data: only static files and the offline screen. Signing out wipes local storage, which is not encrypted.
Status
What is live and what is missing
Live today
- 1The demo is public and asks for no account: the five screens, on generated data.
- 2The app runs in production for its author, behind a session and row-level security.
- 3The multi-user schema is applied, and isolation between two users was verified with a throwaway account that was deleted afterwards.
What is missing before the ten accounts open
- 1Sign-up is written and tested, and closed in two places at once: a row in the database and the sign-up switch in the auth panel, which still answers that registration is disabled.
- 2The confirmation email needs its own sending server: the bundled one is rate-limited by the hour, and with ten sign-ups on the same day somebody ends up with an account they cannot confirm.
- 3The four email templates have to point at the app's confirmation route; the provider's defaults go to its own endpoint, and then the link looks like it does nothing.
That is why this page links the demo and not a sign-up form: publishing a create-account button that returns an error today would be worse than not publishing it.