feat: live phase completion in right panel + saved phase data in PRD page
Made-with: Cursor
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
|
||||
Reference in New Issue
Block a user