nexus/ui/src/pages/Projects.tsx
Nexus Dev 428f033690 refactor(nexus): wave 2.5 follow-ups (presentations + proj derivatives)
Three coordinated changes after reviewing the wave 2 subagent reports:

1. Restore Presentations as the 9th Studio workshop.

Phase 10's subagent dropped Presentations from the workshop grid
because the spec's eight-workshop list didn't include it. But
.planning/PROJECT.md explicitly lists "Presentations & video
generation via Remotion" as an Active v1.7 requirement and the
existing PresentationPanel.tsx is already a real working Remotion
generator. Dropping it was silent feature regression.

  - workshops.ts: add "presentations" slug + Presentation Lucide
    icon, placed between social and convert in canonical order
  - classifyIntent.ts: add pitch-deck / slide-deck / demo-video /
    keynote intent routing (before social so "pitch deck" wins)
  - StudioWorkshopDetail.tsx: import PresentationPanel and add a
    "presentation" case in WorkshopBody
  - workshops.test.ts: expected canonical order updated to 9 slugs
  - classifyIntent.test.ts: 4 new parameterized rows for presentations
  - WorkshopCard.test.tsx: index 7 is now PRESENTATIONS, 8 is CONVERT
  - WorkshopGrid.test.tsx: expected card count 9, canonical order

2. ProjectCard hero-stat derivatives instead of em-dash city.

The shared Project record has none of the fields the spec §7.1 card
depends on: milestoneProgress, nextGate, costBurned, per-project
agent count, phase/milestone array. Wave 2 shipped every card with
"—%" and blank hero numbers — visually underwhelming for a layout
whose whole point is the 72px volt performance stat.

Compute best-effort proxies on the client from data that exists:

  - progress: closed_issues / total_issues × 100, from a single
    issuesApi.list(companyId) query grouped by projectId
  - nextGateName: first pending approval whose payload.projectId
    matches, from a single approvalsApi.list(companyId, "pending")
    query
  - lastActivity: max(project.updatedAt, newest issue.updatedAt in
    the project), rendered as "8m ago"-style diff

Each proxy is annotated with // TODO(phase-11.5) for replacement
when real backend aggregates land. phase, costBurnedCents, and
per-project agent count remain hard gaps — rendered as null which
the card surfaces as em-dashes. These three are explicitly queued
for Phase 11.5.

No backend changes; everything derives from existing endpoints.
Two new useQuery calls in Projects.tsx (issues + pending approvals)
both fire per-company, not per-project, so they stay cheap for the
~dozens-of-projects scale the list targets.

3. Spec updated 8 → 9 workshops everywhere it referred to the count.

docs/specs/2026-04-11-nexus-layout-overhaul.md:
  - §2 IA table: 8 → 9 workshops
  - §6 ASCII header: Eight → Nine
  - §6.3 section title: Eight workshops → Nine workshops
  - §11 decisions log #16: amendment note explaining the 8→9 bump
  - §13 phase 10 description: 8-card → 9-card, with Presentations
    explicitly called out
  - "Folds into Studio as the 8th workshop" → "Folds into Studio
    as a workshop (the legacy /convert route is preserved for
    backwards compat)"

Verification: 75/75 studio tests passing; 52/52 projects tests
passing; tsc clean on studio/ + projects/ + Projects.tsx +
ProjectDetail.tsx + StudioWorkshopDetail.tsx.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 13:04:36 +00:00

315 lines
12 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 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<string>(["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<string, unknown>; probe defensively.
const payload = approval.payload as Record<string, unknown> | 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 (
<EmptyState
icon={Hexagon}
message={`Select a ${VOCAB.company.toLowerCase()} to view projects.`}
/>
);
}
if (isLoading) {
return <PageSkeleton variant="list" />;
}
// Empty state — full-canvas 96px volt hero.
if (projects.length === 0) {
return (
<div
data-testid="projects-empty-state"
className="flex min-h-[60vh] flex-col items-start justify-center gap-8"
>
<h2
data-testid="projects-empty-hero"
className="font-black uppercase text-[96px] leading-none text-primary"
>
No projects yet
</h2>
<button
type="button"
onClick={openNewProject}
data-testid="projects-empty-cta"
className={cn(
"inline-flex items-center gap-2 rounded-md px-5 py-3",
"bg-[#166534] text-white",
"text-[14px] font-semibold uppercase tracking-[0.1em]",
"hover:bg-[#166534]/90 transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
>
<span aria-hidden="true"></span> Start your first project
</button>
</div>
);
}
return (
<div data-testid="projects-page" className="space-y-6">
{/* Title row: section heading + new project CTA */}
<div className="flex items-center justify-between">
<h2 className="text-[14px] font-bold uppercase tracking-[0.14em] text-muted-foreground">
Projects
</h2>
<button
type="button"
onClick={openNewProject}
data-testid="projects-new-project-cta"
className={cn(
"inline-flex items-center gap-2 rounded-md px-4 py-2",
"bg-[#166534] text-white",
"text-[12px] font-semibold uppercase tracking-[0.12em]",
"hover:bg-[#166534]/90 transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
>
<span aria-hidden="true"></span> New project
</button>
</div>
{error && <p className="text-sm text-destructive">{error.message}</p>}
{/* Card grid: 1 col <1024px, 2 cols >=1024px, 16px gap */}
<div
data-testid="projects-grid"
className="grid grid-cols-1 gap-4 lg:grid-cols-2"
>
{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 (
<ProjectCard
key={project.id}
slug={slug}
status={deriveStatus(project.status)}
// TODO(phase-11.5): replace with real milestoneProgress from
// the Project record. For now progress is a proxy:
// closed_issues / total_issues × 100.
progress={derived?.progress ?? null}
// Phase / milestone data is a hard gap (no milestones on
// the Project record). Leave null — ProjectCard renders "—".
phase={null}
// TODO(phase-11.5): replace with the real next-gate slot on
// the Project record. Currently derived from the first
// pending approval whose payload.projectId matches.
nextGateName={derived?.nextGateName ?? null}
// costBurnedCents is still a hard gap in Wave 2 — deferred
// to phase 11.5.
costBurnedCents={null}
lastActivity={formatRelative(latestActivity)}
ariaLabel={`Open project ${slug}`}
onClick={() => navigate(projectUrl(project))}
/>
);
})}
</div>
</div>
);
}