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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>