docs(recurring): record the coherence rule, and catch up §10-11 to what shipped

- AGENTS.md §1: reworded the "no horizon" invariant now that a parameter
  literally named horizonMonths exists. Nothing is stored — ExpectedBand's
  horizon only widens how far one read-only walk looks before the result is
  thrown away — but the old wording read as a contradiction to the next
  reader. The no-materialisation rule itself is unchanged.
- DECISIONS.md: added the "archived mirrors endedFrom" entry that was
  missing, including the previously-undocumented corollary that a
  future-dated cancellation dims the row immediately even though the bill
  keeps projecting until endedFrom.
- docs/specs/2026-09-03-recurring-costs-design.md §10-11: updated to
  describe the shipped UI — the "Coming up" group, the Ends column, the two
  coherence checks that gate a coming-up row (X1, X3), and the tests that
  now cover all of it.
This commit is contained in:
Lucas Winther committed 2026-09-04 09:15:28 +02:00
1 parent e3a1fb366b
commit 92e6b763f2
3 files changed
+82 -12

No files matched your search

+7 -3
View File
@@ -49,9 +49,13 @@ and `docs/specs/`, and is not repeated here.
objects.
- **A recurring cost's occurrences are COMPUTED, never stored.** `Dataset`
holds the rule and the confirmations; `occurrencesByMonth` derives the rest
on every fold. There is no horizon and no "generate ahead" step, and
`upToMonth` comes from the caller so `src/domain/` stays clock-free. Storing
them would reintroduce the materialised snapshot the original design rejected.
on every fold, and `upToMonth` comes from the caller so `src/domain/` stays
clock-free. Nothing is ever written ahead of time — `ExpectedBand`'s
`horizonMonths` only widens how far a single read-only walk looks before
throwing its result away; it is a DISPLAY bound on one function call, not a
"generate ahead" step, and nothing it produces is persisted. Storing
occurrences would reintroduce the materialised snapshot the original design
rejected.
- **On a confirmation, `Purchase.date` is TRUTH and `source.occurrenceDate` is
IDENTITY.** The slot is what a confirmation claims, so the projector stops
emitting it; the date is when the money moved, so the fold counts it there.
+11
View File
@@ -105,6 +105,17 @@ record.
happened. Under projection-only this would not have held, and a dated series
would have been required.
→ [`specs/2026-09-03-recurring-costs-design.md`](specs/2026-09-03-recurring-costs-design.md)
- **`archived` MIRRORS "`endedFrom` is set"**, maintained in one place
(`setRecurringCostEndedFrom`) rather than two independent fields a caller
could desynchronise. `endedFrom` stops the projection; `archived` only dims
the row, the same way an archived post stays listed rather than
disappearing. Neither touches a past occurrence. One consequence is visible
and undocumented until now: dating a cancellation in the future sets
`archived` immediately, so a bill still live for months still renders
dimmed with a "restart" button while it keeps projecting rows right up to
`endedFrom`. Defensible under the rule as chosen, not a bug.
→ [`specs/2026-09-03-recurring-costs-design.md`](specs/2026-09-03-recurring-costs-design.md),
`src/store/actions.ts`
## Design system
@@ -305,18 +305,62 @@ reference the cost.
**`RecurringSection` in Settings**, alongside `PostsSection` — the same CRUD,
reorder and archive idiom, because a recurring cost is defined once and then
left alone. Definition is not the repeated act.
left alone. Definition is not the repeated act. An **"Ends" column** lets the
owner type a cancellation date directly, doing exactly what the "end"/"restart"
button does; both go through `setRecurringCostEndedFrom`, the one place that
keeps `endedFrom` and `archived` paired (§9, and `DECISIONS.md`). Dating a
cancellation in the future dims the row immediately even though it keeps
projecting rows until `endedFrom` — a visible consequence of that pairing,
recorded in `DECISIONS.md` rather than treated as a bug. `updateRecurringCost`
does not accept `archived` or `endedFrom` at all, so a caller cannot split the
pair even by mistake.
**An "Expected" band in the month view**, which is where the repeated act
happens. Each unconfirmed occurrence for the month is one row with a one-click
confirm that writes the purchase at the expected date and amount. The amount is
editable inline before confirming, because paying a different figure is the
common case rather than the exception; anything more unusual is edited
afterwards like any other purchase. Entry speed is a product principle, and a
dialog per bill would defeat it.
happens, in two groups:
**The overdue count** — unconfirmed occurrences from earlier months — appears in
that band. §5 is otherwise silent drift.
- **This month's unconfirmed occurrences** (`pending`). Each is one row with a
one-click confirm that writes the purchase at the expected date and amount.
The amount is editable inline before confirming, because paying a different
figure is the common case rather than the exception; anything more unusual is
edited afterwards like any other purchase. Entry speed is a product
principle, and a dialog per bill would defeat it. Confirming here records
the slot's own date — the projection assumes bills are paid on time, and a
`lastCharge` series only rebases when the recorded date says otherwise.
- **"Coming up"**: at most one row per cost, its next unconfirmed occurrence
strictly after the displayed month, out to a bounded `horizonMonths` (twelve
by default — a read-ahead bound on one function call, nothing stored; see
AGENTS.md §1). Confirming a coming-up row records TODAY, not the future
slot — paying a `lastCharge` bill early, before its projected date arrives,
is exactly what rebases the series, and recording anything else would defeat
the point. A this-month row's subtitle is just its date; a coming-up row's
says "Due `<slot>` — confirming now records it `<today>`" so the early date
is never a surprise. `recordedDateFor(group, today)` is the one function that
decides which date a click records — extracted specifically so that decision
has a test that fails if its two branches are swapped, since nothing else in
the suite would catch it.
A coming-up row is only ever OFFERED when confirming it would leave the
series coherent, checked before the purchase is ever written rather than
disabled-with-an-explanation after the fact:
- **Never for a cost with an unconfirmed occurrence in the displayed month or
any earlier foldable month.** Settle the earlier bill first; offering a
later one while an earlier sits outstanding would let confirming the later
one leave the earlier projection standing, silently double-counting the
bill.
- **Never for a pairing `occurrencesOf`'s walk could not survive.** A
`lastCharge` bill recorded today for a slot far enough in the future can
rebase the series BACKWARDS — the walk's own "did not advance" throw, which
is correct and stays a throw (AGENTS.md §2). `wouldAdvancePast` (in
`domain/occurrences.ts`, built on the same `stepFrom` the walk uses)
answers this without re-deriving the rule.
Both checks omit the row outright rather than rendering it disabled: nobody
asked for an explanation of why they can't pay early yet.
**The overdue count** — unconfirmed occurrences from earlier, foldable
months — appears in that band too, and shares its foldability bound with the
coherence check above.
Three existing rules apply directly to the inline amount field: capture
`event.target.value` into a `const` before `mutate` (AGENTS.md §2, the
@@ -337,12 +381,23 @@ fixed. The field itself stays `type="text"` and parses through
- **`domain/fold.test.ts`** — the two convergence invariants from §6.
- **`store/migrations.test.ts`** — 6 -> 7 adds the empty array and moves no
figure.
- **`store/actions.test.ts`** — `updateRecurringCost` rejects a bad recurrence
or a granularity mismatch, and (its type alone) cannot touch
`archived`/`endedFrom`; `setRecurringCostEndedFrom` keeps the pair coherent
through the button and the "Ends" column alike.
- **`export/json.test.ts`** — acceptance and rejection, including `n: 0`,
`n: 1.5`, a `weekday` of 7, and an unknown `kind`.
- **`domain/currencyDigits.test.ts`** — the file now drives the whole dataset
at zero decimals and asserts that raising the setting to 2 changes what the
fold produces. The `expected` track joins that sweep, so it cannot be the one
path that quietly assumes 2.
- **`ui/components/ExpectedBand.test.ts`** — `resolveExpectedAmount`;
`recordedDateFor`, directly, so the this-month/coming-up date decision has a
test that fails if its two branches are swapped; `expectedGroups`'s horizon
bound and one-row-per-cost dedup; and the coherence rules above, including a
regression built from the exact reported shape (a 30-day `lastCharge` cost
confirmed today for a slot 39 days out) asserting that confirming everything
`expectedGroups` offers and then folding never throws.
`scripts/demo-data.ts` seeds a monthly rent and a 28-day subscription, so the
band is screenshot-verifiable.