Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions apps/web/src/components/settings/DiagnosticsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 <span className="text-[11px] text-muted-foreground/50">Checking</span>;
}

if (relative.status === "invalid") {
return <span className="text-[11px] text-muted-foreground/50">Checked unavailable</span>;
}

return (
<span className="text-[11px] text-muted-foreground/60">
{relative.suffix ? (
Expand Down
10 changes: 7 additions & 3 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 <span className="text-[11px] text-muted-foreground/50">Checked unavailable</span>;
}

return (
<span className="text-[11px] text-muted-foreground/60">
{lastCheckedRelative.suffix ? (
Expand Down
60 changes: 60 additions & 0 deletions apps/web/src/timestampFormat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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();
Expand Down
52 changes: 42 additions & 10 deletions apps/web/src/timestampFormat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down Expand Up @@ -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);
Expand All @@ -79,16 +86,26 @@ 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);
}

/**
* Format a relative time string from an ISO date.
* 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;
Comment thread
cursor[bot] marked this conversation as resolved.
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 };
Expand All @@ -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);
Expand All @@ -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 };
Expand All @@ -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;
}

Expand All @@ -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);
Expand Down
Loading