@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* 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<string, Intl.RelativeTimeFormat>();
|
||||
const dateFormats = new Map<string, Intl.DateTimeFormat>();
|
||||
const numberFormats = new Map<string, Intl.NumberFormat>();
|
||||
|
||||
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;
|
||||
/** 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),
|
||||
|
||||
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 '☆☆☆☆☆';
|
||||
const filled = Math.round(rating);
|
||||
return '★'.repeat(filled) + '☆'.repeat(Math.max(0, 5 - filled));
|
||||
}
|
||||
Reference in New Issue
Block a user