This site uses one functional cookie to keep feature rollouts consistent for you. Nothing is set until you choose. See the privacy notice.
Dev notes
Every budgeting app I have tried dies the same way: logging a coffee takes a form, and after a week I stop bothering. So for /budget I decided the add flow was the product, and everything else — the analytics, the sharing — had to fall out of that without getting in its way.
Adding an expense is a bottom sheet that springs up and asks three questions in order: which category, how much, and — only if you care — when and how to tag it. The category step is a grid of big tap targets, the amount step focuses the field the moment it appears, and the date and time default to now. The common case is a category tap, a number, and done. The third step exists so that logging last night's taxi this morning is still possible, not so that every add has to walk through it.
Everything internal is integer cents, so no stored amount is ever a float. The one place that matters is the edge where a typed “12.34” becomes cents, and my first version got it wrong: 1.005 * 100 is 100.4999… in JavaScript, so it rounded a penny down. The fix was to stop multiplying and append e2 to the string instead — Number("1.005e2") parses to exactly 100.5, which rounds the way a person expects. A test pins that case so I do not undo it later.
Last-30-days, the current billing cycle, and the by-category and by-person splits are all pure functions in lib/budget/analytics.ts. None of them reach for the clock — the page passes now in — so the same function serves the live page and a fixed-date test, and “what does this month look like on the 3rd of next month” is a unit test rather than a thing I wait a month to see. The cycle boundary is computed in UTC so it lines up with the stored timestamps no matter what timezone the browser is in.
The 30-day and billing-cycle totals answer “where am I now”; the history answers “am I trending up”. The same expenses roll up by week, month, or year into a small bar chart, and the current period is compared to the one before it — this month against last, and the same for weeks and years. It is all pure bucketing over the injected clock, so “what does September look like standing in October” is a unit test, not a wait.
The budget lives in the browser, so every level of sharing had to mean something true. The invite link carries the budget itself — base64url-encoded into the query string — and opening it loads that budget and adds you as a person. Making a budget public records the account email others use to ask to join, and the owner sees each request and approves or denies it. What none of it does yet is deliver a request across accounts on its own — that needs the backend, and the page says so instead of implying a shared ledger that updates itself.
Which is why the persistence sits behind pure reducers that take their Storage as an argument, exactly like the updates ticket board. Swapping the browser for a real API later is a change in one file, and the invite link and the join request both become real rather than local stand-ins.
Update — September 14, 2026
The tracker could add fast but not fix a mistake, and it pinned a shared dinner on whoever happened to log it. Both are fixed now: any item opens in an edit sheet — category, amount, when, tags, or delete — and an expense can be split across people.
A £10 dinner three ways isn't 333 + 333 + 333 — that loses a penny. The even split hands the leftover cents to the earliest people one at a time, so the parts always sum back to the whole. It is a pure function with a test that pins exactly that.
const base = Math.floor(amountCents / n);
let remainder = amountCents - base * n;
return ids.map((personId) => {
const extra = remainder > 0 ? 1 : 0;
remainder -= extra;
return { personId, amountCents: base + extra };
});Splitting added an optional splits list to an expense. Every item logged before it simply doesn't have one, and the person breakdown reads a missing split as “the whole amount belongs to its one owner” — the old meaning, unchanged — so nothing had to migrate.
Update — September 15, 2026
Every version so far said the same thing: sharing is a local stand-in until the backend lands. It landed. A signed-in budget now lives in Postgres behind a new /api/budgets domain, and the join request that used to sit in one browser actually pulls another account onto the budget.
The reward for keeping the reducers behind an injected store was that the UI didn't care where the data came from. The presentational pieces — the add sheet, the analytics, the history, the sharing panel — already took a budget and some callbacks. A signed-in view wires them to a react-query hook over the BFF; a signed-out view wires the same components to the localStorage store. The switch is one query against /api/me.
Discovery by email is the sharp edge: “is there a budget for this address” is exactly what you don't want to leak. So a private budget and a budget that doesn't exist answer the same 404, and discovery resolves against the verified, namespaced email claim rather than anything the caller can type. Owner-or-member access is checked on every mutation before the row is touched.
// same answer whether it's private or absent
if (!budget) throw new NotFoundError('No public budget found for that email');