diff --git a/AGENTS.md b/AGENTS.md index 1085f3c..d3caa8d 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,6 +132,22 @@ ship twice — re-introducing one is a regression, not a style choice. not describe. → `src/domain/occurrences.test.ts`, `src/store/actions.test.ts`, `src/export/json.test.ts` +- **Every date an anchored (`lastCharge`) series steps from must be + day-granular**, at BOTH write boundaries — and that means the cost's + `startDate` AND the `Purchase.date` of every charge naming it, because an + anchored cost has no slots and its latest charge's own date is the anchor. + Checking only the `startDate` is what let a month-only charge date — the + DEFAULT for a new purchase, since `emptyDraft` seeds `date: monthId` — save + cleanly and then throw out of every money route on the next fold, with no + way to undo it from the UI. The rule itself is stated ONCE, in + `src/domain/granularity.ts` (`startDateNeedsDay`, `chargeDateNeedsDay`); + both boundaries, the purchase dialog's helpers and the Settings recurring + table all call it rather than restating it, and the 7 -> 8 + migration expands the month-only charge dates schema 7 legitimately holds so + that the migration's output satisfies the import boundary. + → `src/domain/granularity.test.ts`, `src/domain/occurrences.test.ts`, + `src/store/actions.test.ts`, `src/store/migrations.test.ts`, + `src/export/json.test.ts` - **A recurring cost's `anchoring` cannot change once it has a charge**, in `updateRecurringCost`. `calendar` -> `lastCharge` could strip the now-forbidden slot ids mechanically, but the reverse cannot: there is no diff --git a/docs/TODO.md b/docs/TODO.md index bd6efe0..66025db 100755 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -8,7 +8,7 @@ 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`. 730 tests passing, +**Current state:** merged to `main`. 758 tests passing, `bunx tsc --noEmit` clean, `bun run build` succeeds on both the live-fetch and offline paths. @@ -17,11 +17,15 @@ 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 and expands a month-only -`startDate` on an anchored `everyNMonths` cost to a day-granular one. 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. +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 @@ -31,7 +35,86 @@ IndexedDB instead of the app. --- -## 1. Visual redesign, sub-project 3 — Year, Summary, Post detail +## 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 `occurrenceDate`s — 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 @@ -71,7 +154,7 @@ than re-decide** (spec: `2026-09-02-month-view-design.md`): phone. `formatMoney` is still right wherever an amount can be in some other currency — every purchase row, for one. -## 2. Visual redesign, sub-project 4 — dialogs and fast entry +## 3. Visual redesign, sub-project 4 — dialogs and fast entry Untouched. Native `