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>
175 lines
5.8 KiB
TypeScript
175 lines
5.8 KiB
TypeScript
// [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>
|
||
);
|
||
}
|