fix(recurring): reject calendar-impossible startDate, not just wrongly-shaped (C1 residual)

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>
This commit is contained in:
Lucas WintherandClaude Opus 5 committed 2026-09-04 08:16:03 +02:00
1 parent 405cf0bf2d
commit 042bc8597d
8 files changed
+183 -8

No files matched your search

+7 -1
View File
@@ -32,6 +32,12 @@ export function isDayGranular(date: IsoDate): boolean {
* `addDays` directly. Reuses the domain's own parsers rather than a second,
* possibly-diverging regex.
*
* A day-SHAPED value is routed through `toDayOrdinal` even under
* `everyNMonths`: `monthOf` only extracts the month and never checks the day,
* so without this a calendar-impossible date like "2026-09-31" (September has
* 30 days) would pass here and could still be stored, only to throw three
* months later out of the fold.
*
* Exported for `RecurringSection.test.ts` — this is the one thing standing
* between the "Starts" field and C2 (an empty or malformed value bricking
* every route the fold touches), so it is tested directly rather than only
@@ -39,7 +45,7 @@ export function isDayGranular(date: IsoDate): boolean {
*/
export function isValidStartDate(value: string, kind: Recurrence["kind"]): boolean {
try {
if (kind === "everyNMonths") monthOf(value);
if (kind === "everyNMonths" && !isDayGranular(value)) monthOf(value);
else toDayOrdinal(value);
return true;
} catch {