This repository has been archived on 2026-06-07. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
master-ai/vibn-frontend/app/[workspace]/project/[projectId]/(home)/plan/page.tsx

510 lines
21 KiB
TypeScript

"use client";
import React, { useState, useEffect, useCallback } from "react";
import { useParams } from "next/navigation";
import { Loader2, Target, BookOpen, Layers, GitBranch, Pencil, FileText, Check, Plus } from "lucide-react";
import ReactMarkdown from "react-markdown";
import useSWR from "swr";
// Types mapping to our Postgres plan shape
type Plan = {
vision?: string;
brief?: string;
tasks: Array<{ id: string; title: string; description?: string; status: string }>;
decisions: Array<{ id: string; title: string; choice: string; why?: string }>;
};
type Tab = "objective" | "stories" | "features" | "architecture";
// Shared Theme Variables
const INK = {
main: "#1a1918",
muted: "#6b6560",
faint: "#a09a90",
border: "#e8e4dc",
bg: "#ffffff",
bgHover: "#f8f6f2",
};
const fetcher = async (url: string) => {
const res = await fetch(url, { credentials: "include" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
return data.plan;
};
export default function PlanPageV2() {
const params = useParams();
const projectId = (params?.projectId as string) || "";
const [activeTab, setActiveTab] = useState<Tab>("objective");
const { data: plan, error, mutate: mutatePlan } = useSWR<Plan>(
projectId ? `/api/projects/${projectId}/plan` : null,
fetcher,
{ revalidateOnFocus: false, revalidateIfStale: false }
);
// Wrapper for child components to update SWR cache optimally
const setPlan = (newPlan: Plan) => { mutatePlan(newPlan, false); };
if (!plan && !error) {
return (
<div style={{ display: "flex", justifyContent: "center", padding: 100 }}>
<Loader2 className="animate-spin" size={24} color={INK.muted} />
</div>
);
}
if (error || !plan) {
return (
<div style={{ padding: 40, color: "red", textAlign: "center" }}>
{error || "Failed to load plan"}
</div>
);
}
return (
<div style={{ maxWidth: 1000, margin: "0 auto", padding: "40px 20px" }}>
{/* ── HEADER & TABS ── */}
<div style={{ marginBottom: 32 }}>
<div style={{
display: "flex",
gap: 8,
borderBottom: `1px solid ${INK.border}`,
paddingBottom: 0
}}>
<TabButton active={activeTab === "objective"} onClick={() => setActiveTab("objective")} icon={<Target size={14} />} label="Objective" />
<TabButton active={activeTab === "stories"} onClick={() => setActiveTab("stories")} icon={<BookOpen size={14} />} label="User Stories" />
<TabButton active={activeTab === "features"} onClick={() => setActiveTab("features")} icon={<Layers size={14} />} label="Features" />
<TabButton active={activeTab === "architecture"} onClick={() => setActiveTab("architecture")} icon={<GitBranch size={14} />} label="Architecture" />
</div>
</div>
{/* ── CONTENT AREA ── */}
{/*
By rendering them all but toggling display: none, we fix the "always needs to load"
issue you mentioned. The DOM nodes stay mounted, preserving state and preventing jitter.
*/}
<div style={{ display: activeTab === "objective" ? "block" : "none" }}>
<ObjectiveView plan={plan} projectId={projectId} onChange={setPlan} />
</div>
<div style={{ display: activeTab === "stories" ? "block" : "none" }}>
<UserStoriesView plan={plan} projectId={projectId} onChange={setPlan} />
</div>
<div style={{ display: activeTab === "features" ? "block" : "none" }}>
<FeaturesView plan={plan} projectId={projectId} onChange={setPlan} />
</div>
<div style={{ display: activeTab === "architecture" ? "block" : "none" }}>
<ArchitectureView plan={plan} projectId={projectId} onChange={setPlan} />
</div>
</div>
);
}
// ──────────────────────────────────────────────────
// 1. OBJECTIVE VIEW
// The business case / 1-page summary
// ──────────────────────────────────────────────────
function ObjectiveView({ plan, projectId, onChange }: { plan: Plan, projectId: string, onChange: (p: Plan) => void }) {
const [editing, setEditing] = useState(!plan.vision);
const [draft, setDraft] = useState(plan.vision ?? "");
const [saving, setSaving] = useState(false);
const save = async () => {
setSaving(true);
try {
const r = await fetch(`/api/projects/${projectId}/plan`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "vision", text: draft }),
});
const d = await r.json();
if (d.plan) onChange(d.plan);
setEditing(false);
} finally {
setSaving(false);
}
};
return (
<div className="panel-container">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
<div>
</div>
{!editing && (
<button onClick={() => setEditing(true)} className="btn-ghost">
<Pencil size={12} /> Edit
</button>
)}
</div>
{editing ? (
<div style={{ border: `1px solid ${INK.border}`, borderRadius: 8, overflow: "hidden" }}>
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
style={{
width: "100%", minHeight: 400, padding: 20, fontSize: "0.95rem", lineHeight: 1.6,
border: "none", outline: "none", resize: "vertical", fontFamily: "var(--font-sans)",
}}
placeholder="Describe the business objective..."
/>
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8, padding: "12px 20px", background: INK.bgHover, borderTop: `1px solid ${INK.border}` }}>
<button onClick={() => setEditing(false)} className="btn-secondary">Cancel</button>
<button onClick={save} disabled={saving} className="btn-primary">
{saving ? "Saving..." : "Save Objective"}
</button>
</div>
</div>
) : (
<div className="markdown-prose" style={{ background: "#fff", border: `1px solid ${INK.border}`, padding: 32, borderRadius: 8, minHeight: 200 }}>
{plan.vision ? (
<ReactMarkdown>{plan.vision}</ReactMarkdown>
) : (
<div style={{ color: INK.faint, fontStyle: "italic" }}>No objective defined yet. Switch to Architect mode and brainstorm with the AI.</div>
)}
</div>
)}
</div>
);
}
// ──────────────────────────────────────────────────
// 2. USER STORIES VIEW
// ──────────────────────────────────────────────────
// MOCK DATA: In production, this would be parsed from the AI's spec markdown or a JSON array in the database.
const MOCK_JOURNEYS = [
{
id: "j1",
name: "User Dashboard",
persona: "Admin",
goal: "Provides a centralized view of all active jobs, metrics, and pending approvals.",
stories: [
{
id: "s1",
title: "View active jobs",
description: "As an Admin, I want to see a map of today's jobs so I know where my team is.",
criteria: [
{ text: "Map centers on user's GPS location.", done: false },
{ text: "Clicking a pin opens a detail modal.", done: true }
]
},
{
id: "s2",
title: "Approve pending payouts",
description: "As an Admin, I want to review completed jobs and click 'Approve' to trigger Stripe payouts.",
criteria: [
{ text: "List displays jobs with 'completed' status.", done: false },
{ text: "Approve button triggers success toast.", done: false }
]
}
]
},
{
id: "j2",
name: "Authentication & Onboarding",
persona: "Customer",
goal: "Allows a new user to securely create an account and fill out their initial profile.",
stories: [
{
id: "s3",
title: "Sign up via Email",
description: "As a Customer, I want to sign up with my email and a password so my data is secure.",
criteria: [
{ text: "Form validates email format.", done: false },
{ text: "Password must be > 8 characters.", done: false }
]
}
]
}
];
function UserStoriesView({ plan, projectId, onChange }: { plan: Plan, projectId: string, onChange: (p: Plan) => void }) {
const [activeJourneyId, setActiveJourneyId] = useState<string>(MOCK_JOURNEYS[0].id);
const activeJourney = MOCK_JOURNEYS.find(j => j.id === activeJourneyId);
return (
<div className="panel-container" style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div style={{ display: "flex", gap: 24, alignItems: "flex-start" }}>
{/* LEFT COLUMN: The Index */}
<div style={{
width: "30%",
minWidth: 250,
display: "flex",
flexDirection: "column",
gap: 8
}}>
{MOCK_JOURNEYS.map(j => {
const isActive = j.id === activeJourneyId;
return (
<button
key={j.id}
onClick={() => setActiveJourneyId(j.id)}
style={{
textAlign: "left",
padding: "16px 16px",
borderRadius: 8,
background: isActive ? "#fff" : "transparent",
border: isActive ? `1px solid ${INK.border}` : "1px solid transparent",
boxShadow: isActive ? "0 1px 3px rgba(0,0,0,0.02)" : "none",
cursor: "pointer",
transition: "all 0.15s ease",
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 6 }}>
<span style={{ fontWeight: 600, fontSize: "0.95rem", color: isActive ? INK.main : INK.muted }}>
{j.name}
</span>
</div>
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
<span style={{ fontSize: "0.8rem", color: INK.muted, background: INK.bgHover, padding: "2px 6px", borderRadius: 4, border: `1px solid ${INK.border}` }}>
{j.persona}
</span>
<span style={{ fontSize: "0.8rem", color: INK.faint }}>
{j.stories.length} stories
</span>
</div>
</button>
);
})}
<button style={{
marginTop: 8,
padding: "12px",
border: `1px dashed ${INK.border}`,
borderRadius: 8,
background: "transparent",
color: INK.muted,
fontSize: "0.85rem",
fontWeight: 500,
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 6
}}>
<Plus size={14} /> Add Journey
</button>
</div>
{/* RIGHT COLUMN: The Details */}
<div style={{
flex: 1,
background: "#fff",
border: `1px solid ${INK.border}`,
borderRadius: 8,
padding: 32,
boxShadow: "0 1px 3px rgba(0,0,0,0.02)"
}}>
{activeJourney ? (
<>
<div style={{ marginBottom: 32, paddingBottom: 24, borderBottom: `1px solid ${INK.border}` }}>
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 12 }}>
<h3 style={{ fontSize: "1.4rem", fontWeight: 700, color: INK.main, margin: 0 }}>
{activeJourney.name}
</h3>
<span style={{ fontSize: "0.85rem", color: INK.muted, background: INK.bgHover, padding: "4px 8px", borderRadius: 6, border: `1px solid ${INK.border}`, fontWeight: 500 }}>
{activeJourney.persona}
</span>
</div>
<p style={{ fontSize: "0.95rem", color: INK.muted, margin: 0, lineHeight: 1.5 }}>
<strong style={{ color: INK.main }}>Goal:</strong> {activeJourney.goal}
</p>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 32 }}>
{activeJourney.stories.map((story, i) => (
<div key={story.id}>
<div style={{ display: "flex", alignItems: "baseline", gap: 12, marginBottom: 8 }}>
<span style={{ color: INK.faint, fontWeight: 600, fontSize: "0.85rem" }}>US{i + 1}</span>
<h4 style={{ fontSize: "1.05rem", fontWeight: 600, color: INK.main, margin: 0 }}>{story.title}</h4>
</div>
<p style={{ fontSize: "0.95rem", color: INK.muted, margin: "0 0 16px 0", fontStyle: "italic" }}>
"{story.description}"
</p>
<div style={{ background: INK.bgHover, padding: 16, borderRadius: 8, border: `1px solid ${INK.border}` }}>
<div style={{ fontSize: "0.8rem", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em", color: INK.muted, marginBottom: 12 }}>
Acceptance Criteria
</div>
<ul style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: 10 }}>
{story.criteria.map((c, idx) => (
<li key={idx} style={{ display: "flex", alignItems: "flex-start", gap: 10, fontSize: "0.9rem", color: INK.main }}>
<div style={{
width: 16, height: 16, borderRadius: 4, border: `1px solid ${c.done ? "var(--accent)" : INK.faint}`,
background: c.done ? "var(--accent)" : "transparent",
display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0, marginTop: 2
}}>
{c.done && <Check size={12} color="#fff" strokeWidth={3} />}
</div>
<span style={{ textDecoration: c.done ? "line-through" : "none", opacity: c.done ? 0.5 : 1 }}>
{c.text}
</span>
</li>
))}
</ul>
</div>
</div>
))}
</div>
</>
) : (
<div style={{ textAlign: "center", color: INK.muted, padding: 40 }}>Select a journey to view stories</div>
)}
</div>
</div>
</div>
);
}
// ──────────────────────────────────────────────────
// 3. FEATURES VIEW (Replaces Tasks)
// ──────────────────────────────────────────────────
function FeaturesView({ plan, projectId }: { plan: Plan, projectId: string, onChange: (p: Plan) => void }) {
const features = plan.tasks.filter(t => t.status !== "done"); // Simplification for prototype
return (
<div className="panel-container">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 24 }}>
<div>
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, margin: 0, color: INK.main }}>Features Queue</h2>
<p style={{ color: INK.muted, fontSize: "0.85rem", margin: "4px 0 0" }}>High-level capabilities to be delegated to the AI.</p>
</div>
<button className="btn-primary">
<Plus size={14} /> New Feature
</button>
</div>
<div style={{ display: "grid", gap: 12 }}>
{features.length > 0 ? features.map(f => (
<div key={f.id} style={{
border: `1px solid ${INK.border}`,
borderRadius: 8,
padding: 16,
background: "#fff",
display: "flex",
justifyContent: "space-between",
alignItems: "center"
}}>
<div>
<div style={{ fontWeight: 600, fontSize: "0.95rem", color: INK.main }}>{f.title}</div>
{f.description && <div style={{ fontSize: "0.8rem", color: INK.muted, marginTop: 4 }}>{f.description}</div>}
</div>
<button className="btn-secondary" style={{ background: INK.main, color: "#fff", borderColor: INK.main }}>
Delegate
</button>
</div>
)) : (
<div style={{ padding: 40, textAlign: "center", border: `1px solid ${INK.border}`, borderRadius: 8, color: INK.muted }}>
No features defined yet. Use Architect mode to brainstorm.
</div>
)}
</div>
</div>
);
}
// ──────────────────────────────────────────────────
// 4. ARCHITECTURE VIEW
// ──────────────────────────────────────────────────
function ArchitectureView({ plan, projectId }: { plan: Plan, projectId: string, onChange: (p: Plan) => void }) {
return (
<div className="panel-container">
<div style={{ marginBottom: 24 }}>
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, margin: 0, color: INK.main }}>Technical Architecture</h2>
<p style={{ color: INK.muted, fontSize: "0.85rem", margin: "4px 0 0" }}>Decisions made regarding the tech stack, database, and integrations.</p>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
{plan.decisions.length > 0 ? plan.decisions.map(d => (
<div key={d.id} style={{ border: `1px solid ${INK.border}`, borderRadius: 8, padding: 20, background: "#fff" }}>
<div style={{ fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "0.05em", color: INK.faint, marginBottom: 4 }}>
{d.title}
</div>
<div style={{ fontSize: "1.1rem", fontWeight: 600, color: INK.main, marginBottom: 8 }}>
{d.choice}
</div>
{d.why && (
<div style={{ fontSize: "0.85rem", color: INK.muted, lineHeight: 1.5 }}>
{d.why}
</div>
)}
</div>
)) : (
<div style={{ gridColumn: "span 2", padding: 40, textAlign: "center", border: `1px dashed ${INK.border}`, borderRadius: 8, color: INK.muted }}>
No technical decisions logged yet.
</div>
)}
</div>
</div>
);
}
// ── Shared UI Components ──────────────────────────────────────────────────────
function TabButton({ active, onClick, icon, label }: { active: boolean, onClick: () => void, icon: React.ReactNode, label: string }) {
return (
<button
onClick={onClick}
style={{
display: "flex", alignItems: "center", gap: 6,
padding: "10px 16px",
background: "transparent",
border: "none",
borderBottom: active ? `2px solid ${INK.main}` : "2px solid transparent",
color: active ? INK.main : INK.muted,
fontWeight: active ? 600 : 500,
fontSize: "0.9rem",
cursor: "pointer",
transition: "all 0.15s ease",
marginBottom: -1 // Overlap the container's bottom border
}}
>
{icon}
{label}
</button>
);
}
// Global styles injected here for the prototype so it renders cleanly
const styleTag = `
.btn-primary, .btn-secondary, .btn-ghost {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
padding: 8px 16px; border-radius: 6px; font-size: 0.85rem; font-weight: 500;
cursor: pointer; transition: all 0.15s ease; border: 1px solid transparent;
}
.btn-primary { background: #1a1918; color: white; }
.btn-primary:hover { background: #333; }
.btn-secondary { background: #fff; border-color: #e8e4dc; color: #1a1918; }
.btn-secondary:hover { background: #f8f6f2; }
.btn-ghost { background: transparent; color: #6b6560; }
.btn-ghost:hover { background: #f8f6f2; color: #1a1918; }
.markdown-prose h1 { font-size: 1.5rem; font-weight: 700; margin-top: 0; }
.markdown-prose h2 { font-size: 1.25rem; font-weight: 600; margin-top: 1.5rem; }
.markdown-prose p { margin-top: 0.5rem; margin-bottom: 1rem; line-height: 1.6; }
.markdown-prose ul { padding-left: 1.5rem; margin-bottom: 1rem; }
.markdown-prose li { margin-bottom: 0.25rem; }
`;
if (typeof document !== "undefined" && !document.getElementById("plan-v2-styles")) {
const style = document.createElement("style");
style.id = "plan-v2-styles";
style.innerHTML = styleTag;
document.head.appendChild(style);
}