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.
This commit is contained in:
2026-05-09 23:38:13 +02:00
parent 9d2ced4eb6
commit b27dd10470
2 changed files with 234 additions and 17 deletions
+187
View File
@@ -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);
});
});
+47 -17
View File
@@ -2,31 +2,61 @@
// HAFAS returns times in Vienna (CET/CEST). This module provides correct parsing // HAFAS returns times in Vienna (CET/CEST). This module provides correct parsing
// regardless of the server or browser timezone. // regardless of the server or browser timezone.
// Helper: get the offset (in minutes) from UTC for a given date in a specific IANA timezone. // Helper: get the offset (in minutes) from UTC for a given instant in a specific IANA timezone.
function getTimezoneOffsetMinutes(date: Date, tz: string): number { // Reads the hour via Intl.DateTimeFormat in both UTC and the target timezone, then
const utcStr = date.toUTCString(); // computes the difference. Handles midnight wraparound (e.g. UTC 23:xx → TZ 01:xx).
const tzStr = date.toLocaleString("en-US", { timeZone: tz }); function getTimezoneOffsetMinutes(instant: Date, tz: string): number {
const utcDate = new Date(utcStr); const getHour = (timeZone: string) => {
const tzDate = new Date(tzStr); const parts = new Intl.DateTimeFormat("en-US", {
// The difference tells us how much the TZ clock differs from UTC for that instant. timeZone,
return ((tzDate.getTime() - utcDate.getTime()) / 60000) | 0; 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. * Parse a HAFAS date+time string into a proper JavaScript Date.
* HAFAS dates are "YYYYMMDD" and times are "HHMMSS". * HAFAS dates are "YYYYMMDD" and times are "HHMMSS".
* The resulting Date represents the correct UTC instant for the Vienna local time. * 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 { export function parseHafasTime(dateStr: string, timeStr: string): Date {
const y = parseInt(date.slice(0, 4)); const y = parseInt(dateStr.slice(0, 4), 10);
const mo = parseInt(date.slice(4, 6)) - 1; const mo = parseInt(dateStr.slice(4, 6), 10) - 1;
const d = parseInt(date.slice(6, 8)); const d = parseInt(dateStr.slice(6, 8), 10);
const h = parseInt(time.slice(0, 2)); const h = parseInt(timeStr.slice(0, 2), 10);
const m = parseInt(time.slice(2, 4)); const m = parseInt(timeStr.slice(2, 4), 10);
const s = parseInt(time.slice(4, 6)); const s = parseInt(timeStr.slice(4, 6), 10);
const tzOffset = getTimezoneOffsetMinutes(new Date(y, mo, d, h, m, s), "Europe/Vienna"); for (const offsetMin of [60, 120]) {
return new Date(y, mo, d, h, m - tzOffset, s); 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);
} }
/** /**