mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
261 lines
11 KiB
TypeScript
261 lines
11 KiB
TypeScript
/**
|
|
* Text for the reader cards. Plain TypeScript, no React.
|
|
*
|
|
* A card shows a time the way the mail gave it: the wall clock at the place
|
|
* (departure airport, hotel, venue), never converted to the reader's zone.
|
|
* The local date and time are formatted as if they were UTC, with
|
|
* `timeZone: 'UTC'`, so neither the browser's zone nor DST can move them.
|
|
*/
|
|
import { zoneOffsetAt } from './time-zones';
|
|
import type { Address, Item, Moment, Place, Shipment, TransportBooking, TransportLeg } from './types';
|
|
|
|
const MINUTE = 60_000;
|
|
const DAY = 24 * 60 * MINUTE;
|
|
|
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
const TIME_RE = /^\d{2}:\d{2}(?::\d{2})?$/;
|
|
|
|
export interface FormatOptions {
|
|
/** UI locale ("en", "de", ...). */
|
|
locale: string;
|
|
/** true for a 12-hour clock, false for 24 hours, undefined for the locale's own. */
|
|
hour12?: boolean;
|
|
/** "Now", to decide whether the year is shown. Defaults to the current date. */
|
|
now?: Date;
|
|
}
|
|
|
|
/** The moment's local wall clock as a Date whose UTC fields hold it; null when malformed. */
|
|
export function wallClockDate(m: Moment): Date | null {
|
|
if (!DATE_RE.test(m.date)) return null;
|
|
if (m.time !== undefined && !TIME_RE.test(m.time)) return null;
|
|
const time = m.time ? (m.time.length === 5 ? `${m.time}:00` : m.time) : '00:00:00';
|
|
const d = new Date(`${m.date}T${time}Z`);
|
|
return Number.isNaN(d.getTime()) ? null : d;
|
|
}
|
|
|
|
function formatter(options: Intl.DateTimeFormatOptions, opts: FormatOptions): Intl.DateTimeFormat {
|
|
try {
|
|
return new Intl.DateTimeFormat(opts.locale, { ...options, timeZone: 'UTC' });
|
|
} catch {
|
|
return new Intl.DateTimeFormat('en', { ...options, timeZone: 'UTC' });
|
|
}
|
|
}
|
|
|
|
function clockOptions(opts: FormatOptions): Intl.DateTimeFormatOptions {
|
|
if (opts.hour12 === true) return { hour: 'numeric', minute: '2-digit', hour12: true };
|
|
if (opts.hour12 === false) return { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' };
|
|
return { hour: 'numeric', minute: '2-digit' };
|
|
}
|
|
|
|
function dateOptions(m: Moment, opts: FormatOptions): Intl.DateTimeFormatOptions {
|
|
const year = Number(m.date.slice(0, 4));
|
|
const currentYear = (opts.now ?? new Date()).getUTCFullYear();
|
|
return { weekday: 'short', day: 'numeric', month: 'short', ...(year !== currentYear ? { year: 'numeric' } : {}) };
|
|
}
|
|
|
|
/** "Tue, Dec 1" (the year only when it isn't this year). */
|
|
export function formatMomentDate(m: Moment, opts: FormatOptions): string {
|
|
const d = wallClockDate(m);
|
|
return d ? formatter(dateOptions(m, opts), opts).format(d) : m.date;
|
|
}
|
|
|
|
/** "10:15", or undefined for a date-only moment. */
|
|
export function formatMomentTime(m: Moment, opts: FormatOptions): string | undefined {
|
|
if (!m.time) return undefined;
|
|
const d = wallClockDate(m);
|
|
return d ? formatter(clockOptions(opts), opts).format(d) : m.time.slice(0, 5);
|
|
}
|
|
|
|
/** "Tue, Dec 1, 10:15" in the locale's own order; the date alone for a date-only moment. */
|
|
export function formatMoment(m: Moment, opts: FormatOptions): string {
|
|
if (!m.time) return formatMomentDate(m, opts);
|
|
const d = wallClockDate(m);
|
|
if (!d) return `${m.date} ${m.time.slice(0, 5)}`;
|
|
return formatter({ ...dateOptions(m, opts), ...clockOptions(opts) }, opts).format(d);
|
|
}
|
|
|
|
/** Whether two moments fall on the same local date. */
|
|
export function sameLocalDate(a: Moment, b: Moment): boolean {
|
|
return a.date === b.date;
|
|
}
|
|
|
|
/**
|
|
* The moment's UTC offset in minutes east of UTC: the one the mail gave, or
|
|
* the one its resolved zone implies. Undefined for a floating time (shown as
|
|
* written, no zone known) or a date-only moment.
|
|
*/
|
|
export function momentOffsetMinutes(m: Moment): number | undefined {
|
|
if (!m.time) return undefined;
|
|
if (m.offsetMinutes !== undefined) return m.offsetMinutes;
|
|
if (!m.utc) return undefined;
|
|
const wall = wallClockDate(m);
|
|
const instant = Date.parse(m.utc);
|
|
if (!wall || Number.isNaN(instant)) return undefined;
|
|
return Math.round((wall.getTime() - instant) / MINUTE);
|
|
}
|
|
|
|
function instantOf(m: Moment, offset: number): number | undefined {
|
|
if (m.utc) {
|
|
const t = Date.parse(m.utc);
|
|
if (!Number.isNaN(t)) return t;
|
|
}
|
|
const wall = wallClockDate(m);
|
|
return wall ? wall.getTime() - offset * MINUTE : undefined;
|
|
}
|
|
|
|
/**
|
|
* Whether a timed moment's place keeps a different clock than the reader
|
|
* at that instant, so the card marks the time as local to the place.
|
|
* `readerOffsetAt` gives the reader's offset (minutes east of UTC) at an
|
|
* instant; by default the offset of `readerZone`.
|
|
*/
|
|
export function isOtherZone(m: Moment, readerZone: string, readerOffsetAt?: (utcMs: number) => number): boolean {
|
|
const offset = momentOffsetMinutes(m);
|
|
if (offset === undefined) return false;
|
|
const instant = instantOf(m, offset);
|
|
if (instant === undefined) return false;
|
|
try {
|
|
const reader = readerOffsetAt ? readerOffsetAt(instant) : zoneOffsetAt(readerZone, instant);
|
|
return reader !== offset;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** Nights between check-in and check-out dates; undefined when it doesn't add up. */
|
|
export function nightsBetween(checkin: Moment, checkout: Moment): number | undefined {
|
|
const a = wallClockDate({ date: checkin.date });
|
|
const b = wallClockDate({ date: checkout.date });
|
|
if (!a || !b) return undefined;
|
|
const nights = Math.round((b.getTime() - a.getTime()) / DAY);
|
|
return nights > 0 ? nights : undefined;
|
|
}
|
|
|
|
/** "€129.90" in the UI locale; amount and code as given when Intl can't format them. */
|
|
export function formatMoney(total: { amount: string; currency: string }, locale: string): string {
|
|
const n = Number(total.amount);
|
|
if (!Number.isFinite(n)) return `${total.amount} ${total.currency}`;
|
|
try {
|
|
return new Intl.NumberFormat(locale, { style: 'currency', currency: total.currency }).format(n);
|
|
} catch {
|
|
return `${total.amount} ${total.currency}`;
|
|
}
|
|
}
|
|
|
|
function regionName(code: string | undefined, locale: string): string | undefined {
|
|
if (!code) return undefined;
|
|
try {
|
|
return new Intl.DisplayNames([locale], { type: 'region' }).of(code) ?? code;
|
|
} catch {
|
|
return code;
|
|
}
|
|
}
|
|
|
|
/** One line: "Marktplatz 1, 10178 Berlin, Germany". */
|
|
export function formatAddress(address: Address | undefined, locale: string): string | undefined {
|
|
if (!address) return undefined;
|
|
const town = [address.postalCode, address.locality].filter(Boolean).join(' ');
|
|
const country = address.countryName ?? regionName(address.country, locale);
|
|
const parts = [address.street, town, address.region, country].filter((p): p is string => !!p && p.trim() !== '');
|
|
// A street given as one string may already hold the town.
|
|
const unique = parts.filter((p, i) => !parts.slice(0, i).some((q) => q.includes(p)));
|
|
return unique.length ? unique.join(', ') : undefined;
|
|
}
|
|
|
|
/** A place on one line: "Stadtpark, Hamburg"; the name alone when it already names the town. */
|
|
export function formatPlace(place: Place | undefined): string | undefined {
|
|
if (!place) return undefined;
|
|
const name = place.name?.trim();
|
|
const town = place.address?.locality?.trim();
|
|
if (name && town && !name.toLowerCase().includes(town.toLowerCase())) return `${name}, ${town}`;
|
|
return name || town || place.code;
|
|
}
|
|
|
|
/** An airport or station in a leg row: "Frankfurt Airport (FRA)", "Hamburg Hbf". */
|
|
export function legPlaceLabel(place: Place, kind: TransportBooking['kind']): string {
|
|
const name = place.name?.trim() || place.address?.locality?.trim();
|
|
if (kind === 'flight' && place.code) {
|
|
if (!name) return place.code;
|
|
// "Frankfurt" holds the letters FRA; only a code standing on its own counts.
|
|
const code = place.code.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
|
const hasCode = new RegExp(`(^|[^A-Z0-9])${code}([^A-Z0-9]|$)`).test(name.toUpperCase());
|
|
return hasCode ? name : `${name} (${place.code})`;
|
|
}
|
|
return name || place.code || '';
|
|
}
|
|
|
|
/** Carrier and number: "Lufthansa LH 400", "Rail Co ICE 577". */
|
|
export function legService(leg: TransportLeg): string | undefined {
|
|
const number = leg.carrierCode && leg.number && !leg.number.toUpperCase().startsWith(leg.carrierCode.toUpperCase())
|
|
? `${leg.carrierCode} ${leg.number}`
|
|
: leg.number;
|
|
const parts = [leg.carrier, number].filter((p): p is string => !!p);
|
|
return parts.length ? parts.join(' ') : undefined;
|
|
}
|
|
|
|
/**
|
|
* The stops of a route, for its title: airport codes for flights, city or
|
|
* station names otherwise. Connecting legs form one run (FRA, JFK, FRA);
|
|
* a leg that doesn't connect starts the next run.
|
|
*/
|
|
export function routeRuns(booking: TransportBooking): string[][] {
|
|
const legs = booking.legs;
|
|
const towns = legs.flatMap((leg) => [leg.departure.place, leg.arrival?.place]).filter((p): p is Place => !!p)
|
|
.map((p) => p.address?.locality?.trim());
|
|
const useTowns = booking.kind !== 'flight' && towns.every(Boolean)
|
|
&& legs.every((leg) => !leg.arrival || leg.departure.place.address?.locality !== leg.arrival.place.address?.locality);
|
|
const label = (place: Place): string => {
|
|
if (booking.kind === 'flight' && place.code) return place.code;
|
|
if (useTowns && place.address?.locality) return place.address.locality.trim();
|
|
return place.name?.trim() || place.address?.locality?.trim() || place.code || '?';
|
|
};
|
|
const runs: string[][] = [];
|
|
for (const leg of legs) {
|
|
const from = label(leg.departure.place);
|
|
const to = leg.arrival ? label(leg.arrival.place) : undefined;
|
|
const run = runs[runs.length - 1];
|
|
if (run && run[run.length - 1] === from) {
|
|
if (to) run.push(to);
|
|
} else {
|
|
runs.push(to ? [from, to] : [from]);
|
|
}
|
|
}
|
|
return runs;
|
|
}
|
|
|
|
/** The route as plain text, "FRA → JFK → FRA"; runs separated by a comma. */
|
|
export function routeTitle(booking: TransportBooking): string {
|
|
return routeRuns(booking).map((run) => run.join(' → ')).join(', ');
|
|
}
|
|
|
|
/** The items a shipment card lists: the order's, else the parcels', with images from either. */
|
|
export function shipmentItems(shipment: Shipment): Item[] {
|
|
const parcelItems = shipment.parcels.flatMap((p) => p.items);
|
|
if (shipment.items.length === 0) {
|
|
const seen = new Set<string>();
|
|
return parcelItems.filter((item) => (seen.has(item.name) ? false : (seen.add(item.name), true)));
|
|
}
|
|
return shipment.items.map((item) => {
|
|
if (item.image) return item;
|
|
const image = parcelItems.find((p) => p.name === item.name && p.image)?.image;
|
|
return image ? { ...item, image } : item;
|
|
});
|
|
}
|
|
|
|
/** The first `max` item names, how many more there are, and the number of pieces. */
|
|
export function summarizeItems(items: Item[], max = 3): { shown: Item[]; more: number; count: number } {
|
|
const count = items.reduce((sum, item) => sum + (item.quantity && item.quantity > 0 ? item.quantity : 1), 0);
|
|
return { shown: items.slice(0, max), more: Math.max(0, items.length - max), count };
|
|
}
|
|
|
|
/** Whether a link may be shown and opened: http(s) only, checked again here. */
|
|
export function isWebLink(link: { url: string } | undefined): boolean {
|
|
if (!link) return false;
|
|
try {
|
|
const { protocol } = new URL(link.url);
|
|
return protocol === 'http:' || protocol === 'https:';
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|