feat(nexus): add OverviewTab with hero stat + milestones (phase 11)

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) <noreply@anthropic.com>
This commit is contained in:
Nexus Dev 2026-04-11 12:19:53 +00:00
parent a0cb132e9d
commit f20fd0ec8d
2 changed files with 423 additions and 0 deletions

View file

@ -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> = {}): 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<typeof createRoot> | 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(<OverviewTab {...props} />);
});
}
function q<T extends HTMLElement = HTMLElement>(testid: string): T | null {
return container.querySelector<T>(`[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<HTMLElement>('[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<HTMLElement>('[data-testid="milestone-bullet"]')?.textContent).toBe("[✓]");
expect(items[2]!.dataset.state).toBe("next-gate");
expect(items[2]!.querySelector<HTMLElement>('[data-testid="milestone-bullet"]')?.textContent).toBe("[○]");
expect(q("milestone-next-gate-marker")?.textContent).toBe("← Next gate");
expect(items[3]!.querySelector<HTMLElement>('[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<HTMLAnchorElement>("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<HTMLElement>('[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<HTMLElement>('[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("—");
});
});

View file

@ -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<Project, "name" | "status">;
/** 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 (
<span
data-testid="milestone-bullet"
data-state="completed"
aria-hidden="true"
className="inline-block w-5 shrink-0 text-primary"
>
[]
</span>
);
}
if (item.state === "next-gate") {
return (
<span
data-testid="milestone-bullet"
data-state="next-gate"
aria-hidden="true"
className="inline-block w-5 shrink-0 text-[#f4f692]"
>
[]
</span>
);
}
return (
<span
data-testid="milestone-bullet"
data-state="pending"
aria-hidden="true"
className="inline-block w-5 shrink-0 text-muted-foreground"
>
[ ]
</span>
);
}
function MilestoneChecklistCard({
milestones,
}: {
milestones: MilestoneItem[] | null;
}) {
return (
<section
data-testid="overview-milestone-card"
className="rounded-lg border border-border p-6"
>
<h3 className="mb-4 text-[16px] font-bold uppercase tracking-[0.08em] text-foreground">
Current milestone
</h3>
{milestones === null || milestones.length === 0 ? (
<p
data-testid="overview-milestone-empty"
className="text-[14px] text-muted-foreground"
>
No milestones defined
</p>
) : (
<ul className="space-y-2">
{milestones.map((item) => (
<li
key={item.id}
data-testid="milestone-item"
data-state={item.state}
className={cn(
"flex items-center gap-2 text-[14px] font-medium",
item.state === "completed"
? "text-foreground"
: "text-muted-foreground",
)}
>
<MilestoneBullet item={item} />
<span>{item.label}</span>
{item.state === "next-gate" ? (
<span
data-testid="milestone-next-gate-marker"
className="ml-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-[#f4f692]"
>
Next gate
</span>
) : null}
</li>
))}
</ul>
)}
</section>
);
}
function OriginChatCard({ chat }: { chat: OriginChat }) {
return (
<section
data-testid="overview-origin-chat-card"
className="rounded-lg border border-border p-6"
>
<h3 className="mb-2 text-[12px] font-bold uppercase tracking-[0.12em] text-muted-foreground">
Origin chat
</h3>
<blockquote
data-testid="overview-origin-chat-quote"
className="text-[14px] italic text-foreground"
>
&ldquo;{chat.snippet}&rdquo;
</blockquote>
<a
href={chat.href}
data-testid="overview-origin-chat-link"
className={cn(
"mt-3 inline-block text-[12px] font-semibold uppercase tracking-[0.12em] text-primary no-underline hover:underline",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
>
Open chat
</a>
</section>
);
}
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 (
<section
data-testid="overview-activity-card"
className="rounded-lg border border-border p-6"
>
<h3 className="mb-3 text-[12px] font-bold uppercase tracking-[0.12em] text-muted-foreground">
Activity (last 24h)
</h3>
<ul className="space-y-1.5">
{rows.map(([label, value]) => (
<li
key={label}
data-testid="overview-activity-row"
className="flex items-center justify-between text-[14px]"
>
<span className="text-muted-foreground">{label}</span>
<span className="font-mono text-foreground">{value}</span>
</li>
))}
</ul>
</section>
);
}
export function OverviewTab({
project,
progress,
activeAgentsCount,
milestones,
originChat,
activity24h,
}: OverviewTabProps) {
return (
<div data-testid="overview-tab" className="space-y-6">
{/* Hero stat row: 72px volt % + agents-active counter */}
<header className="flex flex-wrap items-end justify-between gap-6">
<div>
<div className="text-[12px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
{project.name}
</div>
<div
data-testid="overview-hero-progress"
className="mt-2 font-black text-[72px] leading-none text-primary"
>
{formatProgress(progress)}
</div>
</div>
<div
data-testid="overview-agents-active"
className="text-[14px] font-bold uppercase tracking-[0.1em] text-muted-foreground"
>
{activeAgentsCount} {activeAgentsCount === 1 ? "agent" : "agents"} active
</div>
</header>
{/* Milestone checklist */}
<MilestoneChecklistCard milestones={milestones} />
{/* Two-up: origin chat (optional) + 24h activity */}
<div className="grid gap-4 sm:grid-cols-2">
{originChat ? <OriginChatCard chat={originChat} /> : null}
<Activity24hCard counts={activity24h} />
</div>
</div>
);
}