feat: live phase completion in right panel + saved phase data in PRD page

Made-with: Cursor
This commit is contained in:
2026-03-02 20:44:36 -08:00
parent 5bfbe86541
commit 585343968e
2 changed files with 201 additions and 124 deletions

View File

@@ -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<string, unknown>;
saved_at: string;
}
interface Project {
id: string;
prd?: string;
prdSections?: Record<string, { status: string; pct: number; content?: string }>;
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 (
<div style={{
marginTop: 10, background: "#f6f4f0", borderRadius: 8,
border: "1px solid #e8e4dc", overflow: "hidden",
}}>
<button
onClick={() => setExpanded(e => !e)}
style={{
width: "100%", textAlign: "left", padding: "10px 14px",
background: "none", border: "none", cursor: "pointer",
display: "flex", alignItems: "center", justifyContent: "space-between",
fontFamily: "Outfit, sans-serif",
}}
>
<span style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.45 }}>
{phase.summary}
</span>
<span style={{ fontSize: "0.7rem", color: "#a09a90", marginLeft: 8, flexShrink: 0 }}>
{expanded ? "▲" : "▼"}
</span>
</button>
{expanded && entries.length > 0 && (
<div style={{ padding: "4px 14px 14px", borderTop: "1px solid #e8e4dc" }}>
{entries.map(([k, v]) => (
<div key={k} style={{ marginTop: 10 }}>
<div style={{
fontSize: "0.6rem", color: "#b5b0a6", textTransform: "uppercase",
letterSpacing: "0.06em", fontWeight: 600, marginBottom: 2,
}}>
{k.replace(/_/g, " ")}
</div>
<div style={{ fontSize: "0.78rem", color: "#2a2824", lineHeight: 1.5 }}>
{formatValue(v)}
</div>
</div>
))}
</div>
)}
</div>
);
}
export default function PRDPage() {
const params = useParams();
const projectId = params.projectId as string;
const [project, setProject] = useState<Project | null>(null);
const [prd, setPrd] = useState<string | null>(null);
const [savedPhases, setSavedPhases] = useState<SavedPhase[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/projects/${projectId}`)
.then((r) => r.json())
.then((d) => {
setProject(d.project);
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);
})
.catch(() => setLoading(false));
});
}, [projectId]);
// Build sections with real status if available
const sections: PRDSection[] = PRD_SECTIONS.map((s) => {
const saved = project?.prdSections?.[s.id];
return {
const phaseMap = new Map(savedPhases.map(p => [p.phase, p]));
const savedPhaseIds = new Set(savedPhases.map(p => p.phase));
const sections = PRD_SECTIONS.map(s => ({
...s,
status: (saved?.status as PRDSection["status"]) ?? "pending",
pct: saved?.pct ?? 0,
content: saved?.content,
};
});
savedPhase: s.phaseId ? phaseMap.get(s.phaseId) ?? null : null,
isDone: s.phaseId ? savedPhaseIds.has(s.phaseId) : false,
}));
// If we have a raw PRD markdown, show that instead of the section list
const hasPRD = Boolean(project?.prd);
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 (
<div
className="vibn-enter"
style={{ padding: "28px 32px", flex: 1, overflow: "auto", fontFamily: "Outfit, sans-serif" }}
>
{hasPRD ? (
/* ── Raw PRD view ── */
<div style={{ padding: "28px 32px", flex: 1, overflow: "auto", fontFamily: "Outfit, sans-serif" }}>
{prd ? (
/* ── Finalized PRD view ── */
<div style={{ maxWidth: 760 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 20 }}>
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", margin: 0 }}>
Product Requirements
</h3>
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.72rem", color: "#6b6560", background: "#f0ece4", padding: "4px 10px", borderRadius: 5 }}>
PRD approved
PRD complete
</span>
</div>
<div style={{
@@ -95,22 +135,22 @@ export default function PRDPage() {
fontSize: "0.88rem", color: "#2a2824",
whiteSpace: "pre-wrap", fontFamily: "Outfit, sans-serif",
}}>
{project?.prd}
{prd}
</div>
</div>
) : (
/* ── Section progress view ── */
<div style={{ maxWidth: 640 }}>
<div style={{ maxWidth: 680 }}>
{/* Progress bar */}
<div style={{
display: "flex", alignItems: "center", gap: 16,
padding: "16px 20px", background: "#fff",
border: "1px solid #e8e4dc", borderRadius: 10,
marginBottom: 20, boxShadow: "0 1px 2px #1a1a1a05",
marginBottom: 24, boxShadow: "0 1px 2px #1a1a1a05",
}}>
<div style={{
fontFamily: "IBM Plex Mono, monospace",
fontSize: "1.4rem", fontWeight: 500, color: "#1a1a1a", minWidth: 48,
fontSize: "1.4rem", fontWeight: 500, color: "#1a1a1a", minWidth: 52,
}}>
{totalPct}%
</div>
@@ -124,7 +164,7 @@ export default function PRDPage() {
</div>
</div>
<span style={{ fontSize: "0.75rem", color: "#a09a90" }}>
{doneCount}/{sections.length} approved
{doneCount}/{sections.length} sections
</span>
</div>
@@ -133,60 +173,72 @@ export default function PRDPage() {
<div
key={s.id}
style={{
display: "flex", alignItems: "center", gap: 12,
padding: "14px 18px", marginBottom: 4,
background: "#fff", borderRadius: 8,
border: "1px solid #e8e4dc",
cursor: "pointer", transition: "border-color 0.12s",
padding: "14px 18px", marginBottom: 6,
background: "#fff", borderRadius: 10,
border: `1px solid ${s.isDone ? "#a5d6a740" : "#e8e4dc"}`,
animationDelay: `${i * 0.04}s`,
}}
className="vibn-enter"
onMouseEnter={(e) => (e.currentTarget.style.borderColor = "#d0ccc4")}
onMouseLeave={(e) => (e.currentTarget.style.borderColor = "#e8e4dc")}
>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
{/* Status icon */}
<div style={{
width: 24, height: 24, borderRadius: 6, flexShrink: 0,
background: s.status === "done" ? "#2e7d3210"
: s.status === "active" ? "#d4a04a12"
: "#f6f4f0",
background: s.isDone ? "#2e7d3210" : "#f6f4f0",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "0.65rem", fontWeight: 700,
color: s.status === "done" ? "#2e7d32"
: s.status === "active" ? "#9a7b3a"
: "#c5c0b8",
color: s.isDone ? "#2e7d32" : "#c5c0b8",
}}>
{s.status === "done" ? "✓" : s.status === "active" ? "◐" : "○"}
</div>
<span style={{ flex: 1, fontSize: "0.84rem", color: "#1a1a1a", fontWeight: 450 }}>
{s.label}
</span>
{/* Mini progress bar */}
<div style={{ width: 60, height: 3, borderRadius: 2, background: "#eae6de" }}>
<div style={{
height: "100%", borderRadius: 2, width: `${s.pct}%`,
background: s.status === "done" ? "#2e7d32"
: s.status === "active" ? "#d4a04a"
: "#d0ccc4",
}} />
{s.isDone ? "✓" : "○"}
</div>
<span style={{
fontSize: "0.68rem", fontFamily: "IBM Plex Mono, monospace",
color: s.status === "done" ? "#2e7d32" : "#a09a90",
fontWeight: 500, minWidth: 28, textAlign: "right",
flex: 1, fontSize: "0.84rem",
color: s.isDone ? "#1a1a1a" : "#a09a90",
fontWeight: s.isDone ? 500 : 400,
}}>
{s.pct}%
{s.label}
</span>
{s.isDone && s.savedPhase && (
<span style={{
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
color: "#2e7d32", background: "#2e7d3210",
padding: "2px 7px", borderRadius: 4, fontWeight: 500,
}}>
saved
</span>
)}
{!s.isDone && !s.phaseId && (
<span style={{
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
color: "#b5b0a6", padding: "2px 7px",
}}>
generated
</span>
)}
</div>
{/* Expandable phase data */}
{s.isDone && s.savedPhase && (
<PhaseDataCard phase={s.savedPhase} />
)}
{/* Pending hint */}
{!s.isDone && (
<div style={{ marginTop: 6, marginLeft: 36, fontSize: "0.72rem", color: "#c5c0b8" }}>
{s.phaseId
? `Complete the ${s.savedPhase ? s.savedPhase.title : "discovery"} phase in Atlas`
: "Will be generated when PRD is finalized"}
</div>
)}
</div>
))}
{/* Hint */}
{doneCount === 0 && (
<p style={{ fontSize: "0.78rem", color: "#b5b0a6", marginTop: 20, textAlign: "center" }}>
Continue chatting with Atlas to complete your PRD
Continue chatting with Atlas saved phases will appear here automatically.
</p>
)}
</div>
)}
</div>

View File

@@ -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<string, unknown>;
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<SavedPhase[]>([]);
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 */}
<SectionLabel>Discovery</SectionLabel>
{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 (
<div
key={i}
key={phase.id}
style={{
display: "flex", alignItems: "center", gap: 10,
padding: "9px 0",
@@ -237,7 +262,7 @@ export function ProjectShell({
fontWeight: isActive ? 600 : 400,
color: isDone ? "#6b6560" : isActive ? "#1a1a1a" : "#b5b0a6",
}}>
{phase}
{phase.label}
</span>
</div>
);
@@ -245,20 +270,20 @@ export function ProjectShell({
<div style={{ height: 1, background: "#f0ece4", margin: "16px 0" }} />
{/* Captured data */}
{/* Captured data — summaries from saved phases */}
<SectionLabel>Captured</SectionLabel>
{capturedEntries.length > 0 ? (
capturedEntries.map(([k, v], i) => (
<div key={i} style={{ marginBottom: 14 }}>
{savedPhases.length > 0 ? (
savedPhases.map((p) => (
<div key={p.phase} style={{ marginBottom: 14 }}>
<div style={{
fontSize: "0.62rem", color: "#b5b0a6",
fontSize: "0.62rem", color: "#2e7d32",
textTransform: "uppercase", letterSpacing: "0.05em",
marginBottom: 3, fontWeight: 600,
marginBottom: 3, fontWeight: 600, display: "flex", alignItems: "center", gap: 4,
}}>
{k}
<span></span><span>{p.title}</span>
</div>
<div style={{ fontSize: "0.8rem", color: "#4a4640", lineHeight: 1.45 }}>
{v}
<div style={{ fontSize: "0.75rem", color: "#4a4640", lineHeight: 1.45 }}>
{p.summary}
</div>
</div>
))