diff --git a/web/src/legal.test.ts b/web/src/legal.test.ts index f37752a..8f56717 100644 --- a/web/src/legal.test.ts +++ b/web/src/legal.test.ts @@ -30,12 +30,18 @@ describe("dayOfYear", () => { expect(dayOfYear(new Date(2026, 11, 31))).toBe(365); }); - // Characterizes CURRENT (mismatched) behavior: dayOfYear uses the real - // calendar (366 in a leap year) while the pipeline's _doy uses a fixed - // non-leap table (max 365). Plan 004 reconciles these; when it does, - // this assertion must change to reflect the new convention. - it("returns 366 for Dec 31 in a leap year (current mismatched behavior)", () => { - expect(dayOfYear(new Date(2028, 11, 31))).toBe(366); + // non-leap convention — must match pipeline/normalize.py _MONTH_START + it("returns 365 for Dec 31 in a leap year (non-leap convention)", () => { + expect(dayOfYear(new Date(2028, 11, 31))).toBe(365); + }); + + it("Feb 29 maps to the same value as Mar 1 (non-leap convention)", () => { + expect(dayOfYear(new Date(2028, 1, 29))).toBe(60); + expect(dayOfYear(new Date(2028, 2, 1))).toBe(60); + }); + + it("matches the pipeline's parse_window start for 05/01 (cross-convention anchor)", () => { + expect(dayOfYear(new Date(2026, 4, 1))).toBe(121); }); }); @@ -135,4 +141,9 @@ describe("isOpen (combined)", () => { it("permitted class and in season -> true", () => { expect(evalOpen(["passenger"], 200, props)).toBe(true); }); + + it("a window ending 12/31 (open_end=365) is OPEN on Dec 31 of a leap year (the leap-year bug this plan fixes)", () => { + const leapProps = { classes: ",passenger,", season: "seasonal", open_start: 121, open_end: 365 }; + expect(evalOpen(["passenger"], dayOfYear(new Date(2028, 11, 31)), leapProps)).toBe(true); + }); }); diff --git a/web/src/legal.ts b/web/src/legal.ts index d92c0a4..35788d1 100644 --- a/web/src/legal.ts +++ b/web/src/legal.ts @@ -4,16 +4,20 @@ // Mirrors the data model from pipeline/normalize.py: // classes ",passenger,motorcycle," (comma-delimited token list) // season "yearlong" | "seasonal" -// open_start day-of-year 1..366 -// open_end day-of-year 1..366 (may be < open_start for winter-wrapping) +// open_start day-of-year 1..365 (non-leap convention) +// open_end day-of-year 1..365 (non-leap convention) (may be < open_start for winter-wrapping) import type { ExpressionSpecification } from "maplibre-gl"; -/** Day-of-year (1..366) for a JS Date, in local time. */ +// Cumulative day-of-year for the 1st of each month (non-leap); index 1..12. +// MUST match _MONTH_START in pipeline/normalize.py — the tiles encode season +// windows with this table, so the frontend has to count days the same way. +// Feb 29 intentionally maps to 60 (== Mar 1), same as the pipeline. +const MONTH_START = [0, 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]; + +/** Day-of-year (1..365, non-leap convention) for a JS Date, in local time. */ export function dayOfYear(d: Date): number { - const start = new Date(d.getFullYear(), 0, 0); - const diff = d.getTime() - start.getTime(); - return Math.floor(diff / 86_400_000); + return MONTH_START[d.getMonth() + 1] + d.getDate(); } /** True-expression: the route permits ANY of the profile's class tokens. diff --git a/web/src/main.ts b/web/src/main.ts index 1c01779..b2e5765 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -111,7 +111,11 @@ for (const p of VEHICLE_PROFILES) { const paramVehicle = new URLSearchParams(location.search).get("vehicle"); vehicleSel.value = VEHICLE_PROFILES.some((p) => p.key === paramVehicle) ? paramVehicle! : "moto_plated"; -dateInput.value = new Date().toISOString().slice(0, 10); +// Local date, not the UTC ISO date string: in the evening (UTC is past +// midnight) the default must still be *today* here — "the night before" is +// the primary use. +const now = new Date(); +dateInput.value = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; hideClosed.checked = new URLSearchParams(location.search).get("hideclosed") === "1"; function selectedTokens(): string[] {