Allow recurring costs and confirmations to remain unassigned when created from a purchase without a post. Add browser backup retention and restore controls, with coverage and documentation updates.
Main moved on by 27 commits since this branch forked, including three schema
steps of its own (budget folders, placement, nested bills). Resolutions worth
knowing:
- The anchored-model migration becomes 10 -> 11; main's 7 -> 10 run first.
- Main's nextUnconfirmedOccurrenceOnOrAfter walked the calendar grid for
every cost, which would have shown the slot-model "next due" for an
anchored bill in the budget tree. Both walks are now generators and the
lookup dispatches on anchoring, like occurrencesOf.
- A folder's "Bills due" summed only occurrences, and an anchored bill's
charges are never occurrences, so a charged phone plan read as 0 due.
budgetTree now adds an anchored bill's charges in the month.
- Main's RecurringSection (with RecurringSplitEditor) supersedes this
branch's split editor; the anchoring rules are ported onto it: start-date
validation via startDateNeedsDay, month precision hidden for lastCharge,
the anchoring control locked once a cost has charges.
- PurchaseDialog keeps main's placement control. The "Recurring cost" select
replaces main's "Unlink from bill" checkbox, since "None" does the same.
A bill created from the dialog nests under its post like one from Settings.
- Dropped main's "bounded projection ignores invalid confirmations" test: it
exercised slot confirmations on a lastCharge cost, which no longer exist.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
updatePurchase resolved the pair it validates with `??`, which cannot tell
"no source key" from "source: undefined, clear the association". Untagging an
anchored charge while dropping it to a month-only date was refused, citing the
cost being removed. Resolve by key presence instead.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
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]>
Both call sites passed the literal "calendar", and the second is already
guarded by `occurrence.confirmable` — which IS `cost.anchoring === "calendar"`.
So the `: null` arm was unreachable and the same decision was expressed twice.
Whether an anchored row gets a Confirm button at all is decided once, in
`ComingUpRow.confirmable`; this function only answers which date a click
records.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
`offerableCosts` offered every non-archived anchored cost whatever the date, so
the default new-purchase flow — month-only, since `emptyDraft` seeds
`date: monthId` — offered the owner a choice that now fails at the boundary.
An anchored cost needs a day, the same way `newCostOptionIsSelectable` already
gates the new-cost pairings. A calendar cost is unaffected: its charge names
the slot it settles and its own date is never stepped from.
An anchored cost stays SELECTED when the date drops to month-only, because a
selection is never taken away out from under the owner — so the dialog says
what is wrong there rather than letting Save be the first place it comes up.
Two more in the same file and its neighbour:
- A recurring cost that has vanished since the dialog opened (deleted from
Settings in another tab) made the purchase save with no association and no
message, leaving the bill still showing as expected. It is refused now.
- The comment above `save()` described `requireConfirmationAdvances`, deleted
in this branch. A write can still be refused; only the stated reason was
stale.
`normalizedStartDateFor` and `isValidStartDate` both asked their own copy of
the granularity rule, and the first had `anchoring` optional — which the unit
handler omitted, silently answering "calendar" for a cost anchored to its last
charge. Both call the domain predicate now, with anchoring required.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Every guard this model shipped with checked the COST's `startDate`
granularity. Nobody checked the CHARGE's. An anchored (`lastCharge`) cost has
no slots, so `anchoredOccurrences` takes the latest charge's own
`Purchase.date` as its anchor and steps forward from it — and `Purchase.date`'s
documented shape allows "YYYY-MM". Both write paths accepted that and the next
fold took every money route down, with no way to recover from the UI: the
anchoring select is disabled on a charged cost, there is no delete action, and
the walk's precheck sits before the `endedFrom` break, so ending the cost does
not help either.
It was reachable by default, not by accident: `emptyDraft` seeds
`date: monthId`, so a new purchase starts month-only, and the dialog offered
every anchored cost regardless. `updatePurchase` accepted the same shape when
"use the month only" truncated the date of an existing anchored charge.
`requireSourcePairing` now takes the RESOLVED date, so an update touching only
the date is checked against the source already stored. Both boundaries also
route a day-shaped value through the domain's own parser, the way each already
does for `startDate`: "2026-02-30" is day-granular and calendar-impossible, and
`PURCHASE_DATE` lets it through, so it would otherwise import clean and throw
from the same frame.
Both files now ask `startDateNeedsDay`/`chargeDateNeedsDay` rather than
restating the rule, and `parseDatasetJson`'s doc no longer claims more than it
checks.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The step stripped `occurrenceDate` from an anchored cost's charges and left
`Purchase.date` alone. Under schema 7 those dates are legitimately month-only:
a monthly cost's grid was month-granular, and `confirmOccurrence` wrote
`date: overrides.date ?? occurrenceDate`, so every one-click confirmation of a
month-granular anchored bill was stored without a day. An anchored cost has no
slots, so after the upgrade that date IS the anchor the series steps from — a
realistic schema-7 dataset therefore migrated cleanly and then threw out of
every money route on first load. The owner has a phone bill; this is their
data, not a hand-edited file.
The day comes from the `occurrenceDate` about to be stripped when that slot
falls in the same month, because it is the day the owner actually settled;
otherwise the 1st, the same convention the `startDate` expansion already uses.
A slot in a DIFFERENT month is not borrowed from — that would move the
purchase, and moving money is what this step must not do.
Extending an unshipped step rather than appending a new one: 7 -> 8 exists only
on this branch, so no stored dataset has been through it.
The regression test builds a schema-7 dataset the app itself would have
written and folds the result, rather than only inspecting the shape. A second
test pins the agreement the two halves of this fix have to keep:
`parseDatasetJson` migrates before it validates, so everything this step
produces must satisfy the import boundary the next commit tightens.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The precheck in `anchoredOccurrences` covered `everyNMonths` only, so a
month-only starting point on an `everyNDays` or `everyNWeeks` anchored cost
fell straight through to `addDays` and threw a bare "Not a day-granular
IsoDate", naming no cost, several frames below `foldBalances` — the exact
failure the precheck exists to prevent, for two thirds of the costs that can
have it. The walk is day-precise under all three kinds, so the check is too.
The rule about WHICH dates need a day was written out separately in four
places (both write boundaries and both controls that offer the pairings). It
now lives once, in `granularity.ts`, alongside the second, different rule the
next commits need: an anchored cost has no slots, so its latest CHARGE's own
`Purchase.date` is the anchor the series steps from, whatever the kind.
Three comments corrected to describe the code that exists:
- `stepFrom` and `anchoredOccurrences` both claimed neither write boundary
rejects a month-only `startDate`. Both do, and have since this model landed.
Pointing the reader at the closed hole is why the open one — the CHARGE's
own date — survived four reviews.
- `Anchoring`'s doc still said confirming the occurrence early is the whole
mechanism. An anchored cost has no occurrence to confirm; recording a charge
is the mechanism.
- `Occurrence.date` described slot identity as universal (it is calendar-only),
and `confirmedBy` did not say it is unconditionally null on the anchored path.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
AGENTS.md §1's old invariant was half wrong the moment anchored costs lost
their slots: occurrenceDate is identity for calendar only. The replacement says
what each schedule is and, more usefully, warns against collapsing them —
that collapse is exactly the bug where paying twice in September pushed the
next charge to November.
§2's termination bullet now describes a defensive assert rather than a live
guard, because the anchored walk steps forward from a real date and the grid
walk never rebases. Also added the anchoring-change-refused-once-charged
guard, which was undocumented in AGENTS.md despite having its own test suite
and UI-level guard.
DECISIONS.md, PRODUCT.md, ARCHITECTURE.md and TODO.md catch up to what
shipped: two projection models and the three guards it deleted, a recurring
cost charging more than once per cycle and being creatable from the purchase
dialog, the anchored walk beside the grid walk, and schema 8 at 730 tests.
The 2026-09-03 spec gets a header note pointing at the new spec and nothing
else — it stays an accurate record of what was decided then.
Demo data seeds two charges in one month on an anchored cost (Car insurance),
so the fixed behaviour is the first thing a screenshot shows.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Finding I3 (Critical): PurchaseDialog calls offerableCosts(dataset, draft.date)
unconditionally in its render body. The native <input type="date"> lets the
owner clear it, setting draft.date to "" — monthOf("") throws, uncaught,
during render. Unlike an event-handler throw an error boundary can react to,
this crashes the dialog with no interaction to undo.
monthOf itself stays strict per AGENTS.md (no softening a domain throw into a
fallback): offerableCosts now wraps it in a UI-layer isParseableMonth check
and returns no costs for an empty (or otherwise unparseable) date, rather than
reporting an error for a form the owner simply hasn't finished.
Checked the other recurringForm.ts helpers that take a date for the same
hole:
- newCostOptionIsSelectable had it too — "everyNMonths"+"calendar" read as
selectable for ANY date length, including empty, though seedCostFromDraft
still hands that combo's date to monthOf. Fixed the same way, so every
"+ New recurring cost..." option is disabled while the date is empty.
- sourceFor and seedCostFromDraft are only ever called at save time, inside
the guarded store.mutate, never from render — left strict, since a bad
date there already surfaces as a caught save-time error exactly like any
other bad-date save (ensureMonth's monthOf in addPurchase). Documented and
tested that determination rather than inventing a second rule for it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Review finding I2: choosing "+ New recurring cost..." with unit "days" (or
"months" anchored to the last charge) while the purchase's date is month-only
threw a store-level error naming a field ("startDate") the purchase dialog
never shows, with no mention of the "set exact date" toggle that fixes it.
Per the controller ruling, this stops OFFERING the action instead of
rewording the throw: newCostOptionIsSelectable (src/ui/recurringForm.ts)
mirrors the store's own needsDay rule and drives `disabled` on each unit and
anchoring <option>, with a hint pointing at "set exact date". Switching to
month-only also resets a stranded selection (e.g. "days") back to the one
pairing that always works, so a previously valid choice never sits selected
in a now-disabled state.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Two requests in one control. Tagging an existing cost is the refresh: the data
runs out, the owner enters the purchase, picks the plan, and the money is
booked while the cycle restarts from that date — because the anchor is the
latest charge. The data cap stops needing a flow of its own.
"+ New recurring cost" seeds a cost from the purchase being typed, and that
purchase becomes its first charge, so an anchored cost created this way is
anchored from the moment it exists. Both land in one mutate call, so a failure
cannot leave a cost with no first charge.
A calendar cost is offered only where its grid has a slot in the purchase's
month. sourceFor resolves that slot by LOOKUP (walking occurrencesOf and
matching the purchase's month) rather than by deriving it from monthOf alone —
everyNDays + calendar is a legal combination whose slots are specific days, so
a derived slot could name one that does not exist on the grid.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The owner's first report: a cost's post was fixed at creation and could not be
changed. RecurringCost has carried splits and splitMode with the same shape as
Purchase since the first version, so this wires up a capability the data model
already had rather than adding one — and a bill can now span envelopes.
SplitEditor is narrowed from PurchaseDraft to the four fields it actually
reads. PurchaseDraft satisfies that structurally, so the dialog's call site
does not change at all.
The anchoring control is disabled once a cost has charges, matching the store's
refusal rather than offering an action that would fail on save.
Also fixes a carried item: the anchoring handler now expands a month-only
startDate in the same mutate call that switches to lastCharge anchoring,
matching the normalisation the recurrence-kind handler already had — an
everyNMonths cost anchored to lastCharge needs a day-granular startDate, and
the store validates that pairing on every write.
This month's row keeps its one-click Confirm for both anchorings — for an
anchored cost that records "paid on time" at the due date, and paying a bill
on schedule is the common case that must not get slower.
A future row is different. Only a calendar cost can be paid early in a way a
button can express, because its grid does not move. Confirming a future
ANCHORED charge is the refresh, and a refresh needs a real date and possibly a
different amount — the purchase dialog's job. A button here would have to
invent a date, and inventing it is what produced the round-1 Critical.
Also rewrites expectedGroups' docstring, which still described the deleted
X1/wouldAdvancePast rule as current behaviour, and drops a stray "X1" label
left over a defensive null-guard it no longer has anything to do with.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
I1: a calendar charge's MISSING occurrenceDate no longer reports the literal
word "undefined" (String(undefined) printed it, and that text appears
nowhere in the file someone is searching). Missing and malformed are split
into two messages; the missing one names the cost, matching the anchored
side's message.
I2: addPurchase/updatePurchase accepted an arbitrary `source` unchecked —
only confirmOccurrence built a coherent one. A later task hand-builds a
source and passes it straight to addPurchase, so this closes it at the store
layer now rather than leaving that task to carry the validation. Both
enforce the same pairing the importer does: a calendar cost's source
requires occurrenceDate, an anchored cost's source forbids it, an unknown
cost throws. updatePurchase checks the resolved pair (incoming source if
given, else the stored one), the same style already used for anchoring.
M1: the "no recurring costs" fold test added last round was a duplicate of
an existing one and couldn't fail independently. Replaced with a test that
actually discriminates which charge an anchored cost's walk steps from —
a 10-day cycle matching the gap between three charges, where anchoring off
the earliest instead of the latest changes whether anything is still
expected in the same month. Verified by making that swap in
occurrences.ts, watching it fail, and reverting.
M2: updateRecurringCost now refuses a charged cost's anchoring change
before checking startDate granularity, so a doomed request fails on the
real reason first rather than making the owner fix an unrelated date
problem and resubmit into the same wall.
Also closed two pre-existing asymmetries with json.ts: requireRecurrence
now rejects an unknown recurrence kind, and both addRecurringCost and
updateRecurringCost reject an unknown anchoring — both previously relied
on TypeScript's unions alone, which a hand-edited object or an `as` cast
does not go through.
fold.test.ts had no case exercising foldBalances/figuresFor against a
lastCharge cost at all. Adds: three same-cycle charges landing in one month
count fully as ACTUAL charges with nothing left EXPECTED in that month (an
anchored occurrence is only ever a future projection, never a settled slot);
and a no-recurring-costs dataset keeps projected == remaining, pinned
separately from the calendar-shaped fixture already covering that shape.
RecurringCost.startDate's doc comment still said "YYYY-MM for everyNMonths,
YYYY-MM-DD otherwise" — no longer true now that an anchored everyNMonths
cost needs a real day too. Rewritten to name the actual rule and point at
where both boundaries enforce it.
Both boundaries enforce both directions, because a cost's anchoring now
determines what a charge against it may carry. A slot id on an anchored cost
describes a schedule the app will not compute.
startDate granularity widens: anchored monthly costs now need a real day,
since their next due date is computed day-precisely from a charge date.
Changing anchoring on a cost that already has charges is refused. Stripping
slot ids one way is mechanical; inventing them the other way is not, and a
guess would leave real money settling nothing.