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:
parent
a0cb132e9d
commit
f20fd0ec8d
2 changed files with 423 additions and 0 deletions
165
ui/src/components/projects/tabs/OverviewTab.test.tsx
Normal file
165
ui/src/components/projects/tabs/OverviewTab.test.tsx
Normal 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("—");
|
||||
});
|
||||
});
|
||||
258
ui/src/components/projects/tabs/OverviewTab.tsx
Normal file
258
ui/src/components/projects/tabs/OverviewTab.tsx
Normal 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"
|
||||
>
|
||||
“{chat.snippet}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue