// 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 }; }