diff --git a/ui/src/components/projects/tabs/OverviewTab.test.tsx b/ui/src/components/projects/tabs/OverviewTab.test.tsx new file mode 100644 index 00000000..f5163423 --- /dev/null +++ b/ui/src/components/projects/tabs/OverviewTab.test.tsx @@ -0,0 +1,165 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { OverviewTab, type OverviewTabProps } from "./OverviewTab"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function baseProps(overrides: Partial = {}): OverviewTabProps { + return { + project: { name: "nexus-design-migration", status: "active" }, + progress: 47, + activeAgentsCount: 4, + milestones: null, + originChat: null, + activity24h: { + commits: null, + issuesClosed: null, + gatesAwaiting: null, + costBurnedCents: null, + }, + ...overrides, + }; +} + +describe("OverviewTab", () => { + let container: HTMLDivElement; + let root: ReturnType | null = null; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = null; + }); + + afterEach(() => { + if (root) { + act(() => { + root!.unmount(); + }); + root = null; + } + if (container.parentNode) container.remove(); + }); + + function render(props: OverviewTabProps) { + root = createRoot(container); + act(() => { + root!.render(); + }); + } + + function q(testid: string): T | null { + return container.querySelector(`[data-testid="${testid}"]`); + } + + it("renders the hero progress as 47% with Inter Black 72px classes", () => { + render(baseProps({ progress: 47 })); + const hero = q("overview-hero-progress"); + expect(hero?.textContent).toBe("47%"); + expect(hero?.className).toContain("font-black"); + expect(hero?.className).toContain("text-[72px]"); + expect(hero?.className).toContain("text-primary"); + }); + + it("renders em-dash progress when null (no milestone data)", () => { + render(baseProps({ progress: null })); + expect(q("overview-hero-progress")?.textContent).toBe("—%"); + }); + + it("renders the 'N AGENTS ACTIVE' counter with pluralization", () => { + render(baseProps({ activeAgentsCount: 4 })); + expect(q("overview-agents-active")?.textContent).toBe("4 agents active"); + + render(baseProps({ activeAgentsCount: 1 })); + expect(q("overview-agents-active")?.textContent).toBe("1 agent active"); + + render(baseProps({ activeAgentsCount: 0 })); + expect(q("overview-agents-active")?.textContent).toBe("0 agents active"); + }); + + it("renders 'No milestones defined' placeholder when milestones is null", () => { + render(baseProps({ milestones: null })); + expect(q("overview-milestone-empty")?.textContent).toBe("No milestones defined"); + }); + + it("renders 'No milestones defined' placeholder when milestones is an empty array", () => { + render(baseProps({ milestones: [] })); + expect(q("overview-milestone-empty")?.textContent).toBe("No milestones defined"); + }); + + it("renders milestone items with [✓]/[○]/[ ] bullets and the next-gate marker", () => { + render( + baseProps({ + milestones: [ + { id: "m1", label: "DESIGN.md drafted", state: "completed" }, + { id: "m2", label: "MIGRATION-PLAN approved", state: "completed" }, + { id: "m3", label: "Phase 4 — typography", state: "next-gate" }, + { id: "m4", label: "Phase 5 — preview", state: "pending" }, + ], + }), + ); + const items = container.querySelectorAll('[data-testid="milestone-item"]'); + expect(items).toHaveLength(4); + expect(items[0]!.dataset.state).toBe("completed"); + expect(items[0]!.textContent).toContain("DESIGN.md drafted"); + expect(items[0]!.querySelector('[data-testid="milestone-bullet"]')?.textContent).toBe("[✓]"); + expect(items[2]!.dataset.state).toBe("next-gate"); + expect(items[2]!.querySelector('[data-testid="milestone-bullet"]')?.textContent).toBe("[○]"); + expect(q("milestone-next-gate-marker")?.textContent).toBe("← Next gate"); + expect(items[3]!.querySelector('[data-testid="milestone-bullet"]')?.textContent).toBe("[ ]"); + }); + + it("renders the origin chat card when originChat is provided", () => { + render( + baseProps({ + originChat: { + conversationId: "conv-1", + snippet: "Don't just redesign the right rail", + href: "/NEX/assistant/conv-1", + }, + }), + ); + expect(q("overview-origin-chat-card")).not.toBeNull(); + expect(q("overview-origin-chat-quote")?.textContent).toContain( + "Don't just redesign the right rail", + ); + expect(q("overview-origin-chat-link")?.href).toContain("/NEX/assistant/conv-1"); + }); + + it("omits the origin chat card when originChat is null", () => { + render(baseProps({ originChat: null })); + expect(q("overview-origin-chat-card")).toBeNull(); + }); + + it("renders activity counts and burned amount when present", () => { + render( + baseProps({ + activity24h: { + commits: 14, + issuesClosed: 3, + gatesAwaiting: 1, + costBurnedCents: 460, + }, + }), + ); + const rows = container.querySelectorAll('[data-testid="overview-activity-row"]'); + expect(rows).toHaveLength(4); + expect(rows[0]!.textContent).toContain("14"); + expect(rows[1]!.textContent).toContain("3"); + expect(rows[2]!.textContent).toContain("1"); + expect(rows[3]!.textContent).toContain("$4.60"); + }); + + it("renders em-dash placeholders for missing activity fields", () => { + render(baseProps()); + const rows = container.querySelectorAll('[data-testid="overview-activity-row"]'); + expect(rows[0]!.textContent).toContain("—"); + expect(rows[1]!.textContent).toContain("—"); + expect(rows[2]!.textContent).toContain("—"); + expect(rows[3]!.textContent).toContain("—"); + }); +}); diff --git a/ui/src/components/projects/tabs/OverviewTab.tsx b/ui/src/components/projects/tabs/OverviewTab.tsx new file mode 100644 index 00000000..1678d365 --- /dev/null +++ b/ui/src/components/projects/tabs/OverviewTab.tsx @@ -0,0 +1,258 @@ +// [nexus] Phase 11 — Project Detail OVERVIEW tab. +// +// Spec §7.2.1: +// • Hero stat row: 72px Inter Black volt percentage + "N AGENTS ACTIVE" +// • Milestone checklist card with [✓]/[○]/[ ] bullets; the next gate +// marker is pale yellow with a "← NEXT GATE" label +// • Origin chat card — links back to the conversation that birthed the +// project. Omitted when the project record has no origin. +// • Activity card — rolling 24h counts for this project +// +// Data gaps (Phase 11): the shared Project type has no milestones, +// no originConversationId, no per-project 24h activity roll-up. Where +// data is missing we render explicit placeholders ("No milestones +// defined", "— commits", etc.) and never fabricate values. See the +// Phase 11 report for the backend gap list. +import type { Project } from "@paperclipai/shared"; +import { cn } from "@/lib/utils"; + +export interface MilestoneItem { + id: string; + label: string; + state: "completed" | "pending" | "next-gate"; +} + +export interface OriginChat { + conversationId: string; + snippet: string; + href: string; +} + +export interface Activity24hCounts { + commits: number | null; + issuesClosed: number | null; + gatesAwaiting: number | null; + costBurnedCents: number | null; +} + +export interface OverviewTabProps { + project: Pick; + /** Milestone progress 0..100, or null when the backend has no data. */ + progress: number | null; + /** Count of agents currently in 'working'/'running' state on this project. */ + activeAgentsCount: number; + /** Milestones for the milestone checklist, or null when unavailable. */ + milestones: MilestoneItem[] | null; + /** Origin chat card data, or null to omit the card. */ + originChat: OriginChat | null; + /** 24h activity rollup. Individual fields may be null. */ + activity24h: Activity24hCounts; +} + +function formatUSD(cents: number | null): string { + if (cents === null) return "—"; + return `$${(cents / 100).toFixed(2)}`; +} + +function formatProgress(progress: number | null): string { + if (progress === null) return "—%"; + return `${Math.round(progress)}%`; +} + +function MilestoneBullet({ item }: { item: MilestoneItem }) { + if (item.state === "completed") { + return ( + + ); + } + if (item.state === "next-gate") { + return ( + + ); + } + return ( + + ); +} + +function MilestoneChecklistCard({ + milestones, +}: { + milestones: MilestoneItem[] | null; +}) { + return ( +
+

