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>
This commit is contained in:
parent
1b7e3d44fe
commit
428f033690
9 changed files with 216 additions and 34 deletions
|
|
@ -23,7 +23,7 @@ In the new frame, **the Assistant is the canvas.** Everything else — Studio, P
|
|||
| Slot | Destination | Route | Mental model |
|
||||
|---|---|---|---|
|
||||
| 1 | **Assistant** | `/assistant` | The home screen. Voice in, voice out. The chat IS the app. |
|
||||
| 2 | **Studio** | `/studio` | Workshop selector for content generation (8 workshops). |
|
||||
| 2 | **Studio** | `/studio` | Workshop selector for content generation (9 workshops). |
|
||||
| 3 | **Projects** | `/projects` | List of all projects with health stats. |
|
||||
| 4 | **Settings** | `/settings` | Workspace, Local AI, Cloud, Voice, Skills, Routines, Telegram, About. |
|
||||
|
||||
|
|
@ -58,7 +58,7 @@ These are scoped to one project. They are not addressable globally. Routes: `/pr
|
|||
| 280px left sidebar with company switcher / sidebar projects / sidebar agents | Replaced by 56px icon rail |
|
||||
| `MobileBottomNav` (company-aware) | Replaced by 4-icon bottom bar matching the desktop rail |
|
||||
| Theme cycle button (Catppuccin/Tokyo/etc.) | Binary light/dark only, configured in Settings, no toggle in chrome |
|
||||
| `ConvertPage` as a top-level route | Folds into Studio as the 8th workshop |
|
||||
| `ConvertPage` as a top-level route | Folds into Studio as a workshop (the legacy `/convert` route is preserved for backwards compat) |
|
||||
| `InboxRootRedirect`, `LegacySettingsRedirect`, `OnboardingRoutePage` URL machinery | Simpler routing |
|
||||
| `PluginPage` as a top-level route slot | Plugin pages render inside Settings or inside a project |
|
||||
| Any UI string containing "company", "companies", "tenant", "workspace member" | Vocabulary cleanup; replace with "workspace" or remove |
|
||||
|
|
@ -304,7 +304,7 @@ Workshop selector for content generation. Replaces the current 7-tab `ContentStu
|
|||
│ │ │
|
||||
│ ⬢│ STUDIO │
|
||||
│ │ ─ ─ ─ ─ ─ │
|
||||
│ ◆│ Eight workshops. Pick one or describe what you need. │
|
||||
│ ◆│ Nine workshops. Pick one or describe what you need. │
|
||||
│ │ │
|
||||
│ ▲│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ │ DIAGRAMS │ │ ICONS │ │ THEMES │ │
|
||||
|
|
@ -335,7 +335,7 @@ Workshop selector for content generation. Replaces the current 7-tab `ContentStu
|
|||
- **Icon glyph:** Lucide icon at 32×32 in volt, top-right of card.
|
||||
- **Click:** navigates to `/studio/{workshop-slug}`.
|
||||
|
||||
### 6.3 Eight workshops
|
||||
### 6.3 Nine workshops
|
||||
|
||||
| Slug | Title | Source | Notes |
|
||||
|---|---|---|---|
|
||||
|
|
@ -697,7 +697,7 @@ Captured from the brainstorming conversation 2026-04-11. These are binding choic
|
|||
| 13 | No sleep / lock button | Approved (kill it) |
|
||||
| 14 | Skill Aggregator lives in Settings → Skills section | Approved |
|
||||
| 15 | Promote-to-project transition: chat compresses to 30%, brainstormer rises into bottom 70%, inset shadow ripple | Approved — "ship that exact animation" |
|
||||
| 16 | Studio is a workshop grid (8 cards), not tabs | Approved |
|
||||
| 16 | Studio is a workshop grid (9 cards), not tabs | Approved 2026-04-11; amended post-Wave-2 from 8 to 9 to restore Presentations (Active v1.7 req), see §6.3 |
|
||||
| 17 | Convert folds into Studio as a workshop, no separate route | Approved |
|
||||
| 18 | Project list cards use 72px Inter Black volt percentage as hero stat (DESIGN.md "performance stat" pattern) | Approved |
|
||||
| 19 | ChatPanel as a global slide-in right rail is killed entirely | Approved |
|
||||
|
|
@ -736,7 +736,7 @@ The visual migration plan ends at Phase 7 (visual QA of the ClickHouse repaint).
|
|||
|---|---|---|---|
|
||||
| **8** | **Frame skeleton** | New 56px icon rail, 48px top strip, killed sidebar/ChatPanel/PropertiesPanel as global elements. Routing simplified (no company prefix as a layout requirement). Old pages render in new frame but look weird — that's expected. | Foundational; blocks phases 9–15 |
|
||||
| **9** | **Assistant mode** | Move PersonalAssistant.tsx to be the canonical Assistant route. Implement History (left) and Memory (right) slide-overs. Implement conversational home state for empty-conversation mode. Replace inline conversation column with slide-over. | Parallelizable with 10, 11 |
|
||||
| **10** | **Studio mode** | Refactor ContentStudio.tsx from 7-tab to 8-card workshop grid. Fold ConvertPage in as 8th workshop. Add freeform Studio prompt with intent routing. Build workshop detail two-column layout. | Parallelizable with 9, 11 |
|
||||
| **10** | **Studio mode** | Refactor ContentStudio.tsx from tabbed layout to a 9-card workshop grid. Fold ConvertPage in as a workshop. Keep the existing Presentations (Remotion) panel as a workshop. Add freeform Studio prompt with intent routing. Build workshop detail two-column layout. | Parallelizable with 9, 11 |
|
||||
| **11** | **Projects + Builder mode** | Build new Projects list with hero-stat cards. Build Project Detail layout with 7-tab Builder strip. Demote global Issues/Agents/Approvals/Costs/Activity/Org/Goals/Inbox routes to per-project tabs. Rename Approvals → Gates. Reuse existing list components, scope by project ID. | Parallelizable with 9, 10 |
|
||||
| **12** | **Promote-to-project transition** | The 700ms animation: chat compresses to 30%, brainstormer rises into 70%, inset shadow ripple, source-conversation label, post-creation banner linking chat to project. Origin chat preservation logic. | Depends on 9 (Assistant) and 11 (Projects) |
|
||||
| **13** | **Settings consolidation** | Single-column Settings with all sections. Skills section (Skill Aggregator inline). Routines section (demoted from top-level). Re-run onboarding link. Drop nested settings routes. | Parallelizable with 12, 14, 15 |
|
||||
|
|
|
|||
|
|
@ -86,8 +86,14 @@ describe("WorkshopCard", () => {
|
|||
expect(svg.getAttribute("class") ?? "").toContain("text-primary");
|
||||
});
|
||||
|
||||
it("renders the Convert workshop card (fold-in from /convert)", () => {
|
||||
it("renders the Presentations workshop card (added post-Wave-2)", () => {
|
||||
renderCard(7, () => {});
|
||||
expect(container.textContent).toContain("PRESENTATIONS");
|
||||
expect(container.textContent).toContain("Pitch decks");
|
||||
});
|
||||
|
||||
it("renders the Convert workshop card (fold-in from /convert)", () => {
|
||||
renderCard(8, () => {});
|
||||
expect(container.textContent).toContain("CONVERT");
|
||||
expect(container.textContent).toContain("File format conversion");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -36,13 +36,13 @@ describe("WorkshopGrid", () => {
|
|||
});
|
||||
}
|
||||
|
||||
it("renders one card per workshop (8 total for Phase 10)", () => {
|
||||
it("renders one card per workshop (9 total post-Wave-2)", () => {
|
||||
renderGrid(() => {});
|
||||
const cards = container.querySelectorAll("button[data-testid^='workshop-card-']");
|
||||
expect(cards.length).toBe(8);
|
||||
expect(cards.length).toBe(9);
|
||||
});
|
||||
|
||||
it("renders the cards in canonical Phase 10 order", () => {
|
||||
it("renders the cards in canonical post-Wave-2 order", () => {
|
||||
renderGrid(() => {});
|
||||
const cards = Array.from(
|
||||
container.querySelectorAll("button[data-testid^='workshop-card-']"),
|
||||
|
|
@ -58,6 +58,7 @@ describe("WorkshopGrid", () => {
|
|||
"documents",
|
||||
"brand-kits",
|
||||
"social",
|
||||
"presentations",
|
||||
"convert",
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -43,6 +43,12 @@ describe("classifyIntent", () => {
|
|||
["I need a logo", "brand-kits"],
|
||||
["style guide please", "brand-kits"],
|
||||
|
||||
// Presentations (checked before social — "pitch deck" is presentation, not social)
|
||||
["build me a pitch deck about our platform", "presentations"],
|
||||
["slide deck for the board", "presentations"],
|
||||
["demo video of the onboarding flow", "presentations"],
|
||||
["keynote-style presentation", "presentations"],
|
||||
|
||||
// Social
|
||||
["twitter post about our launch", "social"],
|
||||
["linkedin carousel on hiring", "social"],
|
||||
|
|
|
|||
|
|
@ -61,6 +61,12 @@ export function classifyIntent(prompt: string): IntentClassification | null {
|
|||
return { slug: "brand-kits", prefilledPrompt: prompt };
|
||||
}
|
||||
|
||||
// Presentations (before social — "pitch deck" and "demo video" are
|
||||
// strongly presentation-shaped even if they could feel socially adjacent)
|
||||
if (/\b(presentation|pitch\s*deck|slide\s*deck|slides|demo\s*video|keynote)\b/.test(lower)) {
|
||||
return { slug: "presentations", prefilledPrompt: prompt };
|
||||
}
|
||||
|
||||
// Social
|
||||
if (/\b(social|tweet|post|instagram|linkedin|twitter|x\.com|carousel)\b/.test(lower)) {
|
||||
return { slug: "social", prefilledPrompt: prompt };
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@ import { describe, expect, it } from "vitest";
|
|||
import { WORKSHOPS, findWorkshop, type WorkshopSlug } from "./workshops";
|
||||
|
||||
describe("WORKSHOPS data", () => {
|
||||
it("defines exactly 8 workshops in the Phase 10 canonical order", () => {
|
||||
it("defines exactly 9 workshops in the Phase 10 canonical order", () => {
|
||||
// Presentations was added as a 9th workshop post-Wave-2 because
|
||||
// PresentationPanel is a real Remotion-backed generator that was
|
||||
// already in the codebase and is an Active v1.7 requirement per
|
||||
// .planning/PROJECT.md. The initial spec called for 8 workshops;
|
||||
// the layout spec was updated to match (see spec §6 revision note).
|
||||
const slugs = WORKSHOPS.map((w) => w.slug);
|
||||
expect(slugs).toEqual([
|
||||
"diagrams",
|
||||
|
|
@ -12,6 +17,7 @@ describe("WORKSHOPS data", () => {
|
|||
"documents",
|
||||
"brand-kits",
|
||||
"social",
|
||||
"presentations",
|
||||
"convert",
|
||||
] satisfies WorkshopSlug[]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
Award,
|
||||
Share2,
|
||||
Repeat,
|
||||
Presentation,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
|
|
@ -26,6 +27,7 @@ export type WorkshopSlug =
|
|||
| "documents"
|
||||
| "brand-kits"
|
||||
| "social"
|
||||
| "presentations"
|
||||
| "convert";
|
||||
|
||||
export interface WorkshopDefinition {
|
||||
|
|
@ -96,6 +98,13 @@ export const WORKSHOPS: WorkshopDefinition[] = [
|
|||
icon: Share2,
|
||||
componentKey: "social",
|
||||
},
|
||||
{
|
||||
slug: "presentations",
|
||||
title: "PRESENTATIONS",
|
||||
subtitle: "Pitch decks and demo videos via Remotion",
|
||||
icon: Presentation,
|
||||
componentKey: "presentation",
|
||||
},
|
||||
{
|
||||
slug: "convert",
|
||||
title: "CONVERT",
|
||||
|
|
|
|||
|
|
@ -6,15 +6,27 @@
|
|||
// 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.
|
||||
// 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";
|
||||
|
|
@ -26,6 +38,111 @@ 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
|
||||
|
|
@ -51,11 +168,31 @@ export function Projects() {
|
|||
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
|
||||
|
|
@ -132,33 +269,41 @@ export function Projects() {
|
|||
>
|
||||
{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`;
|
||||
})()
|
||||
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)}
|
||||
// Data gap: no milestoneProgress on the shared Project type
|
||||
// yet — ProjectCard renders "—%" when null.
|
||||
progress={null}
|
||||
// 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}
|
||||
nextGateName={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={lastActivity}
|
||||
lastActivity={formatRelative(latestActivity)}
|
||||
ariaLabel={`Open project ${slug}`}
|
||||
onClick={() => navigate(projectUrl(project))}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { SocialPostPanel } from "../components/SocialPostPanel";
|
|||
import { DocumentGeneratePanel } from "../components/DocumentGeneratePanel";
|
||||
import { BrandKitPanel } from "../components/BrandKitPanel";
|
||||
import { ConvertPanel } from "../components/ConvertPanel";
|
||||
import { PresentationPanel } from "../components/PresentationPanel";
|
||||
import { ThemeSeedInput } from "../components/ThemeSeedInput";
|
||||
import { ThemePaletteGrid, type PaletteRole } from "../components/ThemePaletteGrid";
|
||||
import { ThemePreviewPanel } from "../components/ThemePreviewPanel";
|
||||
|
|
@ -267,6 +268,8 @@ function WorkshopBody({
|
|||
return <BrandKitPanel companyId={companyId} />;
|
||||
case "social":
|
||||
return <SocialPostPanel companyId={companyId} />;
|
||||
case "presentation":
|
||||
return <PresentationPanel companyId={companyId} />;
|
||||
case "convert":
|
||||
return <ConvertPanel companyId={companyId} />;
|
||||
default:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue