Files
CashuMints.space/web/src/i18n/format.ts
T
michilisandCursor 47e6537dde Expand i18n locales and improve reviews UI
Add many new language packs with RTL support, refresh brand assets, and harden review rendering with tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 05:06:58 +02:00

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);
}