diff --git a/web/src/components/admin/deviceOverrides.tsx b/web/src/components/admin/deviceOverrides.tsx index 29569f5c..d4ffd996 100644 --- a/web/src/components/admin/deviceOverrides.tsx +++ b/web/src/components/admin/deviceOverrides.tsx @@ -23,7 +23,7 @@ import { import { SETTING_KEYS } from "@/lib/settingsContract"; import { cn } from "@/lib/utils"; import type { AdminDeviceSetting } from "@/hooks/queries/admin/users"; -import { formatDate } from "@/lib/datetime"; +import { formatRelativeTime } from "@/lib/date"; export const UNKNOWN_PROFILE_ID = "unknown-profile"; @@ -90,18 +90,7 @@ export function platformLabel(raw: string | undefined): string { } export function formatRelative(iso: string | undefined): string { - if (!iso) return "—"; - const then = new Date(iso).getTime(); - if (Number.isNaN(then)) return "—"; - const diff = Date.now() - then; - const m = Math.round(diff / 60000); - if (m < 1) return "just now"; - if (m < 60) return `${m}m ago`; - const h = Math.round(m / 60); - if (h < 24) return `${h}h ago`; - const d = Math.round(h / 24); - if (d < 30) return `${d}d ago`; - return formatDate(iso); + return formatRelativeTime(iso, { absoluteAfterDays: 30 }) ?? "—"; } export function shortenId(id: string, visible = 8): string { diff --git a/web/src/components/admin/subtitles/AdminSubtitlesTable.tsx b/web/src/components/admin/subtitles/AdminSubtitlesTable.tsx index 46e37b50..b8c40fe9 100644 --- a/web/src/components/admin/subtitles/AdminSubtitlesTable.tsx +++ b/web/src/components/admin/subtitles/AdminSubtitlesTable.tsx @@ -26,7 +26,7 @@ import { providerLabel, staggerRowClass, } from "./subtitleAdminStyles"; -import { formatDate } from "@/lib/datetime"; +import { formatRelativeTime } from "@/lib/date"; interface AdminSubtitlesTableProps { subtitles: AdminDownloadedSubtitle[]; @@ -37,17 +37,7 @@ interface AdminSubtitlesTableProps { } function formatRelative(value: string): string { - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; - const deltaMs = Date.now() - date.getTime(); - const minutes = Math.floor(deltaMs / 60000); - if (minutes < 1) return "just now"; - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.floor(hours / 24); - if (days < 30) return `${days}d ago`; - return formatDate(date); + return formatRelativeTime(value, { rounding: "floor", absoluteAfterDays: 30 }) ?? value; } export default function AdminSubtitlesTable({ diff --git a/web/src/lib/date.test.ts b/web/src/lib/date.test.ts new file mode 100644 index 00000000..82ff54c0 --- /dev/null +++ b/web/src/lib/date.test.ts @@ -0,0 +1,73 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { formatRelativeTime } from "./date"; +import { setDateTimeFormatPreferences } from "./datetime"; + +const NOW = new Date(2026, 5, 5, 12, 0, 0); + +function ago(ms: number): string { + return new Date(NOW.getTime() - ms).toISOString(); +} + +const MINUTE = 60_000; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); +}); + +afterEach(() => { + vi.useRealTimers(); + setDateTimeFormatPreferences({ dateFormat: "auto", timeFormat: "auto" }); +}); + +describe("formatRelativeTime", () => { + it("returns null for missing or unparseable values", () => { + expect(formatRelativeTime(null)).toBeNull(); + expect(formatRelativeTime(undefined)).toBeNull(); + expect(formatRelativeTime("")).toBeNull(); + expect(formatRelativeTime("not-a-date")).toBeNull(); + }); + + it("formats the compact tiers", () => { + expect(formatRelativeTime(ago(10_000))).toBe("just now"); + expect(formatRelativeTime(ago(5 * MINUTE))).toBe("5m ago"); + expect(formatRelativeTime(ago(3 * HOUR))).toBe("3h ago"); + expect(formatRelativeTime(ago(2 * DAY))).toBe("2d ago"); + }); + + it("treats future timestamps as just now", () => { + expect(formatRelativeTime(ago(-5 * MINUTE))).toBe("just now"); + }); + + it("rounds by default and floors when asked", () => { + // 1h31m: round → 2h, floor → 1h. + const value = ago(HOUR + 31 * MINUTE); + expect(formatRelativeTime(value)).toBe("2h ago"); + expect(formatRelativeTime(value, { rounding: "floor" })).toBe("1h ago"); + }); + + it("supports a custom just-now label", () => { + expect(formatRelativeTime(ago(10_000), { justNowLabel: "Just now" })).toBe("Just now"); + }); + + it("switches to an absolute date after the configured number of days", () => { + setDateTimeFormatPreferences({ dateFormat: "YYYY-MM-DD", timeFormat: "auto" }); + expect(formatRelativeTime(ago(29 * DAY), { absoluteAfterDays: 30 })).toBe("29d ago"); + expect(formatRelativeTime(ago(40 * DAY), { absoluteAfterDays: 30 })).toBe("2026-04-26"); + // absoluteAfterDays: 1 skips the day tier entirely (floor keeps 25h at 1d). + expect(formatRelativeTime(ago(25 * HOUR), { rounding: "floor", absoluteAfterDays: 1 })).toBe( + "2026-06-04", + ); + }); + + it("uses a caller-provided absolute renderer", () => { + expect( + formatRelativeTime(ago(40 * DAY), { + absoluteAfterDays: 30, + absolute: (date) => `on ${date.getFullYear()}`, + }), + ).toBe("on 2026"); + }); +}); diff --git a/web/src/lib/date.ts b/web/src/lib/date.ts index a47ecf71..7d7b5c4e 100644 --- a/web/src/lib/date.ts +++ b/web/src/lib/date.ts @@ -1,10 +1,26 @@ import { formatDate } from "@/lib/datetime"; +export interface RelativeTimeOptions { + /** Unit arithmetic: "round" (default) or "floor". */ + rounding?: "round" | "floor"; + /** Label for deltas under one minute (default "just now"). */ + justNowLabel?: string; + /** Switch to an absolute date once the delta reaches this many days. */ + absoluteAfterDays?: number; + /** Absolute-date renderer (default: preference-aware numeric formatDate). */ + absolute?: (date: Date) => string; +} + /** - * Compact relative-time label ("just now", "5m ago", "3h ago", "2d ago"). - * Returns null for missing or unparseable values. + * Compact relative-time label ("just now", "5m ago", "3h ago", "2d ago"), + * optionally switching to an absolute date after `absoluteAfterDays`. + * Returns null for missing or unparseable values so call sites can pick + * their own fallback ("—", "Never", the raw value, ...). */ -export function formatRelativeTime(value: string | null): string | null { +export function formatRelativeTime( + value: string | null | undefined, + options?: RelativeTimeOptions, +): string | null { if (!value) { return null; } @@ -12,18 +28,23 @@ export function formatRelativeTime(value: string | null): string | null { if (Number.isNaN(date.getTime())) { return null; } - const diffMinutes = Math.round((Date.now() - date.getTime()) / 60_000); + const toUnit = options?.rounding === "floor" ? Math.floor : Math.round; + const diffMinutes = toUnit((Date.now() - date.getTime()) / 60_000); if (diffMinutes < 1) { - return "just now"; + return options?.justNowLabel ?? "just now"; } if (diffMinutes < 60) { return `${diffMinutes}m ago`; } - const diffHours = Math.round(diffMinutes / 60); + const diffHours = toUnit(diffMinutes / 60); if (diffHours < 24) { return `${diffHours}h ago`; } - return `${Math.round(diffHours / 24)}d ago`; + const diffDays = toUnit(diffHours / 24); + if (diffDays < (options?.absoluteAfterDays ?? Number.POSITIVE_INFINITY)) { + return `${diffDays}d ago`; + } + return (options?.absolute ?? formatDate)(date); } export function formatBirthDate(dateStr: string): string { diff --git a/web/src/pages/AdminHistoryImport.tsx b/web/src/pages/AdminHistoryImport.tsx index 76bd0497..507ea17a 100644 --- a/web/src/pages/AdminHistoryImport.tsx +++ b/web/src/pages/AdminHistoryImport.tsx @@ -79,10 +79,8 @@ import type { UpdateHistoryImportSourceRequest, } from "@/api/types"; import { cn } from "@/lib/utils"; -import { - formatDate as formatPreferredDate, - formatDateTime as formatPreferredDateTime, -} from "@/lib/datetime"; +import { formatRelativeTime } from "@/lib/date"; +import { formatDateTime as formatPreferredDateTime } from "@/lib/datetime"; // --------------------------------------------------------------------------- // Shared helpers @@ -135,14 +133,7 @@ function StatusBadge({ status }: { status: HistoryImportRun["status"] }) { } function timeAgo(dateStr: string | undefined) { - if (!dateStr) return "Never"; - const d = new Date(dateStr); - const now = Date.now(); - const diff = now - d.getTime(); - if (diff < 60_000) return "just now"; - if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`; - if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`; - return formatPreferredDate(d); + return formatRelativeTime(dateStr, { rounding: "floor", absoluteAfterDays: 1 }) ?? "Never"; } function formatDate(dateStr: string | undefined) { diff --git a/web/src/pages/AdminTasks.tsx b/web/src/pages/AdminTasks.tsx index 6a36f935..748c032c 100644 --- a/web/src/pages/AdminTasks.tsx +++ b/web/src/pages/AdminTasks.tsx @@ -15,6 +15,7 @@ import { import { usePageActivity } from "@/hooks/usePageActivity"; import { cn } from "@/lib/utils"; import type { TaskCategory, TaskInfo, TriggerConfig } from "@/api/types"; +import { formatRelativeTime } from "@/lib/date"; import { formatDateTime as formatPreferredDateTime } from "@/lib/datetime"; const CATEGORY_ORDER: TaskCategory[] = ["library", "metadata", "system"]; @@ -51,18 +52,6 @@ function useTaskClock() { return now; } -function formatRelativeTime(dateStr: string, now: number): string { - const diff = Math.max(0, now - new Date(dateStr).getTime()); - const seconds = Math.floor(diff / 1000); - if (seconds < 60) return "just now"; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.floor(hours / 24); - return `${days}d ago`; -} - function formatDuration(ms: number): string { if (ms <= 0) return "<1ms"; if (ms < 1000) return `${ms}ms`; @@ -250,7 +239,9 @@ function TaskRow({ {!describeSchedule(task.triggers) && No schedule} {task.last_execution && ( - · Last run: {formatRelativeTime(task.last_execution.completed_at, now)} + · Last run:{" "} + {formatRelativeTime(task.last_execution.completed_at, { rounding: "floor" }) ?? + "—"} {typeof task.last_execution.duration_ms === "number" ? ` · Duration: ${formatDuration(task.last_execution.duration_ms)}` : ""} diff --git a/web/src/pages/admin/autoscan/SourcesPanel.tsx b/web/src/pages/admin/autoscan/SourcesPanel.tsx index ef2a9bb9..46fc3d26 100644 --- a/web/src/pages/admin/autoscan/SourcesPanel.tsx +++ b/web/src/pages/admin/autoscan/SourcesPanel.tsx @@ -97,6 +97,7 @@ import { needsConnectionStep, needsDeliveryChoice, } from "./sourceDescriptor"; +import { formatRelativeTime as formatRelativeTimeBase } from "@/lib/date"; // --------------------------------------------------------------------------- // Helpers @@ -125,16 +126,9 @@ function resolveSourceName( } function formatRelativeTime(isoString: string | null): string { - if (!isoString) return "Never"; - const date = new Date(isoString); - const diffMs = Date.now() - date.getTime(); - const diffMin = Math.floor(diffMs / 60_000); - if (diffMin < 1) return "Just now"; - if (diffMin < 60) return `${diffMin}m ago`; - const diffHr = Math.floor(diffMin / 60); - if (diffHr < 24) return `${diffHr}h ago`; - const diffDays = Math.floor(diffHr / 24); - return `${diffDays}d ago`; + return ( + formatRelativeTimeBase(isoString, { rounding: "floor", justNowLabel: "Just now" }) ?? "Never" + ); } // --------------------------------------------------------------------------- diff --git a/web/src/pages/settings/HistoryImportSettings.tsx b/web/src/pages/settings/HistoryImportSettings.tsx index 182cf8f4..e5055002 100644 --- a/web/src/pages/settings/HistoryImportSettings.tsx +++ b/web/src/pages/settings/HistoryImportSettings.tsx @@ -38,7 +38,7 @@ import { } from "./HistoryImportSettings.utils"; import { cn } from "@/lib/utils"; import { AlertTriangle, CheckCircle2, CircleSlash2, Clock, Loader2, XCircle } from "lucide-react"; -import { formatDate } from "@/lib/datetime"; +import { formatRelativeTime as formatRelativeTimeBase } from "@/lib/date"; const STATUS_CONFIG = { queued: { @@ -987,16 +987,13 @@ function HistoryRunCard({ ──────────────────────────────────────────────────────────── */ function formatRelativeTime(dateStr: string): string { - const diffMs = Date.now() - new Date(dateStr).getTime(); - const minutes = Math.floor(diffMs / 60_000); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); - - if (minutes < 1) return "Just now"; - if (minutes < 60) return `${minutes}m ago`; - if (hours < 24) return `${hours}h ago`; - if (days < 7) return `${days}d ago`; - return formatDate(dateStr); + return ( + formatRelativeTimeBase(dateStr, { + rounding: "floor", + justNowLabel: "Just now", + absoluteAfterDays: 7, + }) ?? "" + ); } function formatDuration(startStr: string, endStr: string): string { diff --git a/web/src/pages/settings/WatchProvidersSettings.tsx b/web/src/pages/settings/WatchProvidersSettings.tsx index 9366edb0..f2df7e40 100644 --- a/web/src/pages/settings/WatchProvidersSettings.tsx +++ b/web/src/pages/settings/WatchProvidersSettings.tsx @@ -32,18 +32,10 @@ import { type WatchProviderSyncRun, } from "@/hooks/queries/watchProviders"; import { Input } from "@/components/ui/input"; +import { formatRelativeTime as formatRelativeTimeBase } from "@/lib/date"; function formatRelativeTime(value?: string) { - if (!value) return "Never"; - const timestamp = new Date(value).getTime(); - if (Number.isNaN(timestamp)) return "Never"; - const seconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000)); - if (seconds < 60) return "Just now"; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - return `${Math.floor(hours / 24)}d ago`; + return formatRelativeTimeBase(value, { rounding: "floor", justNowLabel: "Just now" }) ?? "Never"; } function formatRetryAfter(seconds?: number) {