Centralize date/time formatting utilities (#11831)

This commit is contained in:
max
2026-02-08 04:53:31 -08:00
committed by GitHub
parent 74fbbda283
commit a1123dd9be
77 changed files with 1508 additions and 1075 deletions

View File

@@ -1,34 +1,34 @@
import { describe, expect, it } from "vitest";
import { formatAgo, stripThinkingTags } from "./format.ts";
import { formatRelativeTimestamp, stripThinkingTags } from "./format.ts";
describe("formatAgo", () => {
it("returns 'in <1m' for timestamps less than 60s in the future", () => {
expect(formatAgo(Date.now() + 30_000)).toBe("in <1m");
expect(formatRelativeTimestamp(Date.now() + 30_000)).toBe("in <1m");
});
it("returns 'Xm from now' for future timestamps", () => {
expect(formatAgo(Date.now() + 5 * 60_000)).toBe("5m from now");
expect(formatRelativeTimestamp(Date.now() + 5 * 60_000)).toBe("5m from now");
});
it("returns 'Xh from now' for future timestamps", () => {
expect(formatAgo(Date.now() + 3 * 60 * 60_000)).toBe("3h from now");
expect(formatRelativeTimestamp(Date.now() + 3 * 60 * 60_000)).toBe("3h from now");
});
it("returns 'Xd from now' for future timestamps beyond 48h", () => {
expect(formatAgo(Date.now() + 3 * 24 * 60 * 60_000)).toBe("3d from now");
expect(formatRelativeTimestamp(Date.now() + 3 * 24 * 60 * 60_000)).toBe("3d from now");
});
it("returns 'Xs ago' for recent past timestamps", () => {
expect(formatAgo(Date.now() - 10_000)).toBe("10s ago");
expect(formatRelativeTimestamp(Date.now() - 10_000)).toBe("10s ago");
});
it("returns 'Xm ago' for past timestamps", () => {
expect(formatAgo(Date.now() - 5 * 60_000)).toBe("5m ago");
expect(formatRelativeTimestamp(Date.now() - 5 * 60_000)).toBe("5m ago");
});
it("returns 'n/a' for null/undefined", () => {
expect(formatAgo(null)).toBe("n/a");
expect(formatAgo(undefined)).toBe("n/a");
expect(formatRelativeTimestamp(null)).toBe("n/a");
expect(formatRelativeTimestamp(undefined)).toBe("n/a");
});
});

View File

@@ -1,5 +1,9 @@
import { formatDurationHuman } from "../../../src/infra/format-time/format-duration.ts";
import { formatRelativeTimestamp } from "../../../src/infra/format-time/format-relative.ts";
import { stripReasoningTagsFromText } from "../../../src/shared/text/reasoning-tags.js";
export { formatRelativeTimestamp, formatDurationHuman };
export function formatMs(ms?: number | null): string {
if (!ms && ms !== 0) {
return "n/a";
@@ -7,52 +11,6 @@ export function formatMs(ms?: number | null): string {
return new Date(ms).toLocaleString();
}
export function formatAgo(ms?: number | null): string {
if (!ms && ms !== 0) {
return "n/a";
}
const diff = Date.now() - ms;
const absDiff = Math.abs(diff);
const suffix = diff < 0 ? "from now" : "ago";
const sec = Math.round(absDiff / 1000);
if (sec < 60) {
return diff < 0 ? "in <1m" : `${sec}s ago`;
}
const min = Math.round(sec / 60);
if (min < 60) {
return `${min}m ${suffix}`;
}
const hr = Math.round(min / 60);
if (hr < 48) {
return `${hr}h ${suffix}`;
}
const day = Math.round(hr / 24);
return `${day}d ${suffix}`;
}
export function formatDurationMs(ms?: number | null): string {
if (!ms && ms !== 0) {
return "n/a";
}
if (ms < 1000) {
return `${ms}ms`;
}
const sec = Math.round(ms / 1000);
if (sec < 60) {
return `${sec}s`;
}
const min = Math.round(sec / 60);
if (min < 60) {
return `${min}m`;
}
const hr = Math.round(min / 60);
if (hr < 48) {
return `${hr}h`;
}
const day = Math.round(hr / 24);
return `${day}d`;
}
export function formatList(values?: Array<string | null | undefined>): string {
if (!values || values.length === 0) {
return "none";

View File

@@ -1,5 +1,5 @@
import type { CronJob, GatewaySessionRow, PresenceEntry } from "./types.ts";
import { formatAgo, formatDurationMs, formatMs } from "./format.ts";
import { formatRelativeTimestamp, formatDurationHuman, formatMs } from "./format.ts";
export function formatPresenceSummary(entry: PresenceEntry): string {
const host = entry.host ?? "unknown";
@@ -11,14 +11,14 @@ export function formatPresenceSummary(entry: PresenceEntry): string {
export function formatPresenceAge(entry: PresenceEntry): string {
const ts = entry.ts ?? null;
return ts ? formatAgo(ts) : "n/a";
return ts ? formatRelativeTimestamp(ts) : "n/a";
}
export function formatNextRun(ms?: number | null) {
if (!ms) {
return "n/a";
}
return `${formatMs(ms)} (${formatAgo(ms)})`;
return `${formatMs(ms)} (${formatRelativeTimestamp(ms)})`;
}
export function formatSessionTokens(row: GatewaySessionRow) {
@@ -57,7 +57,7 @@ export function formatCronSchedule(job: CronJob) {
return Number.isFinite(atMs) ? `At ${formatMs(atMs)}` : `At ${s.at}`;
}
if (s.kind === "every") {
return `Every ${formatDurationMs(s.everyMs)}`;
return `Every ${formatDurationHuman(s.everyMs)}`;
}
return `Cron ${s.expr}${s.tz ? ` (${s.tz})` : ""}`;
}

View File

@@ -16,7 +16,7 @@ import {
normalizeToolName,
resolveToolProfilePolicy,
} from "../../../../src/agents/tool-policy.js";
import { formatAgo } from "../format.ts";
import { formatRelativeTimestamp } from "../format.ts";
import {
formatCronPayload,
formatCronSchedule,
@@ -1112,7 +1112,9 @@ function renderAgentChannels(params: {
params.agentIdentity,
);
const entries = resolveChannelEntries(params.snapshot);
const lastSuccessLabel = params.lastSuccess ? formatAgo(params.lastSuccess) : "never";
const lastSuccessLabel = params.lastSuccess
? formatRelativeTimestamp(params.lastSuccess)
: "never";
return html`
<section class="grid grid-cols-2">
${renderAgentContextCard(context, "Workspace, identity, and model configuration.")}
@@ -1407,7 +1409,7 @@ function renderAgentFiles(params: {
function renderAgentFileRow(file: AgentFileEntry, active: string | null, onSelect: () => void) {
const status = file.missing
? "Missing"
: `${formatBytes(file.size)} · ${formatAgo(file.updatedAtMs ?? null)}`;
: `${formatBytes(file.size)} · ${formatRelativeTimestamp(file.updatedAtMs ?? null)}`;
return html`
<button
type="button"

View File

@@ -1,7 +1,7 @@
import { html, nothing } from "lit";
import type { DiscordStatus } from "../types.ts";
import type { ChannelsProps } from "./channels.types.ts";
import { formatAgo } from "../format.ts";
import { formatRelativeTimestamp } from "../format.ts";
import { renderChannelConfigSection } from "./channels.config.ts";
export function renderDiscordCard(params: {
@@ -28,11 +28,11 @@ export function renderDiscordCard(params: {
</div>
<div>
<span class="label">Last start</span>
<span>${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : "n/a"}</span>
<span>${discord?.lastStartAt ? formatRelativeTimestamp(discord.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : "n/a"}</span>
<span>${discord?.lastProbeAt ? formatRelativeTimestamp(discord.lastProbeAt) : "n/a"}</span>
</div>
</div>

View File

@@ -1,7 +1,7 @@
import { html, nothing } from "lit";
import type { GoogleChatStatus } from "../types.ts";
import type { ChannelsProps } from "./channels.types.ts";
import { formatAgo } from "../format.ts";
import { formatRelativeTimestamp } from "../format.ts";
import { renderChannelConfigSection } from "./channels.config.ts";
export function renderGoogleChatCard(params: {
@@ -42,11 +42,11 @@ export function renderGoogleChatCard(params: {
</div>
<div>
<span class="label">Last start</span>
<span>${googleChat?.lastStartAt ? formatAgo(googleChat.lastStartAt) : "n/a"}</span>
<span>${googleChat?.lastStartAt ? formatRelativeTimestamp(googleChat.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${googleChat?.lastProbeAt ? formatAgo(googleChat.lastProbeAt) : "n/a"}</span>
<span>${googleChat?.lastProbeAt ? formatRelativeTimestamp(googleChat.lastProbeAt) : "n/a"}</span>
</div>
</div>

View File

@@ -1,7 +1,7 @@
import { html, nothing } from "lit";
import type { IMessageStatus } from "../types.ts";
import type { ChannelsProps } from "./channels.types.ts";
import { formatAgo } from "../format.ts";
import { formatRelativeTimestamp } from "../format.ts";
import { renderChannelConfigSection } from "./channels.config.ts";
export function renderIMessageCard(params: {
@@ -28,11 +28,11 @@ export function renderIMessageCard(params: {
</div>
<div>
<span class="label">Last start</span>
<span>${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : "n/a"}</span>
<span>${imessage?.lastStartAt ? formatRelativeTimestamp(imessage.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : "n/a"}</span>
<span>${imessage?.lastProbeAt ? formatRelativeTimestamp(imessage.lastProbeAt) : "n/a"}</span>
</div>
</div>

View File

@@ -1,7 +1,7 @@
import { html, nothing } from "lit";
import type { ChannelAccountSnapshot, NostrStatus } from "../types.ts";
import type { ChannelsProps } from "./channels.types.ts";
import { formatAgo } from "../format.ts";
import { formatRelativeTimestamp } from "../format.ts";
import { renderChannelConfigSection } from "./channels.config.ts";
import {
renderNostrProfileForm,
@@ -79,7 +79,7 @@ export function renderNostrCard(params: {
</div>
<div>
<span class="label">Last inbound</span>
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
<span>${account.lastInboundAt ? formatRelativeTimestamp(account.lastInboundAt) : "n/a"}</span>
</div>
${
account.lastError
@@ -213,7 +213,7 @@ export function renderNostrCard(params: {
</div>
<div>
<span class="label">Last start</span>
<span>${summaryLastStartAt ? formatAgo(summaryLastStartAt) : "n/a"}</span>
<span>${summaryLastStartAt ? formatRelativeTimestamp(summaryLastStartAt) : "n/a"}</span>
</div>
</div>
`

View File

@@ -2,22 +2,6 @@ import { html, nothing } from "lit";
import type { ChannelAccountSnapshot } from "../types.ts";
import type { ChannelKey, ChannelsProps } from "./channels.types.ts";
export function formatDuration(ms?: number | null) {
if (!ms && ms !== 0) {
return "n/a";
}
const sec = Math.round(ms / 1000);
if (sec < 60) {
return `${sec}s`;
}
const min = Math.round(sec / 60);
if (min < 60) {
return `${min}m`;
}
const hr = Math.round(min / 60);
return `${hr}h`;
}
export function channelEnabled(key: ChannelKey, props: ChannelsProps) {
const snapshot = props.snapshot;
const channels = snapshot?.channels as Record<string, unknown> | null;

View File

@@ -1,7 +1,7 @@
import { html, nothing } from "lit";
import type { SignalStatus } from "../types.ts";
import type { ChannelsProps } from "./channels.types.ts";
import { formatAgo } from "../format.ts";
import { formatRelativeTimestamp } from "../format.ts";
import { renderChannelConfigSection } from "./channels.config.ts";
export function renderSignalCard(params: {
@@ -32,11 +32,11 @@ export function renderSignalCard(params: {
</div>
<div>
<span class="label">Last start</span>
<span>${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : "n/a"}</span>
<span>${signal?.lastStartAt ? formatRelativeTimestamp(signal.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : "n/a"}</span>
<span>${signal?.lastProbeAt ? formatRelativeTimestamp(signal.lastProbeAt) : "n/a"}</span>
</div>
</div>

View File

@@ -1,7 +1,7 @@
import { html, nothing } from "lit";
import type { SlackStatus } from "../types.ts";
import type { ChannelsProps } from "./channels.types.ts";
import { formatAgo } from "../format.ts";
import { formatRelativeTimestamp } from "../format.ts";
import { renderChannelConfigSection } from "./channels.config.ts";
export function renderSlackCard(params: {
@@ -28,11 +28,11 @@ export function renderSlackCard(params: {
</div>
<div>
<span class="label">Last start</span>
<span>${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : "n/a"}</span>
<span>${slack?.lastStartAt ? formatRelativeTimestamp(slack.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : "n/a"}</span>
<span>${slack?.lastProbeAt ? formatRelativeTimestamp(slack.lastProbeAt) : "n/a"}</span>
</div>
</div>

View File

@@ -1,7 +1,7 @@
import { html, nothing } from "lit";
import type { ChannelAccountSnapshot, TelegramStatus } from "../types.ts";
import type { ChannelsProps } from "./channels.types.ts";
import { formatAgo } from "../format.ts";
import { formatRelativeTimestamp } from "../format.ts";
import { renderChannelConfigSection } from "./channels.config.ts";
export function renderTelegramCard(params: {
@@ -36,7 +36,7 @@ export function renderTelegramCard(params: {
</div>
<div>
<span class="label">Last inbound</span>
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
<span>${account.lastInboundAt ? formatRelativeTimestamp(account.lastInboundAt) : "n/a"}</span>
</div>
${
account.lastError
@@ -81,11 +81,11 @@ export function renderTelegramCard(params: {
</div>
<div>
<span class="label">Last start</span>
<span>${telegram?.lastStartAt ? formatAgo(telegram.lastStartAt) : "n/a"}</span>
<span>${telegram?.lastStartAt ? formatRelativeTimestamp(telegram.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${telegram?.lastProbeAt ? formatAgo(telegram.lastProbeAt) : "n/a"}</span>
<span>${telegram?.lastProbeAt ? formatRelativeTimestamp(telegram.lastProbeAt) : "n/a"}</span>
</div>
</div>
`

View File

@@ -14,7 +14,7 @@ import type {
WhatsAppStatus,
} from "../types.ts";
import type { ChannelKey, ChannelsChannelData, ChannelsProps } from "./channels.types.ts";
import { formatAgo } from "../format.ts";
import { formatRelativeTimestamp } from "../format.ts";
import { renderChannelConfigSection } from "./channels.config.ts";
import { renderDiscordCard } from "./channels.discord.ts";
import { renderGoogleChatCard } from "./channels.googlechat.ts";
@@ -73,7 +73,7 @@ export function renderChannels(props: ChannelsProps) {
<div class="card-title">Channel health</div>
<div class="card-sub">Channel status snapshots from the gateway.</div>
</div>
<div class="muted">${props.lastSuccessAt ? formatAgo(props.lastSuccessAt) : "n/a"}</div>
<div class="muted">${props.lastSuccessAt ? formatRelativeTimestamp(props.lastSuccessAt) : "n/a"}</div>
</div>
${
props.lastError
@@ -308,7 +308,7 @@ function renderGenericAccount(account: ChannelAccountSnapshot) {
</div>
<div>
<span class="label">Last inbound</span>
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
<span>${account.lastInboundAt ? formatRelativeTimestamp(account.lastInboundAt) : "n/a"}</span>
</div>
${
account.lastError

View File

@@ -1,9 +1,8 @@
import { html, nothing } from "lit";
import type { WhatsAppStatus } from "../types.ts";
import type { ChannelsProps } from "./channels.types.ts";
import { formatAgo } from "../format.ts";
import { formatRelativeTimestamp, formatDurationHuman } from "../format.ts";
import { renderChannelConfigSection } from "./channels.config.ts";
import { formatDuration } from "./channels.shared.ts";
export function renderWhatsAppCard(params: {
props: ChannelsProps;
@@ -38,19 +37,19 @@ export function renderWhatsAppCard(params: {
<div>
<span class="label">Last connect</span>
<span>
${whatsapp?.lastConnectedAt ? formatAgo(whatsapp.lastConnectedAt) : "n/a"}
${whatsapp?.lastConnectedAt ? formatRelativeTimestamp(whatsapp.lastConnectedAt) : "n/a"}
</span>
</div>
<div>
<span class="label">Last message</span>
<span>
${whatsapp?.lastMessageAt ? formatAgo(whatsapp.lastMessageAt) : "n/a"}
${whatsapp?.lastMessageAt ? formatRelativeTimestamp(whatsapp.lastMessageAt) : "n/a"}
</span>
</div>
<div>
<span class="label">Auth age</span>
<span>
${whatsapp?.authAgeMs != null ? formatDuration(whatsapp.authAgeMs) : "n/a"}
${whatsapp?.authAgeMs != null ? formatDurationHuman(whatsapp.authAgeMs) : "n/a"}
</span>
</div>
</div>

View File

@@ -1,7 +1,7 @@
import { html, nothing } from "lit";
import type { ChannelUiMetaEntry, CronJob, CronRunLogEntry, CronStatus } from "../types.ts";
import type { CronFormState } from "../ui-types.ts";
import { formatAgo, formatMs } from "../format.ts";
import { formatRelativeTimestamp, formatMs } from "../format.ts";
import { pathForTab } from "../navigation.ts";
import { formatCronSchedule, formatNextRun } from "../presenter.ts";
@@ -482,7 +482,7 @@ function formatStateRelative(ms?: number) {
if (typeof ms !== "number" || !Number.isFinite(ms)) {
return "n/a";
}
return formatAgo(ms);
return formatRelativeTimestamp(ms);
}
function renderJobState(job: CronJob) {

View File

@@ -10,7 +10,7 @@ import type {
ExecApprovalsFile,
ExecApprovalsSnapshot,
} from "../controllers/exec-approvals.ts";
import { clampText, formatAgo, formatList } from "../format.ts";
import { clampText, formatRelativeTimestamp, formatList } from "../format.ts";
export type NodesProps = {
loading: boolean;
@@ -130,7 +130,7 @@ function renderDevices(props: NodesProps) {
function renderPendingDevice(req: PendingDevice, props: NodesProps) {
const name = req.displayName?.trim() || req.deviceId;
const age = typeof req.ts === "number" ? formatAgo(req.ts) : "n/a";
const age = typeof req.ts === "number" ? formatRelativeTimestamp(req.ts) : "n/a";
const role = req.role?.trim() ? `role: ${req.role}` : "role: -";
const repair = req.isRepair ? " · repair" : "";
const ip = req.remoteIp ? ` · ${req.remoteIp}` : "";
@@ -189,7 +189,9 @@ function renderPairedDevice(device: PairedDevice, props: NodesProps) {
function renderTokenRow(deviceId: string, token: DeviceTokenSummary, props: NodesProps) {
const status = token.revokedAtMs ? "revoked" : "active";
const scopes = `scopes: ${formatList(token.scopes)}`;
const when = formatAgo(token.rotatedAtMs ?? token.createdAtMs ?? token.lastUsedAtMs ?? null);
const when = formatRelativeTimestamp(
token.rotatedAtMs ?? token.createdAtMs ?? token.lastUsedAtMs ?? null,
);
return html`
<div class="row" style="justify-content: space-between; gap: 8px;">
<div class="list-sub">${token.role} · ${status} · ${scopes} · ${when}</div>
@@ -931,7 +933,7 @@ function renderAllowlistEntry(
entry: ExecApprovalsAllowlistEntry,
index: number,
) {
const lastUsed = entry.lastUsedAt ? formatAgo(entry.lastUsedAt) : "never";
const lastUsed = entry.lastUsedAt ? formatRelativeTimestamp(entry.lastUsedAt) : "never";
const lastCommand = entry.lastUsedCommand ? clampText(entry.lastUsedCommand, 120) : null;
const lastPath = entry.lastResolvedPath ? clampText(entry.lastResolvedPath, 120) : null;
return html`

View File

@@ -1,7 +1,7 @@
import { html } from "lit";
import type { GatewayHelloOk } from "../gateway.ts";
import type { UiSettings } from "../storage.ts";
import { formatAgo, formatDurationMs } from "../format.ts";
import { formatRelativeTimestamp, formatDurationHuman } from "../format.ts";
import { formatNextRun } from "../presenter.ts";
export type OverviewProps = {
@@ -26,7 +26,7 @@ export function renderOverview(props: OverviewProps) {
const snapshot = props.hello?.snapshot as
| { uptimeMs?: number; policy?: { tickIntervalMs?: number } }
| undefined;
const uptime = snapshot?.uptimeMs ? formatDurationMs(snapshot.uptimeMs) : "n/a";
const uptime = snapshot?.uptimeMs ? formatDurationHuman(snapshot.uptimeMs) : "n/a";
const tick = snapshot?.policy?.tickIntervalMs ? `${snapshot.policy.tickIntervalMs}ms` : "n/a";
const authHint = (() => {
if (props.connected || !props.lastError) {
@@ -198,7 +198,7 @@ export function renderOverview(props: OverviewProps) {
<div class="stat">
<div class="stat-label">Last Channels Refresh</div>
<div class="stat-value">
${props.lastChannelsRefresh ? formatAgo(props.lastChannelsRefresh) : "n/a"}
${props.lastChannelsRefresh ? formatRelativeTimestamp(props.lastChannelsRefresh) : "n/a"}
</div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
import { html, nothing } from "lit";
import type { GatewaySessionRow, SessionsListResult } from "../types.ts";
import { formatAgo } from "../format.ts";
import { formatRelativeTimestamp } from "../format.ts";
import { pathForTab } from "../navigation.ts";
import { formatSessionTokens } from "../presenter.ts";
@@ -221,7 +221,7 @@ function renderRow(
onDelete: SessionsProps["onDelete"],
disabled: boolean,
) {
const updated = row.updatedAt ? formatAgo(row.updatedAt) : "n/a";
const updated = row.updatedAt ? formatRelativeTimestamp(row.updatedAt) : "n/a";
const rawThinking = row.thinkingLevel ?? "";
const isBinaryThinking = isBinaryThinkingProvider(row.modelProvider);
const thinking = resolveThinkLevelDisplay(rawThinking, isBinaryThinking);

View File

@@ -1,4 +1,5 @@
import { html, svg, nothing } from "lit";
import { formatDurationCompact } from "../../../../src/infra/format-time/format-duration.ts";
import { extractQueryTerms, filterSessionsByQuery, parseToolSummary } from "../usage-helpers.ts";
// Inline styles for usage view (app uses light DOM, so static styles don't work)
@@ -2461,19 +2462,6 @@ function formatIsoDate(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
function formatDurationShort(ms?: number): string {
if (!ms || ms <= 0) {
return "0s";
}
if (ms >= 60_000) {
return `${Math.round(ms / 60000)}m`;
}
if (ms >= 1000) {
return `${Math.round(ms / 1000)}s`;
}
return `${Math.round(ms)}ms`;
}
function parseYmdDate(dateStr: string): Date | null {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr);
if (!match) {
@@ -2500,23 +2488,6 @@ function formatFullDate(dateStr: string): string {
return date.toLocaleDateString(undefined, { month: "long", day: "numeric", year: "numeric" });
}
function formatDurationMs(ms?: number): string {
if (!ms || ms <= 0) {
return "—";
}
const totalSeconds = Math.round(ms / 1000);
const seconds = totalSeconds % 60;
const minutes = Math.floor(totalSeconds / 60) % 60;
const hours = Math.floor(totalSeconds / 3600);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
if (minutes > 0) {
return `${minutes}m ${seconds}s`;
}
return `${seconds}s`;
}
function downloadTextFile(filename: string, content: string, type = "text/plain") {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
@@ -3467,7 +3438,10 @@ function renderUsageInsights(
stats.throughputCostPerMin !== undefined
? `${formatCost(stats.throughputCostPerMin, 4)} / min`
: "—";
const avgDurationLabel = stats.durationCount > 0 ? formatDurationShort(stats.avgDurationMs) : "—";
const avgDurationLabel =
stats.durationCount > 0
? (formatDurationCompact(stats.avgDurationMs, { spaced: true }) ?? "—")
: "—";
const cacheHint = "Cache hit rate = cache read / (input + cache read). Higher is better.";
const errorHint = "Error rate = errors / total messages. Lower is better.";
const throughputHint = "Throughput shows tokens per minute over active time. Higher is better.";
@@ -3672,7 +3646,7 @@ function renderSessionsCard(
parts.push(`errors:${s.usage.messageCounts.errors}`);
}
if (showColumn("duration") && s.usage?.durationMs) {
parts.push(`dur:${formatDurationMs(s.usage.durationMs)}`);
parts.push(`dur:${formatDurationCompact(s.usage.durationMs, { spaced: true }) ?? "—"}`);
}
return parts;
};
@@ -3976,7 +3950,7 @@ function renderSessionSummary(session: UsageSessionEntry) {
</div>
<div class="session-summary-card">
<div class="session-summary-title">Duration</div>
<div class="session-summary-value">${formatDurationMs(usage.durationMs)}</div>
<div class="session-summary-value">${formatDurationCompact(usage.durationMs, { spaced: true }) ?? "—"}</div>
<div class="session-summary-meta">${formatTs(usage.firstActivity)}${formatTs(usage.lastActivity)}</div>
</div>
</div>