feat(nexus): add ProjectCard hero-stat component (phase 11)
New Projects list card: 72px Inter Black volt hero percentage, pale yellow/volt/forest status dot, 8px progress bar, sub-line with phase and next-gate counter, footer with burn + last activity. Graceful em-dash placeholders when milestone/cost/activity fields are missing (the shared Project type has no milestoneProgress etc. yet — see Phase 11 data-gap report). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3fe4b543cf
commit
a2dab5b4f6
2 changed files with 374 additions and 0 deletions
199
ui/src/components/projects/ProjectCard.test.tsx
Normal file
199
ui/src/components/projects/ProjectCard.test.tsx
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ProjectCard } from "./ProjectCard";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
describe("ProjectCard", () => {
|
||||
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: Parameters<typeof ProjectCard>[0]) {
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root!.render(<ProjectCard {...props} />);
|
||||
});
|
||||
return {
|
||||
get title() {
|
||||
return container.querySelector<HTMLElement>('[data-testid="project-card-title"]');
|
||||
},
|
||||
get hero() {
|
||||
return container.querySelector<HTMLElement>('[data-testid="project-card-hero"]');
|
||||
},
|
||||
get subline() {
|
||||
return container.querySelector<HTMLElement>('[data-testid="project-card-subline"]');
|
||||
},
|
||||
get footer() {
|
||||
return container.querySelector<HTMLElement>('[data-testid="project-card-footer"]');
|
||||
},
|
||||
get fill() {
|
||||
return container.querySelector<HTMLElement>('[data-testid="project-card-progress-fill"]');
|
||||
},
|
||||
get dot() {
|
||||
return container.querySelector<HTMLElement>('[data-testid="project-card-status-dot"]');
|
||||
},
|
||||
get button() {
|
||||
return container.querySelector<HTMLButtonElement>('[data-testid="project-card"]');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("renders the slug uppercase in the title row", () => {
|
||||
const api = render({
|
||||
slug: "NEXUS-DESIGN-MIGRATION",
|
||||
status: "working",
|
||||
progress: 47,
|
||||
phase: { current: 3, total: 7 },
|
||||
nextGateName: "Phase 4 audit",
|
||||
costBurnedCents: 1420,
|
||||
lastActivity: "8m ago",
|
||||
});
|
||||
expect(api.title?.textContent).toBe("NEXUS-DESIGN-MIGRATION");
|
||||
});
|
||||
|
||||
it("renders the hero stat as <N>% with Inter Black 72px class", () => {
|
||||
const api = render({
|
||||
slug: "DEMO",
|
||||
status: "idle",
|
||||
progress: 47,
|
||||
phase: { current: 3, total: 7 },
|
||||
});
|
||||
expect(api.hero?.textContent).toBe("47%");
|
||||
expect(api.hero?.className).toContain("font-black");
|
||||
expect(api.hero?.className).toContain("text-[72px]");
|
||||
expect(api.hero?.className).toContain("text-primary");
|
||||
expect(api.hero?.className).toContain("leading-none");
|
||||
});
|
||||
|
||||
it("renders an em-dash when progress is null (no milestone data)", () => {
|
||||
const api = render({
|
||||
slug: "DEMO",
|
||||
status: "idle",
|
||||
progress: null,
|
||||
});
|
||||
expect(api.hero?.textContent).toBe("—%");
|
||||
});
|
||||
|
||||
it("renders sub-line in the 'Phase N of M · Next gate: {name}' format", () => {
|
||||
const api = render({
|
||||
slug: "DEMO",
|
||||
status: "idle",
|
||||
progress: 50,
|
||||
phase: { current: 3, total: 7 },
|
||||
nextGateName: "Phase 4 audit",
|
||||
});
|
||||
expect(api.subline?.textContent).toBe("Phase 3 of 7 · Next gate: Phase 4 audit");
|
||||
});
|
||||
|
||||
it("uses '—' placeholders in sub-line when data is missing", () => {
|
||||
const api = render({
|
||||
slug: "DEMO",
|
||||
status: "idle",
|
||||
progress: null,
|
||||
});
|
||||
expect(api.subline?.textContent).toBe("Phase — of — · Next gate: —");
|
||||
});
|
||||
|
||||
it("renders footer with USD cost and last activity", () => {
|
||||
const api = render({
|
||||
slug: "DEMO",
|
||||
status: "idle",
|
||||
progress: 25,
|
||||
costBurnedCents: 1420,
|
||||
lastActivity: "8m ago",
|
||||
});
|
||||
expect(api.footer?.textContent).toBe("$14.20 burned · last activity 8m ago");
|
||||
});
|
||||
|
||||
it("uses '—' cost placeholder when cost data is missing", () => {
|
||||
const api = render({
|
||||
slug: "DEMO",
|
||||
status: "idle",
|
||||
progress: 25,
|
||||
});
|
||||
expect(api.footer?.textContent).toBe("— burned · last activity —");
|
||||
});
|
||||
|
||||
it("renders a forest-green dot for idle status", () => {
|
||||
const api = render({ slug: "A", status: "idle", progress: 0 });
|
||||
expect(api.dot?.dataset.status).toBe("idle");
|
||||
expect(api.dot?.className).toContain("bg-[#166534]");
|
||||
});
|
||||
|
||||
it("renders a pulsing volt dot for working status", () => {
|
||||
const api = render({ slug: "A", status: "working", progress: 0 });
|
||||
expect(api.dot?.dataset.status).toBe("working");
|
||||
expect(api.dot?.className).toContain("bg-primary");
|
||||
expect(api.dot?.className).toContain("animate-pulse");
|
||||
});
|
||||
|
||||
it("renders a pale-yellow dot for waiting status", () => {
|
||||
const api = render({ slug: "A", status: "waiting", progress: 0 });
|
||||
expect(api.dot?.dataset.status).toBe("waiting");
|
||||
expect(api.dot?.className).toContain("bg-[#f4f692]");
|
||||
});
|
||||
|
||||
it("sets progress bar fill width from normal progress value", () => {
|
||||
const api = render({ slug: "A", status: "idle", progress: 47 });
|
||||
expect(api.fill?.style.width).toBe("47%");
|
||||
});
|
||||
|
||||
it("clamps progress above 100 to 100", () => {
|
||||
const api = render({ slug: "A", status: "idle", progress: 150 });
|
||||
expect(api.fill?.style.width).toBe("100%");
|
||||
});
|
||||
|
||||
it("clamps negative progress to 0", () => {
|
||||
const api = render({ slug: "A", status: "idle", progress: -10 });
|
||||
expect(api.fill?.style.width).toBe("0%");
|
||||
});
|
||||
|
||||
it("renders 0-width fill when progress is null", () => {
|
||||
const api = render({ slug: "A", status: "idle", progress: null });
|
||||
expect(api.fill?.style.width).toBe("0%");
|
||||
});
|
||||
|
||||
it("fires onClick when the card button is clicked", () => {
|
||||
const onClick = vi.fn();
|
||||
const api = render({ slug: "A", status: "idle", progress: 0, onClick });
|
||||
act(() => {
|
||||
api.button?.click();
|
||||
});
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses slug as aria-label when no override provided", () => {
|
||||
const api = render({ slug: "DEMO", status: "idle", progress: 0 });
|
||||
expect(api.button?.getAttribute("aria-label")).toBe("DEMO");
|
||||
});
|
||||
|
||||
it("accepts an aria-label override", () => {
|
||||
const api = render({
|
||||
slug: "DEMO",
|
||||
status: "idle",
|
||||
progress: 0,
|
||||
ariaLabel: "Open project DEMO",
|
||||
});
|
||||
expect(api.button?.getAttribute("aria-label")).toBe("Open project DEMO");
|
||||
});
|
||||
});
|
||||
175
ui/src/components/projects/ProjectCard.tsx
Normal file
175
ui/src/components/projects/ProjectCard.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
// [nexus] Phase 11 — Projects list hero-stat card.
|
||||
//
|
||||
// A single card in the Projects list grid. The visual focal point is the
|
||||
// 72px Inter Black volt percentage (DESIGN.md §4 "performance stat"). The
|
||||
// card renders:
|
||||
//
|
||||
// • Title row — project name in Inter 700 uppercase 16px + status dot
|
||||
// • Hero stat — Inter Black 900, 72px, volt — the milestone %
|
||||
// • Sub-line — "Phase N of M · Next gate: {name}" in silver
|
||||
// • Progress bar — 8px charcoal track, volt fill, sharp 4px corners
|
||||
// • Footer — "$X burned · last activity {when}" in silver
|
||||
//
|
||||
// Data gaps (Phase 11): the shared `Project` type has no milestoneProgress,
|
||||
// no phasesCompleted/phasesTotal, no nextGate, no costBurned, no
|
||||
// lastActivity fields. When those values aren't available we render an
|
||||
// em-dash placeholder rather than fabricate a value. See the Phase 11
|
||||
// report for the backend gap list.
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type ProjectCardStatus = "idle" | "working" | "waiting";
|
||||
|
||||
export interface ProjectCardProps {
|
||||
/** Uppercase slug displayed as the title (e.g. "NEXUS-DESIGN-MIGRATION"). */
|
||||
slug: string;
|
||||
/** Status dot mapping — priority is waiting > working > idle. */
|
||||
status: ProjectCardStatus;
|
||||
/** Milestone progress 0–100, or null when no data is available. */
|
||||
progress: number | null;
|
||||
/** Optional "Phase N of M" counter (both null when no data). */
|
||||
phase?: { current: number; total: number } | null;
|
||||
/** Optional next-gate display name. Null when unknown. */
|
||||
nextGateName?: string | null;
|
||||
/** Burn total in cents. Null when no cost data available. */
|
||||
costBurnedCents?: number | null;
|
||||
/** Last-activity relative string (already formatted). Null when unknown. */
|
||||
lastActivity?: string | null;
|
||||
/** Click handler — the parent is responsible for routing. */
|
||||
onClick?: () => void;
|
||||
/** Aria-label override when the slug alone is insufficient. */
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
function StatusDot({ status }: { status: ProjectCardStatus }) {
|
||||
if (status === "waiting") {
|
||||
// Pale yellow literal hex — token doesn't ship until MIGRATION-PLAN §3.
|
||||
return (
|
||||
<span
|
||||
data-testid="project-card-status-dot"
|
||||
data-status="waiting"
|
||||
aria-label="Waiting on gate"
|
||||
className="h-2 w-2 rounded-full bg-[#f4f692]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (status === "working") {
|
||||
return (
|
||||
<span
|
||||
data-testid="project-card-status-dot"
|
||||
data-status="working"
|
||||
aria-label="Working"
|
||||
className="h-2 w-2 rounded-full bg-primary animate-pulse"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
data-testid="project-card-status-dot"
|
||||
data-status="idle"
|
||||
aria-label="Idle"
|
||||
className="h-2 w-2 rounded-full bg-[#166534]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function formatCostUSD(cents: number | null | undefined): string {
|
||||
if (cents === null || cents === undefined) return "—";
|
||||
const dollars = cents / 100;
|
||||
return `$${dollars.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatProgress(progress: number | null): ReactNode {
|
||||
if (progress === null) return "—%";
|
||||
return `${Math.round(progress)}%`;
|
||||
}
|
||||
|
||||
function formatPhase(phase: { current: number; total: number } | null | undefined): string {
|
||||
if (!phase) return "Phase — of —";
|
||||
return `Phase ${phase.current} of ${phase.total}`;
|
||||
}
|
||||
|
||||
export function ProjectCard({
|
||||
slug,
|
||||
status,
|
||||
progress,
|
||||
phase,
|
||||
nextGateName,
|
||||
costBurnedCents,
|
||||
lastActivity,
|
||||
onClick,
|
||||
ariaLabel,
|
||||
}: ProjectCardProps) {
|
||||
const nextGateText = nextGateName ? `Next gate: ${nextGateName}` : "Next gate: —";
|
||||
const subLine = `${formatPhase(phase)} · ${nextGateText}`;
|
||||
const costText = formatCostUSD(costBurnedCents);
|
||||
const activityText = lastActivity ? `last activity ${lastActivity}` : "last activity —";
|
||||
const footerLine = `${costText} burned · ${activityText}`;
|
||||
|
||||
// Progress bar width — clamp to 0..100.
|
||||
const barWidth =
|
||||
progress === null ? 0 : Math.max(0, Math.min(100, progress));
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={ariaLabel ?? slug}
|
||||
data-testid="project-card"
|
||||
className={cn(
|
||||
"group block w-full text-left",
|
||||
"rounded-lg border border-border bg-transparent",
|
||||
"px-6 py-6",
|
||||
"transition-colors hover:border-primary/40",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||
)}
|
||||
>
|
||||
{/* Title row: slug + status dot */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span
|
||||
data-testid="project-card-title"
|
||||
className="font-bold uppercase text-[16px] tracking-[0.04em] text-foreground"
|
||||
>
|
||||
{slug}
|
||||
</span>
|
||||
<StatusDot status={status} />
|
||||
</div>
|
||||
|
||||
{/* Hero stat — 72px Inter Black volt */}
|
||||
<div
|
||||
data-testid="project-card-hero"
|
||||
className="mt-4 font-black text-[72px] leading-none text-primary"
|
||||
>
|
||||
{formatProgress(progress)}
|
||||
</div>
|
||||
|
||||
{/* Sub-line */}
|
||||
<div
|
||||
data-testid="project-card-subline"
|
||||
className="mt-3 text-[14px] font-medium text-muted-foreground"
|
||||
>
|
||||
{subLine}
|
||||
</div>
|
||||
|
||||
{/* Progress bar — 8px tall charcoal track, volt fill, sharp 4px corners */}
|
||||
<div
|
||||
data-testid="project-card-progress-track"
|
||||
className="mt-4 h-2 w-full rounded-[4px] bg-border overflow-hidden"
|
||||
>
|
||||
<div
|
||||
data-testid="project-card-progress-fill"
|
||||
className="h-full rounded-[4px] bg-primary transition-[width]"
|
||||
style={{ width: `${barWidth}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer line */}
|
||||
<div
|
||||
data-testid="project-card-footer"
|
||||
className="mt-4 text-[12px] font-normal text-muted-foreground"
|
||||
>
|
||||
{footerLine}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue