116 lines
4.4 KiB
TypeScript
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: "numeric",
|
|
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: "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 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 };
|
|
}
|