From 585343968ef03694b8f14bc9bb0e6c479a913782 Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Mon, 2 Mar 2026 20:44:36 -0800 Subject: [PATCH] feat: live phase completion in right panel + saved phase data in PRD page Made-with: Cursor --- .../project/[projectId]/prd/page.tsx | 254 +++++++++++------- components/layout/project-shell.tsx | 71 +++-- 2 files changed, 201 insertions(+), 124 deletions(-) diff --git a/app/[workspace]/project/[projectId]/prd/page.tsx b/app/[workspace]/project/[projectId]/prd/page.tsx index e581c47..0a2fc2a 100644 --- a/app/[workspace]/project/[projectId]/prd/page.tsx +++ b/app/[workspace]/project/[projectId]/prd/page.tsx @@ -3,67 +3,110 @@ import { useEffect, useState } from "react"; import { useParams } from "next/navigation"; +// Maps each PRD section to the discovery phase that populates it const PRD_SECTIONS = [ - { id: "executive_summary", label: "Executive Summary" }, - { id: "problem_statement", label: "Problem Statement" }, - { id: "vision_metrics", label: "Vision & Success Metrics" }, - { id: "users_personas", label: "Users & Personas" }, - { id: "user_flows", label: "User Flows" }, - { id: "feature_requirements", label: "Feature Requirements" }, - { id: "screen_specs", label: "Screen Specs" }, - { id: "business_model", label: "Business Model" }, - { id: "integrations", label: "Integrations & Dependencies" }, - { id: "non_functional", label: "Non-Functional Reqs" }, - { id: "risks", label: "Risks & Mitigations" }, - { id: "open_questions", label: "Open Questions" }, + { id: "executive_summary", label: "Executive Summary", phaseId: "big_picture" }, + { id: "problem_statement", label: "Problem Statement", phaseId: "big_picture" }, + { id: "vision_metrics", label: "Vision & Success Metrics", phaseId: "big_picture" }, + { id: "users_personas", label: "Users & Personas", phaseId: "users_personas" }, + { id: "user_flows", label: "User Flows", phaseId: "users_personas" }, + { id: "feature_requirements", label: "Feature Requirements", phaseId: "features_scope" }, + { id: "screen_specs", label: "Screen Specs", phaseId: "screens_data" }, + { id: "business_model", label: "Business Model", phaseId: "business_model" }, + { id: "integrations", label: "Integrations & Dependencies", phaseId: "features_scope" }, + { id: "non_functional", label: "Non-Functional Reqs", phaseId: null }, + { id: "risks", label: "Risks & Mitigations", phaseId: "risks_questions" }, + { id: "open_questions", label: "Open Questions", phaseId: "risks_questions" }, ]; -interface PRDSection { - id: string; - label: string; - status: "done" | "active" | "pending"; - pct: number; - content?: string; +interface SavedPhase { + phase: string; + title: string; + summary: string; + data: Record; + saved_at: string; } -interface Project { - id: string; - prd?: string; - prdSections?: Record; +function formatValue(v: unknown): string { + if (v === null || v === undefined) return "—"; + if (Array.isArray(v)) return v.map(item => typeof item === "object" ? JSON.stringify(item) : String(item)).join(", "); + return String(v); +} + +function PhaseDataCard({ phase }: { phase: SavedPhase }) { + const [expanded, setExpanded] = useState(false); + const entries = Object.entries(phase.data).filter(([, v]) => v !== null && v !== undefined && v !== ""); + return ( +
+ + {expanded && entries.length > 0 && ( +
+ {entries.map(([k, v]) => ( +
+
+ {k.replace(/_/g, " ")} +
+
+ {formatValue(v)} +
+
+ ))} +
+ )} +
+ ); } export default function PRDPage() { const params = useParams(); const projectId = params.projectId as string; - const [project, setProject] = useState(null); + const [prd, setPrd] = useState(null); + const [savedPhases, setSavedPhases] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { - fetch(`/api/projects/${projectId}`) - .then((r) => r.json()) - .then((d) => { - setProject(d.project); - setLoading(false); - }) - .catch(() => setLoading(false)); + Promise.all([ + fetch(`/api/projects/${projectId}`).then(r => r.json()).catch(() => ({})), + fetch(`/api/projects/${projectId}/save-phase`).then(r => r.json()).catch(() => ({ phases: [] })), + ]).then(([projectData, phaseData]) => { + setPrd(projectData?.project?.prd ?? null); + setSavedPhases(phaseData?.phases ?? []); + setLoading(false); + }); }, [projectId]); - // Build sections with real status if available - const sections: PRDSection[] = PRD_SECTIONS.map((s) => { - const saved = project?.prdSections?.[s.id]; - return { - ...s, - status: (saved?.status as PRDSection["status"]) ?? "pending", - pct: saved?.pct ?? 0, - content: saved?.content, - }; - }); + const phaseMap = new Map(savedPhases.map(p => [p.phase, p])); + const savedPhaseIds = new Set(savedPhases.map(p => p.phase)); - // If we have a raw PRD markdown, show that instead of the section list - const hasPRD = Boolean(project?.prd); + const sections = PRD_SECTIONS.map(s => ({ + ...s, + savedPhase: s.phaseId ? phaseMap.get(s.phaseId) ?? null : null, + isDone: s.phaseId ? savedPhaseIds.has(s.phaseId) : false, + })); - const totalPct = Math.round(sections.reduce((a, s) => a + s.pct, 0) / sections.length); - const doneCount = sections.filter((s) => s.status === "done").length; + const doneCount = sections.filter(s => s.isDone).length; + const totalPct = Math.round((doneCount / sections.length) * 100); if (loading) { return ( @@ -74,19 +117,16 @@ export default function PRDPage() { } return ( -
- {hasPRD ? ( - /* ── Raw PRD view ── */ +
+ {prd ? ( + /* ── Finalized PRD view ── */

Product Requirements

- PRD approved + PRD complete
- {project?.prd} + {prd}
) : ( /* ── Section progress view ── */ -
+
{/* Progress bar */}
{totalPct}%
@@ -124,7 +164,7 @@ export default function PRDPage() {
- {doneCount}/{sections.length} approved + {doneCount}/{sections.length} sections
@@ -133,60 +173,72 @@ export default function PRDPage() {
(e.currentTarget.style.borderColor = "#d0ccc4")} - onMouseLeave={(e) => (e.currentTarget.style.borderColor = "#e8e4dc")} > - {/* Status icon */} -
- {s.status === "done" ? "✓" : s.status === "active" ? "◐" : "○"} -
- - - {s.label} - - - {/* Mini progress bar */} -
+
+ {/* Status icon */}
+ width: 24, height: 24, borderRadius: 6, flexShrink: 0, + background: s.isDone ? "#2e7d3210" : "#f6f4f0", + display: "flex", alignItems: "center", justifyContent: "center", + fontSize: "0.65rem", fontWeight: 700, + color: s.isDone ? "#2e7d32" : "#c5c0b8", + }}> + {s.isDone ? "✓" : "○"} +
+ + + {s.label} + + + {s.isDone && s.savedPhase && ( + + saved + + )} + {!s.isDone && !s.phaseId && ( + + generated + + )}
- - {s.pct}% - + {/* Expandable phase data */} + {s.isDone && s.savedPhase && ( + + )} + + {/* Pending hint */} + {!s.isDone && ( +
+ {s.phaseId + ? `Complete the ${s.savedPhase ? s.savedPhase.title : "discovery"} phase in Atlas` + : "Will be generated when PRD is finalized"} +
+ )}
))} - {/* Hint */} -

