feat(agent-team): dashboard UI redesign (Board + DAG tabs, Tailwind/shadcn) [read-only] #64
4 changed files with 106 additions and 5 deletions
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
55
agent-team/web/src/lib/time.test.ts
Normal file
55
agent-team/web/src/lib/time.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
43
agent-team/web/src/lib/time.ts
Normal file
43
agent-team/web/src/lib/time.ts
Normal 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}`;
|
||||
}
|
||||
Reference in a new issue