diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index 92d8d3f6827..b1a54feb718 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -23,7 +23,7 @@ import * as Option from "effect/Option"; import { cn } from "../../lib/utils"; import { resolveAndPersistPreferredEditor } from "../../editorPreferences"; -import { formatRelativeTime } from "../../timestampFormat"; +import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat"; import { useEnvironmentQuery } from "../../state/query"; import { primaryServerAvailableEditorsAtom, @@ -65,8 +65,7 @@ function formatBytes(value: number): string { function formatRelative(value: DateTime.Utc | null): string { if (!value) return "No trace records"; - const relative = formatRelativeTime(DateTime.formatIso(value)); - return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value; + return formatRelativeTimeLabel(DateTime.formatIso(value)); } function formatRelativeNoWrap(value: DateTime.Utc | null): string { @@ -755,12 +754,16 @@ function ProcessResourceHistoryTable({ function DiagnosticsLastChecked({ checkedAt }: { checkedAt: DateTime.Utc | null }) { useRelativeTimeTick(); - const relative = checkedAt ? formatRelativeTime(DateTime.formatIso(checkedAt)) : null; + const relative = getRelativeTimeState(checkedAt ? DateTime.formatIso(checkedAt) : null); - if (!relative) { + if (relative.status === "missing") { return Checking; } + if (relative.status === "invalid") { + return Checked unavailable; + } + return ( {relative.suffix ? ( diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 40017d56314..4ac3ce359e0 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -58,7 +58,7 @@ import { import { usePrimaryEnvironment } from "../../state/environments"; import { useProjects } from "../../state/entities"; import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; -import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; +import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat"; import { Button } from "../ui/button"; import { DraftInput } from "../ui/draft-input"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; @@ -134,12 +134,16 @@ const PROVIDER_SETTINGS = DRIVER_OPTIONS.map((definition) => ({ function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) { useRelativeTimeTick(); - const lastCheckedRelative = lastCheckedAt ? formatRelativeTime(lastCheckedAt) : null; + const lastCheckedRelative = getRelativeTimeState(lastCheckedAt); - if (!lastCheckedRelative) { + if (lastCheckedRelative.status === "missing") { return null; } + if (lastCheckedRelative.status === "invalid") { + return Checked unavailable; + } + return ( {lastCheckedRelative.suffix ? ( diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index c68d5b7ee46..c2fe4b62714 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -3,7 +3,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { formatElapsedDurationLabel, formatExpiresInLabel, + formatRelativeTime, + formatRelativeTimeLabel, + formatRelativeTimeUntil, formatRelativeTimeUntilLabel, + formatShortTimestamp, + formatTimestamp, + getRelativeTimeState, getTimestampFormatOptions, } from "./timestampFormat"; @@ -90,6 +96,60 @@ describe("formatExpiresInLabel", () => { }); }); +describe("invalid timestamp inputs", () => { + it("returns an empty timestamp instead of throwing", () => { + expect(() => formatTimestamp("not-a-date", "12-hour")).not.toThrow(); + expect(formatTimestamp("not-a-date", "12-hour")).toBe(""); + }); + + it("returns an empty short timestamp instead of throwing", () => { + expect(() => formatShortTimestamp("not-a-date", "12-hour")).not.toThrow(); + expect(formatShortTimestamp("not-a-date", "12-hour")).toBe(""); + }); + + it("returns an empty relative time label instead of a NaN label", () => { + expect(formatRelativeTime("not-a-date")).toBeNull(); + expect(formatRelativeTimeLabel("not-a-date")).toBe(""); + }); + + it("distinguishes missing and invalid relative time state", () => { + expect(getRelativeTimeState(null)).toEqual({ status: "missing" }); + expect(getRelativeTimeState("not-a-date")).toEqual({ status: "invalid" }); + }); + + it("returns an empty elapsed duration instead of a NaN label", () => { + expect(formatElapsedDurationLabel("not-a-date")).toBe(""); + }); + + it("returns an empty relative time until label instead of a NaN label", () => { + expect(formatRelativeTimeUntil("not-a-date")).toBeNull(); + expect(formatRelativeTimeUntilLabel("not-a-date")).toBe(""); + }); + + it("returns an empty expires-in label instead of a NaN label", () => { + expect(formatExpiresInLabel("not-a-date")).toBe(""); + }); +}); + +describe("getRelativeTimeState", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-04-07T12:00:00.000Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns relative parts for valid timestamps", () => { + expect(getRelativeTimeState("2026-04-07T11:45:00.000Z")).toEqual({ + status: "relative", + value: "15m", + suffix: "ago", + }); + }); +}); + describe("formatElapsedDurationLabel", () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index 28facf877e5..31cc41798eb 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -40,8 +40,15 @@ function getTimestampFormatter( return formatter; } +function parseTimestampDate(isoDate: string): Date | null { + const date = new Date(isoDate); + return Number.isNaN(date.getTime()) ? null : date; +} + export function formatTimestamp(isoDate: string, timestampFormat: TimestampFormat): string { - return getTimestampFormatter(timestampFormat, true).format(new Date(isoDate)); + const date = parseTimestampDate(isoDate); + if (!date) return ""; + return getTimestampFormatter(timestampFormat, true).format(date); } const monthNameFormatter = new Intl.DateTimeFormat(undefined, { month: "long" }); @@ -69,8 +76,8 @@ export function formatChatTimestampTooltip( isoDate: string, timestampFormat: TimestampFormat, ): string { - const date = new Date(isoDate); - if (Number.isNaN(date.getTime())) return ""; + const date = parseTimestampDate(isoDate); + if (!date) return ""; const time = formatShortTimestamp(isoDate, timestampFormat); const day = date.getDate(); const month = monthNameFormatter.format(date); @@ -79,7 +86,9 @@ export function formatChatTimestampTooltip( } export function formatShortTimestamp(isoDate: string, timestampFormat: TimestampFormat): string { - return getTimestampFormatter(timestampFormat, false).format(new Date(isoDate)); + const date = parseTimestampDate(isoDate); + if (!date) return ""; + return getTimestampFormatter(timestampFormat, false).format(date); } /** @@ -87,8 +96,16 @@ export function formatShortTimestamp(isoDate: string, timestampFormat: Timestamp * Returns `{ value: "20s", suffix: "ago" }` or `{ value: "just now", suffix: null }` * so callers can style the numeric portion independently. */ -export function formatRelativeTime(isoDate: string): { value: string; suffix: string | null } { - const diffMs = Date.now() - new Date(isoDate).getTime(); +type RelativeTimeParts = { value: string; suffix: string | null }; +export type RelativeTimeState = + | { status: "missing" } + | { status: "invalid" } + | { status: "relative"; value: string; suffix: string | null }; + +export function formatRelativeTime(isoDate: string): RelativeTimeParts | null { + const date = parseTimestampDate(isoDate); + if (!date) return null; + const diffMs = Date.now() - date.getTime(); if (diffMs < 0) return { value: "just now", suffix: null }; const seconds = Math.floor(diffMs / 1000); if (seconds < 60) return { value: "just now", suffix: null }; @@ -102,15 +119,25 @@ export function formatRelativeTime(isoDate: string): { value: string; suffix: st export function formatRelativeTimeLabel(isoDate: string) { const relative = formatRelativeTime(isoDate); + if (!relative) return ""; return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value; } +export function getRelativeTimeState(isoDate: string | null): RelativeTimeState { + if (!isoDate) return { status: "missing" }; + const relative = formatRelativeTime(isoDate); + if (!relative) return { status: "invalid" }; + return { status: "relative", ...relative }; +} + /** * Relative elapsed duration since an ISO instant, without an "ago" suffix. * Useful for labels like "Connected for 3m". */ export function formatElapsedDurationLabel(isoDate: string, nowMs: number = Date.now()): string { - const diffMs = nowMs - new Date(isoDate).getTime(); + const date = parseTimestampDate(isoDate); + if (!date) return ""; + const diffMs = nowMs - date.getTime(); if (diffMs <= 0) return "just now"; const seconds = Math.floor(diffMs / 1000); @@ -130,8 +157,10 @@ export function formatElapsedDurationLabel(isoDate: string, nowMs: number = Date /** * Relative time until an ISO instant (e.g. expiry). Mirrors {@link formatRelativeTime} but for future times. */ -export function formatRelativeTimeUntil(isoDate: string): { value: string; suffix: string | null } { - const diffMs = new Date(isoDate).getTime() - Date.now(); +export function formatRelativeTimeUntil(isoDate: string): RelativeTimeParts | null { + const date = parseTimestampDate(isoDate); + if (!date) return null; + const diffMs = date.getTime() - Date.now(); if (diffMs <= 0) return { value: "Expired", suffix: null }; const seconds = Math.floor(diffMs / 1000); if (seconds < 5) return { value: "Soon", suffix: null }; @@ -146,6 +175,7 @@ export function formatRelativeTimeUntil(isoDate: string): { value: string; suffi export function formatRelativeTimeUntilLabel(isoDate: string): string { const relative = formatRelativeTimeUntil(isoDate); + if (!relative) return ""; return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value; } @@ -154,7 +184,9 @@ export function formatRelativeTimeUntilLabel(isoDate: string): string { * Pass `nowMs` when a parent tick drives re-renders so the diff matches that snapshot. */ export function formatExpiresInLabel(isoDate: string, nowMs: number = Date.now()): string { - const diffMs = new Date(isoDate).getTime() - nowMs; + const date = parseTimestampDate(isoDate); + if (!date) return ""; + const diffMs = date.getTime() - nowMs; if (diffMs <= 0) return "Expired"; const totalSeconds = Math.floor(diffMs / 1000);