/** * Locale-aware dates, times and numbers, for both halves of the site. * * One object per locale, built from that locale's translator, so a page and the island * that updates it in place format the same timestamp the same way. Everything that used * to be a hand-rolled `toLocaleDateString('en-GB', …)` goes through here now. * * This file ships to the browser, so it stays small: three cached Intl instances and * some arithmetic. */ import { intlTag, type Locale } from './config.js'; import type { Translator } from './translate.js'; const MINUTE = 60; const HOUR = 3600; const DAY = 86_400; const WEEK = 604_800; const MONTH = 2_592_000; const YEAR = 31_536_000; /** Largest first: the first threshold a difference clears is the unit it is said in. */ const UNITS: Array<{ key: string; intl: Intl.RelativeTimeFormatUnit; seconds: number }> = [ { key: 'year', intl: 'year', seconds: YEAR }, { key: 'month', intl: 'month', seconds: MONTH }, { key: 'week', intl: 'week', seconds: WEEK }, { key: 'day', intl: 'day', seconds: DAY }, { key: 'hour', intl: 'hour', seconds: HOUR }, { key: 'minute', intl: 'minute', seconds: MINUTE }, ]; const relativeFormats = new Map(); const dateFormats = new Map(); const numberFormats = new Map(); function relativeFormat(tag: string): Intl.RelativeTimeFormat { let found = relativeFormats.get(tag); if (!found) { found = new Intl.RelativeTimeFormat(tag, { style: 'short', numeric: 'always' }); relativeFormats.set(tag, found); } return found; } function dateFormat(tag: string, options: Intl.DateTimeFormatOptions, id: string): Intl.DateTimeFormat { const cacheKey = `${tag}:${id}`; let found = dateFormats.get(cacheKey); if (!found) { found = new Intl.DateTimeFormat(tag, options); dateFormats.set(cacheKey, found); } return found; } function numberFormat(tag: string): Intl.NumberFormat { let found = numberFormats.get(tag); if (!found) { found = new Intl.NumberFormat(tag); numberFormats.set(tag, found); } return found; } export interface Formatters { locale: Locale; t: Translator; /** "4d ago", "hace 4 d", "4 d geleden". The dense form, used in rows and card feet. */ relative(unix: number | null, now?: number): string; /** Magnitude only: "4d". The big number in the verdict strip. */ shortDuration(unix: number | null, now?: number): string; /** "3 Aug 2026". Precise, for a banner where a fuzzy date is not good enough. */ date(unix: number | null): string; /** "3 August 2026". The long form, for a day heading in the review feed. */ longDate(unix: number): string; /** "Mar 2024", for "first seen". */ monthYear(unix: number): string; /** "03/08/2026, 14:20 UTC". The mint's own clock, always in UTC. */ serverTime(unix: number): string; /** Grouped: 1,000,000 in English, 1.000.000 in Spanish and Dutch. */ number(n: number): string; /** Compact: "500K" / "500 mil", for a limit quoted inside a meta description. */ compact(n: number): string; /** A rating to one decimal, with the locale's own decimal separator. */ decimal(n: number, digits?: number): string; } /** * Build the formatter set for one locale. * * Cheap enough to call per component: the Intl instances behind it are cached by tag, * and the object itself is a handful of closures. */ export function formatters(t: Translator): Formatters { const tag = intlTag(t.locale); /** * The relative form, catalog first. * * `Intl.RelativeTimeFormat` knows every language's grammar and is the right default, * but its short forms are not short enough for this layout: English gives "4 days * ago" where the design has room for "4d ago", and these strings sit in card feet and * dense review rows that were measured against the compact form. * * So the catalog gets first refusal, per unit, and Intl is the fallback. A fourth * language that never adds a single `time.ago.*` key still renders correct, natural * relative times from day one; the three launch languages tune theirs for width. */ const relative = (unix: number | null, now = Date.now() / 1000): string => { if (unix === null) return t('time.never'); const diff = Math.max(0, Math.floor(now - unix)); if (diff < MINUTE) return t('time.justNow'); for (const unit of UNITS) { if (diff < unit.seconds) continue; const n = Math.floor(diff / unit.seconds); const key = `time.ago.${unit.key}`; if (t.has(key)) return t(key, { n }); return relativeFormat(tag).format(-n, unit.intl); } return t('time.justNow'); }; const shortDuration = (unix: number | null, now = Date.now() / 1000): string => { if (unix === null) return t('time.none'); const diff = Math.max(0, Math.floor(now - unix)); // Under a minute still reads as one minute here: this is a magnitude in a big // display number, and "0m" says less than "1m". if (diff < HOUR) return t('time.short.minute', { n: Math.max(1, Math.floor(diff / MINUTE)) }); for (const unit of UNITS) { if (diff < unit.seconds) continue; return t(`time.short.${unit.key}`, { n: Math.floor(diff / unit.seconds) }); } return t('time.short.minute', { n: 1 }); }; return { locale: t.locale, t, relative, shortDuration, date: (unix) => unix === null ? t('time.unknownDate') : dateFormat(tag, { day: 'numeric', month: 'short', year: 'numeric' }, 'short').format(unix * 1000), longDate: (unix) => dateFormat(tag, { day: 'numeric', month: 'long', year: 'numeric' }, 'long').format(unix * 1000), monthYear: (unix) => dateFormat(tag, { month: 'short', year: 'numeric' }, 'monthYear').format(unix * 1000), serverTime: (unix) => dateFormat( tag, { dateStyle: 'short', timeStyle: 'short', timeZone: 'UTC' }, 'serverTime', ).format(unix * 1000) + ' UTC', number: (n) => numberFormat(tag).format(n), compact: (n) => new Intl.NumberFormat(tag, { notation: 'compact', maximumFractionDigits: 1 }).format(n), decimal: (n, digits = 1) => new Intl.NumberFormat(tag, { minimumFractionDigits: digits, maximumFractionDigits: digits, }).format(n), }; } /** Locale-free, so it lives here beside the rest rather than in two places. */ export function starString(rating: number | null): string { if (rating === null) return '☆☆☆☆☆'; // Ratings are integers 0 to 5 in the data; anything else is clamped rather than // trusted, so a stray fractional or out-of-range value can never emit a malformed // row (six stars, negative repeat counts, a half glyph). const filled = Math.min(5, Math.max(0, Math.round(rating))); return '★'.repeat(filled) + '☆'.repeat(5 - filled); }