Commit Graph
7 Commits
Author SHA1 Message Date
Lucas Winther c2b3d00381 docs(specs): the anchored model for recurring costs
The first version modelled the phone bill as "confirm a projected slot early",
and that was wrong at the root. A charge is not a slot claimed ahead of
schedule — it is an event that can happen at any moment, any number of times
per cycle, each one restarting the clock. There is no grid to claim; there is a
moving anchor.

Confirming twice in one September pushed the next due date to November, because
each confirmation consumed a future slot instead of resetting the anchor.
Reproduced under BOTH anchorings, which is what proves the defect is in the
slot model rather than in the rebase rule.

So the two anchorings get two projections, and the asymmetry is the design.
Paying September AND October's rent really does mean nothing is due until
November — for a calendar bill, consuming two slots is correct. Only the
anchored case is wrong, so only the anchored case changes.

The anchored walk cannot fail to advance: it steps forward from a real date
with n >= 1. That makes the entire class of "confirmation dated behind its
slot" defects unreachable — the class that produced three Criticals in round 1
— so wouldAdvancePast, the band's offerability guard and PATH A's
updatePurchase guard are all deleted. This change removes more code than it
adds.

everyNMonths becomes day-granular under lastCharge, since a plan refreshing on
20 September is next due 20 October, not "October". That finally forces a
month-clamp rule (31 Jan + 1 month is 28 Feb), confined to the anchored path.

Also closes the two additive requests: a cost's splits become editable through
the same SplitEditor purchases use, and the purchase dialog can tag a purchase
as a charge for a cost or create one from the purchase being typed. Tagging is
the refresh mechanism, so the data cap stops needing a flow of its own.

Self-review caught the band's Confirm being removed for anchored costs, which
would have made paying a bill on time slower to serve the exceptional case.
2026-09-05 00:38:10 +02:00
Lucas Winther 92e6b763f2 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.
2026-09-04 09:15:28 +02:00
Lucas Winther 2096e11a85 docs(specs): recurring costs take 7, not 6
The decimals change landed first (8d2b2ca) and took schema 6, so this spec's
migration becomes 6 -> 7 and its undo path 7 -> 8. Both specs were drafted
claiming 6; only the number had to move, because the steps were always
independent — that one changed where digits live, this one adds an array.

Three places the same commit made stale rather than merely renumbered:

- The shared split helper is smaller than designed. chargesForPurchaseInMonth
  already takes digits: number rather than a currency table, so it needs no
  currency plumbing.
- Display is now a rounding boundary. The Expected band gets its formatters
  from useMoneyFormat(), not format.ts, and its amount field parses through
  parseMoneyInput as a text input.
- currencyDigits.test.ts no longer drives one zero-decimal CURRENCY; it drives
  the whole dataset at zero decimals. The expected track joins that sweep.
2026-09-03 03:29:26 +02:00
Lucas WintherandClaude Opus 5 8d2b2ca881 feat(money): one decimals setting for the dataset, not one per currency
`Settings.digits` replaces `CurrencyDef.digits`. Every amount rounds to it,
whatever its currency, and `digitsFor` is gone: the sixteen call sites read the
setting, and the domain functions that took a currency table purely to look
digits up take a `digits: number` instead. Schema 6.

The owner asked for this with the cost in front of them, and the cost is real:
a dataset mixing minor units cannot be described any more. Yen at 0 beside
kroner at 2 has no correct global value — 2 gives ¥1234.56, an amount yen
cannot represent; 0 rounds 49.95 DKK to 50 and drifts the carry from the bank.
Nothing in the app warns. docs/specs/2026-09-02-global-decimals-design.md
argues it in full, including what re-adding a per-currency override would take,
and AGENTS.md §1 now says not to reintroduce one without revisiting the
decision.

The 5 -> 6 step takes the MAXIMUM of the per-currency digits, not the base
currency's. A dataset holding a three-decimal currency already has
three-decimal amounts in it, and adopting the base's 2 would truncate the next
edit of one; the maximum can only keep more precision, never less. On every
dataset this app has written all four currencies record 2, so nothing rounds
differently than before.

Changing the setting does not rewrite stored amounts. 49.95 stays 49.95 and
shows as 50; totals recompute at the new precision. Rewriting each Money is a
destructive whole-dataset write, which §1 owes a backup export first, and it is
lossy one way.

