From 5ecb0349d7fa85597bdff313aca48ba3322b5988 Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Wed, 29 Apr 2026 18:02:02 -0700 Subject: [PATCH] feat(plan): add Plan tab as the first project surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new home for everything that happens BEFORE building: - Vision — one-line elevator pitch (mirrors productVision) - Ideas — the "park-it" bin for raw thoughts - Tasks — what needs to happen next (open / done) - Decisions — log of "we chose X over Y because Z" Storage is appended under fs_projects.data.plan so no schema migration is needed. CRUD lives at /api/projects/[projectId]/plan. The bare project URL now redirects to /plan instead of /product, and the AI chat receives decisions + open tasks in its active-project context block — so it stops re-litigating settled questions and knows what's queued up. Made-with: Cursor --- .../project/[projectId]/(home)/page.tsx | 2 +- .../project/[projectId]/(home)/plan/page.tsx | 645 ++++++++++++++++++ app/api/chat/route.ts | 28 +- app/api/projects/[projectId]/plan/route.ts | 220 ++++++ components/project/project-tab-bar.tsx | 11 +- 5 files changed, 899 insertions(+), 7 deletions(-) create mode 100644 app/[workspace]/project/[projectId]/(home)/plan/page.tsx create mode 100644 app/api/projects/[projectId]/plan/route.ts diff --git a/app/[workspace]/project/[projectId]/(home)/page.tsx b/app/[workspace]/project/[projectId]/(home)/page.tsx index 0c4df90e..e5f72c32 100644 --- a/app/[workspace]/project/[projectId]/(home)/page.tsx +++ b/app/[workspace]/project/[projectId]/(home)/page.tsx @@ -18,5 +18,5 @@ export default async function ProjectIndexPage({ params: Promise<{ workspace: string; projectId: string }>; }) { const { workspace, projectId } = await params; - redirect(`/${workspace}/project/${projectId}/product`); + redirect(`/${workspace}/project/${projectId}/plan`); } diff --git a/app/[workspace]/project/[projectId]/(home)/plan/page.tsx b/app/[workspace]/project/[projectId]/(home)/plan/page.tsx new file mode 100644 index 00000000..4f2d02b2 --- /dev/null +++ b/app/[workspace]/project/[projectId]/(home)/plan/page.tsx @@ -0,0 +1,645 @@ +"use client"; + +/** + * Plan tab — the home of the project's thinking. + * + * Sub-areas (always visible, even when empty, so a founder learns the + * model on a brand-new project): + * 1. Vision — the elevator pitch + audience + * 2. Ideas — the "park-it" bin for raw thoughts + * 3. Tasks — what needs to happen next (open / done) + * 4. Decisions — log of "we chose X over Y because Z" + * + * Same UI rhythm as Infrastructure: tiles in the left rail (with + * counts), full detail panel on the right. Everything writes through + * `/api/projects/[projectId]/plan` and persists under + * `fs_projects.data.plan`. The AI chat reads the plan as context so + * decisions don't get re-litigated. + */ + +import { useCallback, useEffect, useState } from "react"; +import { useParams } from "next/navigation"; +import { + Loader2, AlertCircle, Lightbulb, ListTodo, GitBranch, + Sparkles, Plus, Trash2, Check, RotateCcw, Pencil, X, +} from "lucide-react"; + +interface Idea { id: string; text: string; createdAt: string } +interface Task { id: string; text: string; status: "open" | "done"; createdAt: string; doneAt?: string } +interface Decision { id: string; title: string; choice: string; why?: string; createdAt: string } +interface Plan { + vision?: string; + ideas: Idea[]; + tasks: Task[]; + decisions: Decision[]; +} + +type Section = "vision" | "ideas" | "tasks" | "decisions"; + +interface SectionDef { + key: Section; + label: string; + icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>; + blurb: string; +} + +const SECTIONS: SectionDef[] = [ + { key: "vision", label: "Vision", icon: Sparkles, blurb: "What you're building, in one sentence." }, + { key: "ideas", label: "Ideas", icon: Lightbulb, blurb: "Raw capture. Park half-thoughts here." }, + { key: "tasks", label: "Tasks", icon: ListTodo, blurb: "What needs to happen next." }, + { key: "decisions", label: "Decisions", icon: GitBranch, blurb: "Choices already made — so we stop re-litigating them." }, +]; + +// ────────────────────────────────────────────────── +// Page +// ────────────────────────────────────────────────── + +export default function PlanTab() { + const params = useParams(); + const projectId = params.projectId as string; + + const [plan, setPlan] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [active, setActive] = useState
("vision"); + + const load = useCallback(async () => { + try { + const res = await fetch(`/api/projects/${projectId}/plan`, { credentials: "include" }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + setPlan(data.plan); + setError(null); + } catch (e: any) { + setError(e?.message ?? "Failed to load plan"); + } finally { + setLoading(false); + } + }, [projectId]); + + useEffect(() => { load(); }, [load]); + + if (loading && !plan) { + return ( +
+
+ Loading plan… +
+
+ ); + } + if (error || !plan) { + return ( +
+
+ + {error ?? "No plan data"} +
+
+ ); + } + + const counts: Record = { + vision: plan.vision ? 1 : 0, + ideas: plan.ideas.length, + tasks: plan.tasks.filter((t) => t.status === "open").length, + decisions: plan.decisions.length, + }; + + return ( +
+
+ + +
+
+ {active === "vision" && } + {active === "ideas" && } + {active === "tasks" && } + {active === "decisions" && } +
+
+
+
+ ); +} + +// ────────────────────────────────────────────────── +// Left rail tile +// ────────────────────────────────────────────────── + +function SectionTile({ + def, count, active, onClick, +}: { + def: SectionDef; + count: number; + active: boolean; + onClick: () => void; +}) { + const Icon = def.icon; + return ( + + ); +} + +// ────────────────────────────────────────────────── +// Vision +// ────────────────────────────────────────────────── + +function VisionPanel({ + plan, projectId, onChange, +}: { plan: Plan; projectId: string; onChange: (p: Plan) => void }) { + const [draft, setDraft] = useState(plan.vision ?? ""); + const [saving, setSaving] = useState(false); + const [editing, setEditing] = useState(!plan.vision); + + useEffect(() => { setDraft(plan.vision ?? ""); }, [plan.vision]); + + 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 ( +
+ + {editing ? ( + <> +