Add many new language packs with RTL support, refresh brand assets, and harden review rendering with tests. Co-authored-by: Cursor <cursoragent@cursor.com>
184 lines
6.7 KiB
TypeScript
184 lines
6.7 KiB
TypeScript
/**
|
|
* 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;
|
|
/** 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);
|
|
}
|