nexus/ui/src/components/projects/ProjectCard.tsx
Nexus Dev a2dab5b4f6 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>
2026-04-11 12:17:12 +00:00

175 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// [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 0100, 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>
);
}