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:
2026-09-27 05:46:17 +02:00
parent 2c65edfffd
commit bd6aaa1e43
100 changed files with 1193 additions and 964 deletions
+3 -1
View File
@@ -154,7 +154,9 @@ function MembershipPanel({
{/* A React portal still propagates events up the REACT tree, so a click inside this panel
would otherwise reach the feed card that hosts the trigger and start playback. */}
<div onClick={(e) => e.stopPropagation()}>
<div className="text-xs text-muted px-1.5 pb-1.5">{t("playlists.addToPlaylist")}</div>
<div className="text-xs text-muted px-1.5 pb-1.5">
{t("playlists.addToPlaylistTitle")}
</div>
<div
ref={fade.ref}
style={fade.style}
+3 -3
View File
@@ -11,7 +11,7 @@ import { Copy, Radio, Search } from "lucide-react";
import { api, type LogFacet, type LogQuery, type ServerLogRow } from "../lib/api";
import { copyText } from "../lib/clipboard";
import { formatDateTime } from "../lib/format";
import { formatDateTimeSec } from "../lib/format";
import { nextNavId, notifyNav, rememberNavPosition } from "../lib/history";
import { LEVEL_GROUPS, type LevelGroup } from "../lib/log";
import {
@@ -406,7 +406,7 @@ export default function AdminLogs() {
level={row.level}
event={row.event ?? "—"}
msg={row.msg}
title={formatDateTime(row.ts)}
title={formatDateTimeSec(row.ts)}
open={open === row.id}
onToggle={() => setOpen(open === row.id ? null : row.id)}
>
@@ -475,7 +475,7 @@ function RowDetail({
<div className="flex flex-col gap-2">
<dl className="grid grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-0.5 font-sans text-xs">
<dt>{t("adminLogs.fields.when")}</dt>
<dd className="tabular-nums">{formatDateTime(row.ts)}</dd>
<dd className="tabular-nums">{formatDateTimeSec(row.ts)}</dd>
<dt>{t("adminLogs.fields.source")}</dt>
<dd className="font-mono">
{row.source} · {row.logger}
+2 -7
View File
@@ -33,7 +33,7 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { notifyActionError } from "../lib/actionErrors";
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Me, type ChannelDetail } from "../lib/api";
import type { FeedView } from "../lib/feedView";
import { channelYouTubeUrl, formatViews } from "../lib/format";
import { channelYouTubeUrl, formatDate, formatViews } from "../lib/format";
import { ChannelTagsControl } from "./ChannelTags";
import { useChannelTagActions } from "../lib/useChannelTagActions";
import { Button } from "./ui/Button";
@@ -285,12 +285,7 @@ export default function ChannelPage({
const name = ch?.title ?? initialName ?? channelId;
const ytUrl = channelYouTubeUrl(channelId, ch?.handle);
const joined = ch?.published_at
? new Date(ch.published_at).toLocaleDateString(i18n.language, {
year: "numeric",
month: "short",
})
: null;
const joined = ch?.published_at ? formatDate(ch.published_at, i18n.language) : null;
// Handle + stats on one compact meta line under the name (saves the separate stats row).
const metaParts = [
ch?.handle ? `@${ch.handle.replace(/^@/, "")}` : null,
+5 -4
View File
@@ -8,12 +8,12 @@ import { useLiveQuery } from "../lib/useLiveQuery";
import { useScrollFade } from "../lib/useScrollFade";
import { IconButton } from "./ui/IconButton";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { relativeTime } from "../lib/format";
import { notifyActionError } from "../lib/actionErrors";
import { partnerPub, POLL_MS, refreshPartnerPub, useKeyState } from "../lib/messaging";
import * as e2ee from "../lib/e2ee";
import KeyGate from "./KeyGate";
import { Textarea } from "./ui/Input";
import { RelativeTime } from "./ui/RelativeTime";
// The message list + composer for one conversation, shared by the full Messages page and the
// floating dock window. Handles client-side decryption and encrypt-on-send; the surrounding
@@ -194,9 +194,10 @@ export default function ChatThread({
}`}
>
{text}
<div className={`text-3xs mt-1 ${mine ? "text-accent-fg/70" : "text-muted"}`}>
{relativeTime(m.created_at)}
</div>
<RelativeTime
at={m.created_at}
className={`block text-3xs mt-1 ${mine ? "text-accent-fg/70" : "text-muted"}`}
/>
</div>
</div>
);
+2 -4
View File
@@ -11,6 +11,7 @@ import {
selectDateChip,
type DatePreset,
} from "../lib/dateFilter";
import { formatDate } from "../lib/format";
import { Button } from "./ui/Button";
import { SelectTrigger } from "./ui/SelectTrigger";
import { Input } from "./ui/Input";
@@ -57,10 +58,7 @@ export default function DateFilterSelect({
const key = deriveDateKey(filters, customOpen, presets);
const fmt = (s: string) => {
const d = parseDay(s);
return d
? new Intl.DateTimeFormat(i18n.language, { month: "short", day: "numeric" }).format(d)
: s;
return parseDay(s) ? formatDate(s, i18n.language) : s;
};
const rangeSummary = () => {
const { dateFrom: f, dateTo: to } = filters;
+3 -5
View File
@@ -16,7 +16,7 @@ import {
useStepCount,
useStepPrompt,
} from "../lib/debugRecorder";
import { formatDuration } from "../lib/format";
import { formatDateTimeSec, formatDuration } from "../lib/format";
import { useLayer } from "../lib/layerStack";
import { notify } from "../lib/notifications";
import { qk } from "../lib/queryKeys";
@@ -372,7 +372,7 @@ const chipTone = (s: DebugSession): string => {
/** The last sessions, newest first — reopening one opens its composer, with what was written. */
function SessionHistory() {
const { t, i18n } = useTranslation();
const { t } = useTranslation();
const q = useQuery({ queryKey: qk.debugSessions(), queryFn: api.debugSessions });
const items = q.data?.items.filter((s) => s.ended_at) ?? [];
if (q.isError && !q.data)
@@ -394,9 +394,7 @@ function SessionHistory() {
key={s.id}
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm"
>
<span className="tabular-nums">
{new Date(s.started_at).toLocaleString(i18n.language)}
</span>
<span className="tabular-nums">{formatDateTimeSec(s.started_at)}</span>
<span className="min-w-0 flex-1 truncate text-muted">
{s.draft?.title || t(`settings.diagnostics.levels.${s.level}`)}
</span>
@@ -122,9 +122,7 @@ describe("DiagnosticsSettings", () => {
fireEvent.click(levelButton("settings.diagnostics.levels.DEBUG"));
fireEvent.click(screen.getByTestId("diagnostics-consent-confirm"));
await waitFor(() => expect(screen.getByTestId("diagnostics-status")).toBeTruthy());
expect(screen.getByTestId("diagnostics-status").textContent).toContain(
"settings.diagnostics.hoursMinutes:24h0m",
);
expect(screen.getByTestId("diagnostics-status").textContent).toContain("24 hr 0 min");
} finally {
vi.useRealTimers();
}
@@ -11,6 +11,7 @@ import SegmentedToggle from "./ui/SegmentedToggle";
import { Section, SettingRow } from "./ui/form";
import MyLogViewer from "./MyLogViewer";
import { RecorderSettings } from "./DebugRecorder";
import { formatUnit } from "../lib/format";
const LEVELS: readonly DiagnosticsLevel[] = ["INFO", "DEBUG", "TRACE"];
@@ -171,8 +172,8 @@ export default function DiagnosticsSettings() {
level: levelName(current),
time:
remaining.h > 0
? t("settings.diagnostics.hoursMinutes", remaining)
: t("settings.diagnostics.minutes", remaining),
? `${formatUnit(remaining.h, "hour")} ${formatUnit(remaining.m, "minute")}`
: formatUnit(remaining.m, "minute"),
})}
</span>
</span>
+3 -3
View File
@@ -5,7 +5,7 @@ import { ExternalLink, FileText, Plus, Server, X } from "lucide-react";
import { api, type DebugScreenshot, type DebugSession, type ServerLogRow } from "../lib/api";
import { closeComposer } from "../lib/debugRecorder";
import { formatBytes } from "../lib/format";
import { formatBytes, formatDateTimeSec, formatUnit } from "../lib/format";
import { notify } from "../lib/notifications";
import { useMe } from "../lib/useMe";
import {
@@ -303,8 +303,8 @@ function ComposerBody({
<div ref={root} className="flex flex-col gap-4">
<p className="text-sm text-muted">
{t("recorder.composer.meta", {
start: started.toLocaleString(i18n.language),
minutes,
start: formatDateTimeSec(started.toISOString()),
minutes: formatUnit(minutes, "minute"),
level: t(`settings.diagnostics.levels.${session.level}`),
})}
</p>
+5 -4
View File
@@ -8,7 +8,6 @@ import { useLiveQuery } from "../lib/useLiveQuery";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { relativeTime } from "../lib/format";
import { partnerPub, POLL_MS, SYSTEM_ID, useKeyState } from "../lib/messaging";
import { useHistorySubview } from "../lib/history";
import { openChat } from "../lib/chatDock";
@@ -22,6 +21,7 @@ import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
import { SectionLabel } from "./ui/Headings";
import { RelativeTime } from "./ui/RelativeTime";
type View =
| "list"
@@ -177,9 +177,10 @@ function ConversationList({
>
{c.partner.name}
</span>
<span className="ml-auto text-2xs text-muted shrink-0">
{relativeTime(c.last_message.created_at)}
</span>
<RelativeTime
at={c.last_message.created_at}
className="ml-auto text-2xs text-muted shrink-0"
/>
</div>
<div className="flex items-center gap-2">
<span
@@ -36,7 +36,7 @@ import {
type VideoHiddenMeta,
type VideoWatchedMeta,
} from "../lib/notifications";
import { channelYouTubeUrl, relativeFromMs, relativeTime } from "../lib/format";
import { channelYouTubeUrl } from "../lib/format";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { LEVEL_STYLE } from "./ui/toastLevels";
@@ -57,6 +57,7 @@ import { type ActionMenuItem } from "./ui/ActionMenu";
import { menuGroups } from "./ui/menuGroups";
import { HOVER_REVEAL } from "./ui/reveal";
import { useConfirm } from "./confirmContext";
import { RelativeTime } from "./ui/RelativeTime";
// The single notification center. "System" = durable, server-backed notifications (inbox
// phase 1). "Activity" = the client-side transient events (action confirmations, errors, with
@@ -545,7 +546,7 @@ function NotificationRow({
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<span className="font-medium text-sm">{title}</span>
<span className="text-2xs text-muted shrink-0">{relativeTime(n.created_at)}</span>
<RelativeTime at={n.created_at} className="text-2xs text-muted shrink-0" />
</div>
{body && <div className="text-sm text-muted mt-0.5">{body}</div>}
{videos.length > 0 && (
@@ -634,7 +635,7 @@ function ClientActivityRow({
)}
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-2xs text-muted">{relativeFromMs(n.ts)}</span>
<RelativeTime at={n.ts} className="text-2xs text-muted" />
{needsAction && (
<span className="text-3xs uppercase tracking-wide font-semibold text-accent">
{t("notifications.actionNeeded")}
@@ -637,7 +637,7 @@ describe("Playlists · the header", () => {
it("sums what is known, and says nothing about a runtime nothing sums to", async () => {
mount({ 1: [vid("A", 0), untimed("B")] });
expect(await meta()).toBe("playlists.itemCount · 50m");
expect(await meta()).toBe("playlists.itemCount · 50 min");
cleanup();
mount({ 1: [untimed("C")] });
@@ -14,7 +14,8 @@ vi.mock("react-i18next", async (importOriginal) => {
return {
...actual,
useTranslation: () => ({
t: (key: string, o?: { n?: number }) => (o?.n != null ? `${key}:${o.n}` : key),
t: (key: string, o?: { n?: number; d?: string }) =>
o?.d != null ? `${key}:${o.d}` : o?.n != null ? `${key}:${o.n}` : key,
i18n: { language: "en" },
}),
};
@@ -223,11 +224,11 @@ describe("a double-tap on a side third skips 10 s (C5)", () => {
pressPicture("touch", 800);
expect(bandHidden()).toBe(false); // …which the second takes back
await waitFor(() => expect(lastStart(session)).toEqual(["A", 1510]));
expect(screen.getByText("plex.player.tapForward:10")).toBeTruthy();
expect(screen.getByText("plex.player.tapForward:10 sec")).toBeTruthy();
pressPicture("touch", 800);
await waitFor(() => expect(lastStart(session)).toEqual(["A", 1520]));
expect(screen.getByText("plex.player.tapForward:20")).toBeTruthy();
expect(screen.getByText("plex.player.tapForward:20 sec")).toBeTruthy();
pressPicture("touch", 50); // the other side, a fresh first tap: a toggle, no skip
expect(lastStart(session)).toEqual(["A", 1520]);
@@ -249,7 +250,7 @@ describe("a double-tap on a side third skips 10 s (C5)", () => {
pressPicture("touch", 100);
pressPicture("touch", 100);
await waitFor(() => expect(lastStart(session)).toEqual(["A", 1490]));
expect(screen.getByText("plex.player.tapBack:10")).toBeTruthy();
expect(screen.getByText("plex.player.tapBack:10 sec")).toBeTruthy();
});
});
+2 -1
View File
@@ -84,6 +84,7 @@ import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { Slider } from "./ui/NativeControls";
import { formatUnit } from "../lib/format";
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
const PlexInfo = lazy(() => import("./PlexInfo"));
@@ -865,7 +866,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
seekTo(playhead(absRef, loadStartRef) + zone * TAP_SKIP_S);
flashOsd(
t(zone < 0 ? "plex.player.tapBack" : "plex.player.tapForward", {
n: skips * TAP_SKIP_S,
d: formatUnit(skips * TAP_SKIP_S, "second"),
}),
);
return;
@@ -77,7 +77,7 @@ async function pickFromMenu(open: () => void, label: string) {
describe("PlexPlaylistView · the header's meta line", () => {
it("sums the known runtimes and counts an unknown one as nothing", async () => {
expect(await meta([movie("1", 5400), movie("2"), movie("3", 1800)])).toBe(
"plex.browse.items · 2h 0m",
"plex.browse.items · 2 hr 0 min",
);
});
+9 -5
View File
@@ -22,7 +22,7 @@ import { useLiveQuery } from "../lib/useLiveQuery";
import { Button } from "./ui/Button";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { useConfirm } from "./confirmContext";
import { formatNumber, relativeTime } from "../lib/format";
import { formatDateTime, formatNumber, formatUnit, relativeTime } from "../lib/format";
import { notify } from "../lib/notifications";
import Tooltip from "./ui/Tooltip";
import { notifyActionError } from "../lib/actionErrors";
@@ -48,11 +48,12 @@ function secsUntil(iso: string | null): number | null {
function fmtCountdown(s: number): string {
if (s <= 0) return i18n.t("time.eta.now");
if (s < 60) return `${s}s`;
// The countdown speaks the UI language's units (DESIGN Copy), like every other duration.
if (s < 60) return formatUnit(s, "second");
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ${s % 60}s`;
if (m < 60) return `${formatUnit(m, "minute")} ${formatUnit(s % 60, "second")}`;
const h = Math.floor(m / 60);
return `${h}h ${m % 60}m`;
return `${formatUnit(h, "hour")} ${formatUnit(m % 60, "minute")}`;
}
const DOT_CLASS: Record<string, string> = {
@@ -243,7 +244,10 @@ function JobRow({
</Tooltip>
)}
</div>
<div className="text-2xs text-muted truncate">
<div
className="text-2xs text-muted truncate"
title={(!job.running && formatDateTime(job.last_finished)) || undefined}
>
{job.running
? t("scheduler.runningNow")
: job.last_finished
+4 -8
View File
@@ -1,7 +1,7 @@
import { useState } from "react";
import { withActiveAccount } from "../lib/activeAccount";
import { qk } from "../lib/queryKeys";
import { formatDateTime } from "../lib/format";
import { formatDate, formatDateTime } from "../lib/format";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Activity, Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react";
@@ -519,7 +519,7 @@ function SignInMethods({ me }: { me: Me }) {
// and does a one-time "Plex is master" import. Admin-only (rides the server admin token) and only
// shown when the Plex module is enabled. Two-way push/pull arrive in later phases.
function PlexWatchSync() {
const { t, i18n } = useTranslation();
const { t } = useTranslation();
const qc = useQueryClient();
const link = useQuery({ queryKey: qk.plexWatchLink(), queryFn: () => api.plexWatchLink() });
const enabled = link.data?.sync_enabled ?? false;
@@ -562,9 +562,7 @@ function PlexWatchSync() {
// Reflect the toggle's target immediately (optimistic) so the switch responds on click rather than
// only flipping once the multi-second import returns — otherwise it reads as "nothing happened".
const shown = toggle.isPending ? Boolean(toggle.variables) : enabled;
const last = link.data?.last_watch_sync_at
? new Date(link.data.last_watch_sync_at).toLocaleString(i18n.language)
: null;
const last = link.data?.last_watch_sync_at ? formatDateTime(link.data.last_watch_sync_at) : null;
return (
<Section title={t("settings.plexSync.title")}>
@@ -670,9 +668,7 @@ export function DownloadAuth() {
) : (
<div className="divide-y divide-border">
{hosts.map((h) => {
const when = h.updated_at
? new Date(h.updated_at).toLocaleDateString(i18n.language)
: "";
const when = h.updated_at ? formatDate(h.updated_at, i18n.language) : "";
const open = openHost === h.host_key;
return (
<div key={h.host_key} className="py-2" data-testid={`cookie-host-${h.host_key}`}>
+3 -3
View File
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { History, Pause, Play, RefreshCw } from "lucide-react";
import { api, type AdminQuotaRow, type Me } from "../lib/api";
import { formatEta, formatNumber, quotaActionLabel } from "../lib/format";
import { formatEta, formatNumber, formatUnit, quotaActionLabel } from "../lib/format";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { useWizard } from "./wizardContext";
@@ -259,7 +259,7 @@ function SystemStats() {
className="flex items-center gap-1.5"
options={RANGES.map((d) => ({
value: String(d),
label: t("stats.rangeDays", { count: d }),
label: t("stats.rangeDays", { d: formatUnit(d, "day") }),
}))}
value={String(days)}
onChange={(v) => setDays(Number(v))}
@@ -286,7 +286,7 @@ function SystemStats() {
<div className={`${SURFACE.card} p-3 mb-4`}>
<div className="text-xs text-muted mb-2">
{t("stats.dailyTotal", {
count: data.range_days,
d: formatUnit(data.range_days, "day"),
units: formatNumber(grandTotal),
})}
</div>
@@ -143,13 +143,13 @@ describe("the card on a shelf", () => {
const title = titleOf(card).closest(".font-medium")!;
expect(title.className).toContain("truncate");
expect(title.className).not.toContain("line-clamp-2");
expect(card.querySelector("span[title]")).toBeNull();
expect(card.querySelector("time[title]")).toBeNull();
expect(screen.getByRole("link", { name: "LEMMiNO" })).toBeTruthy();
});
it("off a shelf keeps two title lines and the age", () => {
const card = renderSmall(false);
expect(titleOf(card).closest(".font-medium")!.className).toContain("line-clamp-2");
expect(card.querySelector("span[title]")).not.toBeNull();
expect(card.querySelector("time[title]")).not.toBeNull();
});
});
+2 -11
View File
@@ -35,6 +35,7 @@ import {
} from "./ui/mediaCardStyles";
import { MediaCard, MediaProgress } from "./ui/MediaCard";
import { MediaResumeButtons } from "./MediaResumeButtons";
import { RelativeTime } from "./ui/RelativeTime";
/** The "…" that opens the video's action list, and whether its menu is the open one. */
type More = { props: Record<string, unknown>; open: boolean };
@@ -700,17 +701,7 @@ function VideoCard({
{/* The small card keeps the relative time and moves the date into its tooltip: at a
shelf's width the full pair wrapped to a third meta line, and the tallest card sets
the whole shelf's height (every card fills its slot). */}
{spec.dense ? (
<span
title={
video.published_at ? formatDate(video.published_at, i18n.language) : undefined
}
>
{relativeTime(video.published_at)}
</span>
) : (
published
)}
{spec.dense ? <RelativeTime at={video.published_at} /> : published}
</div>
</>
)
+2 -9
View File
@@ -1,8 +1,7 @@
import type { TFunction } from "i18next";
import { relativeTime, formatDate } from "../lib/format";
import i18n from "../i18n";
import type { AuditRow } from "../lib/api";
import type { Column } from "./ui/DataTable";
import { RelativeTime } from "./ui/RelativeTime";
function actionLabel(t: TFunction, action: string): string {
// Canonical key → localized label; falls back to the raw key for any unmapped action.
@@ -44,13 +43,7 @@ export function auditColumns(
sortValue: (r) => r.created_at ?? "",
cardPrimary: true,
render: (r) => (
<span
className="text-muted tabular-nums"
title={r.created_at ? formatDate(r.created_at, i18n.language) : ""}
>
{/* `|| "—"` not a null check: relativeTime is empty for a missing OR unreadable date. */}
{relativeTime(r.created_at) || "—"}
</span>
<RelativeTime at={r.created_at} seconds empty="—" className="text-muted tabular-nums" />
),
},
{
+3 -2
View File
@@ -1,7 +1,7 @@
import type { ComponentProps, ReactNode } from "react";
import type { TFunction } from "i18next";
import { NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
import { formatCountOrDash, formatTotalHours, relativeTime } from "../lib/format";
import { formatCountOrDash, formatTotalHours } from "../lib/format";
import type { Column } from "./ui/DataTable";
import { PriorityStepper } from "./ui/PriorityStepper";
import ChannelLink from "./ChannelLink";
@@ -9,6 +9,7 @@ import AboutCell from "./AboutCell";
import { ChannelTagsControl, type SuggestedTag } from "./ChannelTags";
import { StoredCell } from "./channelCells";
import { isFullySynced } from "./channelSync";
import { RelativeTime } from "./ui/RelativeTime";
/** The shared "subscribers" DataTable column (right-aligned, sortable, em-dash for null) — identical
* in the Channels manager and the ChannelDiscovery table, which show subscriber counts on different
@@ -148,7 +149,7 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
width: "6rem",
sortable: true,
sortValue: (c) => (c.last_video_at ? new Date(c.last_video_at).getTime() : 0),
render: (c) => <span className="text-muted">{relativeTime(c.last_video_at) || "—"}</span>,
render: (c) => <RelativeTime at={c.last_video_at} empty="—" className="text-muted" />,
},
{
key: "length",
+1 -1
View File
@@ -111,7 +111,7 @@ function NewCollectionPanel({
return (
<div className={`${SURFACE.panel} mb-6 p-4`}>
<div className="mb-2 flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold">{t("plex.collection.newCollection")}</h3>
<h3 className="text-sm font-semibold">{t("plex.collection.newCollectionTitle")}</h3>
<IconButton size="md" onClick={onClose} label={t("common.close")}>
<X />
</IconButton>
@@ -85,7 +85,7 @@ describe("PlexPosterCard a11y", () => {
);
expect(screen.getByText("2023")).toBeTruthy();
expect(screen.getByText("7.5")).toBeTruthy(); // rating.toFixed(1)
expect(screen.getByText("1h 53m")).toBeTruthy();
expect(screen.getByText("1 hr 53 min")).toBeTruthy();
expect(screen.getByRole("button", { name: movie().title }).getAttribute("title")).toBeNull();
});
+1 -1
View File
@@ -43,7 +43,7 @@ domain-free.
B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a
link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
| data display | `DataTable`, `tableHeader` + `tableAlign`, `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup` |
| data display | `DataTable`, `tableHeader` + `tableAlign`, `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy") |
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
@@ -0,0 +1,40 @@
// @vitest-environment jsdom
//
// docs/DESIGN.md "Copy": a relative time in a list carries the exact moment on hover.
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render } from "@testing-library/react";
import { formatDateTime, formatDateTimeSec, relativeTime } from "../../lib/format";
import { RelativeTime } from "./RelativeTime";
afterEach(cleanup);
const THREE_DAYS_AGO = new Date(Date.now() - 3 * 86400_000).toISOString();
describe("RelativeTime", () => {
it("says how long ago, and names the exact minute on hover", () => {
const { container } = render(<RelativeTime at={THREE_DAYS_AGO} />);
const el = container.querySelector("time")!;
expect(el.textContent).toBe(relativeTime(THREE_DAYS_AGO));
expect(el.getAttribute("title")).toBe(formatDateTime(THREE_DAYS_AGO));
expect(el.getAttribute("dateTime")).toBe(THREE_DAYS_AGO);
});
it("takes epoch millis, and the log form with seconds", () => {
const ms = Date.parse(THREE_DAYS_AGO);
const { container } = render(<RelativeTime at={ms} seconds />);
expect(container.querySelector("time")!.getAttribute("title")).toBe(
formatDateTimeSec(THREE_DAYS_AGO),
);
});
it("shows the placeholder for a missing or unreadable moment, never a bare title", () => {
const { container } = render(
<>
<RelativeTime at={null} empty="—" />
<RelativeTime at="not a date" />
</>,
);
expect(container.textContent).toBe("—");
expect(container.querySelector("time")).toBeNull();
});
});
@@ -0,0 +1,32 @@
import { formatDateTime, formatDateTimeSec, relativeFromMs } from "../../lib/format";
// docs/DESIGN.md "Copy": a relative time ("3d ago") says how recent something is in a list, and the
// exact moment is one hover away — so the pair travels together here instead of every list
// remembering (or forgetting) its own `title`. `seconds` is the log form (the audit). An unreadable
// or missing moment renders `empty` ("—" in a table cell) or nothing.
export function RelativeTime({
at,
seconds = false,
empty = "",
className,
}: {
/** An ISO timestamp, or epoch millis (a client-side notification). */
at: string | number | null | undefined;
seconds?: boolean;
empty?: string;
className?: string;
}) {
const ms = typeof at === "number" ? at : at ? new Date(at).getTime() : NaN;
const text = Number.isFinite(ms) ? relativeFromMs(ms) : "";
if (!text) return empty ? <span className={className}>{empty}</span> : null;
const iso = new Date(ms).toISOString();
return (
<time
dateTime={iso}
title={seconds ? formatDateTimeSec(iso) : formatDateTime(iso)}
className={className}
>
{text}
</time>
);
}