Skip to content

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

Open the demoEverything responds. Nothing is saved.

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.

A personal tracking app, and the reason behind every decision — Today
TodayWhat is still unlogged, the body, the day's spending and the habits. One sentence reads the day: the dimension furthest from your own baseline.
A personal tracking app, and the reason behind every decision — Log
LogThe whole day in one form and one button. One submission is eight independent writes: if a spending amount is unreadable, weight and habits are still saved.
A personal tracking app, and the reason behind every decision — History
HistoryWaist and weight with their gaps left unfilled, spending by category, and habits with a five-state calendar.
A personal tracking app, and the reason behind every decision — Finances
FinancesThe month read by pace, not size: how much you have spent against how much you should have by now, shaped by your own closed months.

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:

  1. 1Typesstrict TypeScript, not one error
  2. 2FormatPrettier in check mode
  3. 3LintESLint with the Next config
  4. 4Tests526 tests across 38 files
  5. 5Buildfull production compile
  6. 6Bundle leak12 personal terms, none in the 28 files served
  7. 7Dependenciesaudit with no high-severity findings
  8. 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.