From 0e451bf51ed2eb167064cdf52040232a5dae6c74 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Sat, 9 May 2026 23:57:41 +0200 Subject: [PATCH] Refactor hafasDateTime to use Intl.DateTimeFormat for Vienna TZ --- src/lib/__tests__/hafas-time.test.ts | 137 ++++++++++++++++++++++----- src/lib/hafas-time.ts | 37 +++++++- 2 files changed, 148 insertions(+), 26 deletions(-) diff --git a/src/lib/__tests__/hafas-time.test.ts b/src/lib/__tests__/hafas-time.test.ts index a036254..db236d1 100644 --- a/src/lib/__tests__/hafas-time.test.ts +++ b/src/lib/__tests__/hafas-time.test.ts @@ -182,34 +182,123 @@ describe("getTimezoneOffsetMinutes (via parseHafasTime)", () => { }); 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"); + /* + * hafasDateTime is the inverse of parseHafasTime: it takes a JavaScript + * Date (UTC instant) and returns the Vienna-local HAFAS date/time strings. + * The roundtrip hafasDateTime(parseHafasTime(d, t)) === {date: d, time: t} + * must hold for every valid Vienna local time. + */ + + describe("roundtrip through parseHafasTime", () => { + function roundtrip(dateStr: string, timeStr: string) { + const parsed = parseHafasTime(dateStr, timeStr); + const { date, time } = hafasDateTime(parsed); + return { date, time }; + } + + it("roundtrips standard CET times", () => { + expect(roundtrip("20240115", "080000")).toEqual({ + date: "20240115", + time: "080000000", + }); + expect(roundtrip("20240201", "000000")).toEqual({ + date: "20240201", + time: "000000000", + }); + expect(roundtrip("20241231", "235900")).toEqual({ + date: "20241231", + time: "235900000", + }); + }); + + it("roundtrips standard CEST times", () => { + expect(roundtrip("20240715", "080000")).toEqual({ + date: "20240715", + time: "080000000", + }); + expect(roundtrip("20240801", "000000")).toEqual({ + date: "20240801", + time: "000000000", + }); + }); + + it("roundtrips spring DST transition (Mar 31, 2024)", () => { + expect(roundtrip("20240331", "010000")).toEqual({ + date: "20240331", + time: "010000000", + }); + expect(roundtrip("20240331", "030000")).toEqual({ + date: "20240331", + time: "030000000", + }); + expect(roundtrip("20240331", "120000")).toEqual({ + date: "20240331", + time: "120000000", + }); + }); + + it("roundtrips fall DST transition (Oct 27, 2024)", () => { + expect(roundtrip("20241027", "000000")).toEqual({ + date: "20241027", + time: "000000000", + }); + expect(roundtrip("20241027", "023000")).toEqual({ + date: "20241027", + time: "023000000", + }); + expect(roundtrip("20241027", "120000")).toEqual({ + date: "20241027", + time: "120000000", + }); + }); + + it("roundtrips leap year", () => { + expect(roundtrip("20240229", "120000")).toEqual({ + date: "20240229", + time: "120000000", + }); + }); }); - 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"); - }); + describe("UTC-based correctness", () => { + it("converts a UTC instant to correct Vienna HAFAS strings (CET)", () => { + // Jan 15 07:00 UTC = Jan 15 08:00 Vienna (CET) + const utc = new Date(Date.UTC(2024, 0, 15, 7, 0, 0)); + const { date, time } = hafasDateTime(utc); + expect(date).toBe("20240115"); + expect(time).toBe("080000000"); + }); - 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); + it("converts a UTC instant to correct Vienna HAFAS strings (CEST)", () => { + // Jul 15 06:00 UTC = Jul 15 08:00 Vienna (CEST) + const utc = new Date(Date.UTC(2024, 6, 15, 6, 0, 0)); + const { date, time } = hafasDateTime(utc); + expect(date).toBe("20240715"); + expect(time).toBe("080000000"); + }); - // hafasDateTime reads local TZ components — on a UTC machine: - const parsed = parseHafasTime(date, time); + it("handles midnight wraparound (UTC 23:00 → Vienna +1 day 00:00)", () => { + // Jan 14 23:00 UTC = Jan 15 00:00 Vienna (CET) + const utc = new Date(Date.UTC(2024, 0, 14, 23, 0, 0)); + const { date, time } = hafasDateTime(utc); + expect(date).toBe("20240115"); + expect(time).toBe("000000000"); + }); - // 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); + it("handles midnight wraparound (Vienna midnight is still previous UTC day)", () => { + // Vienna Jan 1 00:00 CET = Dec 31 23:00 UTC + const utc = new Date(Date.UTC(2023, 11, 31, 23, 0, 0)); + const { date, time } = hafasDateTime(utc); + expect(date).toBe("20240101"); + expect(time).toBe("000000000"); + }); + + it("pads single-digit components correctly", () => { + // Jan 2 08:03:07 Vienna (CET) = Jan 2 07:03:07 UTC + const utc = new Date(Date.UTC(2024, 0, 2, 7, 3, 7)); + const { date, time } = hafasDateTime(utc); + expect(date).toBe("20240102"); + expect(time).toBe("080307000"); + }); }); }); diff --git a/src/lib/hafas-time.ts b/src/lib/hafas-time.ts index 897ac2a..78418a4 100644 --- a/src/lib/hafas-time.ts +++ b/src/lib/hafas-time.ts @@ -71,12 +71,45 @@ export function parseHafasTime(dateStr: string, timeStr: string): Date { return new Date(Date.UTC(y, mo, d, h, m, s, 0) - 60 * 60_000); } +// Helper: extract date/time components of an instant in a given IANA timezone +// via Intl.DateTimeFormat. Returns { year, month (1-based), day, hour, minute, second }. +function getDateTimeParts(instant: Date, tz: string) { + const fmt = new Intl.DateTimeFormat("en-US", { + timeZone: tz, + year: "numeric", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + second: "numeric", + hour12: false, + }); + + const parts = fmt.formatToParts(instant); + const get = (type: string) => parseInt(parts.find((p) => p.type === type)!.value, 10); + + return { + year: get("year"), + month: get("month"), // 1-based + day: get("day"), + hour: get("hour"), + minute: get("minute"), + second: get("second"), + }; +} + /** * Build HAFAS date and time strings from a JavaScript Date. + * + * Components are extracted in Europe/Vienna so the output is always a valid + * Vienna-local HAFAS timestamp, regardless of the server's timezone. + * This is the inverse of parseHafasTime: hafasDateTime(parseHafasTime(d, t)) + * will return { date: d, time: t } for every valid Vienna date/time pair. */ export function hafasDateTime(date: Date): { date: string; time: string } { const pad = (n: number) => String(n).padStart(2, "0"); - const d = `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}`; - const t = `${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}000`; + const p = getDateTimeParts(date, "Europe/Vienna"); + const d = `${p.year}${pad(p.month)}${pad(p.day)}`; + const t = `${pad(p.hour)}${pad(p.minute)}${pad(p.second)}000`; return { date: d, time: t }; }