Part 13 shipped budgets the cheap way: one budget field on the shopping list. "This shop: £30." It worked, and it was quietly wrong — because nobody budgets per list. People budget per month, and a month contains several shops, a cleared list, maybe a receipt from a store you never made a list for.
The wrongness became unmissable after Part 18's purchase ledger made clearing the Done section a recommended habit: clear your list and the Expenses tab's headline — computed from the list — reset to zero mid-month. Your month didn't reset. The tab was measuring the wrong object.
TL;DR — Budgets moved off lists onto a
monthlyBudgetsmap on the user profile:"YYYY-MM" → amount, explicit entries only. The month in force resolves by carry-forward from the most recent earlier entry, never a later one — which is precisely what makes correcting a past month unable to rewrite the present. The headline now sums the purchase ledger (checkedAt), so Clear Done can't zero your month; receipt lines that never touched a list are a separate caption, never the headline; a year totals only the months that have started; and the old trip budget survives, demoted to a "This shop" card.
(Part 19 of Building CannyCart, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)
The data model: a map, not a number
A single monthlyBudget number on the profile would have the same disease as the list budget — no memory. You couldn't correct last month, and a year total would be a lie (monthly × 12 assumes the budget never changed). So the field is a map, stored as one a.json() field on UserProfile:
/** "YYYY-MM" → budget for that month. Explicit entries only. */
export type BudgetMap = Record<string, number>;
Two familiar conventions ride along. The parse tolerates string-or-already-parsed (the a.json() lesson from Part 14 that once cost a whole session of silent failures — the unwrap even loops, in case a string contains a string). And the map is cached in AsyncStorage so the budget bar is right on the very first frame, before the profile query resolves — the exact pattern Part 17 used so currency never flashes wrong. Money UI doesn't get to flicker.
The carry-forward rule (the heart of the post)
A budget map raises the question that determines whether the feature is trustworthy: what's the budget for a month with no entry? The resolver's docstring is the design document:
/**
* The budget in force for a month: its own entry, else the most recent entry
* BEFORE it — never after. A budget is a standing commitment that applies until
* you change it, so it carries forward; months before the first entry
* legitimately have none, and the UI offers to set one.
*
* Because of that direction, correcting a past month can't move the present:
* the first budget you ever set is for the month you're standing in, so it's an
* explicit entry every later month carries from.
*/
export function budgetForMonth(map: BudgetMap, key: string): number | null {
if (map[key] != null) return map[key];
let best: string | null = null;
for (const k of Object.keys(map)) {
if (k < key && (best === null || k > best)) best = k;
}
return best === null ? null : map[best];
}
Three properties fall out of that one direction choice:
- A budget behaves like a standing order. Set £200 in March and July is still £200 unless you say otherwise — which matches how people actually think about budgets.
- Editing the past is safe. Go back and set January to £150 (you're allowed — maybe January really was different) and nothing after your first-ever entry moves, because the month you first set a budget in got an explicit entry, and every later month carries forward from that or something newer. Backfill can never leak forward.
-
Months before your first entry honestly have no budget.
null, not zero, not inherited-from-the-future — and the UI offers to set one rather than inventing one.
There's even a tiny load-bearing detail underneath: "YYYY-MM" sorts lexicographically in calendar order, which is why plain string comparison (k < key) is the date math. The key format was chosen for exactly this.
A companion helper, hasOwnBudget, distinguishes "this month's own entry" from "inherited" — purely to drive honest copy: the bar says when a budget is carried forward rather than pretending you set it.
The headline rides the ledger
The other half of the rework: what the budget bar measures. The old selector summed the active list's items — the thing Clear Done deletes. The new spine is the purchase ledger, and the hook's comment carries the reasoning:
/**
* Everything spent in one week / month / year, independent of any list.
*
* The spine is the purchase ledger (ShoppingItem.checkedAt), which survives
* Clear Done — that is the whole reason the Expenses tab can stop resetting to
* zero when a list is cleared.
*
* Receipt lines that never became list items are summed SEPARATELY and reported
* as a caption, never folded into the headline: they never passed through a list
* or a budget, so a budget bar must not measure them. No double counting —
* a line that matched a list item, or that was added to the list, already exists
* as a bought item with a checkedAt and is in the headline.
*/
That second paragraph is the subtle one. A receipt can contain lines that never touched your list (you turned off "add unmatched items", or logged a receipt for a shop you never listed). Those amounts are real spending — but they never passed through the list-and-budget loop, so folding them into the bar would make the budget measure things it never governed. They show as a caption under the headline: counted out loud, never blended — the same honesty rule as Part 16's foreign-receipt exclusions. And because a matched or added line already exists as a bought item, the split is also what makes double-counting structurally impossible.
The period rules
The tab got a Week | Month | Year picker (shared machinery with Part 18's history screen — separate persisted preferences, because History defaults to week and Expenses to month). Each granularity gets its own honest budget behavior:
- Month is the native unit: full bar, editable — including for past months.
- Week doesn't get its own budget (nobody re-budgets weekly); it names the containing month's budget for context.
-
Year totals only the months that have started. Never
monthly × 12— in March, your year-to-date budget is three months' worth, resolved per month through the carry-forward rule, so a mid-year budget change is reflected exactly.
Demotions and repairs
Two consequences worth their own lines:
The trip budget survives, demoted. The per-list budget from Part 13 became a "This shop" card — same selector as before, now scoped to what it actually measures: the current trip against its own optional budget. Nothing was deleted; the MVP feature just found its correct size.
The savings screen got un-blanked. Part 16's savings originally iterated list items — so once Clear Done became a habit, clearing blanked the savings screen, and the copy claimed nothing was cheaper: a statement about a comparison that never ran. It now falls back from "your list" to "what you buy regularly" (recent purchases from the ledger, same two-distinct-prices gate), so the one coral screen can't be emptied by good hygiene. A recurring theme of this rework: Part 18 changed user behavior, and every selector that assumed the old behavior had to be found and re-grounded.
What I took away
- Budget the period, not the container. Lists are trips; money is monthly. The MVP measured the container because the container was there.
- Carry-forward must point backwards. "Most recent earlier entry" is what makes a budget a standing commitment and makes editing history safe. Any rule that can read a later entry lets the past rewrite the present.
-
Pick key formats that sort.
"YYYY-MM"made string comparison the entire date engine. - A headline should measure what survives. Summing ephemeral state (a clearable list) put a reset button on a monthly number. The ledger is durable; the bar now is too.
- When you change a habit, audit every selector that assumed the old one. Clear-Done-as-rhythm broke two screens that silently depended on hoarding.
Next up
Voice was the first way into the app; the camera pointed at receipts was the second. Part 20 closes the input triangle: photographing the handwritten list on your fridge — and why it reuses the voice review sheet on purpose.
Where does your app store a number on the wrong object because that object was conveniently nearby? Mine was a monthly budget living on a shopping list.
Top comments (0)