authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-07-17 23:24:10+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-17 23:24:10+02:00
logb655a9f183cc9f6b2fd54bf11ea28acb50d23cfd
tree6775a41fbe63c65b3ef2bd5b11fa87a8b5589df6
parent4e3f2f04d577152672110303b432a72218fe4e61
signature Signed by PGP key B5690EEEBB952194

[codex] fix: guard invalid web timestamps (#3515)

Co-authored-by: Codex <codex@openai.com> Co-authored-by: Julius Marminge <julius0216@outlook.com>

4 files changed, 117 insertions(+), 18 deletions(-)

apps/web/src/components/settings/DiagnosticsSettings.tsx+8-5
...@@ -23,7 +23,7 @@ import * as Option from "effect/Option";...@@ -23,7 +23,7 @@ import * as Option from "effect/Option";
2323
24import { cn } from "../../lib/utils";24import { cn } from "../../lib/utils";
25import { resolveAndPersistPreferredEditor } from "../../editorPreferences";25import { resolveAndPersistPreferredEditor } from "../../editorPreferences";
26import { formatRelativeTime } from "../../timestampFormat";26import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat";
27import { useEnvironmentQuery } from "../../state/query";27import { useEnvironmentQuery } from "../../state/query";
28import {28import {
29 primaryServerAvailableEditorsAtom,29 primaryServerAvailableEditorsAtom,
...@@ -65,8 +65,7 @@ function formatBytes(value: number): string {...@@ -65,8 +65,7 @@ function formatBytes(value: number): string {
6565
66function formatRelative(value: DateTime.Utc | null): string {66function formatRelative(value: DateTime.Utc | null): string {
67 if (!value) return "No trace records";67 if (!value) return "No trace records";
68 const relative = formatRelativeTime(DateTime.formatIso(value));68 return formatRelativeTimeLabel(DateTime.formatIso(value));
69 return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value;
70}69}
7170
72function formatRelativeNoWrap(value: DateTime.Utc | null): string {71function formatRelativeNoWrap(value: DateTime.Utc | null): string {
...@@ -755,12 +754,16 @@ function ProcessResourceHistoryTable({...@@ -755,12 +754,16 @@ function ProcessResourceHistoryTable({
755754
756function DiagnosticsLastChecked({ checkedAt }: { checkedAt: DateTime.Utc | null }) {755function DiagnosticsLastChecked({ checkedAt }: { checkedAt: DateTime.Utc | null }) {
757 useRelativeTimeTick();756 useRelativeTimeTick();
758 const relative = checkedAt ? formatRelativeTime(DateTime.formatIso(checkedAt)) : null;757 const relative = getRelativeTimeState(checkedAt ? DateTime.formatIso(checkedAt) : null);
759758
760 if (!relative) {759 if (relative.status === "missing") {
761 return <span className="text-[11px] text-muted-foreground/50">Checking</span>;760 return <span className="text-[11px] text-muted-foreground/50">Checking</span>;
762 }761 }
763762
763 if (relative.status === "invalid") {
764 return <span className="text-[11px] text-muted-foreground/50">Checked unavailable</span>;
765 }
766
764 return (767 return (
765 <span className="text-[11px] text-muted-foreground/60">768 <span className="text-[11px] text-muted-foreground/60">
766 {relative.suffix ? (769 {relative.suffix ? (
apps/web/src/components/settings/SettingsPanels.tsx+7-3
...@@ -58,7 +58,7 @@ import {...@@ -58,7 +58,7 @@ import {
58import { usePrimaryEnvironment } from "../../state/environments";58import { usePrimaryEnvironment } from "../../state/environments";
59import { useProjects } from "../../state/entities";59import { useProjects } from "../../state/entities";
60import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState";60import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState";
61import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat";61import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat";
62import { Button } from "../ui/button";62import { Button } from "../ui/button";
63import { DraftInput } from "../ui/draft-input";63import { DraftInput } from "../ui/draft-input";
64import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";64import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
...@@ -134,12 +134,16 @@ const PROVIDER_SETTINGS = DRIVER_OPTIONS.map((definition) => ({...@@ -134,12 +134,16 @@ const PROVIDER_SETTINGS = DRIVER_OPTIONS.map((definition) => ({
134134
135function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) {135function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) {
136 useRelativeTimeTick();136 useRelativeTimeTick();
137 const lastCheckedRelative = lastCheckedAt ? formatRelativeTime(lastCheckedAt) : null;137 const lastCheckedRelative = getRelativeTimeState(lastCheckedAt);
138138
139 if (!lastCheckedRelative) {139 if (lastCheckedRelative.status === "missing") {
140 return null;140 return null;
141 }141 }
142142
143 if (lastCheckedRelative.status === "invalid") {
144 return <span className="text-[11px] text-muted-foreground/50">Checked unavailable</span>;
145 }
146
143 return (147 return (
144 <span className="text-[11px] text-muted-foreground/60">148 <span className="text-[11px] text-muted-foreground/60">
145 {lastCheckedRelative.suffix ? (149 {lastCheckedRelative.suffix ? (
apps/web/src/timestampFormat.test.ts+60
...@@ -3,7 +3,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"...@@ -3,7 +3,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"
3import {3import {
4 formatElapsedDurationLabel,4 formatElapsedDurationLabel,
5 formatExpiresInLabel,5 formatExpiresInLabel,
6 formatRelativeTime,
7 formatRelativeTimeLabel,
8 formatRelativeTimeUntil,
6 formatRelativeTimeUntilLabel,9 formatRelativeTimeUntilLabel,
10 formatShortTimestamp,
11 formatTimestamp,
12 getRelativeTimeState,
7 getTimestampFormatOptions,13 getTimestampFormatOptions,
8} from "./timestampFormat";14} from "./timestampFormat";
915
...@@ -90,6 +96,60 @@ describe("formatExpiresInLabel", () => {...@@ -90,6 +96,60 @@ describe("formatExpiresInLabel", () => {
90 });96 });
91});97});
9298
99describe("invalid timestamp inputs", () => {
100 it("returns an empty timestamp instead of throwing", () => {
101 expect(() => formatTimestamp("not-a-date", "12-hour")).not.toThrow();
102 expect(formatTimestamp("not-a-date", "12-hour")).toBe("");
103 });
104
105 it("returns an empty short timestamp instead of throwing", () => {
106 expect(() => formatShortTimestamp("not-a-date", "12-hour")).not.toThrow();
107 expect(formatShortTimestamp("not-a-date", "12-hour")).toBe("");
108 });
109
110 it("returns an empty relative time label instead of a NaN label", () => {
111 expect(formatRelativeTime("not-a-date")).toBeNull();
112 expect(formatRelativeTimeLabel("not-a-date")).toBe("");
113 });
114
115 it("distinguishes missing and invalid relative time state", () => {
116 expect(getRelativeTimeState(null)).toEqual({ status: "missing" });
117 expect(getRelativeTimeState("not-a-date")).toEqual({ status: "invalid" });
118 });
119
120 it("returns an empty elapsed duration instead of a NaN label", () => {
121 expect(formatElapsedDurationLabel("not-a-date")).toBe("");
122 });
123
124 it("returns an empty relative time until label instead of a NaN label", () => {
125 expect(formatRelativeTimeUntil("not-a-date")).toBeNull();
126 expect(formatRelativeTimeUntilLabel("not-a-date")).toBe("");
127 });
128
129 it("returns an empty expires-in label instead of a NaN label", () => {
130 expect(formatExpiresInLabel("not-a-date")).toBe("");
131 });
132});
133
134describe("getRelativeTimeState", () => {
135 beforeEach(() => {
136 vi.useFakeTimers();
137 vi.setSystemTime(new Date("2026-04-07T12:00:00.000Z"));
138 });
139
140 afterEach(() => {
141 vi.useRealTimers();
142 });
143
144 it("returns relative parts for valid timestamps", () => {
145 expect(getRelativeTimeState("2026-04-07T11:45:00.000Z")).toEqual({
146 status: "relative",
147 value: "15m",
148 suffix: "ago",
149 });
150 });
151});
152
93describe("formatElapsedDurationLabel", () => {153describe("formatElapsedDurationLabel", () => {
94 beforeEach(() => {154 beforeEach(() => {
95 vi.useFakeTimers();155 vi.useFakeTimers();
apps/web/src/timestampFormat.ts+42-10
...@@ -40,8 +40,15 @@ function getTimestampFormatter(...@@ -40,8 +40,15 @@ function getTimestampFormatter(
40 return formatter;40 return formatter;
41}41}
4242
43function parseTimestampDate(isoDate: string): Date | null {
44 const date = new Date(isoDate);
45 return Number.isNaN(date.getTime()) ? null : date;
46}
47
43export function formatTimestamp(isoDate: string, timestampFormat: TimestampFormat): string {48export function formatTimestamp(isoDate: string, timestampFormat: TimestampFormat): string {
44 return getTimestampFormatter(timestampFormat, true).format(new Date(isoDate));49 const date = parseTimestampDate(isoDate);
50 if (!date) return "";
51 return getTimestampFormatter(timestampFormat, true).format(date);
45}52}
4653
47const monthNameFormatter = new Intl.DateTimeFormat(undefined, { month: "long" });54const monthNameFormatter = new Intl.DateTimeFormat(undefined, { month: "long" });
...@@ -69,8 +76,8 @@ export function formatChatTimestampTooltip(...@@ -69,8 +76,8 @@ export function formatChatTimestampTooltip(
69 isoDate: string,76 isoDate: string,
70 timestampFormat: TimestampFormat,77 timestampFormat: TimestampFormat,
71): string {78): string {
72 const date = new Date(isoDate);79 const date = parseTimestampDate(isoDate);
73 if (Number.isNaN(date.getTime())) return "";80 if (!date) return "";
74 const time = formatShortTimestamp(isoDate, timestampFormat);81 const time = formatShortTimestamp(isoDate, timestampFormat);
75 const day = date.getDate();82 const day = date.getDate();
76 const month = monthNameFormatter.format(date);83 const month = monthNameFormatter.format(date);
...@@ -79,7 +86,9 @@ export function formatChatTimestampTooltip(...@@ -79,7 +86,9 @@ export function formatChatTimestampTooltip(
79}86}
8087
81export function formatShortTimestamp(isoDate: string, timestampFormat: TimestampFormat): string {88export function formatShortTimestamp(isoDate: string, timestampFormat: TimestampFormat): string {
82 return getTimestampFormatter(timestampFormat, false).format(new Date(isoDate));89 const date = parseTimestampDate(isoDate);
90 if (!date) return "";
91 return getTimestampFormatter(timestampFormat, false).format(date);
83}92}
8493
85/**94/**
...@@ -87,8 +96,16 @@ export function formatShortTimestamp(isoDate: string, timestampFormat: Timestamp...@@ -87,8 +96,16 @@ export function formatShortTimestamp(isoDate: string, timestampFormat: Timestamp
87 * Returns `{ value: "20s", suffix: "ago" }` or `{ value: "just now", suffix: null }`96 * Returns `{ value: "20s", suffix: "ago" }` or `{ value: "just now", suffix: null }`
88 * so callers can style the numeric portion independently.97 * so callers can style the numeric portion independently.
89 */98 */
90export function formatRelativeTime(isoDate: string): { value: string; suffix: string | null } {99type RelativeTimeParts = { value: string; suffix: string | null };
91 const diffMs = Date.now() - new Date(isoDate).getTime();100export type RelativeTimeState =
101 | { status: "missing" }
102 | { status: "invalid" }
103 | { status: "relative"; value: string; suffix: string | null };
104
105export function formatRelativeTime(isoDate: string): RelativeTimeParts | null {
106 const date = parseTimestampDate(isoDate);
107 if (!date) return null;
108 const diffMs = Date.now() - date.getTime();
92 if (diffMs < 0) return { value: "just now", suffix: null };109 if (diffMs < 0) return { value: "just now", suffix: null };
93 const seconds = Math.floor(diffMs / 1000);110 const seconds = Math.floor(diffMs / 1000);
94 if (seconds < 60) return { value: "just now", suffix: null };111 if (seconds < 60) return { value: "just now", suffix: null };
...@@ -102,15 +119,25 @@ export function formatRelativeTime(isoDate: string): { value: string; suffix: st...@@ -102,15 +119,25 @@ export function formatRelativeTime(isoDate: string): { value: string; suffix: st
102119
103export function formatRelativeTimeLabel(isoDate: string) {120export function formatRelativeTimeLabel(isoDate: string) {
104 const relative = formatRelativeTime(isoDate);121 const relative = formatRelativeTime(isoDate);
122 if (!relative) return "";
105 return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value;123 return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value;
106}124}
107125
126export function getRelativeTimeState(isoDate: string | null): RelativeTimeState {
127 if (!isoDate) return { status: "missing" };
128 const relative = formatRelativeTime(isoDate);
129 if (!relative) return { status: "invalid" };
130 return { status: "relative", ...relative };
131}
132
108/**133/**
109 * Relative elapsed duration since an ISO instant, without an "ago" suffix.134 * Relative elapsed duration since an ISO instant, without an "ago" suffix.
110 * Useful for labels like "Connected for 3m".135 * Useful for labels like "Connected for 3m".
111 */136 */
112export function formatElapsedDurationLabel(isoDate: string, nowMs: number = Date.now()): string {137export function formatElapsedDurationLabel(isoDate: string, nowMs: number = Date.now()): string {
113 const diffMs = nowMs - new Date(isoDate).getTime();138 const date = parseTimestampDate(isoDate);
139 if (!date) return "";
140 const diffMs = nowMs - date.getTime();
114 if (diffMs <= 0) return "just now";141 if (diffMs <= 0) return "just now";
115142
116 const seconds = Math.floor(diffMs / 1000);143 const seconds = Math.floor(diffMs / 1000);
...@@ -130,8 +157,10 @@ export function formatElapsedDurationLabel(isoDate: string, nowMs: number = Date...@@ -130,8 +157,10 @@ export function formatElapsedDurationLabel(isoDate: string, nowMs: number = Date
130/**157/**
131 * Relative time until an ISO instant (e.g. expiry). Mirrors {@link formatRelativeTime} but for future times.158 * Relative time until an ISO instant (e.g. expiry). Mirrors {@link formatRelativeTime} but for future times.
132 */159 */
133export function formatRelativeTimeUntil(isoDate: string): { value: string; suffix: string | null } {160export function formatRelativeTimeUntil(isoDate: string): RelativeTimeParts | null {
134 const diffMs = new Date(isoDate).getTime() - Date.now();161 const date = parseTimestampDate(isoDate);
162 if (!date) return null;
163 const diffMs = date.getTime() - Date.now();
135 if (diffMs <= 0) return { value: "Expired", suffix: null };164 if (diffMs <= 0) return { value: "Expired", suffix: null };
136 const seconds = Math.floor(diffMs / 1000);165 const seconds = Math.floor(diffMs / 1000);
137 if (seconds < 5) return { value: "Soon", suffix: null };166 if (seconds < 5) return { value: "Soon", suffix: null };
...@@ -146,6 +175,7 @@ export function formatRelativeTimeUntil(isoDate: string): { value: string; suffi...@@ -146,6 +175,7 @@ export function formatRelativeTimeUntil(isoDate: string): { value: string; suffi
146175
147export function formatRelativeTimeUntilLabel(isoDate: string): string {176export function formatRelativeTimeUntilLabel(isoDate: string): string {
148 const relative = formatRelativeTimeUntil(isoDate);177 const relative = formatRelativeTimeUntil(isoDate);
178 if (!relative) return "";
149 return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value;179 return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value;
150}180}
151181
...@@ -154,7 +184,9 @@ export function formatRelativeTimeUntilLabel(isoDate: string): string {...@@ -154,7 +184,9 @@ export function formatRelativeTimeUntilLabel(isoDate: string): string {
154 * Pass `nowMs` when a parent tick drives re-renders so the diff matches that snapshot.184 * Pass `nowMs` when a parent tick drives re-renders so the diff matches that snapshot.
155 */185 */
156export function formatExpiresInLabel(isoDate: string, nowMs: number = Date.now()): string {186export function formatExpiresInLabel(isoDate: string, nowMs: number = Date.now()): string {
157 const diffMs = new Date(isoDate).getTime() - nowMs;187 const date = parseTimestampDate(isoDate);
188 if (!date) return "";
189 const diffMs = date.getTime() - nowMs;
158 if (diffMs <= 0) return "Expired";190 if (diffMs <= 0) return "Expired";
159191
160 const totalSeconds = Math.floor(diffMs / 1000);192 const totalSeconds = Math.floor(diffMs / 1000);