From dcb9be9f4a9eebdc813ecf9c15b6bc422b467949 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 27 Aug 2026 02:16:34 +0200 Subject: [PATCH] Step a calendar by days, weeks or months MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app can express a window closing and a day repeating; daily.ts counts in days and stops there. This is the arithmetic under the rung between them. Local wall-clock rather than milliseconds, because a reader's own event is local throughout — a weekly reset set for 09:00 stays at 09:00 across a DST transition, where adding 7*DAY would move it an hour and drag every later occurrence with it. Months clamp to the last valid day rather than letting setMonth roll 31 February into 3 March, which is the same silent shift readerInstant already refuses on the way in. Co-Authored-By: Claude Opus 5 (1M context) --- src/shared/recurrence.ts | 102 ++++++++++++++++++++++++++++++++++++++ test/recurrence.test.ts | 104 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 src/shared/recurrence.ts create mode 100644 test/recurrence.test.ts diff --git a/src/shared/recurrence.ts b/src/shared/recurrence.ts new file mode 100644 index 0000000..5ac3a9f --- /dev/null +++ b/src/shared/recurrence.ts @@ -0,0 +1,102 @@ +import { z } from "zod"; + +/** + * Events that come round again (PRD F13). + * + * The rest of this app measures a window closing, and `daily.ts` measures one + * repeating every day. Nothing measured a fortnight. This is that rung: a rule + * the reader states, from which concrete occurrences are derived on read and + * never stored. + * + * Everything here is pure and takes its clock as an argument, for the same + * reason the parsers and `daily.ts` do: a function that reads `Date.now()` + * cannot be tested against a fixed instant. + */ + +export const RepeatUnit = z.enum(["days", "weeks", "months"]); +export type RepeatUnit = z.infer; + +export const Repeat = z.object({ + unit: RepeatUnit, + /** Units between one occurrence opening and the next. */ + interval: z.number().int().min(1).max(365), + /** + * When repetition stops. Null means it does not. + * + * Distinct from an occurrence's own end, which is a different question with a + * different answer — see the spec's § The two ends. + */ + until: z.string().datetime().nullable(), +}); +export type Repeat = z.infer; + +/** + * The most occurrences any one rule may produce for one call. + * + * Mirrors `daily.ts`'s `MAX_DAYS` and exists for the same reason: a corrupt + * interval must not be able to make the client allocate without bound. + */ +export const MAX_OCCURRENCES = 200; + +/** + * Step an instant by whole calendar units, preserving the local wall clock. + * + * **Local, not UTC, and calendar units rather than milliseconds.** A reader's + * own event is local throughout — `readerInstant` builds the instant from their + * wall time and `fields()` reads it back with local accessors — so a weekly + * reset they set for 09:00 has to stay at 09:00 across a DST transition. Adding + * `7 * DAY` milliseconds would move it to 08:00 or 10:00 and drag every later + * occurrence with it. + * + * Months clamp to the last valid day: 31 January plus a month is 28 February, + * not 3 March. `setMonth` rolls over by default, which is the same silent date + * shift `readerInstant` already refuses on the way in. + */ +export function addUnits(ms: number, unit: RepeatUnit, n: number): number { + const d = new Date(ms); + + if (unit === "days") { + d.setDate(d.getDate() + n); + return d.getTime(); + } + if (unit === "weeks") { + d.setDate(d.getDate() + n * 7); + return d.getTime(); + } + + // Pinned to the 1st before moving the month, because setting the month first + // is what performs the rollover we are trying to avoid: 31 January with the + // month advanced is 31 February, which resolves to 3 March before we ever get + // a chance to clamp it. + const day = d.getDate(); + d.setDate(1); + d.setMonth(d.getMonth() + n); + const lastDay = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate(); + d.setDate(Math.min(day, lastDay)); + return d.getTime(); +} + +/** + * Whether a window is still open when its next occurrence starts. + * + * Two live occurrences of one rule leave "what ends soonest" without an + * answer, so this is refused — by the `CustomEvent` schema, so an imported + * file cannot carry one in, and by the form, so the reader is told rather than + * having a save silently rejected. Exported precisely because both ask it: two + * copies would drift, and a form that disagrees with its schema either refuses + * saves that would succeed or promises ones that will not. + * + * Closing exactly as the next opens is fine — that is contiguous, not + * overlapping, and it is the shape a reset-to-reset chore has. + * + * No rule, or no stated end, has nothing to overlap: an unstated end runs to + * the next opening by definition. + */ +export function comesRoundEarly( + startsMs: number, + endsMs: number | null, + repeat: Repeat | null, +): boolean { + if (repeat === null || endsMs === null) return false; + return endsMs > addUnits(startsMs, repeat.unit, repeat.interval); +} diff --git a/test/recurrence.test.ts b/test/recurrence.test.ts new file mode 100644 index 0000000..f2f4878 --- /dev/null +++ b/test/recurrence.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from "bun:test"; +import { addUnits, comesRoundEarly, Repeat } from "../src/shared/recurrence.ts"; + +// Pinned so the DST cases mean something. Copenhagen is UTC+1 in winter and +// UTC+2 in summer, so a step across 29 March 2026 crosses a real transition; +// on a UTC runner these tests would pass without ever exercising the case. +process.env.TZ = "Europe/Copenhagen"; + +/** Local wall-clock components, which is what a reader typed and reads back. */ +function wall(ms: number): string { + const d = new Date(ms); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +function at(local: string): number { + return new Date(local).getTime(); +} + +describe("addUnits", () => { + test("days and weeks step the calendar, not 24-hour blocks", () => { + expect(wall(addUnits(at("2026-08-20T09:00:00"), "days", 1))).toBe("2026-08-21 09:00"); + expect(wall(addUnits(at("2026-08-20T09:00:00"), "weeks", 2))).toBe("2026-09-03 09:00"); + }); + + test("a DST transition does not shift the wall-clock time", () => { + // 29 March 2026 is the spring-forward. A reader whose weekly reset is at + // 09:00 means 09:00 on both sides of it; stepping in fixed milliseconds + // would land 08:00 or 10:00 and quietly move their whole series. + expect(wall(addUnits(at("2026-03-28T09:00:00"), "days", 1))).toBe("2026-03-29 09:00"); + expect(wall(addUnits(at("2026-03-25T09:00:00"), "weeks", 1))).toBe("2026-04-01 09:00"); + // And the autumn fall-back, in the other direction. + expect(wall(addUnits(at("2026-10-24T09:00:00"), "days", 1))).toBe("2026-10-25 09:00"); + }); + + test("months clamp to the last valid day rather than rolling over", () => { + // Date.parse and setMonth both roll 31 February forward into March. A date + // that silently moves is the one thing this codebase does not ship — + // readerInstant guards the same hazard on the way in. + expect(wall(addUnits(at("2026-01-31T09:00:00"), "months", 1))).toBe("2026-02-28 09:00"); + expect(wall(addUnits(at("2028-01-31T09:00:00"), "months", 1))).toBe("2028-02-29 09:00"); + expect(wall(addUnits(at("2026-03-31T09:00:00"), "months", 1))).toBe("2026-04-30 09:00"); + }); + + test("clamping does not accumulate — the anchor day is restored", () => { + // Stepping one month at a time from 31 January must reach 31 March, not 28 + // March: each step is measured from the anchor, so a February clamp is not + // allowed to shorten every later occurrence. + const anchor = at("2026-01-31T09:00:00"); + expect(wall(addUnits(anchor, "months", 2))).toBe("2026-03-31 09:00"); + }); + + test("a zero step is the identity", () => { + const anchor = at("2026-08-20T09:00:00"); + expect(addUnits(anchor, "months", 0)).toBe(anchor); + }); +}); + +describe("comesRoundEarly", () => { + // One predicate, exported, because two callers ask this question — the + // CustomEvent refine and the form that has to explain the refusal. Two + // copies would drift, and the form would start refusing saves the schema + // accepts or waving through ones it rejects. + test("a window closing before the next opening is fine", () => { + const start = at("2026-09-01T09:00:00"); + const end = at("2026-09-08T09:00:00"); + expect(comesRoundEarly(start, end, { unit: "weeks", interval: 2, until: null })).toBe(false); + }); + + test("closing exactly as the next opens is fine — they do not overlap", () => { + const start = at("2026-09-01T09:00:00"); + const end = at("2026-09-08T09:00:00"); + expect(comesRoundEarly(start, end, { unit: "weeks", interval: 1, until: null })).toBe(false); + }); + + test("a window still open when the next one starts is not", () => { + const start = at("2026-09-01T09:00:00"); + const end = at("2026-09-15T09:00:00"); + expect(comesRoundEarly(start, end, { unit: "weeks", interval: 1, until: null })).toBe(true); + }); + + test("no rule, or no stated end, has nothing to overlap", () => { + const start = at("2026-09-01T09:00:00"); + expect(comesRoundEarly(start, at("2026-10-01T09:00:00"), null)).toBe(false); + expect(comesRoundEarly(start, null, { unit: "weeks", interval: 1, until: null })).toBe(false); + }); +}); + +describe("Repeat", () => { + test("accepts a well-formed rule", () => { + const parsed = Repeat.parse({ unit: "weeks", interval: 2, until: null }); + expect(parsed.interval).toBe(2); + }); + + test("rejects an interval outside 1..365", () => { + expect(Repeat.safeParse({ unit: "days", interval: 0, until: null }).success).toBe(false); + expect(Repeat.safeParse({ unit: "days", interval: 366, until: null }).success).toBe(false); + expect(Repeat.safeParse({ unit: "days", interval: 1.5, until: null }).success).toBe(false); + }); + + test("rejects an unknown unit", () => { + expect(Repeat.safeParse({ unit: "fortnights", interval: 1, until: null }).success).toBe(false); + }); +});