Files
siftlode/frontend/src/lib/format.ts
T
peter 4aaaaa7860 chore(comments): drop epic, sprint and review-round ids from the code
The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").

Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.

Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).

One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
2026-10-05 00:19:39 +02:00

259 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { TFunction } from "i18next";
import i18n from "../i18n";
// 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.
// 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<string, Intl.NumberFormat>();
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): string {
if (!iso) return "";
return relativeFromMs(new Date(iso).getTime(), lang);
}
/** 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]}`;
}
/** Download speed from bytes/sec, e.g. "2.4 MB/s". */
export function formatSpeed(bps: number | null | undefined, lang: string): string {
if (!bps) return "";
return `${formatBytes(bps, lang)}/s`;
}
/** 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<string, Intl.RelativeTimeFormat>();
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): 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 i18n.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/<id> 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.<key>) 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" } : {}),
});
}