// Minimal i18n: dictionaries per language, `{name}` placeholders, persisted choice. // To add a language: copy en.ts, translate the values, register it in `languages`. // TypeScript enforces that every language defines every key of `en`. import { useSyncExternalStore } from "react"; import { en, type Messages } from "./en"; import { de } from "./de"; export type Key = keyof typeof en; export const languages: Record = { en: { name: "English", messages: en }, de: { name: "Deutsch", messages: de }, }; const STORAGE_KEY = "codecraft.lang"; /** English is the default; other languages are chosen in the settings. */ function detect(): string { const saved = localStorage.getItem(STORAGE_KEY); return saved && languages[saved] ? saved : "en"; } let current = detect(); document.documentElement.lang = current; const listeners = new Set<() => void>(); export function onLangChange(fn: () => void) { listeners.add(fn); return () => void listeners.delete(fn); } export function getLang() { return current; } export function setLang(lang: string) { if (!languages[lang]) return; current = lang; localStorage.setItem(STORAGE_KEY, lang); document.documentElement.lang = lang; listeners.forEach((l) => l()); } export function t(key: Key, vars?: Record): string { let s: string = languages[current].messages[key] ?? en[key] ?? key; if (vars) { for (const [k, v] of Object.entries(vars)) s = s.replaceAll(`{${k}}`, String(v)); } return s; } /** Re-render on language change. Returns `t` for convenience. */ export function useT() { useSyncExternalStore( (l) => { listeners.add(l); return () => listeners.delete(l); }, () => current, ); return t; } // --- locale-aware formatting ------------------------------------------------- export function fmtNumber(n: number, digits = 0) { return new Intl.NumberFormat(current, { maximumFractionDigits: digits }).format(n); } export function fmtCompact(n: number) { return new Intl.NumberFormat(current, { notation: "compact", maximumFractionDigits: 1 }).format(n); } export function fmtCost(n: number, currency: string) { const digits = n !== 0 && Math.abs(n) < 1 ? 4 : 2; try { return new Intl.NumberFormat(current, { style: "currency", currency, maximumFractionDigits: digits }).format(n); } catch { return `${n.toFixed(digits)} ${currency}`; } } export function fmtPercent(n: number) { return new Intl.NumberFormat(current, { style: "percent", maximumFractionDigits: 1 }).format(n); } export function fmtDate(ms: number, withTime = true) { return new Intl.DateTimeFormat(current, withTime ? { dateStyle: "medium", timeStyle: "short" } : { dateStyle: "medium" }).format(ms); } export function fmtRelative(ms: number) { const diff = (ms - Date.now()) / 1000; const rtf = new Intl.RelativeTimeFormat(current, { numeric: "auto" }); const abs = Math.abs(diff); if (abs < 60) return rtf.format(Math.round(diff), "second"); if (abs < 3600) return rtf.format(Math.round(diff / 60), "minute"); if (abs < 86400) return rtf.format(Math.round(diff / 3600), "hour"); return rtf.format(Math.round(diff / 86400), "day"); } export function weekdayNames(): string[] { // Index 0 = Sunday, matching SQLite's %w. const base = new Date(Date.UTC(2024, 0, 7)); // a Sunday return Array.from({ length: 7 }, (_, i) => new Intl.DateTimeFormat(current, { weekday: "short", timeZone: "UTC" }).format(new Date(base.getTime() + i * 86400000)), ); }