Refactor hafasDateTime to use Intl.DateTimeFormat for Vienna TZ
This commit is contained in:
@@ -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("pads single-digit month/day/hour/minute", () => {
|
||||
const input = new Date("2024-01-02T03:04:05");
|
||||
const { date, time } = hafasDateTime(input);
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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("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");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
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("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);
|
||||
expect(time).toBe("080307000");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+35
-2
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user