Files
time_to_leave/packages/core/src/hafas-time.ts
T
2026-05-10 21:19:39 +02:00

116 lines
4.4 KiB
TypeScript

// Timezone-aware parsing for HAFAS times.
// HAFAS returns times in Vienna (CET/CEST). This module provides correct parsing
// regardless of the server or browser timezone.
// Helper: get the offset (in minutes) from UTC for a given instant in a specific
// IANA timezone.
//
// CAVEAT — Hour-level precision only. This helper reads the hour via
// Intl.DateTimeFormat (hour part only), so it returns incorrect results for
// timezones with fractional-hour offsets (e.g. India +05:30, Nepal +05:45).
// It is designed exclusively for "Europe/Vienna" (CET/CEST), which only uses
// full-hour offsets (+01:00 / +02:00). Do not reuse for other timezones.
function getTimezoneOffsetMinutes(instant: Date, tz: string): number {
const getHour = (timeZone: string) => {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
hour: "2-digit",
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.
* HAFAS dates are "YYYYMMDD" and times are "HHMMSS".
* 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.
*
* Ambiguous times during the fall-back DST transition (02:xx on Oct 27, 2024):
* HAFAS resolves ambiguous local times by using the post-transition (standard)
* interpretation. Our loop tries CET (+60) before CEST (+120), so ambiguous
* hours naturally resolve to CET — matching HAFAS convention. For example
* "023000" on "20241027" → Oct 27 01:30:00 UTC (the second 02:30, not the
* first).
*/
export function parseHafasTime(dateStr: string, timeStr: string): Date {
const y = parseInt(dateStr.slice(0, 4), 10);
const mo = parseInt(dateStr.slice(4, 6), 10) - 1;
const d = parseInt(dateStr.slice(6, 8), 10);
const h = parseInt(timeStr.slice(0, 2), 10);
const m = parseInt(timeStr.slice(2, 4), 10);
const s = parseInt(timeStr.slice(4, 6), 10);
for (const offsetMin of [60, 120]) {
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);
}
// 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: "2-digit",
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 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 };
}