import type { TFunction } from "i18next"; // The one home of the app's LOCALE forms — numbers, units, dates (alphabetical order: `collate.ts`) — // all in the UI language, never the browser's (lint: `local/locale-format` refuses a hand-built one elsewhere). // // Every formatter takes the language (or `t`) as an ARGUMENT, and that is load-bearing, not style: // the React Compiler memoizes a call on its arguments, so a formatter that read the language // off the i18n singleton kept its old answer after a language switch — a card still said "2 hr 4 min" // beside a hero saying "2 ó 16 p". Passing `i18n.language` (from useTranslation) makes the language a // dependency the compiler can see; the signature makes forgetting it a type error. It is also why // this file never imports the app's i18n: the public /watch entry formats a file size through it, and // that import alone carried every locale of every area into the page a shared link opens. // Intl formatters are expensive to build and every grid formats one per card: one per language × // options, built lazily. Keyed by language because the user can switch it at runtime. const numberFormats = new Map(); function numberFormat(lang: string, opts: Intl.NumberFormatOptions = {}): Intl.NumberFormat { const key = `${lang}|${JSON.stringify(opts)}`; let f = numberFormats.get(key); if (!f) { f = new Intl.NumberFormat(lang, opts); numberFormats.set(key, f); } return f; } export function relativeTime(iso: string | null, lang: string, t: TFunction): string { if (!iso) return ""; return relativeFromMs(new Date(iso).getTime(), lang, t); } /** Human file size, binary units (1024), the number in the UI language: 501747 -> "490 KB", * 5368709120 -> "5.0 GB" / "5,0 GB". The unit symbols are the same in both languages. */ export function formatBytes(bytes: number | null | undefined, lang: string): string { const n = Number(bytes); if (!n || n < 0) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB"]; let i = 0; let v = n; while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } const num = v >= 100 || i === 0 ? formatNumber(Math.round(v), lang) : formatDecimal(v, lang); return `${num} ${units[i]}`; } /** Relative-time tiers as [seconds-per-unit, Intl unit]. The unit is what the divisor produces, * and Intl.RelativeTimeFormat turns the pair into the localized words — so a divisor can no * longer be paired with the wrong label, which is exactly what shifted every tier by one from * ea317c0 until 2026-07-20 (minutes rendered as "Ns ago", hours as "N min ago", on up the scale). * * MUST stay sorted ascending, with the first row doubling as the "just now" threshold: sorting it * largest-first — the natural instinct — would make every timestamp under a year read "just now". * Module-level because it is constant, and exported so the tests can hold that invariant. */ // A non-empty tuple type (first element + rest) so `RELATIVE_UNITS[0]` is known-present under // noUncheckedIndexedAccess — the "sub-minute" branch relies on that first entry always existing. export const RELATIVE_UNITS: readonly [ [number, Intl.RelativeTimeFormatUnit], ...[number, Intl.RelativeTimeFormatUnit][], ] = [ [60, "minute"], [3600, "hour"], [86400, "day"], [604800, "week"], [2592000, "month"], [31536000, "year"], ]; // One formatter per language, built lazily: relativeFromMs runs once per rendered timestamp // across a virtualized feed, and constructing an Intl formatter is the expensive part. Keyed by // language because the user can switch it at runtime. `narrow` is the style that matches the // app's compact timestamps; `numeric: "always"` keeps it counting ("1d ago", never "yesterday"). const relativeFormatters = new Map(); function relativeFormatter(lang: string): Intl.RelativeTimeFormat { let formatter = relativeFormatters.get(lang); if (!formatter) { formatter = new Intl.RelativeTimeFormat(lang, { style: "narrow", numeric: "always" }); relativeFormatters.set(lang, formatter); } return formatter; } /** Relative-time label from an epoch-millis timestamp (the ms-based half of relativeTime, for * client-side notifications whose timestamps are already numbers). One implementation shared * instead of re-implemented per component. Empty string for a timestamp that can't be read, so a * caller's `|| "—"` fallback covers it like a missing one. */ export function relativeFromMs(ms: number, lang: string, t: TFunction): string { const secs = Math.max(0, (Date.now() - ms) / 1000); if (!Number.isFinite(secs)) return ""; // Sub-minute is the one case Intl has no wording for — it would say "0m ago". if (secs < RELATIVE_UNITS[0][0]) return t("time.justNow", { lng: lang }); let [div, unit] = RELATIVE_UNITS[0]; for (const [unitSecs, unitName] of RELATIVE_UNITS) { if (secs < unitSecs) break; div = unitSecs; unit = unitName; } return relativeFormatter(lang).format(-Math.floor(secs / div), unit); } // The canonical YouTube URL for a channel: its @handle when known (nicer), else the // /channel/ form. One place so every channel link across the app stays consistent. export function channelYouTubeUrl(id: string, handle?: string | null): string { return handle ? `https://www.youtube.com/@${handle.replace(/^@/, "")}` : `https://www.youtube.com/channel/${id}`; } export function formatDuration(sec: number | null): string { if (sec == null) return ""; const h = Math.floor(sec / 3600); const m = Math.floor((sec % 3600) / 60); const s = Math.floor(sec % 60); const pad = (n: number) => String(n).padStart(2, "0"); return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`; } // A duration's unit in the UI language, in the browser's own short form (docs/DESIGN.md "Copy"): // "90 min", "2 hr" / "90 p", "2 ó". Hand-typed "90m" / "2h" were English in both languages and // came in three spellings. One formatter per language × unit, built lazily (the grids format one per card). export type DurationUnit = "second" | "minute" | "hour" | "day"; export function formatUnit( value: number, unit: DurationUnit, lang: string, digits?: number, ): string { const fraction = digits == null ? {} : { minimumFractionDigits: digits, maximumFractionDigits: digits }; return numberFormat(lang, { style: "unit", unit, unitDisplay: "short", ...fraction }).format( value, ); } /** Compact human runtime ("1 hr 30 min", "45 min", "45 sec"; HU "1 ó 30 p") from seconds. Formats * BOTH a single item's length (Plex movies/episodes) and an aggregate total (playlist playtime, on * the YouTube and the Plex side alike) — it is the one renderer for a playlist total, so a total * never appears in two shapes on one screen. Empty string for a missing/zero duration. * The sub-minute tier is load-bearing, not decoration: an aggregate is the only input that can land * between 1 and 59 seconds (a playlist of Shorts), and flooring that to a flat "0 min" made a * non-empty playlist report no runtime at all. Keep it, or that case silently regresses. */ export function formatRuntime(sec: number | null | undefined, lang: string): string { if (!sec) return ""; if (sec < 60) return formatUnit(Math.floor(sec), "second", lang); const h = Math.floor(sec / 3600); const m = Math.floor((sec % 3600) / 60); return h ? `${formatUnit(h, "hour", lang)} ${formatUnit(m, "minute", lang)}` : formatUnit(m, "minute", lang); } // Human label for a canonical quota-action key (see backend app.quota.QuotaAction). Resolved // from i18n (quotaActions.) so it's translated in all UI languages; falls back to the raw // key for any unmapped/legacy value. export function quotaActionLabel(t: TFunction, action: string): string { return t(`quotaActions.${action}`, { defaultValue: action }); } /** Coarse, human ETA for a remaining duration in seconds ("~3 hours", "~2 days"). Translated * via the `time.eta.*` keys (was hardcoded English, violating the trilingual rule). */ export function formatEta(t: TFunction, seconds: number): string { if (seconds <= 0) return t("time.eta.done"); const hours = seconds / 3600; if (hours < 1) return t("time.eta.lessThanHour"); if (hours < 24) return t("time.eta.hours", { count: Math.round(hours) }); return t("time.eta.days", { count: Math.round(hours / 24) }); } /** Compact whole-channel total length ("12 hr", "45 min"), hours-scale so H:MM:SS isn't huge. */ export function formatTotalHours(sec: number, lang: string): string { if (!sec) return "—"; const h = Math.round(sec / 3600); return h >= 1 ? formatUnit(h, "hour", lang) : formatUnit(Math.max(1, Math.round(sec / 60)), "minute", lang); } /** A grouped number in the UI language ("1,234,567" in English, "1 234 567" in Hungarian). A bare * `n.toLocaleString()` groups in the BROWSER's locale instead, so an English UI in a Hungarian * browser showed space-grouped figures and vice versa — the number half of the same defect * `formatDateTime` fixes for dates. Empty string for a missing/non-finite value, so a caller's * `|| "—"` fallback covers it like a missing one. */ export function formatNumber(n: number | null | undefined, lang: string): string { if (n == null || !Number.isFinite(n)) return ""; return numberFormat(lang).format(n); } /** A number with `digits` decimals in the UI language — a rating "8.3" / "8,3" (a bare `toFixed` * always wrote the English decimal point). `minDigits` below `digits` drops trailing zeros: "5" GB. */ export function formatDecimal(n: number, lang: string, digits = 1, minDigits = digits): string { const opts = { minimumFractionDigits: minDigits, maximumFractionDigits: digits }; return numberFormat(lang, opts).format(n); } /** A big count, compact, in the UI language: "1.2K" / "1,2 E", "3.4M" / "3,4 M". The minimum is * pinned for the reason `formatMoney` gives. Empty for null. */ export function formatCompact(n: number | null | undefined, lang: string): string { if (n == null || !Number.isFinite(n)) return ""; const opts = { notation: "compact", minimumFractionDigits: 0, maximumFractionDigits: 1 } as const; return numberFormat(lang, opts).format(n); } /** A US-dollar sum, compact ("$160M"): TMDB budgets and revenues are USD whatever the reader's * language. The minimum is load-bearing, not a default written out: given only a maximum, a compact * format is free to pad to it. The same options render "$160.0M" on Node 22 and "$160M" on Node 24 * (measured 2026-09-20), and browsers split along the same line. Pin it rather than inherit it. */ export function formatMoney(n: number, lang: string): string { return numberFormat(lang, { notation: "compact", style: "currency", currency: "USD", minimumFractionDigits: 0, maximumFractionDigits: 1, }).format(n); } // A count for a right-aligned table cell: the humanized number, or an em-dash when unknown (null). export function formatCountOrDash(n: number | null | undefined, lang: string): string { return n != null ? formatCompact(n, lang) : "—"; } // The app's date forms (docs/DESIGN.md "Copy"), all in the UI language — never a bare // `toLocaleString()`, which formats in the BROWSER's locale (an English UI showed a Hungarian-shaped // date) and, with no options, as numbers with seconds ("9/27/2026, 8:30:14 AM"): // relativeTime "3d ago" / "3 napja" — recency in a list; the exact form on hover // formatDate "Sep 27, 2026" / "2026. szept. 27." // formatDateTime "Sep 27, 2026, 08:30 AM" / "2026. szept. 27. 08:30" // formatDateTimeSec the same with seconds — only the server log, the audit and the recorder // lint (`no-restricted-syntax`) keeps date formatting in this file. Empty for null. const DAY: Intl.DateTimeFormatOptions = { year: "numeric", month: "short", day: "numeric" }; const MINUTE: Intl.DateTimeFormatOptions = { ...DAY, hour: "2-digit", minute: "2-digit" }; export function formatDateTime(iso: string | null | undefined, lang: string): string { return iso ? new Date(iso).toLocaleString(lang, MINUTE) : ""; } export function formatDateTimeSec(iso: string | null | undefined, lang: string): string { return iso ? new Date(iso).toLocaleString(lang, { ...MINUTE, second: "2-digit" }) : ""; } // Absolute day-precision date (e.g. "12 Jan 2017"), shown alongside a relative time. Empty for null. // A DATE-only value ("2026-09-17": a release, the legal pages' last update) is a calendar day, not an // instant: `new Date` reads it as UTC midnight, which is still the day before west of UTC, so it is // shown in UTC. A timestamp is shown in the viewer's own zone, as it should be. export function formatDate(iso: string | null | undefined, lang: string): string { if (!iso) return ""; const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(iso); return new Date(iso).toLocaleDateString(lang, { ...DAY, ...(dateOnly ? { timeZone: "UTC" } : {}), }); }