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:
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user