// [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. // // Phase 11.5 decision (wave 2.5 controller pass): the shared Project // type is missing milestoneProgress / nextGate / costBurned / agent // counts, but we don't want em-dash city to kill the visual impact of // the 72px volt hero percentage. Instead we compute best-effort // derivatives from data that DOES exist: // - progress ← closed_issue_count / total_issue_count (from one // shared issuesApi.list query, grouped per-project) // - nextGateName ← first pending approval whose payload.projectId // matches (from one shared approvalsApi.list query) // - lastActivity ← max(project.updatedAt, newest issue activity in // the project) rendered as "8m ago"-style diff // Each proxy is marked with a `// TODO(phase-11.5)` comment and will // be replaced by a real backend field when it lands. import { useEffect, useMemo } from "react"; import { VOCAB } from "@paperclipai/branding"; import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "@/lib/router"; import type { Issue, Approval } from "@paperclipai/shared"; import { projectsApi } from "../api/projects"; import { issuesApi } from "../api/issues"; import { approvalsApi } from "../api/approvals"; import { useCompany } from "../context/CompanyContext"; import { useDialog } from "../context/DialogContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; import { PageSkeleton } from "../components/PageSkeleton"; 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"; /** Issue statuses that count as "closed" for progress computation. */ const CLOSED_ISSUE_STATUSES = new Set(["done", "cancelled"]); /** * Derive per-project aggregates from a flat list of issues and pending * approvals. Runs once on the client per Projects page render — cheap * enough for the ~dozens-of-projects scale the list targets. * * TODO(phase-11.5): replace with server-side aggregates on the Project * record when the backend exposes them. */ function buildProjectDerivatives( issues: Issue[] | undefined, pendingApprovals: Approval[] | undefined, ): Map< string, { progress: number | null; closedCount: number; totalCount: number; nextGateName: string | null; latestActivityAt: Date | null; } > { const map = new Map< string, { progress: number | null; closedCount: number; totalCount: number; nextGateName: string | null; latestActivityAt: Date | null; } >(); for (const issue of issues ?? []) { if (!issue.projectId) continue; const row = map.get(issue.projectId) ?? { progress: null, closedCount: 0, totalCount: 0, nextGateName: null, latestActivityAt: null, }; row.totalCount += 1; if (CLOSED_ISSUE_STATUSES.has(issue.status)) { row.closedCount += 1; } // Track the newest issue update timestamp for lastActivity. const issueUpdated = issue.updatedAt ? typeof issue.updatedAt === "string" ? new Date(issue.updatedAt) : issue.updatedAt : null; if ( issueUpdated && (!row.latestActivityAt || issueUpdated.getTime() > row.latestActivityAt.getTime()) ) { row.latestActivityAt = issueUpdated; } map.set(issue.projectId, row); } for (const [projectId, row] of map.entries()) { row.progress = row.totalCount > 0 ? (row.closedCount / row.totalCount) * 100 : null; map.set(projectId, row); } for (const approval of pendingApprovals ?? []) { // Approval.payload is untyped Record; probe defensively. const payload = approval.payload as Record | undefined; const projectId = typeof payload?.projectId === "string" ? payload.projectId : null; if (!projectId) continue; const row = map.get(projectId) ?? { progress: null, closedCount: 0, totalCount: 0, nextGateName: null, latestActivityAt: null, }; if (row.nextGateName === null) { const title = (typeof payload?.title === "string" && payload.title) || (typeof payload?.name === "string" && payload.name) || approval.type; row.nextGateName = title; } map.set(projectId, row); } return map; } function formatRelative(then: Date | null): string | null { if (!then) return null; const ms = Date.now() - then.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`; } 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" }]); }, [setBreadcrumbs]); const { data: allProjects, isLoading, error } = useQuery({ queryKey: queryKeys.projects.list(selectedCompanyId!), queryFn: () => projectsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); // TODO(phase-11.5): replace with server-side aggregates on Project record. // For now we fetch all issues and all pending approvals once and derive // per-project counts on the client. This is O(#issues + #approvals) on the // frontend but only fires per-company, not per-project, so it stays cheap. const { data: allIssues } = useQuery({ queryKey: queryKeys.issues.list(selectedCompanyId!), queryFn: () => issuesApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); const { data: pendingApprovals } = useQuery({ queryKey: [...queryKeys.approvals.list(selectedCompanyId!), "pending"], queryFn: () => approvalsApi.list(selectedCompanyId!, "pending"), enabled: !!selectedCompanyId, }); const projects = useMemo( () => (allProjects ?? []).filter((p) => !p.archivedAt), [allProjects], ); const derivatives = useMemo( () => buildProjectDerivatives(allIssues, pendingApprovals), [allIssues, pendingApprovals], ); if (!selectedCompanyId) { 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}

} {/* Card grid: 1 col <1024px, 2 cols >=1024px, 16px gap */}
{projects.map((project) => { const slug = (project.urlKey ?? project.name).toUpperCase(); const derived = derivatives.get(project.id); // Take the newer of project.updatedAt and the latest issue // update inside this project — whichever happened more recently. const projectUpdatedAt = project.updatedAt ? typeof project.updatedAt === "string" ? new Date(project.updatedAt) : project.updatedAt : null; const latestActivity = derived?.latestActivityAt && (!projectUpdatedAt || derived.latestActivityAt.getTime() > projectUpdatedAt.getTime()) ? derived.latestActivityAt : projectUpdatedAt; return ( navigate(projectUrl(project))} /> ); })}
); }