feat: add an airport time zone table for flight cards

This commit is contained in:
Linus Rath
2026-10-30 04:18:53 +01:00
parent 3945d38bbd
commit a529e6e57c
5 changed files with 403 additions and 0 deletions
+8
View File
@@ -55,3 +55,11 @@ Vendored test tool (scripts/a11y-guard/axe.min.js): axe-core 4.10.3,
Copyright (c) 2015-2025 Deque Systems, Inc., licensed under the Mozilla
Public License 2.0 (https://mozilla.org/MPL/2.0/). It runs only in the
accessibility check in CI and is not part of the app.
Third-party data:
- The airport time zone table (lib/structured-mail/data/airport-timezones.json,
generated by scripts/generate-airport-timezones.mjs) is derived from
mwgg/Airports (https://github.com/mwgg/Airports), Copyright (c) 2014 mwgg,
licensed under the MIT License, whose text is the same as the one quoted
above.
@@ -0,0 +1,127 @@
import { describe, expect, it } from 'vitest';
import table from '../data/airport-timezones.json';
import {
countryZone,
fixedOffsetZone,
isValidTimeZone,
loadAirportZones,
resolveZone,
wallClockAt,
zonedTimeToUtc,
zoneOffsetAt,
} from '../time-zones';
const utc = (iso: string) => Date.parse(iso);
describe('airport time zone table', () => {
it('maps IATA codes to their IANA zones', async () => {
const zoneOf = await loadAirportZones();
expect(zoneOf('FRA')).toBe('Europe/Berlin');
expect(zoneOf('JFK')).toBe('America/New_York');
expect(zoneOf('SFO')).toBe('America/Los_Angeles');
expect(zoneOf('NRT')).toBe('Asia/Tokyo');
expect(zoneOf('HNL')).toBe('Pacific/Honolulu');
expect(zoneOf('syd')).toBe('Australia/Sydney');
});
it('knows nothing about codes it does not have', async () => {
const zoneOf = await loadAirportZones();
expect(zoneOf('QQQ')).toBeUndefined();
expect(zoneOf('FRAX')).toBeUndefined();
expect(zoneOf(undefined)).toBeUndefined();
});
it('holds only valid zone names and indexes inside the zone list', () => {
expect(Object.keys(table.airports).length).toBeGreaterThan(7000);
for (const zone of table.zones) expect(isValidTimeZone(zone), zone).toBe(true);
for (const index of Object.values(table.airports)) {
expect(index).toBeGreaterThanOrEqual(0);
expect(index).toBeLessThan(table.zones.length);
}
});
});
describe('fixedOffsetZone', () => {
it('inverts the sign for whole hours', () => {
expect(fixedOffsetZone(-8 * 60)).toBe('Etc/GMT+8');
expect(fixedOffsetZone(5 * 60)).toBe('Etc/GMT-5');
expect(fixedOffsetZone(14 * 60)).toBe('Etc/GMT-14');
expect(fixedOffsetZone(-12 * 60)).toBe('Etc/GMT+12');
});
it('keeps the UTC instant for half hours and offsets without an Etc zone', () => {
expect(fixedOffsetZone(0)).toBe('Etc/UTC');
expect(fixedOffsetZone(330)).toBe('Etc/UTC');
expect(fixedOffsetZone(345)).toBe('Etc/UTC');
expect(fixedOffsetZone(-13 * 60)).toBe('Etc/UTC');
});
it('produces zones whose offset is the one asked for', () => {
for (const hours of [-12, -8, -1, 1, 5, 9, 14]) {
const zone = fixedOffsetZone(hours * 60);
expect(zoneOffsetAt(zone, utc('2026-07-01T00:00:00Z'))).toBe(hours * 60);
}
});
});
describe('zoneOffsetAt and wall clocks', () => {
it('follows DST', () => {
expect(zoneOffsetAt('Europe/Berlin', utc('2026-01-15T12:00:00Z'))).toBe(60);
expect(zoneOffsetAt('Europe/Berlin', utc('2026-07-15T12:00:00Z'))).toBe(120);
expect(zoneOffsetAt('America/Los_Angeles', utc('2027-03-05T04:15:00Z'))).toBe(-480);
expect(zoneOffsetAt('Asia/Kathmandu', utc('2026-07-15T12:00:00Z'))).toBe(345);
});
it('turns an instant into the local date and time across the date line', () => {
expect(wallClockAt(utc('2027-03-05T04:15:00Z'), 'America/Los_Angeles')).toEqual({ date: '2027-03-04', time: '20:15:00' });
expect(wallClockAt(utc('2026-12-01T00:30:00Z'), 'Asia/Tokyo')).toEqual({ date: '2026-12-01', time: '09:30:00' });
expect(wallClockAt(utc('2026-12-01T00:30:00Z'), 'Pacific/Honolulu')).toEqual({ date: '2026-11-30', time: '14:30:00' });
});
it('turns a local time back into the instant, and refuses DST gaps and overlaps', () => {
expect(zonedTimeToUtc('2027-03-04', '20:15', 'America/Los_Angeles')).toBe(utc('2027-03-05T04:15:00Z'));
expect(zonedTimeToUtc('2026-12-01', '09:30:00', 'Asia/Tokyo')).toBe(utc('2026-12-01T00:30:00Z'));
// 02:30 on 29 March 2026 doesn't exist in Berlin; 02:30 on 25 October happens twice.
expect(zonedTimeToUtc('2026-03-29', '02:30', 'Europe/Berlin')).toBeNull();
expect(zonedTimeToUtc('2026-10-25', '02:30', 'Europe/Berlin')).toBeNull();
expect(zonedTimeToUtc('2026-13-01', '10:00', 'Europe/Berlin')).toBeNull();
});
});
describe('countryZone', () => {
it('names the zone of single-zone countries only', () => {
expect(countryZone('DE')).toBe('Europe/Berlin');
expect(countryZone(' de ')).toBe('Europe/Berlin');
expect(countryZone('US')).toBeUndefined();
expect(countryZone('ES')).toBeUndefined();
expect(countryZone('Germany')).toBeUndefined();
});
it('does not trust France for a time without an offset', () => {
expect(countryZone('FR')).toBe('Europe/Paris');
expect(countryZone('FR', { floating: true })).toBeUndefined();
});
});
describe('resolveZone', () => {
const at = utc('2026-06-04T09:44:00Z');
it('accepts a named zone that agrees with the offset', () => {
expect(resolveZone({ named: { zone: 'Europe/Berlin', source: 'country' }, offsetMinutes: 120, utcMs: at }))
.toEqual({ zone: 'Europe/Berlin', source: 'country' });
});
it('reports a mismatch when table and mail disagree', () => {
expect(resolveZone({ named: { zone: 'Europe/Berlin', source: 'airport' }, offsetMinutes: 60, utcMs: at })).toBe('mismatch');
});
it('falls back to a fixed offset, then to UTC, without a named zone', () => {
expect(resolveZone({ offsetMinutes: -480, utcMs: at })).toEqual({ zone: 'Etc/GMT+8', source: 'offset' });
expect(resolveZone({ offsetMinutes: 330, utcMs: at })).toEqual({ zone: 'Etc/UTC', source: 'utc' });
});
it('reads a time without offset in the named zone, or leaves it floating', () => {
expect(resolveZone({ named: { zone: 'Asia/Tokyo', source: 'airport' } })).toEqual({ zone: 'Asia/Tokyo', source: 'airport' });
expect(resolveZone({})).toEqual({ zone: null, source: 'floating' });
});
});
File diff suppressed because one or more lines are too long
+194
View File
@@ -0,0 +1,194 @@
/**
* Time zones for booking cards. Markup gives local times with a UTC offset
* and never an IANA zone; the card shows the place's local time and the
* calendar wants a zone that stays right across DST changes. Plain
* TypeScript on Intl, no React, so the native app can reuse it.
*/
const MINUTE = 60_000;
/**
* Countries that use one IANA zone throughout, so an address in them names
* the zone. Countries whose territory spans zones (US, CA, RU, BR, AU, MX,
* ID, ES with the Canaries, PT with the Azores, ...) are left out on purpose.
*/
const SINGLE_ZONE_COUNTRIES: Record<string, string> = {
AD: 'Europe/Andorra', AE: 'Asia/Dubai', AL: 'Europe/Tirane', AR: 'America/Argentina/Buenos_Aires',
AT: 'Europe/Vienna', BA: 'Europe/Sarajevo', BE: 'Europe/Brussels', BG: 'Europe/Sofia',
BY: 'Europe/Minsk', CH: 'Europe/Zurich', CN: 'Asia/Shanghai', CO: 'America/Bogota',
CR: 'America/Costa_Rica', CU: 'America/Havana', CY: 'Asia/Nicosia', CZ: 'Europe/Prague',
DE: 'Europe/Berlin', DK: 'Europe/Copenhagen', DO: 'America/Santo_Domingo', EE: 'Europe/Tallinn',
EG: 'Africa/Cairo', FI: 'Europe/Helsinki', FR: 'Europe/Paris', GB: 'Europe/London',
GR: 'Europe/Athens', HK: 'Asia/Hong_Kong', HR: 'Europe/Zagreb', HU: 'Europe/Budapest',
IE: 'Europe/Dublin', IL: 'Asia/Jerusalem', IN: 'Asia/Kolkata', IS: 'Atlantic/Reykjavik',
IT: 'Europe/Rome', JM: 'America/Jamaica', JP: 'Asia/Tokyo', KE: 'Africa/Nairobi',
KR: 'Asia/Seoul', LI: 'Europe/Vaduz', LK: 'Asia/Colombo', LT: 'Europe/Vilnius',
LU: 'Europe/Luxembourg', LV: 'Europe/Riga', MA: 'Africa/Casablanca', MC: 'Europe/Monaco',
MD: 'Europe/Chisinau', ME: 'Europe/Podgorica', MK: 'Europe/Skopje', MT: 'Europe/Malta',
MY: 'Asia/Kuala_Lumpur', NL: 'Europe/Amsterdam', NO: 'Europe/Oslo', NP: 'Asia/Kathmandu',
NZ: 'Pacific/Auckland', PA: 'America/Panama', PE: 'America/Lima', PH: 'Asia/Manila',
PL: 'Europe/Warsaw', QA: 'Asia/Qatar', RO: 'Europe/Bucharest', RS: 'Europe/Belgrade',
SE: 'Europe/Stockholm', SG: 'Asia/Singapore', SI: 'Europe/Ljubljana', SK: 'Europe/Bratislava',
SM: 'Europe/San_Marino', TH: 'Asia/Bangkok', TN: 'Africa/Tunis', TR: 'Europe/Istanbul',
TW: 'Asia/Taipei', UY: 'America/Montevideo', VA: 'Europe/Vatican', VN: 'Asia/Ho_Chi_Minh',
ZA: 'Africa/Johannesburg',
};
/**
* Countries in the table whose postal addresses also cover overseas places
* in other zones (a hotel on Réunion often writes "France"). Good enough
* when the mail's offset confirms the zone, not for a time without one.
*/
const NOT_FOR_FLOATING_TIMES = new Set(['FR']);
/** The single IANA zone of a country (ISO 3166-1 alpha-2), if it has one. */
export function countryZone(country: string | undefined, opts: { floating?: boolean } = {}): string | undefined {
const code = country?.trim().toUpperCase();
if (!code || !/^[A-Z]{2}$/.test(code)) return undefined;
if (opts.floating && NOT_FOR_FLOATING_TIMES.has(code)) return undefined;
return SINGLE_ZONE_COUNTRIES[code];
}
const offsetFormatters = new Map<string, Intl.DateTimeFormat>();
function partsFormatter(zone: string): Intl.DateTimeFormat {
let formatter = offsetFormatters.get(zone);
if (!formatter) {
formatter = new Intl.DateTimeFormat('en-US', {
timeZone: zone,
hourCycle: 'h23',
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
});
offsetFormatters.set(zone, formatter);
}
return formatter;
}
export function isValidTimeZone(zone: string): boolean {
try {
partsFormatter(zone);
return true;
} catch {
return false;
}
}
interface WallClock { year: number; month: number; day: number; hour: number; minute: number; second: number }
function wallClockParts(utcMs: number, zone: string): WallClock {
const parts: Record<string, number> = {};
for (const part of partsFormatter(zone).formatToParts(new Date(utcMs))) {
if (part.type !== 'literal') parts[part.type] = Number(part.value);
}
return { year: parts.year, month: parts.month, day: parts.day, hour: parts.hour % 24, minute: parts.minute, second: parts.second };
}
/** UTC offset of `zone` at `utcMs`, in minutes east of UTC (Berlin in winter: 60). */
export function zoneOffsetAt(zone: string, utcMs: number): number {
const w = wallClockParts(utcMs, zone);
const asUtc = Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute, w.second);
return Math.round((asUtc - Math.floor(utcMs / 1000) * 1000) / MINUTE);
}
const pad = (n: number, width = 2) => String(n).padStart(width, '0');
/** Local date and time in `zone` at `utcMs`: `{ date: 'YYYY-MM-DD', time: 'HH:mm:ss' }`. */
export function wallClockAt(utcMs: number, zone: string): { date: string; time: string } {
const w = wallClockParts(utcMs, zone);
return {
date: `${pad(w.year, 4)}-${pad(w.month)}-${pad(w.day)}`,
time: `${pad(w.hour)}:${pad(w.minute)}:${pad(w.second)}`,
};
}
/**
* The UTC instant of a local wall-clock time in `zone`. A time that falls in
* a DST gap or overlap has no single answer; returns null then, so a caller
* never shows a guessed instant.
*/
export function zonedTimeToUtc(date: string, time: string, zone: string): number | null {
const d = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
const t = /^(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(time);
if (!d || !t) return null;
const naive = Date.UTC(+d[1], +d[2] - 1, +d[3], +t[1], +t[2], +(t[3] ?? 0));
const check = new Date(naive);
if (check.getUTCMonth() !== +d[2] - 1 || check.getUTCDate() !== +d[3] || check.getUTCHours() !== +t[1] || +t[2] > 59 || +(t[3] ?? 0) > 59) {
return null;
}
const candidates = new Set<number>();
for (const probe of [naive - 14 * 60 * MINUTE, naive, naive + 14 * 60 * MINUTE]) {
const offset = zoneOffsetAt(zone, probe);
const utc = naive - offset * MINUTE;
if (zoneOffsetAt(zone, utc) === offset) candidates.add(utc);
}
return candidates.size === 1 ? [...candidates][0] : null;
}
/**
* A zone that has a fixed UTC offset. Whole hours map to `Etc/GMT∓N`, whose
* sign is inverted (UTC−8 is `Etc/GMT+8`). Anything else, a half-hour offset
* or one outside `Etc/GMT-14` … `Etc/GMT+12`, has no Etc zone: the caller
* keeps the UTC instant and gets `Etc/UTC`.
*/
export function fixedOffsetZone(offsetMinutes: number): string {
if (offsetMinutes === 0 || offsetMinutes % 60 !== 0) return 'Etc/UTC';
const hours = offsetMinutes / 60;
if (hours > 14 || hours < -12) return 'Etc/UTC';
return hours > 0 ? `Etc/GMT-${hours}` : `Etc/GMT+${-hours}`;
}
type AirportTable = { zones: string[]; airports: Record<string, number> };
let airportTable: Promise<AirportTable> | null = null;
/**
* Load the IATA → IANA table (scripts/generate-airport-timezones.mjs). It is
* its own chunk, fetched from the app's origin the first time a flight card
* needs it. A failed load is forgotten so the next card can retry.
*/
export function loadAirportZones(): Promise<(code: string | undefined) => string | undefined> {
if (!airportTable) {
airportTable = import('./data/airport-timezones.json').then((mod) => (mod.default ?? mod) as AirportTable);
airportTable.catch(() => { airportTable = null; });
}
return airportTable.then((table) => (code) => {
const key = code?.trim().toUpperCase();
if (!key || !/^[A-Z]{3}$/.test(key)) return undefined;
const index = table.airports[key];
return index === undefined ? undefined : table.zones[index];
});
}
export type ZoneSource = 'airport' | 'country' | 'offset' | 'utc' | 'floating';
export interface ResolvedZone {
/** IANA zone, or null for a floating time that stays as written. */
zone: string | null;
source: ZoneSource;
}
/**
* Pick the zone for one end of a booking. `named` is the zone the place
* implies (airport table or single-zone country). With an offset from the
* mail, the named zone must give that same offset at that instant, otherwise
* the mail and the table disagree and the result is `mismatch` (the caller
* drops the booking: we can't tell which of the two is wrong). Without a
* named zone the offset gives a fixed-offset zone. Without an offset the
* time is read in the named zone, or stays floating.
*/
export function resolveZone(input: {
named?: { zone: string; source: 'airport' | 'country' };
offsetMinutes?: number;
utcMs?: number;
}): ResolvedZone | 'mismatch' {
const { named, offsetMinutes, utcMs } = input;
if (offsetMinutes !== undefined && utcMs !== undefined) {
if (named) {
return zoneOffsetAt(named.zone, utcMs) === offsetMinutes ? { zone: named.zone, source: named.source } : 'mismatch';
}
const zone = fixedOffsetZone(offsetMinutes);
return { zone, source: zone === 'Etc/UTC' && offsetMinutes !== 0 ? 'utc' : 'offset' };
}
if (named) return { zone: named.zone, source: named.source };
return { zone: null, source: 'floating' };
}
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env node
/**
* Generate the IATA airport code → IANA time zone table that flight cards
* use (lib/structured-mail/data/airport-timezones.json).
*
* node scripts/generate-airport-timezones.mjs [path/to/airports.json]
*
* Booking markup gives flight times with a UTC offset only; the calendar
* wants the airport's IANA zone, so the event stays right across DST changes.
* The source is mwgg/Airports (MIT, see NOTICE), pinned to one commit and
* checked against its SHA-256. Pass a local copy of airports.json to skip the
* download. The output is committed; rerun only when moving the pin.
*/
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const COMMIT = 'a89e4e0dda9d9878337e2da190ebe1ef1d6af705';
const SHA256 = 'a7754cbe722ea21f0f9ab1ef07f4f07ee1e85b9cc99191ebde0b49fabb3c149a';
const SOURCE = `https://raw.githubusercontent.com/mwgg/Airports/${COMMIT}/airports.json`;
const here = path.dirname(fileURLToPath(import.meta.url));
const OUT = path.resolve(here, '..', 'lib', 'structured-mail', 'data', 'airport-timezones.json');
async function readSource() {
if (process.argv[2]) return fs.readFileSync(process.argv[2]);
const response = await fetch(SOURCE);
if (!response.ok) throw new Error(`GET ${SOURCE}: HTTP ${response.status}`);
return Buffer.from(await response.arrayBuffer());
}
function isValidZone(zone) {
try {
new Intl.DateTimeFormat('en', { timeZone: zone });
return true;
} catch {
return false;
}
}
const raw = await readSource();
const digest = crypto.createHash('sha256').update(raw).digest('hex');
if (digest !== SHA256) throw new Error(`airports.json SHA-256 ${digest}, expected ${SHA256}`);
const byCode = new Map();
let conflicts = 0;
for (const airport of Object.values(JSON.parse(raw.toString('utf8')))) {
const code = typeof airport.iata === 'string' ? airport.iata.trim().toUpperCase() : '';
if (!/^[A-Z]{3}$/.test(code) || !airport.tz || !isValidZone(airport.tz)) continue;
const known = byCode.get(code);
if (known && known !== airport.tz) {
// Two airports claim one code with different zones: drop the code, a
// wrong zone is worse than the offset fallback.
byCode.set(code, null);
conflicts++;
continue;
}
if (known === undefined) byCode.set(code, airport.tz);
}
const zones = [...new Set([...byCode.values()].filter(Boolean))].sort();
const zoneIndex = new Map(zones.map((zone, index) => [zone, index]));
const airports = {};
for (const code of [...byCode.keys()].sort()) {
const zone = byCode.get(code);
if (zone) airports[code] = zoneIndex.get(zone);
}
const table = { source: `mwgg/Airports@${COMMIT.slice(0, 12)} (MIT)`, zones, airports };
fs.mkdirSync(path.dirname(OUT), { recursive: true });
fs.writeFileSync(OUT, JSON.stringify(table) + '\n');
console.log(`${Object.keys(airports).length} airports, ${zones.length} zones, ${conflicts} conflicting codes dropped → ${path.relative(process.cwd(), OUT)}`);