From b27dd104700b3ef98cb5e0f59c2361caa95287d7 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Sat, 9 May 2026 23:38:13 +0200 Subject: [PATCH] Refactor HAFAS time parsing to handle DST transitions Replace naive timezone offset calculation with a verification loop. Try plausible offsets (CET/CEST), verify against actual offset at candidate timestamp, and return the first match. This correctly handles daylight saving time transitions in Vienna. --- src/lib/__tests__/hafas-time.test.ts | 187 +++++++++++++++++++++++++++ src/lib/hafas-time.ts | 64 ++++++--- 2 files changed, 234 insertions(+), 17 deletions(-) create mode 100644 src/lib/__tests__/hafas-time.test.ts diff --git a/src/lib/__tests__/hafas-time.test.ts b/src/lib/__tests__/hafas-time.test.ts new file mode 100644 index 0000000..f12390b --- /dev/null +++ b/src/lib/__tests__/hafas-time.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect } from "vitest"; +import { parseHafasTime, hafasDateTime } from "../hafas-time"; + +describe("getTimezoneOffsetMinutes (via parseHafasTime)", () => { + /* + * We can't test getTimezoneOffsetMinutes directly (not exported), but we can + * validate its correctness by checking that parseHafasTime produces the + * expected UTC timestamps for known Vienna local times. + * + * CET = UTC+1 (offset 60 min) — winter + * CEST = UTC+2 (offset 120 min) — summer + * + * To verify independence from server timezone, we compare the resulting + * Date's UTC components (getUTCHours, getUTCMinutes, etc.). + */ + + describe("Standard Winter (CET, UTC+1)", () => { + it("parses 08:00 Vienna (Jan) as 07:00 UTC", () => { + const d = parseHafasTime("20240115", "080000"); + expect(d.getUTCFullYear()).toBe(2024); + expect(d.getUTCMonth()).toBe(0); // January + expect(d.getUTCDate()).toBe(15); + expect(d.getUTCHours()).toBe(7); + expect(d.getUTCMinutes()).toBe(0); + expect(d.getUTCSeconds()).toBe(0); + }); + + it("parses midnight Vienna as 23:00 UTC previous day", () => { + const d = parseHafasTime("20240201", "000000"); + expect(d.getUTCFullYear()).toBe(2024); + expect(d.getUTCMonth()).toBe(0); // January + expect(d.getUTCDate()).toBe(31); + expect(d.getUTCHours()).toBe(23); + expect(d.getUTCMinutes()).toBe(0); + }); + + it("parses 23:59 Vienna as 22:59 UTC same day", () => { + const d = parseHafasTime("20241231", "235900"); + expect(d.getUTCDate()).toBe(31); + expect(d.getUTCHours()).toBe(22); + expect(d.getUTCMinutes()).toBe(59); + }); + }); + + describe("Standard Summer (CEST, UTC+2)", () => { + it("parses 08:00 Vienna (July) as 06:00 UTC", () => { + const d = parseHafasTime("20240715", "080000"); + expect(d.getUTCFullYear()).toBe(2024); + expect(d.getUTCMonth()).toBe(6); // July + expect(d.getUTCDate()).toBe(15); + expect(d.getUTCHours()).toBe(6); + expect(d.getUTCMinutes()).toBe(0); + }); + + it("parses midnight Vienna as 22:00 UTC previous day", () => { + const d = parseHafasTime("20240801", "000000"); + expect(d.getUTCDate()).toBe(31); + expect(d.getUTCMonth()).toBe(6); // July + expect(d.getUTCHours()).toBe(22); + }); + }); + + describe("Spring DST Transition (March 31, 2024)", () => { + /* + * In Austria 2024: + * - At 02:00 CET clocks jump to 03:00 CEST + * - 02:00-02:59 does NOT exist + * - 01:59 CET → 03:00 CEST + */ + + it("treats 01:00 on Mar 31 as CET (UTC+1) → 00:00 UTC", () => { + const d = parseHafasTime("20240331", "010000"); + expect(d.getUTCDate()).toBe(31); + expect(d.getUTCHours()).toBe(0); + expect(d.getUTCMinutes()).toBe(0); + }); + + it("treats 03:00 on Mar 31 as CEST (UTC+2) → 01:00 UTC", () => { + const d = parseHafasTime("20240331", "030000"); + expect(d.getUTCDate()).toBe(31); + expect(d.getUTCHours()).toBe(1); + expect(d.getUTCMinutes()).toBe(0); + }); + + it("treats 12:00 on Mar 31 as CEST (UTC+2) → 10:00 UTC", () => { + const d = parseHafasTime("20240331", "120000"); + expect(d.getUTCHours()).toBe(10); + }); + + it("treats 00:00 on Mar 31 as CET (still before transition) → 23:00 UTC Mar 30", () => { + const d = parseHafasTime("20240331", "000000"); + expect(d.getUTCDate()).toBe(30); + expect(d.getUTCHours()).toBe(23); + }); + }); + + describe("Fall DST Transition (October 27, 2024)", () => { + /* + * In Austria 2024: + * - At 03:00 CEST clocks go back to 02:00 CET + * - 02:00-02:59 occurs twice (ambiguous) + * - Before 03:00 CEST is UTC+2, after is UTC+1 + */ + + it("treats 00:00 on Oct 27 as CEST (before transition) → 22:00 UTC Oct 26", () => { + const d = parseHafasTime("20241027", "000000"); + expect(d.getUTCDate()).toBe(26); + expect(d.getUTCHours()).toBe(22); + }); + + it("treats 01:00 on Oct 27 as CEST (before transition) → 23:00 UTC Oct 26", () => { + const d = parseHafasTime("20241027", "010000"); + expect(d.getUTCDate()).toBe(26); + expect(d.getUTCHours()).toBe(23); + }); + + it("treats 03:00 on Oct 27 as CET (after transition) → 02:00 UTC", () => { + // 03:00 CEST never fires — at 03:00 the clock becomes 02:00 CET. + // The iterative verification picks whichever offset the Intl API + // reports at the resulting candidate. For 03:00 on this day the + // offset will be +1 (CET), so 03:00 → 02:00 UTC. + const d = parseHafasTime("20241027", "030000"); + expect(d.getUTCDate()).toBe(27); + expect(d.getUTCHours()).toBe(2); + }); + + it("treats 12:00 on Oct 27 as CET (after transition) → 11:00 UTC", () => { + const d = parseHafasTime("20241027", "120000"); + expect(d.getUTCHours()).toBe(11); + }); + }); + + describe("Day after transition", () => { + it("Apr 1 (day after spring) is solidly CEST → 08:00 = 06:00 UTC", () => { + const d = parseHafasTime("20240401", "080000"); + expect(d.getUTCHours()).toBe(6); + }); + + it("Oct 28 (day after fall) is solidly CET → 08:00 = 07:00 UTC", () => { + const d = parseHafasTime("20241028", "080000"); + expect(d.getUTCHours()).toBe(7); + }); + }); + + describe("Leap year", () => { + it("parses Feb 29 correctly", () => { + const d = parseHafasTime("20240229", "120000"); + expect(d.getUTCMonth()).toBe(1); // February + // CET in Feb, so 12:00 Vienna = 11:00 UTC + expect(d.getUTCDate()).toBe(29); + expect(d.getUTCHours()).toBe(11); + }); + }); +}); + +describe("hafasDateTime", () => { + it("formats a Date into HAFAS date and time strings", () => { + const input = new Date("2024-11-15T14:30:00"); + const { date, time } = hafasDateTime(input); + expect(date).toBe("20241115"); + expect(time).toBe("143000000"); + }); + + it("pads single-digit month/day/hour/minute", () => { + const input = new Date("2024-01-02T03:04:05"); + const { date, time } = hafasDateTime(input); + expect(date).toBe("20240102"); + expect(time).toBe("030405000"); + }); + + it("roundtrips through parseHafasTime for a standard CET date", () => { + // Note: hafasDateTime uses local-timezone getters, so we construct + // a Date in a known state. parseHafasTime then converts the Vienna + // local time to UTC. This is not a perfect inverse, but we can + // at least verify the format is valid. + const originalDate = new Date(Date.UTC(2024, 0, 15, 7, 0, 0)); // 2024-01-15 07:00 UTC = 08:00 CET + const { date, time } = hafasDateTime(originalDate); + + // hafasDateTime reads local TZ components — on a UTC machine: + const parsed = parseHafasTime(date, time); + + // The parsed result must be a valid Date (not NaN) + expect(Number.isNaN(parsed.getTime())).toBe(false); + expect(parsed.getFullYear()).toBeGreaterThanOrEqual(2023); + expect(parsed.getFullYear()).toBeLessThanOrEqual(2025); + }); +}); diff --git a/src/lib/hafas-time.ts b/src/lib/hafas-time.ts index d8f1f0f..0615f0c 100644 --- a/src/lib/hafas-time.ts +++ b/src/lib/hafas-time.ts @@ -2,31 +2,61 @@ // HAFAS returns times in Vienna (CET/CEST). This module provides correct parsing // regardless of the server or browser timezone. -// Helper: get the offset (in minutes) from UTC for a given date in a specific IANA timezone. -function getTimezoneOffsetMinutes(date: Date, tz: string): number { - const utcStr = date.toUTCString(); - const tzStr = date.toLocaleString("en-US", { timeZone: tz }); - const utcDate = new Date(utcStr); - const tzDate = new Date(tzStr); - // The difference tells us how much the TZ clock differs from UTC for that instant. - return ((tzDate.getTime() - utcDate.getTime()) / 60000) | 0; +// Helper: get the offset (in minutes) from UTC for a given instant in a specific IANA timezone. +// Reads the hour via Intl.DateTimeFormat in both UTC and the target timezone, then +// computes the difference. Handles midnight wraparound (e.g. UTC 23:xx → TZ 01:xx). +function getTimezoneOffsetMinutes(instant: Date, tz: string): number { + const getHour = (timeZone: string) => { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + hour: "numeric", + hour12: false, + }).formatToParts(instant); + return parseInt(parts.find((p) => p.type === "hour")!.value, 10); + }; + + const utcH = getHour("UTC"); + const tzH = getHour(tz); + + let diff = tzH - utcH; + // Handle midnight wraparound: + // e.g. UTC 23 → Vienna 01: diff = -22, but actual offset is +2. + // Vienna is always between +1 and +2, so |diff| > 12 means we crossed midnight. + if (diff < -12) diff += 24; + if (diff > 12) diff -= 24; + + return diff * 60; } /** * Parse a HAFAS date+time string into a proper JavaScript Date. * HAFAS dates are "YYYYMMDD" and times are "HHMMSS". * The resulting Date represents the correct UTC instant for the Vienna local time. + * + * We try each plausible Vienna offset (CET +60 or CEST +120), compute the + * candidate UTC timestamp via Date.UTC, and verify by checking that Vienna's + * offset at that candidate instant matches the assumed offset. The first match + * wins. This correctly handles DST transitions. */ -export function parseHafasTime(date: string, time: string): Date { - const y = parseInt(date.slice(0, 4)); - const mo = parseInt(date.slice(4, 6)) - 1; - const d = parseInt(date.slice(6, 8)); - const h = parseInt(time.slice(0, 2)); - const m = parseInt(time.slice(2, 4)); - const s = parseInt(time.slice(4, 6)); +export function parseHafasTime(dateStr: string, timeStr: string): Date { + const y = parseInt(dateStr.slice(0, 4), 10); + const mo = parseInt(dateStr.slice(4, 6), 10) - 1; + const d = parseInt(dateStr.slice(6, 8), 10); + const h = parseInt(timeStr.slice(0, 2), 10); + const m = parseInt(timeStr.slice(2, 4), 10); + const s = parseInt(timeStr.slice(4, 6), 10); - const tzOffset = getTimezoneOffsetMinutes(new Date(y, mo, d, h, m, s), "Europe/Vienna"); - return new Date(y, mo, d, h, m - tzOffset, s); + for (const offsetMin of [60, 120]) { + const candidateTs = Date.UTC(y, mo, d, h, m, s, 0) - offsetMin * 60_000; + const actualOffset = getTimezoneOffsetMinutes(new Date(candidateTs), "Europe/Vienna"); + if (actualOffset === offsetMin) { + return new Date(candidateTs); + } + } + + // Fallback — should not happen for valid Vienna times. + // Treat as CET (+1). + return new Date(Date.UTC(y, mo, d, h, m, s, 0) - 60 * 60_000); } /**