diff --git a/src/shared/dateWindow.test.ts b/src/shared/dateWindow.test.ts new file mode 100644 index 00000000..ca785be6 --- /dev/null +++ b/src/shared/dateWindow.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { + BOARD_WINDOW_DAYS_BACK, + BOARD_WINDOW_DAYS_FORWARD, + SCHEDULE_WINDOW_DAYS_BACK, + SCHEDULE_WINDOW_DAYS_FORWARD, + MAP_WINDOW_DAYS_BACK, + MAP_WINDOW_MONTHS_FORWARD, + isInBoardWindow, + isInScheduleWindow, + isInMapWindow, + boardWindowBounds, + scheduleWindowBounds, + mapWindowBounds, +} from "./dateWindow.js"; + +describe("dateWindow constants", () => { + it("exports the TZ-defined numerical bounds", () => { + expect(BOARD_WINDOW_DAYS_BACK).toBe(1); + expect(BOARD_WINDOW_DAYS_FORWARD).toBe(14); + expect(SCHEDULE_WINDOW_DAYS_BACK).toBe(1); + expect(SCHEDULE_WINDOW_DAYS_FORWARD).toBe(330); + expect(MAP_WINDOW_DAYS_BACK).toBe(1); + expect(MAP_WINDOW_MONTHS_FORWARD).toBe(6); + }); +}); + +describe("dateWindow helpers (clock frozen at 2026-05-15)", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 4, 15, 12, 0, 0)); // May 15, 2026 noon local time + }); + afterEach(() => { + vi.useRealTimers(); + }); + + // Helper to format date as YYYY-MM-DD for comparison + function toYmd(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + } + + describe("4.1.2-R12: isInBoardWindow (-1/+14 days)", () => { + it("accepts today (yyyyMMdd)", () => { + expect(isInBoardWindow("20260515")).toBe(true); + }); + it("accepts yesterday (-1 day)", () => { + expect(isInBoardWindow("20260514")).toBe(true); + }); + it("rejects -2 days", () => { + expect(isInBoardWindow("20260513")).toBe(false); + }); + it("accepts today + 14 days", () => { + expect(isInBoardWindow("20260529")).toBe(true); + }); + it("rejects today + 15 days", () => { + expect(isInBoardWindow("20260530")).toBe(false); + }); + it("rejects malformed input", () => { + expect(isInBoardWindow("")).toBe(false); + expect(isInBoardWindow("2026-05-15")).toBe(false); + expect(isInBoardWindow("20269999")).toBe(false); + }); + }); + + describe("4.1.2-R12: isInScheduleWindow (-1/+330 days)", () => { + it("accepts today + 330 days", () => { + expect(isInScheduleWindow("20270410")).toBe(true); + }); + it("rejects today + 331 days", () => { + expect(isInScheduleWindow("20270411")).toBe(false); + }); + it("rejects -2 days", () => { + expect(isInScheduleWindow("20260513")).toBe(false); + }); + }); + + describe("4.1.2-R12: isInMapWindow (-1 day / +6 months)", () => { + it("accepts today + 6 months (same calendar day)", () => { + expect(isInMapWindow("20261115")).toBe(true); + }); + it("rejects today + 6 months + 1 day", () => { + expect(isInMapWindow("20261116")).toBe(false); + }); + it("rejects -2 days", () => { + expect(isInMapWindow("20260513")).toBe(false); + }); + }); + + it("exposes bounds tuples for UI consumers", () => { + const [bMin, bMax] = boardWindowBounds(); + expect(toYmd(bMin)).toBe("2026-05-14"); + expect(toYmd(bMax)).toBe("2026-05-29"); + const [sMin, sMax] = scheduleWindowBounds(); + expect(toYmd(sMin)).toBe("2026-05-14"); + expect(toYmd(sMax)).toBe("2027-04-10"); + const [mMin, mMax] = mapWindowBounds(); + expect(toYmd(mMin)).toBe("2026-05-14"); + expect(toYmd(mMax)).toBe("2026-11-15"); + }); +}); diff --git a/src/shared/dateWindow.ts b/src/shared/dateWindow.ts new file mode 100644 index 00000000..14d7b9aa --- /dev/null +++ b/src/shared/dateWindow.ts @@ -0,0 +1,82 @@ +/** + * Centralized date-window constants and membership helpers per TZ ยง4.1.2. + * + * All date arguments are "yyyyMMdd" strings (TZ URL date format). + * Bounds are inclusive on both ends. Day-level comparison (time zeroed). + */ + +export const BOARD_WINDOW_DAYS_BACK = 1; +export const BOARD_WINDOW_DAYS_FORWARD = 14; +export const SCHEDULE_WINDOW_DAYS_BACK = 1; +export const SCHEDULE_WINDOW_DAYS_FORWARD = 330; +export const MAP_WINDOW_DAYS_BACK = 1; +export const MAP_WINDOW_MONTHS_FORWARD = 6; + +function today(): Date { + const d = new Date(); + d.setHours(0, 0, 0, 0); + return d; +} + +function addDays(base: Date, days: number): Date { + const d = new Date(base); + d.setDate(d.getDate() + days); + return d; +} + +function addMonths(base: Date, months: number): Date { + const d = new Date(base); + d.setMonth(d.getMonth() + months); + return d; +} + +function parseYyyymmdd(s: string): Date | null { + if (!/^\d{8}$/.test(s)) return null; + const y = Number(s.slice(0, 4)); + const m = Number(s.slice(4, 6)); + const d = Number(s.slice(6, 8)); + if (m < 1 || m > 12 || d < 1 || d > 31) return null; + const dt = new Date(y, m - 1, d); + if (dt.getFullYear() !== y || dt.getMonth() !== m - 1 || dt.getDate() !== d) return null; + return dt; +} + +function inRange(date: Date, min: Date, max: Date): boolean { + return date.getTime() >= min.getTime() && date.getTime() <= max.getTime(); +} + +export function boardWindowBounds(): [Date, Date] { + const base = today(); + return [addDays(base, -BOARD_WINDOW_DAYS_BACK), addDays(base, BOARD_WINDOW_DAYS_FORWARD)]; +} + +export function scheduleWindowBounds(): [Date, Date] { + const base = today(); + return [addDays(base, -SCHEDULE_WINDOW_DAYS_BACK), addDays(base, SCHEDULE_WINDOW_DAYS_FORWARD)]; +} + +export function mapWindowBounds(): [Date, Date] { + const base = today(); + return [addDays(base, -MAP_WINDOW_DAYS_BACK), addMonths(base, MAP_WINDOW_MONTHS_FORWARD)]; +} + +export function isInBoardWindow(yyyymmdd: string): boolean { + const d = parseYyyymmdd(yyyymmdd); + if (!d) return false; + const [min, max] = boardWindowBounds(); + return inRange(d, min, max); +} + +export function isInScheduleWindow(yyyymmdd: string): boolean { + const d = parseYyyymmdd(yyyymmdd); + if (!d) return false; + const [min, max] = scheduleWindowBounds(); + return inRange(d, min, max); +} + +export function isInMapWindow(yyyymmdd: string): boolean { + const d = parseYyyymmdd(yyyymmdd); + if (!d) return false; + const [min, max] = mapWindowBounds(); + return inRange(d, min, max); +}