- Continue chatting with Atlas to complete your PRD -

+ {doneCount === 0 && ( +

+ Continue chatting with Atlas — saved phases will appear here automatically. +

+ )}
)}
diff --git a/components/layout/project-shell.tsx b/components/layout/project-shell.tsx index 72094ec..5e5321f 100644 --- a/components/layout/project-shell.tsx +++ b/components/layout/project-shell.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; -import { ReactNode } from "react"; +import { ReactNode, useEffect, useState } from "react"; import { VIBNSidebar } from "./vibn-sidebar"; import { Toaster } from "sonner"; @@ -33,14 +33,22 @@ const TABS = [ ]; const DISCOVERY_PHASES = [ - "Big Picture", - "Users & Personas", - "Features", - "Business Model", - "Screens", - "Risks", + { id: "big_picture", label: "Big Picture" }, + { id: "users_personas", label: "Users & Personas" }, + { id: "features_scope", label: "Features" }, + { id: "business_model", label: "Business Model" }, + { id: "screens_data", label: "Screens" }, + { id: "risks_questions", label: "Risks" }, ]; +interface SavedPhase { + phase: string; + title: string; + summary: string; + data: Record; + saved_at: string; +} + function timeAgo(dateStr?: string): string { if (!dateStr) return "—"; const date = new Date(dateStr); @@ -90,8 +98,6 @@ export function ProjectShell({ projectDescription, projectStatus, projectProgress, - discoveryPhase = 0, - capturedData = {}, createdAt, updatedAt, featureCount = 0, @@ -100,7 +106,26 @@ export function ProjectShell({ const activeTab = TABS.find((t) => pathname?.includes(`/${t.path}`))?.id ?? "overview"; const progress = projectProgress ?? 0; - const capturedEntries = Object.entries(capturedData); + const [savedPhases, setSavedPhases] = useState([]); + + useEffect(() => { + fetch(`/api/projects/${projectId}/save-phase`) + .then(r => r.json()) + .then(d => setSavedPhases(d.phases ?? [])) + .catch(() => {}); + + // Refresh every 10s while the user is chatting with Atlas + const interval = setInterval(() => { + fetch(`/api/projects/${projectId}/save-phase`) + .then(r => r.json()) + .then(d => setSavedPhases(d.phases ?? [])) + .catch(() => {}); + }, 10_000); + return () => clearInterval(interval); + }, [projectId]); + + const savedPhaseIds = new Set(savedPhases.map(p => p.phase)); + const firstUnsavedIdx = DISCOVERY_PHASES.findIndex(p => !savedPhaseIds.has(p.id)); return ( <> @@ -212,11 +237,11 @@ export function ProjectShell({ {/* Discovery phases */} Discovery {DISCOVERY_PHASES.map((phase, i) => { - const isDone = i < discoveryPhase; - const isActive = i === discoveryPhase; + const isDone = savedPhaseIds.has(phase.id); + const isActive = !isDone && i === firstUnsavedIdx; return (
- {phase} + {phase.label}
); @@ -245,20 +270,20 @@ export function ProjectShell({
- {/* Captured data */} + {/* Captured data — summaries from saved phases */} Captured - {capturedEntries.length > 0 ? ( - capturedEntries.map(([k, v], i) => ( -
+ {savedPhases.length > 0 ? ( + savedPhases.map((p) => ( +
- {k} + {p.title}
-
- {v} +
+ {p.summary}
))