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