From ab09db80f3e64fe5466e3c8a84261f0fe3188690 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:40:00 +0000 Subject: [PATCH 1/4] fix: guard invalid web timestamps Co-authored-by: Codex --- apps/web/src/timestampFormat.test.ts | 23 +++++++++++++++++++++++ apps/web/src/timestampFormat.ts | 25 +++++++++++++++++++------ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index c68d5b7ee46..7dcb6c82cb9 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -3,7 +3,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { formatElapsedDurationLabel, formatExpiresInLabel, + formatRelativeTimeLabel, formatRelativeTimeUntilLabel, + formatShortTimestamp, + formatTimestamp, getTimestampFormatOptions, } from "./timestampFormat"; @@ -90,6 +93,26 @@ 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(formatRelativeTimeLabel("not-a-date")).toBe(""); + }); + + it("returns an empty elapsed duration instead of a NaN label", () => { + expect(formatElapsedDurationLabel("not-a-date")).toBe(""); + }); +}); + describe("formatElapsedDurationLabel", () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index 28facf877e5..10053de19f1 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); } /** @@ -88,7 +97,9 @@ export function formatShortTimestamp(isoDate: string, timestampFormat: Timestamp * 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(); + const date = parseTimestampDate(isoDate); + if (!date) return { value: "", suffix: 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 }; @@ -110,7 +121,9 @@ export function formatRelativeTimeLabel(isoDate: string) { * 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); From 876e840b334eb95e140608fb67df4b15a778f761 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:48:17 +0000 Subject: [PATCH 2/4] Guard future timestamp formatters Co-authored-by: Codex --- apps/web/src/timestampFormat.test.ts | 8 ++++++++ apps/web/src/timestampFormat.ts | 8 ++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index 7dcb6c82cb9..13eecdbb3e4 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -111,6 +111,14 @@ describe("invalid timestamp inputs", () => { 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(formatRelativeTimeUntilLabel("not-a-date")).toBe(""); + }); + + it("returns an empty expires-in label instead of a NaN label", () => { + expect(formatExpiresInLabel("not-a-date")).toBe(""); + }); }); describe("formatElapsedDurationLabel", () => { diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index 10053de19f1..cdc66e01280 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -144,7 +144,9 @@ 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(); + const date = parseTimestampDate(isoDate); + if (!date) return { value: "", suffix: 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 }; @@ -167,7 +169,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); From 58f5bbcafa81f27d86b41e645e6fdf160395cdc9 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 23 Jun 2026 05:55:58 +0000 Subject: [PATCH 3/4] fix: return null for invalid relative timestamps Co-authored-by: Codex --- .../src/components/settings/DiagnosticsSettings.tsx | 5 ++--- apps/web/src/timestampFormat.test.ts | 4 ++++ apps/web/src/timestampFormat.ts | 12 ++++++++---- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index 92d8d3f6827..97d1b80faa4 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 { formatRelativeTime, formatRelativeTimeLabel } 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 { diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index 13eecdbb3e4..79f5d0e671c 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -3,7 +3,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { formatElapsedDurationLabel, formatExpiresInLabel, + formatRelativeTime, formatRelativeTimeLabel, + formatRelativeTimeUntil, formatRelativeTimeUntilLabel, formatShortTimestamp, formatTimestamp, @@ -105,6 +107,7 @@ describe("invalid timestamp inputs", () => { }); it("returns an empty relative time label instead of a NaN label", () => { + expect(formatRelativeTime("not-a-date")).toBeNull(); expect(formatRelativeTimeLabel("not-a-date")).toBe(""); }); @@ -113,6 +116,7 @@ describe("invalid timestamp inputs", () => { }); 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(""); }); diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index cdc66e01280..610052083da 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -96,9 +96,11 @@ 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 } { +type RelativeTimeParts = { value: string; suffix: string | null }; + +export function formatRelativeTime(isoDate: string): RelativeTimeParts | null { const date = parseTimestampDate(isoDate); - if (!date) return { value: "", suffix: null }; + 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); @@ -113,6 +115,7 @@ 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; } @@ -143,9 +146,9 @@ 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 } { +export function formatRelativeTimeUntil(isoDate: string): RelativeTimeParts | null { const date = parseTimestampDate(isoDate); - if (!date) return { value: "", suffix: null }; + if (!date) return null; const diffMs = date.getTime() - Date.now(); if (diffMs <= 0) return { value: "Expired", suffix: null }; const seconds = Math.floor(diffMs / 1000); @@ -161,6 +164,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; } From dd3b03afc4028c00d802ff3e02422ddf717bcef3 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 23 Jun 2026 06:03:52 +0000 Subject: [PATCH 4/4] fix: show neutral invalid last-checked timestamps Co-authored-by: Codex --- .../settings/DiagnosticsSettings.tsx | 10 +++++--- .../components/settings/SettingsPanels.tsx | 10 +++++--- apps/web/src/timestampFormat.test.ts | 25 +++++++++++++++++++ apps/web/src/timestampFormat.ts | 11 ++++++++ 4 files changed, 50 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index 97d1b80faa4..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, formatRelativeTimeLabel } from "../../timestampFormat"; +import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat"; import { useEnvironmentQuery } from "../../state/query"; import { primaryServerAvailableEditorsAtom, @@ -754,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 79f5d0e671c..c2fe4b62714 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -9,6 +9,7 @@ import { formatRelativeTimeUntilLabel, formatShortTimestamp, formatTimestamp, + getRelativeTimeState, getTimestampFormatOptions, } from "./timestampFormat"; @@ -111,6 +112,11 @@ describe("invalid timestamp inputs", () => { 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(""); }); @@ -125,6 +131,25 @@ describe("invalid timestamp inputs", () => { }); }); +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 610052083da..31cc41798eb 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -97,6 +97,10 @@ export function formatShortTimestamp(isoDate: string, timestampFormat: Timestamp * so callers can style the numeric portion independently. */ 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); @@ -119,6 +123,13 @@ export function formatRelativeTimeLabel(isoDate: string) { 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".