DEV Community

Ibukun Demehin
Ibukun Demehin

Posted on

Budgets live on months, not lists: the period-first Expenses rework

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 monthlyBudgets map 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>;
Enter fullscreen mode Exit fullscreen mode

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];
}
Enter fullscreen mode Exit fullscreen mode

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.
 */
Enter fullscreen mode Exit fullscreen mode

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)