diff --git a/ui/src/components/projects/ProjectCard.test.tsx b/ui/src/components/projects/ProjectCard.test.tsx new file mode 100644 index 00000000..7d89164d --- /dev/null +++ b/ui/src/components/projects/ProjectCard.test.tsx @@ -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 | 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[0]) { + root = createRoot(container); + act(() => { + root!.render(); + }); + return { + get title() { + return container.querySelector('[data-testid="project-card-title"]'); + }, + get hero() { + return container.querySelector('[data-testid="project-card-hero"]'); + }, + get subline() { + return container.querySelector('[data-testid="project-card-subline"]'); + }, + get footer() { + return container.querySelector('[data-testid="project-card-footer"]'); + }, + get fill() { + return container.querySelector('[data-testid="project-card-progress-fill"]'); + }, + get dot() { + return container.querySelector('[data-testid="project-card-status-dot"]'); + }, + get button() { + return container.querySelector('[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 % 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"); + }); +}); diff --git a/ui/src/components/projects/ProjectCard.tsx b/ui/src/components/projects/ProjectCard.tsx new file mode 100644 index 00000000..11930f7f --- /dev/null +++ b/ui/src/components/projects/ProjectCard.tsx @@ -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 ( + + ); + } + if (status === "working") { + return ( + + ); + } + return ( + + ); +} + +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 ( + + ); +}