Files
budget2.0/docs/TODO.md
T
Lucas WintherandClaude Opus 5 7f6cf4692a docs: the day-granularity guard, and two known holes in the recurring model
AGENTS.md §2 gains the guard this fix wave installed, stated as covering BOTH
an anchored cost's `startDate` and its charges' own dates — checking only the
first is what let the second through — and naming the one place the rule now
lives so a fifth copy does not appear.

Two items for docs/TODO.md, neither of them a defect this branch introduced:

- **Editing a live calendar cost re-grids it.** `updateRecurringCost` refuses
  an `anchoring` change on a charged cost but accepts `recurrence` and
  `startDate`, which for a calendar cost ARE the grid — so every charge's
  stored `occurrenceDate` can be orphaned and the paid bill counts twice.
  Reproduced and measured: a 7-day cost with its second slot settled goes from
  `expected 4000 / projected 0` to `expected 6000 / projected -2000` when `n`
  becomes 5. Silent money wrongness, and the honest fix is a product decision
  about what editing a live bill means, so the behaviour is left alone.
- **An anchored cost has no history of expectations.** Recording a charge
  erases every earlier month's expectation, so `overdue` collapses to 0 and a
  past month's `projected` moves (`remaining` does not). It follows from the
  spec, but it sits in tension with §1's "archiving must not retroactively
  remove past expected charges" and deserves saying out loud. Measured
  figures included.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-06 18:32:31 +02:00

14 KiB
Executable File

TODO

Work still queued, for whoever picks it up next, human or agent. Ordered. Each item says enough to act on without re-deriving the reasoning.

Finished work is not listed here. It is in the commit messages, and the decisions behind it are in docs/specs/ — one spec per sub-project, each recording the alternatives that were rejected. Rules that outlived their task moved to AGENTS.md.

Current state: merged to main. 758 tests passing, bunx tsc --noEmit clean, bun run build succeeds on both the live-fetch and offline paths.

