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:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`}>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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" />
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user