feat(design): the Copy rule — names, verbs, dates and units (E28 S3g)
The owner's picks (all the recommended options) become DESIGN.md's Copy chapter and a sweep of both languages (742 values, no key drift): - One name per feature: the rail's label, which may shorten it (Alerts -> Notifications, Fiókok -> Felhasználók, Download Center -> Downloads, Channel manager -> Channels); one "sync from YouTube" verb. - Four verbs, four meanings (Delete / Remove / Dismiss / Discard, and their Hungarian pairs), checked against what each action does. - Sentence case, the app's places capitalised; "…" on a command that asks first (a popover or form title got its own key without it); one-sentence messages without a stop or a tick, "Couldn't …"; curly quotes. - Dates only from lib/format.ts, in the UI language: formatDateTime names the month and stops at the minute (it printed "9/27/2026, 8:30:14 AM"), formatDateTimeSec for the log, the audit and the recorder; ui/RelativeTime pairs "3d ago" with the exact minute on hover. local/date-format refuses toLocale*String and Intl date formatters elsewhere. - Units from Intl, short (formatUnit): runtimes, totals, the Scheduler countdown, the stats range, the player's tap skip. "2h 13m" was English in both languages. - src/i18n/copyRule.test.ts holds the machine-readable part (quotes, "...", ticks, bare unit letters).
This commit is contained in:
+41
-15
@@ -96,19 +96,34 @@ export function formatDuration(sec: number | null): string {
|
||||
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).
|
||||
// A duration's unit in the UI language, in the browser's own short form (docs/DESIGN.md "Copy", E28
|
||||
// S3g): "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";
|
||||
const unitFormatters = new Map<string, Intl.NumberFormat>();
|
||||
export function formatUnit(value: number, unit: DurationUnit): string {
|
||||
const key = `${i18n.language}|${unit}`;
|
||||
let f = unitFormatters.get(key);
|
||||
if (!f) {
|
||||
f = new Intl.NumberFormat(i18n.language, { style: "unit", unit, unitDisplay: "short" });
|
||||
unitFormatters.set(key, f);
|
||||
}
|
||||
return f.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 "0m" made a
|
||||
* 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): string {
|
||||
if (!sec) return "";
|
||||
if (sec < 60) return `${Math.floor(sec)}s`;
|
||||
if (sec < 60) return formatUnit(Math.floor(sec), "second");
|
||||
const h = Math.floor(sec / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
return h ? `${h}h ${m}m` : `${m}m`;
|
||||
return h ? `${formatUnit(h, "hour")} ${formatUnit(m, "minute")}` : formatUnit(m, "minute");
|
||||
}
|
||||
|
||||
// Human label for a canonical quota-action key (see backend app.quota.QuotaAction). Resolved
|
||||
@@ -128,11 +143,11 @@ export function formatEta(seconds: number): string {
|
||||
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. */
|
||||
/** Compact whole-channel total length ("12 hr", "45 min"), 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 ? `${formatNumber(h)} h` : `${Math.max(1, Math.round(sec / 60))} m`;
|
||||
return h >= 1 ? formatUnit(h, "hour") : formatUnit(Math.max(1, Math.round(sec / 60)), "minute");
|
||||
}
|
||||
|
||||
// One grouped-number formatter per language, built lazily — the same reason as the relative-time
|
||||
@@ -169,10 +184,23 @@ export function formatCountOrDash(n: number | null | undefined): string {
|
||||
return n != null ? formatViews(n) : "—";
|
||||
}
|
||||
|
||||
// Absolute date AND time in the UI language (e.g. "16 Sept 2026, 08:30:14"). A bare `toLocaleString()`
|
||||
// formats in the browser's locale instead, so an English UI showed a Hungarian-shaped date. Empty for null.
|
||||
// The app's date forms (docs/DESIGN.md "Copy", E28 S3g), 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): string {
|
||||
return iso ? new Date(iso).toLocaleString(i18n.language) : "";
|
||||
return iso ? new Date(iso).toLocaleString(i18n.language, MINUTE) : "";
|
||||
}
|
||||
|
||||
export function formatDateTimeSec(iso: string | null | undefined): string {
|
||||
return iso ? new Date(iso).toLocaleString(i18n.language, { ...MINUTE, second: "2-digit" }) : "";
|
||||
}
|
||||
|
||||
// Absolute day-precision date (e.g. "12 Jan 2017"), shown alongside a relative time. Empty for null.
|
||||
@@ -183,9 +211,7 @@ 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, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
...DAY,
|
||||
...(dateOnly ? { timeZone: "UTC" } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user