Schema is at version 8. v1 -> v2 made allocation rules a dated series; v2 -> v3 moved currencies into the dataset; v3 -> v4 drops a stored rate-service URL that can no longer work; v4 -> v5 adds sterling to an existing dataset; v5 -> v6 collapses per-currency decimals into one dataset-wide setting; v6 -> v7 adds Dataset.recurring; v7 -> v8 moves a lastCharge cost off slots — it strips occurrenceDate from every anchored charge, expands a month-only startDate on an anchored everyNMonths cost to a day-granular one, and expands a month-only Purchase.date on every anchored charge (an anchored cost has no slots, so its charges' own dates are what the next due date is measured from; the day comes from the occurrenceDate being stripped when that names one in the same month, otherwise the 1st). Unlike the first six, this step is NOT behaviour-preserving by construction: an anchored cost's projected due dates genuinely change shape, because they no longer come from a grid at all. It is still tested, the same as the others.

A migration applies on load, in memory; it is written back on the next mutation, not by load() itself. So a freshly migrated dataset shows the new shape immediately while IndexedDB still holds the old version for a while. That is not a bug, but it will mislead you if you verify a migration by reading IndexedDB instead of the app.


1. Two things the recurring-cost model gets wrong, both known

Neither is a bug introduced by the anchored model. Both are recorded here because they were found while reviewing it, and both are the kind of thing a future reader would otherwise rediscover the hard way.

Editing a live calendar cost re-grids it and orphans its charges

updateRecurringCost refuses an anchoring change once a cost has charges (AGENTS.md §2 says why), but it ACCEPTS recurrence and startDate changes on the same charged cost. For a calendar cost those two fields ARE the grid, so changing either moves every slot — and every existing charge still names, in source.occurrenceDate, a slot that no longer exists. The confirmation stops matching anything, the slot it settled reappears as unpaid, and the bill is counted twice: once as the purchase that was really paid, once as an expectation that never was.

Reproduction, run and confirmed:

  1. A calendar cost, everyNDays with n: 7, 1000 kr, starting 2026-09-01, allocated 5000 kr in a 2026-09 with 20000 income.
  2. Confirm the SECOND slot — confirmOccurrence(cost, "2026-09-08"), one real purchase carrying occurrenceDate: "2026-09-08".
  3. September reads expected 4000, projected 0, remaining 4000.
  4. Change n to 5. Nothing else.
  5. September reads expected 6000, projected -2000, remaining 4000.

The paid bill now counts as both a charge and an expectation. Nothing warns.

The second slot, not the first, on purpose: the new grid (01, 06, 11, …) still contains 2026-09-01, so a confirmation of the FIRST slot survives the re-grid by luck and the damage is smaller (expected 5000, projected -1000 — still wrong, because the grid gained a slot). Any confirmed slot the new grid does not happen to contain is orphaned outright.

Do NOT patch this by silently rewriting the stored occurrenceDates — there is no honest mapping from one grid to another, which is exactly the reasoning that made an anchoring change refusable. The real question is a product one: what should editing a live bill MEAN? Plausible answers are refusing the edit the way an anchoring change is refused, ending the cost and starting a new one (what the owner is told to do today for anchoring), or giving RecurringCost a dated series the way Post.rules already has, so a rent increase or a schedule change takes effect from a month rather than retroactively. That is a design decision with a spec behind it, not a patch.

An anchored cost has no HISTORY of expectations

This follows from the spec and is not a defect, but it sits in tension with AGENTS.md §1's "archiving must not retroactively remove past expected charges", so it deserves saying out loud.

anchoredOccurrences emits occurrences from ONE anchor: the latest charge. Recording a charge therefore erases every earlier month's expectation for that cost — there is nothing left in the dataset that says a charge was once expected in July. Consequences, all by design:

  • overdue in ExpectedBand is 0 for any anchored cost that has ever been charged — which is every real one, after its first charge. It counts unconfirmed occurrences in earlier months, and an anchored cost projects none behind its anchor. (An anchored cost with NO charges yet does project into the past from its startDate, and those do count, so the figure is not structurally zero; it collapses to zero the moment a charge is recorded.)
  • A PAST month's projected figure moves when a charge is recorded today, because the expectation that month used to carry stops being projected. remaining — the real, spent-money figure — is untouched, so the historical record of what happened is intact; it is the projection that is not historical.

Measured on a monthly anchored cost of 300 kr starting 2026-06-01, in a dataset folding from 2026-06 with the current month at 2026-09. Before any charge: July reads expected 300, projected 9400, remaining 10000, and overdue is 3. After one charge dated 2026-09-04: July reads expected 0, projected 10000, remaining 10000, and overdue is 0.

A calendar cost behaves the way §1 describes: its grid is fixed, so past slots stay put whether they were ever settled or not. The two anchorings differ here on purpose (AGENTS.md §1's schedule invariant), and unifying them would reintroduce the bug the anchored model exists to fix.

2. Visual redesign, sub-project 3 — Year, Summary, Post detail

The owner's brief for the whole redesign: "right now it looks a bit boring, simple and ugly. We want sleek, modern, and nice to use." On scoping: both themes with a toggle, a full rethink of every screen, purchases compact and grouped by date, ship a webfont — and "override everything you want for this step."

Sub-projects 1 (design foundation, bb3c661) and 2 (month view, 7b71811) are done, each with a spec in docs/specs/. These three screens are on the new foundation but have not been rethought. The open question is the 12-month matrix: it only fits by scrolling, and compact number formatting may be what replaces that rather than a scroll container.

The diagnosis that shaped the decomposition, which still applies: a design system already existed and was never wired up. src/index.css defined the paper/ink/teal palette with a contract document behind it, while the app painted shadcn's default white and near-black; the tokens reached the screen in two places only. Doing the foundation last is how that happened. Do not repeat it — extend the foundation rather than styling a screen locally.

Three things sub-project 2 settled that these screens should inherit rather than re-decide (spec: 2026-09-02-month-view-design.md):

  • Meter geometry lives in src/ui/meterSegments.ts, pure and tested, with <Meter> as the only thing that paints it. A third meter belongs there too, not inline in a component. Negatives clamp rather than invert, because a segment of negative width puts the gradient's stops out of order and paints something arbitrary instead of failing.
  • Two markups, not one reflowing grid. The post table is a real <table> from sm: up and a list of two-line blocks below it. At 390px the layout genuinely stops being a table, and role="table" on it would describe rows and columns that no longer exist on screen. The year matrix faces the same question with more columns to lose.
  • State the currency once per table, not per cell. Every figure in the post table is base currency, so formatSignedAmount prints bare numbers and the legend names DKK once. This is what stopped "+4,219.61 DKK" wrapping on a phone. formatMoney is still right wherever an amount can be in some other currency — every purchase row, for one.

3. Visual redesign, sub-project 4 — dialogs and fast entry

Untouched. Native <select> elements are still browser-default, and up / down / archive in Settings are bare text buttons. Covers the purchase dialog, the split editor, the plan editor and bulk entry.

There is already a spec for fast-entry behaviour — 2026-09-02-fast-entry-refinements-design.md. Read it before restyling, so the visual work does not fight it.

4. Currency work deliberately left undone

All three were decided against at the time, not forgotten. Reopen only with a reason.

  • The income field and the split editor's fixed-amount fields do not parse a currency — only the purchase total does. Same helper (parseMoneyInput), same three-line pattern. The income field is arguably wrong to make multi-currency at all, since a month has one income.
  • No currency picker convenience. Adding a currency means typing the code, name, symbol and decimals by hand. A bundled ISO 4217 list would be nicer, and is pure data.
  • Symbol collisions resolve to the first currency defined with that symbol. Fine for one "$" currency; if the owner adds two, typing the code disambiguates and nothing warns them. Tested, documented, not solved.

5. Nobody has independently reviewed the rule-history editing surfaces

Tasks 3-6 of the versioned-allocation-rules work — setRuleFrom / removeRuleFrom, the JSON import validation, the Settings rule-history editor, and the month view's "change from here" — were executed by hand rather than dispatched, and no second pair of eyes has been over the diff.

Worth reading first: the four commit messages, de128e3, 1cb91d3, e3023d0 and f94e458. They record why each surface works the way it does, including three of the four places the original briefs were wrong — a number field that could not be cleared, a component that kept the previous post's rule because it was not keyed, and missing validation that let a NaN allocation into the fold.

(The fuller task log lived in a tool-managed ledger under .superpowers/, which ignores its own contents and was never part of the repository. This file used to send readers there, which worked only on the machine that wrote it.)

Both surfaces have since been seen working in a browser. What is missing is review, not verification.

6. Still needs a human

scripts/screenshot.ts drives headless Chrome, so this list is much shorter than it was. Already seen and confirmed working: the carry meter (including red where a post is overspent), the rule-history editor, "change from here", the baked FX rates arriving in a fresh dataset, the reset writing a fresh dataset to IndexedDB, and every route at desktop and mobile widths in both themes.

Genuinely outstanding:

  • How it looks and feels. A screenshot proves a layout is not broken. It cannot tell you the design is good, and the container's fonts are not the owner's fonts.
  • Offline behaviour and the install prompt. The service worker itself is no longer unverified: it demonstrably installs, activates, claims the page, precaches all 11 assets, and applies an update cleanly (e6da85e). What is still unverified is a real offline load with the network actually cut, and Chrome's install prompt appearing.
  • The pre-destructive backup actually landing as a file. Import and reset both download a JSON backup before replacing the dataset, and only proceed when that reports success — but headless Chrome's download is not observable here, so the backup is issued, never confirmed openable.
  • The generated .ods opening in a real spreadsheet. Validated structurally: mimetype first and stored, well-formed XML, numeric cells.
  • Keyboard flow in fast entry — Tab traversal, Backspace-to-remove, and focus placement after a row auto-appends. Drivable over CDP in principle; not yet done.
  • GitHub Actions has never been executed.
  • Whether two-stage confirmation matches how the owner actually reconciles a bank statement. Recurring costs (docs/specs/2026-09-03-recurring-costs-design.md) project expected occurrences and require a click to turn one into a real purchase, rather than charging automatically. That is a product bet a week of real use answers and no test can.
  • Whether the anchored (lastCharge) model actually matches how the owner's phone plan bills. The design (docs/specs/2026-09-05-recurring-costs-anchored-model.md) rebases the series on whatever was last charged, restarting the clock every time — a reasonable model of "refreshes when the data runs out", built from the owner's own description rather than from watching a real bill arrive. Only a cycle or two of real use confirms the shape is right.
  • The overdue count against a large, real backlog. ExpectedBand counts unconfirmed occurrences from earlier months, and demo data does now accumulate a modest one (a few months of an unconfirmed monthly bill plus a biweekly one, ~14 occurrences by September) that renders without incident. Nobody has seen the count or the layout against years of neglect or dozens of distinct bills.