mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
154 lines
7.1 KiB
TypeScript
154 lines
7.1 KiB
TypeScript
/**
|
|
* Second validation pass, once zones can be looked up: give every time its
|
|
* zone and instant, then check order and durations on UTC instants (an
|
|
* eastbound flight over the date line lands "earlier" on the wall clock).
|
|
* A booking whose times don't hold together is dropped.
|
|
*/
|
|
import { countryZone, resolveZone, wallClockAt, zoneOffsetAt, zonedTimeToUtc, type ResolvedZone } from './time-zones';
|
|
import type { Moment, Place, StructuredCard, TransportBooking, TransportLeg } from './types';
|
|
|
|
type ZoneOf = (code: string | undefined) => string | undefined;
|
|
|
|
const MINUTE = 60_000;
|
|
const HOUR = 60 * MINUTE;
|
|
const LIMITS = {
|
|
flight: { min: 10 * MINUTE, max: 20 * HOUR },
|
|
train: { min: MINUTE, max: 48 * HOUR },
|
|
bus: { min: MINUTE, max: 48 * HOUR },
|
|
} as const;
|
|
|
|
/** Whether resolving these cards needs the airport table. */
|
|
export function needsAirportZones(cards: StructuredCard[]): boolean {
|
|
return cards.some((card) => card.kind === 'flight');
|
|
}
|
|
|
|
interface Named { zone: string; source: 'airport' | 'country' }
|
|
|
|
function namedZone(place: Place | undefined, kind: StructuredCard['kind'], zoneOf: ZoneOf | undefined, floating: boolean): Named | undefined {
|
|
if (kind === 'flight') {
|
|
const zone = zoneOf?.(place?.code);
|
|
return zone ? { zone, source: 'airport' } : undefined;
|
|
}
|
|
const zone = countryZone(place?.address?.country, { floating });
|
|
return zone ? { zone, source: 'country' } : undefined;
|
|
}
|
|
|
|
/**
|
|
* Attach zone and instant to a timed moment. 'drop' when table and mail
|
|
* disagree, or when a floating local time falls into a DST gap or overlap.
|
|
* An instant without a known wall clock (a pass's UTC time, `instantOnly`)
|
|
* takes date, time and offset from the place's zone, or is dropped without one.
|
|
*/
|
|
function resolveMoment(m: Moment, named: Named | undefined): Moment | 'drop' {
|
|
if (m.instantOnly) {
|
|
if (!named || !m.utc) return 'drop';
|
|
const ms = Date.parse(m.utc);
|
|
const local = wallClockAt(ms, named.zone);
|
|
return { date: local.date, time: local.time, offsetMinutes: zoneOffsetAt(named.zone, ms), utc: m.utc, zone: named.zone };
|
|
}
|
|
if (!m.time) return m;
|
|
const utcMs = m.utc ? Date.parse(m.utc) : undefined;
|
|
const resolved: ResolvedZone | 'mismatch' = resolveZone({ named, offsetMinutes: m.offsetMinutes, utcMs });
|
|
if (resolved === 'mismatch') return 'drop';
|
|
if (m.utc || resolved.zone === null) return { ...m, zone: resolved.zone };
|
|
const utc = zonedTimeToUtc(m.date, m.time, resolved.zone);
|
|
if (utc === null) return 'drop';
|
|
return { ...m, zone: resolved.zone, utc: new Date(utc).toISOString().replace('.000Z', 'Z') };
|
|
}
|
|
|
|
function resolveLeg(kind: TransportBooking['kind'], leg: TransportLeg, zoneOf: ZoneOf | undefined): TransportLeg | null {
|
|
const dep = resolveMoment(leg.departure.at, namedZone(leg.departure.place, kind, zoneOf, leg.departure.at.offsetMinutes === undefined));
|
|
if (dep === 'drop' || !dep.utc) return null;
|
|
let arrivalAt: Moment | undefined;
|
|
if (leg.arrival?.at) {
|
|
const arr = resolveMoment(leg.arrival.at, namedZone(leg.arrival.place, kind, zoneOf, leg.arrival.at.offsetMinutes === undefined));
|
|
if (arr === 'drop' || !arr.utc) return null;
|
|
const duration = Date.parse(arr.utc) - Date.parse(dep.utc);
|
|
if (duration < LIMITS[kind].min || duration > LIMITS[kind].max) return null;
|
|
arrivalAt = arr;
|
|
}
|
|
return {
|
|
...leg,
|
|
departure: { ...leg.departure, at: dep },
|
|
...(leg.arrival ? { arrival: { ...leg.arrival, ...(arrivalAt ? { at: arrivalAt } : {}) } } : {}),
|
|
};
|
|
}
|
|
|
|
/** Order two resolved moments: by instant when both have one, else by wall clock. */
|
|
function notBefore(later: Moment, earlier: Moment): boolean {
|
|
if (later.utc && earlier.utc) return Date.parse(later.utc) >= Date.parse(earlier.utc);
|
|
return `${later.date}T${later.time ?? '00:00:00'}` >= `${earlier.date}T${earlier.time ?? '00:00:00'}`;
|
|
}
|
|
|
|
function resolveCard(card: StructuredCard, zoneOf: ZoneOf | undefined): StructuredCard | null {
|
|
switch (card.kind) {
|
|
case 'flight': case 'train': case 'bus': {
|
|
const legs: TransportLeg[] = [];
|
|
for (const leg of card.legs) {
|
|
const resolved = resolveLeg(card.kind, leg, zoneOf);
|
|
if (!resolved) return null;
|
|
legs.push(resolved);
|
|
}
|
|
return { ...card, legs };
|
|
}
|
|
case 'lodging': {
|
|
const place: Place = { address: card.address };
|
|
const resolveEnd = (m: Moment): Moment | 'drop' => {
|
|
const named = namedZone(place, card.kind, zoneOf, m.offsetMinutes === undefined);
|
|
// A time with neither offset nor a known zone says nothing reliable:
|
|
// keep the date only.
|
|
if (m.time && m.offsetMinutes === undefined && !named) return { date: m.date };
|
|
return resolveMoment(m, named);
|
|
};
|
|
const checkin = resolveEnd(card.checkin);
|
|
const checkout = resolveEnd(card.checkout);
|
|
if (checkin === 'drop' || checkout === 'drop') return null;
|
|
if (checkin.time && checkout.time && !notBefore(checkout, checkin)) return null;
|
|
return { ...card, checkin, checkout };
|
|
}
|
|
case 'rentalcar': {
|
|
const pickupAt = resolveMoment(card.pickup.at, namedZone(card.pickup.place, card.kind, zoneOf, card.pickup.at.offsetMinutes === undefined));
|
|
const dropoffAt = resolveMoment(card.dropoff.at, namedZone(card.dropoff.place, card.kind, zoneOf, card.dropoff.at.offsetMinutes === undefined));
|
|
if (pickupAt === 'drop' || dropoffAt === 'drop') return null;
|
|
if (!notBefore(dropoffAt, pickupAt) || notBefore(pickupAt, dropoffAt)) return null;
|
|
return { ...card, pickup: { ...card.pickup, at: pickupAt }, dropoff: { ...card.dropoff, at: dropoffAt } };
|
|
}
|
|
case 'event': {
|
|
const named = (m: Moment) => namedZone(card.venue, card.kind, zoneOf, m.offsetMinutes === undefined);
|
|
const start = resolveMoment(card.start, named(card.start));
|
|
if (start === 'drop') return null;
|
|
// An end without its own offset (a date-only end turned into the end of
|
|
// that day) belongs to the start's zone when the venue names none.
|
|
const endNamed = card.end && card.end.offsetMinutes === undefined && !named(card.end) && start.zone
|
|
? { zone: start.zone, source: 'country' as const }
|
|
: card.end ? named(card.end) : undefined;
|
|
const end = card.end ? resolveMoment(card.end, endNamed) : undefined;
|
|
if (end === 'drop') return null;
|
|
if (end && !notBefore(end, start)) return null;
|
|
return { ...card, start, ...(end ? { end } : {}) };
|
|
}
|
|
case 'restaurant': {
|
|
const start = resolveMoment(card.start, namedZone({ address: card.address }, card.kind, zoneOf, card.start.offsetMinutes === undefined));
|
|
if (start === 'drop') return null;
|
|
return { ...card, start };
|
|
}
|
|
case 'shipment':
|
|
return card;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve zones and drop cards whose times don't hold together. `zoneOf` is
|
|
* the airport lookup from loadAirportZones(); without it, flight cards are
|
|
* dropped (the card must not show before its zones are known).
|
|
*/
|
|
export function resolveCards(cards: StructuredCard[], zoneOf?: ZoneOf): StructuredCard[] {
|
|
const out: StructuredCard[] = [];
|
|
for (const card of cards) {
|
|
if (card.kind === 'flight' && !zoneOf) continue;
|
|
const resolved = resolveCard(card, zoneOf);
|
|
if (resolved) out.push(resolved);
|
|
}
|
|
return out;
|
|
}
|