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]>
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]>
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.
Strips occurrenceDate from an anchored cost's charges, because that path has
no slots and a leftover slot id is a lie a future reader would trust. Expands
a month-only startDate on an anchored monthly cost to the 1st, because day
precision now needs a real day. calendar costs are not touched at all.
Deliberately NOT behaviour-preserving — an anchored cost's projected dates
change, which is the point. Every recorded figure is preserved: no Money is
touched, no purchase added or removed.
C1 (Critical): a month-only startDate on an anchored everyNMonths cost with
no charges yet reached addMonthsToDate directly and threw a bare parsing
error naming no cost, several frames below foldBalances. anchoredOccurrences
now checks its starting cursor's granularity before the walk begins and
throws a cost-named, actionable error instead. No new validation at either
write boundary — that stays out of scope here. stepFrom's docstring no longer
asserts a guarantee ("required to carry a day-granular startDate") that
nothing in the code enforces; it now says what actually protects it.
I1 (Important): the anchored walk's own termination throw had zero test
coverage — both surviving "termination" tests use the cost() fixture, whose
default anchoring is calendar, so they only ever exercised gridOccurrences.
Proven by mutation (replacing the anchored throw with break left every test
passing). Added a test that actually walks anchoredOccurrences, and corrected
the inline comment that falsely claimed coverage existed.
I2 (Important): confirmationsFor's throw is reachable from data the JSON
importer currently accepts (a calendar-cost purchase missing occurrenceDate),
not just a hypothetical programming error — its docstring said "programming
error upstream," which isn't true yet. Docstring corrected, and the throw is
now pinned by a test instead of only asserted in prose.
I3 (Important): nothing asserted that editing a charge's date backwards
re-anchors the series instead of throwing — exactly the behaviour deleting
requireConfirmationAdvances made structurally safe, and exactly the gap left
by deleting round-1's "a date more than one cycle before the slot it claims
is refused" test. Added the inverse: same fixture, but asserting the edit now
succeeds, the fold still folds cleanly, and the series re-anchors to the
edited date.
Minor: round-1 deleted ExpectedBand.test.ts's "stays suppressed once X3 no
longer applies" (X1-only), leaving its underlying scenario (day-granular
anchored cost, slot beyond the displayed month, X3 already satisfied)
completely untested even though still reachable. Added a test pinning what
expectedGroups actually does with it now that X1 is gone: the row is offered,
not suppressed — so a future reintroduction of suppression here is a visible,
deliberate change instead of a silent one.
Left untouched, as instructed: ExpectedBand.tsx's stale X1 docstring and the
Settings anchoring dropdown (both belong to later tasks).
A lastCharge cost's next due date is one cycle after the latest actual charge.
So it can be charged any number of times per cycle, each charge restarts the
clock, and the reported bug is gone: two payments in September leave the next
due in October, not November.
calendar is untouched and keeps its grid. Paying September AND October's rent
really does mean nothing is due until November — the bug was only ever a bug
for the anchored case.
occurrencesOf's second parameter changes from a confirmations Map to the raw
purchase list: the anchored walk needs chargesFor's view (every Purchase.date
for the cost, latest wins), the grid walk needs confirmationsFor's view
(purchases keyed by the slot they settle), and only occurrencesOf itself knows
which to build. occurrencesByMonth now passes dataset.purchases straight
through instead of pre-building a map.
stepFrom's `anchoring === "lastCharge" && date !== monthOf(date)` guard (Task
2 scaffolding, for the old month-only-cursor walk) is deleted. The anchored
walk's cursor is always day-granular now (startDate or a real charge date), so
the condition was dead; if a month-only date ever reaches that branch,
addMonthsToDate throwing is correct, not a bug to paper over.
confirmationsFor's `if (!purchase.source.occurrenceDate) continue` scaffolding
guard (also Task 2) is now a thrown error instead: the function is calendar-
only, a lastCharge charge should never reach it, and continuing silently would
hide a programming error as a slot that merely looks unpaid.
DELETED: rebasedFrom, wouldAdvancePast, requireConfirmationAdvances, and the
tests that existed only to exercise them (6 in occurrences.test.ts describing
the old Map-keyed lastCharge slot-rebasing and its termination throw, 3 in
ExpectedBand.test.ts exercising the band's X1 "would this pairing advance the
series" suppression, 5 in actions.test.ts exercising
requireConfirmationAdvances's refusal and its pass-throughs — see each file's
CONTROLLER RULING comment for the line-by-line reasoning). All three existed
to stop a confirmation landing behind the slot it claims; with no slots on the
anchored path and no rebasing on the grid path, that situation cannot arise
any more. The anchored walk's own termination throw stays, as a defensive
assert against n < 1 — never softened into a break.
ExpectedBand.tsx and actions.ts also had their wouldAdvancePast-dependent code
removed (the X1 condition and requireConfirmationAdvances respectively) so
this task's suite would compile and run; Task 6/7 own reintroducing whatever
anchored-model-correct guard, if any, replaces them.
This removes more code than it adds.
A lastCharge cost has no grid of slots, so a purchase against one settles
nothing — its own date is the whole story. occurrenceDate stays required for
calendar, where a slot is exactly what a payment settles.
stepFrom needs the anchoring because everyNMonths means two different things:
a month-granular grid under calendar, a day-precise clamped step under
lastCharge. The lastCharge branch only takes the day-precise path when its
input actually carries a day (date !== monthOf(date)) — a still-unconfirmed
or paid-on-schedule everyNMonths cost has no day to be precise about yet
(its startDate is documented as month-only), and without this guard the
existing walk crashes calling addMonthsToDate on a bare month. This also
means an off-schedule confirmation that rebases the series now correctly
stays day-precise afterward, which updates one pre-existing occurrencesOf
expectation (I3's "paying after the month" case) to match.
Three call sites read source.occurrenceDate as definite and are rewritten
properly in later tasks (3, 5, 6); each gets a narrow scaffolding guard
with a `// rewritten in Task N` comment rather than a cast or `!`.
A phone plan that refreshes on 20 September is next due 20 October, not
"October". The anchored path is day-granular, so month arithmetic finally
needs a clamp rule: 31 Jan + 1 month is 28 Feb.
Clamping measures from the ORIGINAL day rather than stepping month by month,
so 31 Jan + 2 months is 31 March and not 28 March. Date-free like the rest of
days.ts; daysInMonth already knew the leap rule.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A confirmation is an ordinary Purchase, and the ordinary purchase dialog can
edit its date. Under lastCharge anchoring that date is what the walk measures
the next occurrence from, so moving it back more than a cycle made the walk
step backwards and throw out of foldBalances -- every month route, the year
view and the summary down at once, from an edit that never mentioned recurring
costs.
updatePurchase is the write that can invalidate an already-valid confirmation,
so it now asks wouldAdvancePast -- the same helper ExpectedBand asks before
offering a row -- and refuses the edit instead. The walk's throw is unchanged:
a step that lands backwards really is bad data, and this stops it being
created. A purchase with no source is not checked at all, and a source naming
a cost that no longer exists falls through.
PurchaseDialog was closing on save regardless of the outcome, with its
useMutate error rendered nowhere, so a refusal looked like a save that did
nothing. mutate now reports whether the write landed, and the dialog stays
open and shows the reason when it did not.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
setRecurringCostEndedFrom is the only place that keeps archived and
endedFrom paired, and endRecurringCost/restoreRecurringCost/the Ends column
already delegate to it — but updateRecurringCost still typed its `changes`
as Partial<Omit<RecurringCost, "id">> and Object.assign'd it, so a caller
passing either field alone would desynchronise the pair. No current call
site does this, so it was latent; excluding both keys from the accepted
type makes it impossible rather than merely unexercised.
Critical (X1): confirming a "Coming up" row records TODAY against a future
slot, and occurrencesOf throws — permanently, since the purchase persists —
if that pairing can't advance the series. Reproduced with the demo's own Car
insurance (everyNDays 30, lastCharge, from 2026-08-14): viewing 2026-09 and
confirming its coming-up row (slot 2026-10-13, recorded 2026-09-04) bricks
every month route, yearView, summaryView and PostYearRoute. `expectedGroups`
now omits a coming-up row outright (never disabled-with-an-explanation)
unless `wouldAdvancePast` says recording it today would leave the walk able
to advance past that slot.
Important (X3): a cost with an outstanding occurrence in the displayed month
or any earlier foldable month never gets a coming-up row either — that was
the double-counting path into X1 (Car insurance listed under both "Expected"
2026-09-13 and "Coming up" 2026-10-13; confirming the later while the earlier
stayed pending would double-count the bill). Settle the earlier bill first.
Important (X2): extracted the this-month-vs-coming-up recorded-date decision
into `recordedDateFor(group, today)`, previously inlined as `row(occurrence,
null)` vs `row(occurrence, today)` with nothing testing that the two aren't
swapped. Tested directly.
Minor: the "Coming up" label is now an `<h3>` (matching MonthRoute's own
group-heading convention) instead of a `<p>`, so assistive tech gets the
section divider.
New tests in ExpectedBand.test.ts cover all of the above, including a
regression built from the exact reported shape: confirming everything
expectedGroups offers as "coming up" and then folding must never throw.
Pre-fix, that reproduction throws:
Recurring cost "Car insurance" did not advance past 2026-10-13
(produced 2026-10-04). Purchase <id> claims that slot but is dated
2026-09-04, which does not move the series past 2026-10-13 — correct
its date so the series advances.
occurrencesOf's "did not advance" throw is correct and permanent once a bad
purchase is written, so a caller planning to record a confirmation on a date
other than its slot needs to ask, up front, whether that pairing survives the
walk. Extract the rebase-from-a-confirmation rule (previously inlined once)
into `rebasedFrom`, reuse it from the walk itself, and build `wouldAdvancePast`
on top of it and `stepFrom` rather than re-deriving the rule a third time.
No behavioural change to occurrencesOf itself.
Gap 2 (spec §10): endedFrom is typed as any IsoDate and the spec calls it
"cancellation", but Settings only ever wrote currentMonth — backdating or
forward-dating a cancellation was import-only.
Add an "Ends" column beside "Starts" in the Recurring costs table, edited and
validated the same way (isValidEndedFrom — no granularity rule, per §10, but
still refuses a calendar-impossible date like "2026-02-30"). Empty means "not
ended" and clears the cancellation.
Coherence rule: `archived` mirrors "endedFrom is set". setRecurringCostEndedFrom
is the one place that now enforces it — endRecurringCost and
restoreRecurringCost (the one-click row buttons) delegate to it, so the column
and the buttons can never leave a bill dimmed-but-live or ended-but-undimmed.
Also closes the same calendar-impossible gap in the JSON importer, which
checked endedFrom's shape but not its calendar validity, unlike startDate.
Gap 1 (spec §5): ExpectedBand only listed this month's occurrences, so a
lastCharge bill whose pending slot had rolled into next month (the phone-bill
flagship case) was not confirmable without navigating there first.
Extend the walk 12 months past the displayed month (occurrencesByMonth's own
upToMonth bound; foldBalances is untouched) and surface a "Coming up" group:
at most one row per recurring cost, its next unconfirmed occurrence. That row
confirms EARLY, so it records today rather than the future slot date — which
is exactly what rebases a lastCharge series — and shows both dates so the
owner can see what will be written. `today` is exported from src/store/index.ts
next to `currentMonth`, keeping the clock out of src/domain/.
expectedGroups() is the pure, exported core (pending/overdue/comingUp);
ExpectedBand renders it and still returns null when all three are empty.
requireStartDateGranularity (store/actions.ts) and its import-path twin
(export/json.ts) checked date SHAPE with a regex, not date VALIDITY, so
"2026-09-31" (September has 30 days) passed both and threw later out of
foldBalances instead of at the write boundary. isValidStartDate
(RecurringSection.tsx) had the same hole for a day-shaped value under
everyNMonths. Separately, monthOf's regex (domain/months.ts) was
unanchored at the end, so "2026-091" was silently read as September 2026.
Route day-shaped values through toDayOrdinal and month-shaped values
through monthOf (now anchored to accept only "YYYY-MM" or "YYYY-MM-DD" in
full, never a prefix) at all three write boundaries, converting whatever
they throw into each file's own error style.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
`parsed ?? undefined` conflated "field never touched" with "field edited
to text that doesn't parse" — both produced null, so typing "30 GBP" in a
DKK/USD dataset silently confirmed the purchase at the cost's own amount
instead, with no feedback. resolveExpectedAmount now distinguishes the
two: untouched still confirms at the cost's amount in one click (the fast
path is unchanged), but edited-and-unparseable disables Confirm and shows
an inline message.
Also excludes months before settings.foldStartMonth from the "earlier"
overdue count: foldBalances never folds those, so an occurrence out there
was never actually holding the projected balance down, and counting it
made the explanatory sentence below the count claim a gap that wasn't in
the number being shown.
hasActivity only checked carriedIn/allocation/charges, so an archived post
whose sole activity this month was an unconfirmed recurring occurrence
(zero remaining, nonzero expected/projected) was dropped from
monthView.rows — its Projected figure never reached the month view even
though ExpectedBand listed the same occurrence. remaining and projected
are both fully derived from five raw fields; the check now covers all
five.
Under lastCharge anchoring, an everyNMonths cost stepped from the
confirmation's exact date even when that date fell earlier in the same
month as the slot it claims (e.g. rent for 2026-09 paid 2026-08-28). Since
stepFrom normalises through monthOf, that produced the same month as the
cursor, which the walk read as "did not advance" and threw — for a
completely ordinary early payment.
The month-granular step now runs from whichever is later, the cursor or
the confirmation's month, so a payment within the same month simply does
not rebase. A payment recorded in a LATER month still rebases the series
forward, symmetric with the day-granular cap-hit-early behaviour.
Also corrects the termination error's message, which claimed a purchase
was dated "more than one step earlier" regardless of how early it
actually was.
The unit dropdown could rewrite a cost's recurrence kind to a day-granular
one without touching its month-only startDate, committing exactly the
pairing the store now refuses. It now expands startDate to the first of
the month in the SAME mutate call that changes the kind.
The Starts field itself was free text committed on every keystroke,
including an emptied field ("" reaches monthOf() and throws with the same
blast radius). It is now an uncontrolled input that only commits on blur,
and only when the text parses as an IsoDate of the granularity the cost's
recurrence kind requires; an invalid edit is discarded rather than stored.
isValidStartDate and normalizedStartDateFor are exported and covered
directly in RecurringSection.test.ts, since this is what stands between
this field and a repeat of C1/C2.
non-array recurring field (C1, M7)
parseDatasetJson now enforces the same rule as the store: everyNDays and
everyNWeeks require a day-granular startDate, since occurrencesOf hands it
straight to addDays. Without this, a hand-edited or exported-then-mutated
v7 file could import a cost that bricks every route touching the fold.
Updates the module docstring's claim that it "validates far enough that
the fold cannot throw" to actually cover this case.
Also switches the recurring field to requireArray, matching its four
sibling collections (posts/months/purchases/fxRates) — a non-array
`recurring` used to silently import as empty instead of being rejected.
addRecurringCost and updateRecurringCost now reject a day-granular
recurrence (everyNDays/everyNWeeks) paired with a month-only startDate.
occurrencesOf hands startDate straight to addDays for those kinds, which
throws "Not a day-granular IsoDate" — reachable in two clicks by adding a
cost (always created as everyNMonths, startDate "YYYY-MM") and then
switching its unit in the dropdown without touching Starts.
updateRecurringCost checks the resolved pair (changes merged onto the
stored cost) on every call, not just one that touches recurrence or
startDate, since either field alone can break the pairing.
Also corrects endRecurringCost's doc comment: archiving does not hide a
recurring cost from the list, it dims it — the code already matches
PostsSection, only the comment was wrong (M6).
The zero-decimals guard drove every money path through rounding except
expectedForMonth and fold.ts's projected balance, added by the recurring
costs feature with the fixture's recurring array still empty. Adds an
unconfirmed, non-base-currency, fixed-split recurring cost so the FX
conversion and the split division both land on a non-whole amount at
zero digits, and extends the fold/month-view/two-decimals assertions to
cover it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
PostTable gained a Projected column (both the sm: table and the
mobile block markup) so the fold's second balance — what is left
after unconfirmed recurring bills — is finally visible, muted so
Remaining stays the primary figure. The legend now explains it.
RecurringSection's interval input widened from w-14 to w-20 so
two-digit values like 28 and 30 no longer collide with the native
number spinner.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The repeated act, so it is a row and a button rather than a dialog — entry
speed is a product principle and a modal per bill would defeat it. The amount
is editable inline because paying a different figure is common; anything more
unusual is edited afterwards like any other purchase.
Confirmation is offered on a pending occurrence whose date has not arrived yet,
deliberately. A bill anchored to its last charge can charge EARLY, and
confirming the pending slot with the real date is how the series rebases.
The overdue count is not decoration. An occurrence nobody ever confirms holds
the projected balance down forever, which is the honest reading of an
unreconciled commitment but is invisible without it.
Returns null when nothing is expected, so a dataset with no recurring costs
sees no change to the month view at all.
Follows PostsSection: same table, same xs ghost row actions, same add row.
Definition is the rare act, so it lives here rather than in the month view.
Placed after Posts because a cost's splits reference posts, and before the
data section so the destructive import controls stay last.
'end' sets endedFrom to the current month and archives in one action, which is
what the owner means by cancelling a subscription. Past occurrences are
untouched, so no historical figure moves.
I3: purchase.source: null (and a null entry in recurring[]) reached a bare
property access and threw a raw TypeError instead of ImportValidationError.
null is not undefined, so the existing `!== undefined` guard let it through;
source: null is a plausible hand-edit since the sibling field on the same
purchase literal is schedule: null. Both paths now check for null/non-object
explicitly and refuse with the file's own wording, matching the defensive
style already used two lines away (cost.amount?.currency,
schedule?.slices ?? []).
I1/I2: splitMode and splits[].value were unchecked on both purchases and
recurring costs. An unrecognised splitMode silently takes charges.ts's
"fixed" branch instead of "percent" (or vice versa), turning an intended
percentage into an absolute amount with no error. A non-finite split value
propagates as NaN through distributeByWeight/distributeByAmount, and
roundMoney(NaN, digits) returns NaN rather than throwing. Both are now
checked in one new requireSplits() helper shared by the purchases loop and
the recurring-costs loop, replacing each loop's separate copy of the
no-splits/one-absorber/unknown-post checks rather than adding a third copy.
Per AGENTS.md §3, requireSplits only rejects non-numbers: negative values,
values over 100, and splits that don't sum to the total stay deliberately
unchecked.
The one check that is load-bearing rather than tidy: a recurrence interval
below 1, or a fractional one, is REFUSED rather than clamped. The projection
walk terminates only if every step strictly advances, and a clamped zero would
hand the owner a schedule their file does not describe — silently.
Purchase.source is checked too: the cost it names must exist, and the
occurrence date must parse. PURCHASE_DATE already accepts both YYYY-MM and
YYYY-MM-DD, which is exactly the range a slot date can take.
confirmOccurrence writes an ordinary Purchase: the slot it claims goes in
source.occurrenceDate, the date the money moved goes on the purchase, and the
two differ whenever a bill is paid off schedule. Splits are copied rather than
shared, so editing the purchase cannot rewrite the cost.
endRecurringCost sets endedFrom AND archived, because they do different jobs —
one stops the projection, the other only hides the row. Archiving alone would
leave a cancelled bill projecting forever. Neither touches a past occurrence,
so no historical figure moves.
requireRecurrence rejects n below 1 and non-integers rather than clamping.
The walk terminates only if every step advances, and the importer is not the
only write path.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
remaining is untouched: carriedIn + allocation - charges, exactly as before, so
no figure the owner has reconciled against their bank can move when they add a
recurring cost. projected runs beside it on its own carry, subtracting
unconfirmed occurrences as well.
Two invariants pin it, both tested. With no recurring costs the tracks are
identical in every month — that is the behaviour-preservation guard for every
dataset that exists today. Once a month's occurrences are all confirmed they
reconverge there, because a confirmation is an ordinary purchase counted in
charges and skipped by expected.
An unconfirmed occurrence in a PAST month therefore holds projected below
remaining forever. That is the honest reading — an unreconciled commitment —
and the month view surfaces the count so it is not silent.
Also required to typecheck once PostMonthFigures gains these fields:
- src/store/migrations.test.ts: delete the two @ts-expect-error directives
that stood over the "NO figure changes" guard test since Task 2. That test
is now green — the point of this task.
- src/ui/meterSegments.test.ts: the figures() test helper builds a complete
PostMonthFigures literal; default expected to 0, projectedCarriedIn to
carriedIn, and projected to remaining, matching its existing partial.X ??
style.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
expectedForMonth mirrors chargesForMonth for money that has not moved yet, and
skips confirmed occurrences — those are ordinary purchases that chargesForMonth
has already counted, so counting both would double every settled bill.
The split machinery is extracted rather than duplicated. Two implementations
could round differently or absorb the remainder differently, and the gap would
show up as a penny of drift between the fold's two tracks with no cause the
owner could find. remainderIndexOf and splitPartsOfTotal keep their signatures
as wrappers, so no existing caller changes.
Smaller than planned: 8d2b2ca already made these functions take digits rather
than a currency table, so the shared helper needs no currency plumbing.
A walk rather than a per-month query: under lastCharge anchoring, where a
series sits in a given month depends on every confirmation before it, so the
projection starts at startDate and steps forward. Bounded by upToMonth, which
the caller supplies — the domain still reads no clock.
The data-cap behaviour is one line. `from` is the confirmation's own date
under lastCharge, so confirming a slot early moves every later occurrence with
it. Confirm the 2026-01-31 slot with a purchase dated the 12th and the next
lands on 2026-02-11, not 2026-03-02.
The walk throws if a step fails to advance. n >= 1 is enforced at both write
boundaries so this should be unreachable, but a hang is worse than a throw and
dropping a bill silently is worse than both.
Rewrote the "deleting the confirming purchase" test per controller ruling: the
brief's version was byte-identical to the unconfirmed-series test above it and
asserted nothing new. It now walks the phone cost with the capHit
confirmations, then walks the same cost with an empty map, and asserts the
dates differ and match the unconfirmed series — which is what "deleting the
purchase restores the series" actually means.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Recurrence is two orthogonal fields, not a list of special cases: a kind
(everyNMonths / everyNDays / everyNWeeks) and an anchoring (calendar /
lastCharge). The phone bill that recharges early when its data cap is hit is
everyNDays(30) anchored to lastCharge — the domain never learns what a data cap
is, because confirming the occurrence early already records the date.
Purchase.source pairs occurrenceDate (identity: which slot this claims) with
Purchase.date (truth: when the money moved). They differ whenever a bill is
paid off schedule, and lastCharge measures from the latter.
RecurringCost.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.
The 6 -> 7 step writes an empty array and reads nothing. Decimals took 6.
Recurrences of 28 and 30 days do not align to months, so the domain needs
day-level maths for the first time. It gets it the way months.ts does: an
integer ordinal, no Date anywhere. A Date would make 2026-03-29 a different
instant per timezone while being the same day everywhere, and a 28-day step
must not gain or lose a day at a DST boundary.
Hinnant's days_from_civil, which shifts the year to start in March so the leap
day is the last day rather than an insertion, removing every special case.
No isDayGranular export: nothing in the plan consumes it, later tasks branch
on a recurrence kind and import validation uses a regex directly.
`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]>
Two fixes that looked like one. The horizontal scrollbar under the currencies
table was not a width problem, and widening alone would never have removed it.
The column: `max-w-4xl` here was 256px narrower than the shell's `main`, which
is already `max-w-6xl`, so the card edges stepped inward whenever you arrived
from the month view. The cap is simply gone rather than raised to `max-w-6xl` —
`main` caps the width and pads it, so a 6xl cap on the child could never bind,
and two caps that agree are still two places to change.
The scrollbar: the row's remove button carries `-mr-2`, so its label sits flush
with the card's content edge rather than 8px inside it. To a scroll container
that overhang IS overflow, and because the table is `w-full` it scaled with the
column — 862 in 854 before, 1070 in 1062 after widening. Exactly 8px at every
width, which is why no amount of widening helped. The wrapper now bleeds 8px
right and pads it back, so the overhang lands inside the padding box: same
table width, same button position, no scrollbar. The same `-mx-2 px-2` shape as
the purchase rows in acb4d97.
A first attempt at diagnosis blamed the table's natural width and was wrong; a
second, zeroing the margin in the page, appeared to disprove the margin too,
because a transition was still interpolating it (-7.73662px when measured).
Waiting out the transition is what made the cause visible.
Verified in Chrome across every route: /settings, /month and /summary have no
scrolling container whose content overflows it. /year still does, by 282px,
which is YearMatrix being genuinely wider than the column — that scrollbar is
doing its job and is left alone.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Dark Reader looks for `<meta name="color-scheme">` first. Finding none, it
waits for a stylesheet and then samples text and background colours at
several points of the screen, plus some further heuristics, to decide
whether the page needs darkening. On the dark palette that verdict is
wrong: it inverts an app that is already dark, a second time.
So the tag now states which scheme is actually live. It ships in the markup
as `light dark`, before any script runs; the pre-paint script narrows it to
the one in use, and `applyResolvedTheme` keeps it there when the toggle
moves — the same two-signal write as the `.dark` class, in both places.
`var dark` moved out of the `try` in that script. It has to be readable
after a throwing localStorage in order to set the meta, and false is the
right value there: it is what `readThemePreference` -> "system" ->
`prefersDark` resolves to on the module side, so the two still agree in the
one case that cannot be read.
Both setters create the tag when it is absent rather than skipping the
write. A silent no-op leaves Dark Reader guessing, which is the bug.
The drift guard now runs the real script, lifted out of the HTML, instead
of matching its text — it asserts class and meta together for a stored
dark, a stored light, and no preference at all. Text matching would have
passed a script that declared the tag and never set it.
Not `<meta name="darkreader-lock">`: that switches the extension off
wholesale, including in light mode, where a user who installed it wants it
working. Only the dark palette asks it to stand down.
Verified in Chrome across stored dark, stored light, system-dark and a live
toggle with no reload: one tag, content and computed `color-scheme`
agreeing each time. Whether the extension honours it needs a human with
Dark Reader installed.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Three defects in the purchase list, plus NativeSelect for the rule-kind
picker.
Alignment: `px-1` on the row indented every description 4px past its own
date heading, so the list had two left edges 4px apart. `-mx-2 px-2` puts
the text exactly under the heading and bleeds the hover wash outward, so the
highlight has an inset instead of stopping flush against the first
character.
The hover-action reservation: a fixed `w-[7.5rem]` kept the amounts from
shifting when edit/delete appeared, which is right on a desktop. On a phone
it was doubly wrong — there is no hover, so `opacity-0` made these the
hardest controls in the app to reach, and the reservation still spent 120px
of a 350px row hiding them, truncating descriptions to "Weeken…", then
"Bik…", and the financed row to nothing but its badge.
So the row is one line from `sm:` up and two below it: description on its
own line, amount and actions on the next. `sm:contents` dissolves the
wrapper above `sm:`, so the desktop row is the same flat flex line it always
was. This is the call PostTable already makes at that width, for the same
reason.
Also: the month steps wrapped mid-label into "← 2026-" / "08" on a phone,
and the error alert's "dismiss" was an unpadded underlined word.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Section's hint ran the full width of the card — about 160 characters a line
at 12px on a 1140px card, more than twice a readable measure, and the
Currencies and Posts hints both wrapped to three lines that read as a wall
rather than as one aside. Capped at 75ch.
PostTable's "change from here" was a single line of 11px text with no
padding, so a 15px target. It gets padding cancelled by equal negative
margins, so the hit area grows and no baseline moves.
UpdatePrompt used `rounded` (4px) where the card system is 8px, which made
the one panel that appears over everything else the only one with a tighter
radius than its surroundings.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Replaces the two-loose-buttons pattern with Segmented on both, so
spend/balance and by-post/by-month read as two states of one control rather
than a filled primary action beside an unrelated outline button.
Summary also had two row-height defects: "All time" was `sm` in an
`items-end` row of default-height fields, so it sat 4px off their baseline;
and the by-month branch's first cell was `py-2` where every other cell in
the table is `py-2.5`, making those rows 4px shorter than the by-post ones.
The rest of the diff is indentation — both Sections had their children at
the parent's own indent level.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>