+ Current milestone +

+ {milestones === null || milestones.length === 0 ? ( +

+ No milestones defined +

+ ) : ( +
    + {milestones.map((item) => ( +
  • + + {item.label} + {item.state === "next-gate" ? ( + + ← Next gate + + ) : null} +
  • + ))} +
+ )} +
+ ); +} + +function OriginChatCard({ chat }: { chat: OriginChat }) { + return ( +
+

+ Origin chat +

+
+ “{chat.snippet}” +
+ + → Open chat + +
+ ); +} + +function Activity24hCard({ counts }: { counts: Activity24hCounts }) { + const rows: Array<[string, string]> = [ + ["commits", counts.commits === null ? "—" : String(counts.commits)], + [ + "issues closed", + counts.issuesClosed === null ? "—" : String(counts.issuesClosed), + ], + [ + "gates awaiting", + counts.gatesAwaiting === null ? "—" : String(counts.gatesAwaiting), + ], + ["burned", formatUSD(counts.costBurnedCents)], + ]; + return ( +
+

+ Activity (last 24h) +

+
    + {rows.map(([label, value]) => ( +
  • + {label} + {value} +
  • + ))} +
+
+ ); +} + +export function OverviewTab({ + project, + progress, + activeAgentsCount, + milestones, + originChat, + activity24h, +}: OverviewTabProps) { + return ( +
+ {/* Hero stat row: 72px volt % + agents-active counter */} +
+
+
+ {project.name} +
+
+ {formatProgress(progress)} +
+
+
+ {activeAgentsCount} {activeAgentsCount === 1 ? "agent" : "agents"} active +
+
+ + {/* Milestone checklist */} + + + {/* Two-up: origin chat (optional) + 24h activity */} +
+ {originChat ? : null} + +
+
+ ); +}