Nine tasks. One through three are the domain and run in strict order: the
clamped month step, the type and signature changes, then the split walk.
Four (migration), five (validation) and six (the band) each depend on three.
Seven (the split editor) is independent. Eight (the purchase dialog) needs
seven and five. Nine is docs and demo data.
Task three carries two deletions that belong tidily to tasks five and six.
They are done early because deleting wouldAdvancePast breaks compilation in
both files, and a task that cannot run its own suite cannot be reviewed.
Every expected value was checked by hand rather than derived from the
implementation: the 30-day step from 2026-09-12 lands on 2026-10-12 then
2026-11-11; 31 Aug + 6 months clamps to 28 Feb 2027; and 31 Jan + 2 months is
31 March, which only holds if the clamp measures from the original day instead
of stepping month by month. That last one is the trap in Task 1 and it has its
own test.
Flagged as the known risk: making SplitEditor generic touches the purchase
dialog, which is unrelated to recurring costs. The claim that PurchaseDraft
satisfies SplitTarget structurally is checkable by tsc alone and should be
verified before the rest of that task is written.
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.
Rent every month, a subscription every 28 days, and a phone bill every 30 days
whose clock RESTARTS when its data cap is hit early. A recurring cost projects
EXPECTED charges forward; confirming one writes a real Purchase. The fold now
carries two balances per post per month: `remaining`, untouched by this feature,
and `projected`, which subtracts commitments that have not been paid yet.
The data-cap behaviour is not a feature of its own. Under `lastCharge`
anchoring the next occurrence is measured from the confirming purchase's OWN
date, so confirming a slot early rebases the series — and two-stage
confirmation already records that date. The domain never learns what a data cap
is. That collapse is most of why this design is small: three recurrence kinds
and one anchoring field, not a list of special cases.
Two consequences worth not re-litigating. `everyNMonths` carries no day,
because rent occurs IN September and IsoDate already permits "YYYY-MM" — which
keeps day arithmetic out of the commonest case. And `amount` is a single
mutable Money with no dated series, safe ONLY because confirmed purchases hold
what was actually paid; under projection-only it would destroy history.
Occurrences are computed, never stored. `upToMonth` comes from the caller, so
src/domain/ stays clock-free.
Schema 7. 651 tests, tsc clean, build green.
Four defects were found by review rather than by the suite, each reachable and
each invisible to a passing test:
- Creating a cost then switching its unit stored a month-only startDate on a
day-granular kind, which threw out of the fold and took every money route
down. Two clicks. The validators checked date SHAPE where the UI checked
VALIDITY, so a calendar-impossible "2026-09-31" survived a second round.
- Confirming a future occurrence recorded today against a slot more than one
cycle out, which walks the series backwards. Same crash, introduced by the
change that closed a spec gap.
- Editing a confirmed purchase's date in the ordinary purchase dialog — an act
with nothing to do with recurring costs — could do the same thing, and the
dialog swallowed the refusal so a save appeared to silently do nothing.
- Import accepted a splitMode the fold would silently read as "fixed", turning
intended percentages into absolute amounts.
AGENTS.md §3 held throughout: negatives, percentages past 100 and non-summing
splits are all still accepted, deliberately.
Design: docs/specs/2026-09-03-recurring-costs-design.md
Plan: docs/plans/2026-09-03-recurring-costs.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
- 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.
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) <noreply@anthropic.com>
PRODUCT.md described recurring costs as repeating "monthly, every N days,
or every N weeks", understating everyNMonths, which supports an interval
like the other two kinds.
demo-data.ts gave "Rent" both an ordinary monthly purchase and a
never-confirmed recurring cost from 2026-06 onward, so the demo's Rent
Projected read as double-charged for June through September. The ordinary
purchase now stops once the recurring cost exists.
`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) <noreply@anthropic.com>
Two rules that only review catches, so they go in AGENTS.md §1: occurrences are
computed and never stored, and a confirmation's date and its slot are different
facts. Collapsing the second pair into one field would silently delete the
phone-bill behaviour, which is why it is written down rather than left to the
types.
§2 gets the termination guard. n >= 1 is rejected at both write boundaries
rather than clamped, and the walk throws rather than breaking, because a bill
silently dropped from a budget is worse than a visible error.
Demo data seeds a monthly rent and a 28-day subscription — the second is what
puts two occurrences in one month, which is the case worth looking at. It also
seeds a biweekly cost and a lastCharge-anchored one, so every recurrence kind
and both anchorings show up in Settings and the month view.
DECISIONS.md, PRODUCT.md, ARCHITECTURE.md and TODO.md are updated to match:
schema is at 7, the month view carries a second Projected balance beside
Remaining, and TODO.md records what a week of real use — not a test — will
have to answer.
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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.