Display turned out to be the hole in the plan. src/ui/format.ts pinned
minimumFractionDigits: 2 in a module-level Intl.NumberFormat, so it never read
the dataset at all — equally broken under per-currency digits, where a JPY
rendered "3,333.00", so a pre-existing gap rather than a regression. Shipping a
Settings control that display ignores would look broken at 0 and be lossy at 3,
hiding precision the dataset holds. So format.ts takes digits like roundMoney
does, useMoneyFormat() binds them for the 64 call sites, and postRowCells takes
the formatter as an argument because it is a plain helper, not a component.

currencyDigits.test.ts is rewritten rather than deleted. Driving a dataset at
ZERO decimals through rounding, both FX directions, both split modes, plan
division, the fold and the month view still tells a real implementation from
one assuming 2 — it just cannot prove per-currency flow, because there is none.
It also asserts that raising the setting to 2 changes what the fold produces.

Verified in Chrome, not only in tests: a v5 dataset with digits 2/0/3 written
straight into IndexedDB migrates to a setting of 3, the table loses its
Decimals column, and editing the control persists schema 6 with digits stripped
from each currency and income left at 20000.55. The same month reads 600/206 at
zero decimals and 600.000/205.561 at three, so the figures recompute rather
than merely reformat.

Not verified here: the .ods in a real spreadsheet.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-03 03:12:14 +02:00
Lucas WintherandClaude Opus 5 596de45525 docs(specs): recurring costs, expected against actual
Bills that repeat: rent monthly, a subscription every 28 days, a phone bill
every 30 days whose clock restarts when the data cap is hit early.

A recurring cost projects expected charges forward; each occurrence is
confirmed into a real Purchase when the money moves. The fold gains a second
balance, so `remaining` never shifts and `projected` says what is left after
commitments. The two reconverge as a month settles.

The phone bill reads like a third recurrence kind and is not one. It charges
30 days after the PREVIOUS CHARGE, and the data cap only makes a charge happen
early — a date two-stage confirmation already records. So `anchoring` is one
field, `calendar` or `lastCharge`, and the domain never learns what a data cap
is. That collapse is most of why this design is small.

Two consequences worth not re-litigating later:

- `everyNMonths` carries no day. Rent occurs IN September, and `IsoDate`
  already permits a bare "YYYY-MM", so the commonest case needs no day
  arithmetic and no February clamping rule.
- `amount` is a single mutable Money with no RuleVersion-style series. Safe
  ONLY because confirmed purchases hold what was actually paid; under
  projection-only it would destroy history.

Occurrences are computed, never stored — materialised snapshots were rejected
in the original design, and an unbounded recurrence would otherwise need a
horizon. `upToMonth` comes from the caller, so src/domain/ stays clock-free.

Takes schema 6. The global-decimals spec claimed 6 too and is reassigned to 7;
both specs now say so. The steps are independent and compose in either order.

Spec only. The two settings-layout fixes in the tree are unrelated and stay
unstaged.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-03 02:50:05 +02:00
Lucas WintherandClaude Opus 5 824c2f5f59 docs: make AGENTS.md the only agent guide, restructured into five sections
AGENTS.md now holds the instructions and CLAUDE.md and GEMINI.md are
pointers to it. Three files of instructions become three files that
disagree; one file every agent reads cannot.

The content is reorganised rather than rewritten, into: the invariants,
the guards that fail the build, the validation this codebase deliberately
does not perform, the environment, and where to read next. Every guard
now names the test that catches it, and the two rules with no test behind
them are called out as such, because "only review catches this" is
information the reader needs.

Restructuring surfaced one rule that was missing here entirely — sw.js
must stay unhashed and be served no-cache — which had only ever been
written down in ARCHITECTURE.md.

Dropped: the `bun init` boilerplate code samples. Every rule they carried
is still stated; the hello-world test and the three frontend snippets are
not.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-02 21:57:38 +02:00
Lucas WintherandClaude Opus 5 d83edccc79 docs: move the long-form docs into docs/, without the superpowers nesting
ARCHITECTURE, PRODUCT and TODO move out of the repository root, and
docs/superpowers/{specs,plans} flattens to docs/{specs,plans}:
"superpowers" names the tool that wrote those documents, not a kind of
document.

No content changes. This commit is the move, plus the roughly thirty
cross-references the move invalidated.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-02 21:56:59 +02:00