Refactor hafasDateTime to use Intl.DateTimeFormat for Vienna TZ

This commit is contained in:
2026-05-09 23:57:41 +02:00
parent 2737bac3a9
commit 0e451bf51e
2 changed files with 148 additions and 26 deletions
+113 -24
View File
@@ -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");
});
});
});
+35 -2
View File
@@ -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 };
}