refactor(web): consolidate duplicated relative-time helpers
Eight copies of the compact "5m ago / 3h ago" relative-time formatter existed across components and pages, differing only in rounding, "just now" capitalization, absolute-date cutoff, and missing-value fallback. Extend lib/date.ts formatRelativeTime with options for those axes (backwards compatible for existing callers) and convert the duplicates to thin wrappers preserving each call site's exact output. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
committed by
Quick104
co-authored by
Claude Fable 5
parent
2dedf3ff26
commit
9c585e75a2
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
+28
-7
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) && <span>No schedule</span>}
|
||||
{task.last_execution && (
|
||||
<span className="ml-2">
|
||||
· 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)}`
|
||||
: ""}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user