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) {