From c3689f11b1fffc55907dca670f69b0e486b42e47 Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Sat, 11 Apr 2026 12:23:14 +0000 Subject: [PATCH] refactor(nexus): rewrite Projects list with hero-stat cards (phase 11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the EntityRow table with the spec §7.1 hero-stat card grid: full-width cards on <1024px, 2-up on lg+, 16px gap. Each card uses the new ProjectCard component (72px Inter Black volt hero, status dot, progress bar, sub-line, footer). Adds the spec's empty state — full-bleed 96px Inter Black volt "NO PROJECTS YET" with a forest-green "⊕ START YOUR FIRST PROJECT" CTA. The regular top-right "⊕ NEW PROJECT" CTA is also forest-green. Both CTAs wire to the existing openNewProject dialog. Data gaps: the card passes progress=null, phase=null, nextGateName= null, costBurnedCents=null because the shared Project type doesn't carry those fields yet. ProjectCard renders em-dash placeholders gracefully. Status is mapped from the existing ProjectStatus enum (paused → waiting, active/running → working, else idle). Also fixes an unrelated test fixture that used the stale "active" ProjectStatus literal which tsc rejects. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../projects/tabs/OverviewTab.test.tsx | 2 +- ui/src/pages/Projects.tsx | 166 +++++++++++++----- 2 files changed, 125 insertions(+), 43 deletions(-) diff --git a/ui/src/components/projects/tabs/OverviewTab.test.tsx b/ui/src/components/projects/tabs/OverviewTab.test.tsx index f5163423..9e252314 100644 --- a/ui/src/components/projects/tabs/OverviewTab.test.tsx +++ b/ui/src/components/projects/tabs/OverviewTab.test.tsx @@ -10,7 +10,7 @@ import { OverviewTab, type OverviewTabProps } from "./OverviewTab"; function baseProps(overrides: Partial = {}): OverviewTabProps { return { - project: { name: "nexus-design-migration", status: "active" }, + project: { name: "nexus-design-migration", status: "in_progress" }, progress: 47, activeAgentsCount: 4, milestones: null, diff --git a/ui/src/pages/Projects.tsx b/ui/src/pages/Projects.tsx index 8a705867..4a8f0878 100644 --- a/ui/src/pages/Projects.tsx +++ b/ui/src/pages/Projects.tsx @@ -1,23 +1,45 @@ +// [nexus] Phase 11 — Projects list page (hero-stat cards). +// +// Replaces the old Paperclip EntityRow table with the spec §7.1 +// hero-stat card grid. Cards stack full-width on <1024px and 2-up on +// >=1024px. A forest-green "+ NEW PROJECT" CTA sits top-right. When +// the project list is empty we render the 96px "NO PROJECTS YET" +// empty state canvas. +// +// Data gaps (Phase 11): the shared Project type has no +// milestoneProgress, nextGate, costBurned, lastActivity, or per- +// project agent counts. Each card renders "—" placeholders where +// data is missing. See the Phase 11 report for the full gap list. import { useEffect, useMemo } from "react"; import { VOCAB } from "@paperclipai/branding"; import { useQuery } from "@tanstack/react-query"; +import { useNavigate } from "@/lib/router"; import { projectsApi } from "../api/projects"; import { useCompany } from "../context/CompanyContext"; import { useDialog } from "../context/DialogContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; -import { EntityRow } from "../components/EntityRow"; -import { StatusBadge } from "../components/StatusBadge"; -import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; -import { formatDate, projectUrl } from "../lib/utils"; -import { Button } from "@/components/ui/button"; -import { Hexagon, Plus } from "lucide-react"; +import { EmptyState } from "../components/EmptyState"; +import { Hexagon } from "lucide-react"; +import { projectUrl } from "../lib/utils"; +import { cn } from "@/lib/utils"; +import { ProjectCard, type ProjectCardStatus } from "../components/projects/ProjectCard"; + +function deriveStatus(status: string): ProjectCardStatus { + // Waiting > working > idle priority. For Phase 11 we only have the + // coarse ProjectStatus enum to work with; the finer-grained "has + // pending gate" / "has working agent" checks are data gaps. + if (status === "paused" || status === "awaiting_input") return "waiting"; + if (status === "active" || status === "running") return "working"; + return "idle"; +} export function Projects() { const { selectedCompanyId } = useCompany(); const { openNewProject } = useDialog(); const { setBreadcrumbs } = useBreadcrumbs(); + const navigate = useNavigate(); useEffect(() => { setBreadcrumbs([{ label: "Projects" }]); @@ -28,61 +50,121 @@ export function Projects() { queryFn: () => projectsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); + const projects = useMemo( () => (allProjects ?? []).filter((p) => !p.archivedAt), [allProjects], ); if (!selectedCompanyId) { - return ; + return ( + + ); } if (isLoading) { return ; } + // Empty state — full-canvas 96px volt hero. + if (projects.length === 0) { + return ( +
+

+ No projects yet +

+ +
+ ); + } + return ( -
-
- +
+ {/* Title row: section heading + new project CTA */} +
+

+ Projects +

+
{error &&

{error.message}

} - {!isLoading && projects.length === 0 && ( - - )} - - {projects.length > 0 && ( -
- {projects.map((project) => ( - =1024px, 16px gap */} +
+ {projects.map((project) => { + const slug = (project.urlKey ?? project.name).toUpperCase(); + const lastActivity = project.updatedAt + ? (() => { + const d = typeof project.updatedAt === "string" + ? new Date(project.updatedAt) + : project.updatedAt; + const ms = Date.now() - d.getTime(); + const mins = Math.floor(ms / 60_000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + return `${days}d ago`; + })() + : null; + return ( + - {project.targetDate && ( - - {formatDate(project.targetDate)} - - )} - -
- } + slug={slug} + status={deriveStatus(project.status)} + // Data gap: no milestoneProgress on the shared Project type + // yet — ProjectCard renders "—%" when null. + progress={null} + phase={null} + nextGateName={null} + costBurnedCents={null} + lastActivity={lastActivity} + ariaLabel={`Open project ${slug}`} + onClick={() => navigate(projectUrl(project))} /> - ))} -
- )} + ); + })} +
); }