From f20fd0ec8d9c1dc65ea1fdf64600121ea5b76271 Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Sat, 11 Apr 2026 12:19:53 +0000 Subject: [PATCH] feat(nexus): add OverviewTab with hero stat + milestones (phase 11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the default Builder tab per spec §7.2.1: • 72px Inter Black volt hero percentage + "N AGENTS ACTIVE" counter • Milestone checklist card with [✓]/[○]/[ ] bullets and pale yellow next-gate marker + "← NEXT GATE" label • Optional origin chat card (hidden when project has no origin) • 24h activity rollup card (commits, issues closed, gates awaiting, burned) All data inputs are typed with nullable fields so callers can pass null for any missing slice and the tab renders explicit em-dash or "No milestones defined" placeholders without fabricating values. The Project type doesn't currently carry milestones, origin chat, or 24h rollups — see Phase 11 report for the backend gap list. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../projects/tabs/OverviewTab.test.tsx | 165 +++++++++++ .../components/projects/tabs/OverviewTab.tsx | 258 ++++++++++++++++++ 2 files changed, 423 insertions(+) create mode 100644 ui/src/components/projects/tabs/OverviewTab.test.tsx create mode 100644 ui/src/components/projects/tabs/OverviewTab.tsx 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} + +
+
+ ); +}