| author | |
| committer | |
| log | b655a9f183cc9f6b2fd54bf11ea28acb50d23cfd |
| tree | 6775a41fbe63c65b3ef2bd5b11fa87a8b5589df6 |
| parent | 4e3f2f04d577152672110303b432a72218fe4e61 |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 23 | 23 | ||
| 24 | import { cn } from "../../lib/utils"; | 24 | import { cn } from "../../lib/utils"; |
| 25 | import { resolveAndPersistPreferredEditor } from "../../editorPreferences"; | 25 | import { resolveAndPersistPreferredEditor } from "../../editorPreferences"; |
| 26 | import { formatRelativeTime } from "../../timestampFormat"; | 26 | import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat"; |
| 27 | import { useEnvironmentQuery } from "../../state/query"; | 27 | import { useEnvironmentQuery } from "../../state/query"; |
| 28 | import { | 28 | import { |
| 29 | primaryServerAvailableEditorsAtom, | 29 | primaryServerAvailableEditorsAtom, |
| ... | @@ -65,8 +65,7 @@ function formatBytes(value: number): string { | ... | @@ -65,8 +65,7 @@ function formatBytes(value: number): string { |
| 65 | 65 | ||
| 66 | function formatRelative(value: DateTime.Utc | null): string { | 66 | function 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 | } |
| 71 | 70 | ||
| 72 | function formatRelativeNoWrap(value: DateTime.Utc | null): string { | 71 | function formatRelativeNoWrap(value: DateTime.Utc | null): string { |
| ... | @@ -755,12 +754,16 @@ function ProcessResourceHistoryTable({ | ... | @@ -755,12 +754,16 @@ function ProcessResourceHistoryTable({ |
| 755 | 754 | ||
| 756 | function DiagnosticsLastChecked({ checkedAt }: { checkedAt: DateTime.Utc | null }) { | 755 | function 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); |
| 759 | 758 | ||
| 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 | } |
| 763 | 762 | ||
| 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 { |
| 58 | import { usePrimaryEnvironment } from "../../state/environments"; | 58 | import { usePrimaryEnvironment } from "../../state/environments"; |
| 59 | import { useProjects } from "../../state/entities"; | 59 | import { useProjects } from "../../state/entities"; |
| 60 | import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; | 60 | import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; |
| 61 | import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; | 61 | import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat"; |
| 62 | import { Button } from "../ui/button"; | 62 | import { Button } from "../ui/button"; |
| 63 | import { DraftInput } from "../ui/draft-input"; | 63 | import { DraftInput } from "../ui/draft-input"; |
| 64 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | 64 | import { 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) => ({ |
| 134 | 134 | ||
| 135 | function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) { | 135 | function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) { |
| 136 | useRelativeTimeTick(); | 136 | useRelativeTimeTick(); |
| 137 | const lastCheckedRelative = lastCheckedAt ? formatRelativeTime(lastCheckedAt) : null; | 137 | const lastCheckedRelative = getRelativeTimeState(lastCheckedAt); |
| 138 | 138 | ||
| 139 | if (!lastCheckedRelative) { | 139 | if (lastCheckedRelative.status === "missing") { |
| 140 | return null; | 140 | return null; |
| 141 | } | 141 | } |
| 142 | 142 | ||
| 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" |
| 3 | import { | 3 | import { |
| 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"; |
| 9 | 15 | ||
| ... | @@ -90,6 +96,60 @@ describe("formatExpiresInLabel", () => { | ... | @@ -90,6 +96,60 @@ describe("formatExpiresInLabel", () => { |
| 90 | }); | 96 | }); |
| 91 | }); | 97 | }); |
| 92 | 98 | ||
| 99 | describe("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 | |||
| 134 | describe("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 | |||
| 93 | describe("formatElapsedDurationLabel", () => { | 153 | describe("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 | } |
| 42 | 42 | ||
| 43 | function parseTimestampDate(isoDate: string): Date | null { | ||
| 44 | const date = new Date(isoDate); | ||
| 45 | return Number.isNaN(date.getTime()) ? null : date; | ||
| 46 | } | ||
| 47 | |||
| 43 | export function formatTimestamp(isoDate: string, timestampFormat: TimestampFormat): string { | 48 | export 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 | } |
| 46 | 53 | ||
| 47 | const monthNameFormatter = new Intl.DateTimeFormat(undefined, { month: "long" }); | 54 | const 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 | } |
| 80 | 87 | ||
| 81 | export function formatShortTimestamp(isoDate: string, timestampFormat: TimestampFormat): string { | 88 | export 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 | } |
| 84 | 93 | ||
| 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 | */ |
| 90 | export function formatRelativeTime(isoDate: string): { value: string; suffix: string | null } { | 99 | type RelativeTimeParts = { value: string; suffix: string | null }; |
| 91 | const diffMs = Date.now() - new Date(isoDate).getTime(); | 100 | export type RelativeTimeState = |
| 101 | | { status: "missing" } | ||
| 102 | | { status: "invalid" } | ||
| 103 | | { status: "relative"; value: string; suffix: string | null }; | ||
| 104 | |||
| 105 | export 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 |
| 102 | 119 | ||
| 103 | export function formatRelativeTimeLabel(isoDate: string) { | 120 | export 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 | } |
| 107 | 125 | ||
| 126 | export 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 | */ |
| 112 | export function formatElapsedDurationLabel(isoDate: string, nowMs: number = Date.now()): string { | 137 | export 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"; |
| 115 | 142 | ||
| 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 | */ |
| 133 | export function formatRelativeTimeUntil(isoDate: string): { value: string; suffix: string | null } { | 160 | export 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 |
| 146 | 175 | ||
| 147 | export function formatRelativeTimeUntilLabel(isoDate: string): string { | 176 | export 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 | } |
| 151 | 181 | ||
| ... | @@ -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 | */ |
| 156 | export function formatExpiresInLabel(isoDate: string, nowMs: number = Date.now()): string { | 186 | export 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"; |
| 159 | 191 | ||
| 160 | const totalSeconds = Math.floor(diffMs / 1000); | 192 | const totalSeconds = Math.floor(diffMs / 1000); |