From 8d0b45f83a251adab8f8f09f8596dbb93259f78f Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 16 Oct 2026 05:37:36 +0200 Subject: [PATCH] fix(tasks): refuse a cycle start that is not a real date A corrupt import is stored as it came, and "2026-13-01" passed the pattern, then threw in periodOf on every render. "2026-02-30" rolled into March instead and moved the cycle. The schema now round-trips the date, so either is kept on disk and left off screen like any unreadable task. Found by the schema-guardian review. Co-Authored-By: Claude Opus 5.5 --- src/shared/period.ts | 13 ++++++++++++- test/period.test.ts | 7 +++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/shared/period.ts b/src/shared/period.ts index 5a7ffa0..fdf0b27 100644 --- a/src/shared/period.ts +++ b/src/shared/period.ts @@ -17,7 +17,18 @@ import { DAY } from "./time.ts"; /** ISO weekday: Monday is 1, Sunday is 7. */ export const WeekdaySchema = z.number().int().min(1).max(7); -const DayKey = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); +/** + * A real calendar date. The round trip refuses "2026-13-01", which would throw + * in `periodOf`, and "2026-02-30", which would roll into March and move the + * cycle — a corrupt import is stored verbatim, so the schema is the guard. + */ +const DayKey = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .refine((s) => { + const ms = Date.parse(`${s}T00:00:00Z`); + return Number.isFinite(ms) && new Date(ms).toISOString().slice(0, 10) === s; + }); export const PeriodSchema = z.union([ z.object({ unit: z.literal("week"), startsOn: WeekdaySchema }), diff --git a/test/period.test.ts b/test/period.test.ts index 25b76c4..640f8ea 100644 --- a/test/period.test.ts +++ b/test/period.test.ts @@ -124,4 +124,11 @@ describe("periodOf: cycles", () => { expect(PeriodSchema.safeParse(cycle(367, "2026-01-01")).success).toBe(false); expect(PeriodSchema.safeParse(cycle(14, "16 Oct")).success).toBe(false); }); + + test("a start that is not a real date is refused, not rolled over or thrown on later", () => { + // A corrupt import is stored verbatim; the schema is what keeps it off screen. + expect(PeriodSchema.safeParse(cycle(14, "2026-13-01")).success).toBe(false); + expect(PeriodSchema.safeParse(cycle(14, "2026-02-30")).success).toBe(false); + expect(PeriodSchema.safeParse(cycle(14, "2028-02-29")).success).toBe(true); + }); });