| 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 | 23 | |
| 24 | 24 | import { cn } from "../../lib/utils"; |
| 25 | 25 | import { resolveAndPersistPreferredEditor } from "../../editorPreferences"; |
| 26 | import { formatRelativeTime } from "../../timestampFormat"; | |
| 26 | import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat"; | |
| 27 | 27 | import { useEnvironmentQuery } from "../../state/query"; |
| 28 | 28 | import { |
| 29 | 29 | primaryServerAvailableEditorsAtom, |
| ... | ... | @@ -65,8 +65,7 @@ function formatBytes(value: number): string { |
| 65 | 65 | |
| 66 | 66 | function formatRelative(value: DateTime.Utc | null): string { |
| 67 | 67 | if (!value) return "No trace records"; |
| 68 | const relative = formatRelativeTime(DateTime.formatIso(value)); | |
| 69 | return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value; | |
| 68 | return formatRelativeTimeLabel(DateTime.formatIso(value)); | |
| 70 | 69 | } |
| 71 | 70 | |
| 72 | 71 | function formatRelativeNoWrap(value: DateTime.Utc | null): string { |
| ... | ... | @@ -755,12 +754,16 @@ function ProcessResourceHistoryTable({ |
| 755 | 754 | |
| 756 | 755 | function DiagnosticsLastChecked({ checkedAt }: { checkedAt: DateTime.Utc | null }) { |
| 757 | 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 | 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 | 767 | return ( |
| 765 | 768 | <span className="text-[11px] text-muted-foreground/60"> |
| 766 | 769 | {relative.suffix ? ( |
apps/web/src/components/settings/SettingsPanels.tsx+7-3| ... | ... | @@ -58,7 +58,7 @@ import { |
| 58 | 58 | import { usePrimaryEnvironment } from "../../state/environments"; |
| 59 | 59 | import { useProjects } from "../../state/entities"; |
| 60 | 60 | import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; |
| 61 | import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; | |
| 61 | import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat"; | |
| 62 | 62 | import { Button } from "../ui/button"; |
| 63 | 63 | import { DraftInput } from "../ui/draft-input"; |
| 64 | 64 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; |
| ... | ... | @@ -134,12 +134,16 @@ const PROVIDER_SETTINGS = DRIVER_OPTIONS.map((definition) => ({ |
| 134 | 134 | |
| 135 | 135 | function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) { |
| 136 | 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 | 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 | 147 | return ( |
| 144 | 148 | <span className="text-[11px] text-muted-foreground/60"> |
| 145 | 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 | 3 | import { |
| 4 | 4 | formatElapsedDurationLabel, |
| 5 | 5 | formatExpiresInLabel, |
| 6 | formatRelativeTime, | |
| 7 | formatRelativeTimeLabel, | |
| 8 | formatRelativeTimeUntil, | |
| 6 | 9 | formatRelativeTimeUntilLabel, |
| 10 | formatShortTimestamp, | |
| 11 | formatTimestamp, | |
| 12 | getRelativeTimeState, | |
| 7 | 13 | getTimestampFormatOptions, |
| 8 | 14 | } from "./timestampFormat"; |
| 9 | 15 | |
| ... | ... | @@ -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 | 153 | describe("formatElapsedDurationLabel", () => { |
| 94 | 154 | beforeEach(() => { |
| 95 | 155 | vi.useFakeTimers(); |
apps/web/src/timestampFormat.ts+42-10| ... | ... | @@ -40,8 +40,15 @@ function getTimestampFormatter( |
| 40 | 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 | 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 | 54 | const monthNameFormatter = new Intl.DateTimeFormat(undefined, { month: "long" }); |
| ... | ... | @@ -69,8 +76,8 @@ export function formatChatTimestampTooltip( |
| 69 | 76 | isoDate: string, |
| 70 | 77 | timestampFormat: TimestampFormat, |
| 71 | 78 | ): string { |
| 72 | const date = new Date(isoDate); | |
| 73 | if (Number.isNaN(date.getTime())) return ""; | |
| 79 | const date = parseTimestampDate(isoDate); | |
| 80 | if (!date) return ""; | |
| 74 | 81 | const time = formatShortTimestamp(isoDate, timestampFormat); |
| 75 | 82 | const day = date.getDate(); |
| 76 | 83 | const month = monthNameFormatter.format(date); |
| ... | ... | @@ -79,7 +86,9 @@ export function formatChatTimestampTooltip( |
| 79 | 86 | } |
| 80 | 87 | |
| 81 | 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 | 96 | * Returns `{ value: "20s", suffix: "ago" }` or `{ value: "just now", suffix: null }` |
| 88 | 97 | * so callers can style the numeric portion independently. |
| 89 | 98 | */ |
| 90 | export function formatRelativeTime(isoDate: string): { value: string; suffix: string | null } { | |
| 91 | const diffMs = Date.now() - new Date(isoDate).getTime(); | |
| 99 | type RelativeTimeParts = { value: string; suffix: string | null }; | |
| 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 | 109 | if (diffMs < 0) return { value: "just now", suffix: null }; |
| 93 | 110 | const seconds = Math.floor(diffMs / 1000); |
| 94 | 111 | if (seconds < 60) return { value: "just now", suffix: null }; |
| ... | ... | @@ -102,15 +119,25 @@ export function formatRelativeTime(isoDate: string): { value: string; suffix: st |
| 102 | 119 | |
| 103 | 120 | export function formatRelativeTimeLabel(isoDate: string) { |
| 104 | 121 | const relative = formatRelativeTime(isoDate); |
| 122 | if (!relative) return ""; | |
| 105 | 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 | 134 | * Relative elapsed duration since an ISO instant, without an "ago" suffix. |
| 110 | 135 | * Useful for labels like "Connected for 3m". |
| 111 | 136 | */ |
| 112 | 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 | 141 | if (diffMs <= 0) return "just now"; |
| 115 | 142 | |
| 116 | 143 | const seconds = Math.floor(diffMs / 1000); |
| ... | ... | @@ -130,8 +157,10 @@ export function formatElapsedDurationLabel(isoDate: string, nowMs: number = Date |
| 130 | 157 | /** |
| 131 | 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 } { | |
| 134 | const diffMs = new Date(isoDate).getTime() - Date.now(); | |
| 160 | export function formatRelativeTimeUntil(isoDate: string): RelativeTimeParts | null { | |
| 161 | const date = parseTimestampDate(isoDate); | |
| 162 | if (!date) return null; | |
| 163 | const diffMs = date.getTime() - Date.now(); | |
| 135 | 164 | if (diffMs <= 0) return { value: "Expired", suffix: null }; |
| 136 | 165 | const seconds = Math.floor(diffMs / 1000); |
| 137 | 166 | if (seconds < 5) return { value: "Soon", suffix: null }; |
| ... | ... | @@ -146,6 +175,7 @@ export function formatRelativeTimeUntil(isoDate: string): { value: string; suffi |
| 146 | 175 | |
| 147 | 176 | export function formatRelativeTimeUntilLabel(isoDate: string): string { |
| 148 | 177 | const relative = formatRelativeTimeUntil(isoDate); |
| 178 | if (!relative) return ""; | |
| 149 | 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 | 184 | * Pass `nowMs` when a parent tick drives re-renders so the diff matches that snapshot. |
| 155 | 185 | */ |
| 156 | 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 | 190 | if (diffMs <= 0) return "Expired"; |
| 159 | 191 | |
| 160 | 192 | const totalSeconds = Math.floor(diffMs / 1000); |