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]>
This commit is contained in:
Lucas Winther
2026-09-06 18:32:31 +02:00
co-authored by Claude Opus 5
parent 0095507522
commit 7f6cf4692a
2 changed files with 110 additions and 11 deletions
+16
View File
@@ -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
+94 -11
View File
@@ -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 `<select>` elements are still browser-default, and
`up` / `down` / `archive` in Settings are bare text buttons. Covers the purchase
@@ -81,7 +164,7 @@ 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.
## 3. Currency work deliberately left undone
## 4. Currency work deliberately left undone
All three were decided against at the time, not forgotten. Reopen only with a
reason.
@@ -97,7 +180,7 @@ reason.
Fine for one "$" currency; if the owner adds two, typing the code
disambiguates and nothing warns them. Tested, documented, not solved.
## 4. Nobody has independently reviewed the rule-history editing surfaces
## 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,
@@ -117,7 +200,7 @@ 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.
## 5. Still needs a human
## 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