feat(agent-team): render all dashboard times as Eastern 24h (EDT/EST)

Add src/lib/time.ts formatEastern() — parses both backend timestamp shapes
(the 'YYYY-MM-DD HH:MM:SS UTC' snapshot string and ISO offset transition times),
renders them in America/New_York (DST-aware: EDT/EST) as 24h
'YYYY-MM-DD HH:MM:SS ZZZ'. Wire into TopBar (snapshot clock + tooltip) and the
TaskDrawer timeline (entered_at). Adds time.test.ts (8 cases incl. DST + day
boundary). 44 frontend tests green.
This commit is contained in:
Adam Moussa 2026-06-24 18:56:56 -04:00
parent ebb8e1991f
commit 05c92be874
4 changed files with 106 additions and 5 deletions

View file

@ -12,6 +12,7 @@ import {
CollapsibleContent,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { formatEastern } from "@/lib/time";
interface Props {
detail: TaskDetail | null;
@ -171,8 +172,8 @@ export function TaskDrawer({ detail, loading, error, onClose }: Props) {
</div>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
{step.entered_at && (
<span className="text-xs text-muted-foreground">
{step.entered_at}
<span className="text-xs text-muted-foreground tabular-nums">
{formatEastern(step.entered_at)}
</span>
)}
{step.status && (

View file

@ -1,4 +1,5 @@
import { cn } from "@/lib/utils";
import { formatEastern } from "@/lib/time";
import type { StateResponse } from "../api";
interface Props {
@ -50,9 +51,10 @@ const CHIPS: ChipDef[] = [
// Top status bar: brand, summary chips, connection health, and snapshot clock.
export function TopBar({ state, connected, generatedAt }: Props) {
const summary = state?.summary;
const snapshot = formatEastern(generatedAt);
const title = connected
? `Live — snapshot at ${generatedAt ?? "—"}`
: `Disconnected — last snapshot at ${generatedAt ?? "—"}`;
? `Live — snapshot at ${snapshot}`
: `Disconnected — last snapshot at ${snapshot}`;
return (
<header
@ -117,7 +119,7 @@ export function TopBar({ state, connected, generatedAt }: Props) {
)}
/>
<span className="text-xs text-muted-foreground tabular-nums">
{generatedAt ?? "—"}
{snapshot}
</span>
</div>
</header>

View file

@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { formatEastern, parseServerDate } from "./time";
describe("formatEastern", () => {
it("formats the snapshot 'YYYY-MM-DD HH:MM:SS UTC' shape as Eastern 24h (EDT in summer)", () => {
// 22:54:48 UTC on 2026-06-24 is 18:54:48 EDT (UTC-4).
expect(formatEastern("2026-06-24 22:54:48 UTC")).toBe(
"2026-06-24 18:54:48 EDT",
);
});
it("formats ISO offset timestamps as Eastern 24h", () => {
expect(formatEastern("2026-06-24T22:48:10.437876+00:00")).toBe(
"2026-06-24 18:48:10 EDT",
);
});
it("uses EST in winter (DST-aware)", () => {
// 12:00 UTC on 2026-01-15 is 07:00 EST (UTC-5).
expect(formatEastern("2026-01-15 12:00:00 UTC")).toBe(
"2026-01-15 07:00:00 EST",
);
});
it("crosses the day boundary correctly (late UTC -> earlier Eastern, prior day)", () => {
// 02:30 UTC on 2026-06-25 is 22:30 EDT on 2026-06-24.
expect(formatEastern("2026-06-25T02:30:00+00:00")).toBe(
"2026-06-24 22:30:00 EDT",
);
});
it("returns the em-dash for null / undefined / unparseable input", () => {
expect(formatEastern(null)).toBe("—");
expect(formatEastern(undefined)).toBe("—");
expect(formatEastern("not a date")).toBe("—");
});
});
describe("parseServerDate", () => {
it("treats the bare 'UTC'-suffixed shape as UTC", () => {
const d = parseServerDate("2026-06-24 22:54:48 UTC");
expect(d?.toISOString()).toBe("2026-06-24T22:54:48.000Z");
});
it("parses ISO offsets", () => {
const d = parseServerDate("2026-06-24T22:48:10.000+00:00");
expect(d?.toISOString()).toBe("2026-06-24T22:48:10.000Z");
});
it("returns null for empty/unparseable input", () => {
expect(parseServerDate(null)).toBeNull();
expect(parseServerDate("")).toBeNull();
expect(parseServerDate("nope")).toBeNull();
});
});

View file

@ -0,0 +1,43 @@
// Time formatting for the dashboard. All backend timestamps are UTC; we render
// them in US Eastern (America/New_York — EDT in summer, EST in winter, DST-aware)
// as 24-hour `YYYY-MM-DD HH:MM:SS ZZZ`.
//
// The backend emits two shapes:
// - "2026-06-24 22:54:48 UTC" (status snapshot generated_at)
// - "2026-06-24T22:48:10.437876+00:00" (ISO transition timestamps)
// parseServerDate normalizes both to a Date; everything else is ISO that the
// JS engine parses natively.
const EASTERN_FMT = new Intl.DateTimeFormat("en-CA", {
timeZone: "America/New_York",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23", // 24-hour, 00–23 (never "24:xx")
timeZoneName: "short",
});
/** Parse a backend timestamp (UTC) into a Date, or null if unparseable. */
export function parseServerDate(raw: string | null | undefined): Date | null {
if (!raw) return null;
let s = String(raw).trim();
// "YYYY-MM-DD HH:MM:SS UTC" -> ISO "YYYY-MM-DDTHH:MM:SSZ"
if (s.endsWith(" UTC")) s = s.slice(0, -4).trim().replace(" ", "T") + "Z";
const d = new Date(s);
return Number.isNaN(d.getTime()) ? null : d;
}
/**
* Format a backend UTC timestamp as Eastern 24-hour time:
* "2026-06-24 18:54:48 EDT". Returns "—" for null/unparseable input.
*/
export function formatEastern(raw: string | null | undefined): string {
const d = parseServerDate(raw);
if (!d) return "—";
const p: Record<string, string> = {};
for (const part of EASTERN_FMT.formatToParts(d)) p[part.type] = part.value;
return `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute}:${p.second} ${p.timeZoneName}`;
}