Epic-end deep review, round 1. Six findings survived verification; five of them were one defect wearing different hats. formatRuntime floored 1-59s to "0m". Harmless while it only ever formatted a Plex movie's length, but 12D repurposed it for an aggregate, and a total is the only input that can land under a minute: a playlist of Shorts reported "0m" while the rail beside it read "0:45". Fixed in the function, not behind a `>= 60` guard at each of the seven call sites — the invariant belongs where it cannot be forgotten. Its contract comment claimed "Plex movie/episode lengths" and was two epics stale. The rail rendered the same playlist total as formatDuration (1:23:45) while the new header rendered formatRuntime (1h 23m) — two shapes of one number, side by side on one screen. The rail now uses formatRuntime too; formatTotalHours had already recorded that H:MM:SS is wrong for an aggregate. The rename input kept min-w-0 without flex-1 in the compacted row, so it was the only shrinkable child beside a shrink-0 action cluster. The Plex half of this same epic already had the fix; this restores the symmetry. plex.browse.items had no plural forms, so a one-item Plex playlist read "1 items". Pre-existing, but the new header is the call site where count=1 is ordinary. Both languages get both forms, no key drift. formatRuntime had no unit tests at all across seven call sites. Added a tier table plus a sweep asserting no non-zero second ever renders as "0m". Dropped after 3/3 adversarial refutations: the meta-line segment ordering (the filtered count is labelled, and count-then-runtime is the convention) and the null-duration lower bound (the server's own SUM has skipped NULLs since long before this branch; changing only the client would split them).
157 lines
7.1 KiB
TypeScript
157 lines
7.1 KiB
TypeScript
import i18n from "../i18n";
|
|
|
|
export function relativeTime(iso: string | null): string {
|
|
if (!iso) return "";
|
|
return relativeFromMs(new Date(iso).getTime());
|
|
}
|
|
|
|
/** Human file size, binary units (1024). e.g. 501747 -> "490 KB", 5368709120 -> "5.0 GB". */
|
|
export function formatBytes(bytes: number | null | undefined): 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++;
|
|
}
|
|
return `${v >= 100 || i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
|
|
}
|
|
|
|
/** Download speed from bytes/sec, e.g. "2.4 MB/s". */
|
|
export function formatSpeed(bps: number | null | undefined): string {
|
|
if (!bps) return "";
|
|
return `${formatBytes(bps)}/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): 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");
|
|
let [div, unit] = RELATIVE_UNITS[0];
|
|
for (const [unitSecs, unitName] of RELATIVE_UNITS) {
|
|
if (secs < unitSecs) break;
|
|
div = unitSecs;
|
|
unit = unitName;
|
|
}
|
|
return relativeFormatter(i18n.language).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)}`;
|
|
}
|
|
|
|
/** Compact human runtime ("1h 30m", "45m", "45s") 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 (nothing to show).
|
|
* 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 "0m" made a
|
|
* non-empty playlist report no runtime at all. Keep it, or that case silently regresses. */
|
|
export function formatRuntime(sec?: number | null): string {
|
|
if (!sec) return "";
|
|
if (sec < 60) return `${Math.floor(sec)}s`;
|
|
const h = Math.floor(sec / 3600);
|
|
const m = Math.floor((sec % 3600) / 60);
|
|
return h ? `${h}h ${m}m` : `${m}m`;
|
|
}
|
|
|
|
// 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(action: string): string {
|
|
return i18n.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(seconds: number): string {
|
|
if (seconds <= 0) return i18n.t("time.eta.done");
|
|
const hours = seconds / 3600;
|
|
if (hours < 1) return i18n.t("time.eta.lessThanHour");
|
|
if (hours < 24) return i18n.t("time.eta.hours", { count: Math.round(hours) });
|
|
return i18n.t("time.eta.days", { count: Math.round(hours / 24) });
|
|
}
|
|
|
|
/** Compact whole-channel total length ("12 h", "45 m"), hours-scale so H:MM:SS isn't huge. */
|
|
export function formatTotalHours(sec: number): string {
|
|
if (!sec) return "—";
|
|
const h = Math.round(sec / 3600);
|
|
return h >= 1 ? `${h.toLocaleString()} h` : `${Math.max(1, Math.round(sec / 60))} m`;
|
|
}
|
|
|
|
export function formatViews(n: number | null): string {
|
|
if (n == null) return "";
|
|
if (n >= 1e9) return `${(n / 1e9).toFixed(1).replace(/\.0$/, "")}B`;
|
|
if (n >= 1e6) return `${(n / 1e6).toFixed(1).replace(/\.0$/, "")}M`;
|
|
if (n >= 1e3) return `${(n / 1e3).toFixed(1).replace(/\.0$/, "")}K`;
|
|
return String(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): string {
|
|
return n != null ? formatViews(n) : "—";
|
|
}
|
|
|
|
// Absolute day-precision date (e.g. "12 Jan 2017"), shown alongside a relative time. Empty for null.
|
|
export function formatDate(iso: string | null | undefined, lang: string): string {
|
|
return iso
|
|
? new Date(iso).toLocaleDateString(lang, { year: "numeric", month: "short", day: "numeric" })
|
|
: "";
|
|
}
|