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); + }); });