feat(plan): add Plan tab as the first project surface

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
This commit is contained in:
2026-04-29 18:02:02 -07:00
parent b706fa0e89
commit 5ecb0349d7
5 changed files with 899 additions and 7 deletions

View File

@@ -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`);
}

View File

@@ -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<Plan | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [active, setActive] = useState<Section>("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 (
<div style={pageWrap}>
<div style={{ display: "flex", alignItems: "center", gap: 10, color: INK.mid }}>
<Loader2 size={16} className="animate-spin" /> Loading plan
</div>
</div>
);
}
if (error || !plan) {
return (
<div style={pageWrap}>
<div style={errorBox}>
<AlertCircle size={14} style={{ marginRight: 6, verticalAlign: -2 }} />
{error ?? "No plan data"}
</div>
</div>
);
}
const counts: Record<Section, number> = {
vision: plan.vision ? 1 : 0,
ideas: plan.ideas.length,
tasks: plan.tasks.filter((t) => t.status === "open").length,
decisions: plan.decisions.length,
};
return (
<div style={pageWrap}>
<div style={grid}>
<aside style={leftCol}>
<h2 style={heading}>Plan</h2>
{SECTIONS.map((def) => (
<SectionTile
key={def.key}
def={def}
count={counts[def.key]}
active={active === def.key}
onClick={() => setActive(def.key)}
/>
))}
</aside>
<section style={rightCol}>
<div style={panel}>
{active === "vision" && <VisionPanel plan={plan} projectId={projectId} onChange={setPlan} />}
{active === "ideas" && <IdeasPanel plan={plan} projectId={projectId} onChange={setPlan} />}
{active === "tasks" && <TasksPanel plan={plan} projectId={projectId} onChange={setPlan} />}
{active === "decisions" && <DecisionsPanel plan={plan} projectId={projectId} onChange={setPlan} />}
</div>
</section>
</div>
</div>
);
}
// ──────────────────────────────────────────────────
// Left rail tile
// ──────────────────────────────────────────────────
function SectionTile({
def, count, active, onClick,
}: {
def: SectionDef;
count: number;
active: boolean;
onClick: () => void;
}) {
const Icon = def.icon;
return (
<button
type="button"
onClick={onClick}
style={{
...tileButton,
background: active ? "#fff" : "transparent",
borderColor: active ? INK.border : "transparent",
boxShadow: active ? "0 1px 3px rgba(0,0,0,0.04)" : "none",
}}
aria-pressed={active}
>
<Icon size={14} style={{ color: INK.mid, flexShrink: 0 }} />
<span style={{ ...tileLabel, color: active ? INK.ink : INK.mid }}>{def.label}</span>
<span style={countPill}>{count}</span>
</button>
);
}
// ──────────────────────────────────────────────────
// 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 (
<div>
<PanelHeader title="Vision" hint="One sentence on what you're building and who it's for." />
{editing ? (
<>
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="A simple CRM for solo founders who hate spreadsheets…"
rows={5}
style={textarea}
/>
<div style={{ display: "flex", gap: 8, marginTop: 10 }}>
<button onClick={save} disabled={saving} style={primaryBtn}>
{saving ? <Loader2 size={13} className="animate-spin" /> : <Check size={13} />}
Save
</button>
{plan.vision && (
<button onClick={() => { setDraft(plan.vision ?? ""); setEditing(false); }} style={ghostBtn}>
Cancel
</button>
)}
</div>
</>
) : (
<>
<p style={visionText}>{plan.vision}</p>
<button onClick={() => setEditing(true)} style={ghostBtn}>
<Pencil size={12} /> Edit
</button>
</>
)}
</div>
);
}
// ──────────────────────────────────────────────────
// Ideas
// ──────────────────────────────────────────────────
function IdeasPanel({
plan, projectId, onChange,
}: { plan: Plan; projectId: string; onChange: (p: Plan) => void }) {
const [draft, setDraft] = useState("");
const [saving, setSaving] = useState(false);
const add = async () => {
const text = draft.trim();
if (!text) return;
setSaving(true);
try {
const r = await fetch(`/api/projects/${projectId}/plan`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "idea", text }),
});
const d = await r.json();
if (d.plan) onChange(d.plan);
setDraft("");
} finally { setSaving(false); }
};
const remove = async (id: string) => {
const r = await fetch(`/api/projects/${projectId}/plan?kind=idea&id=${id}`, { method: "DELETE" });
const d = await r.json();
if (d.plan) onChange(d.plan);
};
return (
<div>
<PanelHeader title="Ideas" hint="Park half-thoughts here. Promote the good ones to tasks later." />
<div style={addRow}>
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") add(); }}
placeholder="A new idea…"
style={input}
/>
<button onClick={add} disabled={saving || !draft.trim()} style={primaryBtn}>
<Plus size={13} /> Add
</button>
</div>
{plan.ideas.length === 0 ? (
<div style={emptyBox}>No ideas yet.</div>
) : (
<ul style={list}>
{plan.ideas.map((idea) => (
<li key={idea.id} style={listRow}>
<span style={{ flex: 1, minWidth: 0 }}>{idea.text}</span>
<span style={timestampStyle}>{relativeTime(idea.createdAt)}</span>
<button onClick={() => remove(idea.id)} style={iconBtn} title="Delete">
<Trash2 size={13} />
</button>
</li>
))}
</ul>
)}
</div>
);
}
// ──────────────────────────────────────────────────
// Tasks
// ──────────────────────────────────────────────────
function TasksPanel({
plan, projectId, onChange,
}: { plan: Plan; projectId: string; onChange: (p: Plan) => void }) {
const [draft, setDraft] = useState("");
const [saving, setSaving] = useState(false);
const add = async () => {
const text = draft.trim();
if (!text) return;
setSaving(true);
try {
const r = await fetch(`/api/projects/${projectId}/plan`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "task", text }),
});
const d = await r.json();
if (d.plan) onChange(d.plan);
setDraft("");
} finally { setSaving(false); }
};
const toggle = async (t: Task) => {
const r = await fetch(`/api/projects/${projectId}/plan`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "task", id: t.id, status: t.status === "open" ? "done" : "open" }),
});
const d = await r.json();
if (d.plan) onChange(d.plan);
};
const remove = async (id: string) => {
const r = await fetch(`/api/projects/${projectId}/plan?kind=task&id=${id}`, { method: "DELETE" });
const d = await r.json();
if (d.plan) onChange(d.plan);
};
const open = plan.tasks.filter((t) => t.status === "open");
const done = plan.tasks.filter((t) => t.status === "done");
return (
<div>
<PanelHeader title="Tasks" hint="What needs to happen next. The AI sees open tasks as project context." />
<div style={addRow}>
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") add(); }}
placeholder="A new task…"
style={input}
/>
<button onClick={add} disabled={saving || !draft.trim()} style={primaryBtn}>
<Plus size={13} /> Add
</button>
</div>
{open.length === 0 && done.length === 0 ? (
<div style={emptyBox}>No tasks yet.</div>
) : (
<>
{open.length > 0 && (
<>
<div style={subHeading}>Open · {open.length}</div>
<ul style={list}>
{open.map((t) => (
<li key={t.id} style={listRow}>
<button onClick={() => toggle(t)} style={checkbox} title="Mark done">
<span style={{ width: 12, height: 12, border: `1.5px solid ${INK.mid}`, borderRadius: 3 }} />
</button>
<span style={{ flex: 1, minWidth: 0 }}>{t.text}</span>
<span style={timestampStyle}>{relativeTime(t.createdAt)}</span>
<button onClick={() => remove(t.id)} style={iconBtn} title="Delete">
<Trash2 size={13} />
</button>
</li>
))}
</ul>
</>
)}
{done.length > 0 && (
<>
<div style={{ ...subHeading, marginTop: 18 }}>Done · {done.length}</div>
<ul style={list}>
{done.map((t) => (
<li key={t.id} style={{ ...listRow, opacity: 0.6 }}>
<button onClick={() => toggle(t)} style={checkbox} title="Mark open">
<Check size={12} />
</button>
<span style={{ flex: 1, minWidth: 0, textDecoration: "line-through" }}>{t.text}</span>
<span style={timestampStyle}>{relativeTime(t.doneAt ?? t.createdAt)}</span>
<button onClick={() => toggle(t)} style={iconBtn} title="Re-open">
<RotateCcw size={13} />
</button>
<button onClick={() => remove(t.id)} style={iconBtn} title="Delete">
<Trash2 size={13} />
</button>
</li>
))}
</ul>
</>
)}
</>
)}
</div>
);
}
// ──────────────────────────────────────────────────
// Decisions
// ──────────────────────────────────────────────────
function DecisionsPanel({
plan, projectId, onChange,
}: { plan: Plan; projectId: string; onChange: (p: Plan) => void }) {
const [adding, setAdding] = useState(false);
const [title, setTitle] = useState("");
const [choice, setChoice] = useState("");
const [why, setWhy] = useState("");
const [saving, setSaving] = useState(false);
const add = async () => {
if (!title.trim() || !choice.trim()) return;
setSaving(true);
try {
const r = await fetch(`/api/projects/${projectId}/plan`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "decision", title: title.trim(), choice: choice.trim(), why: why.trim() || undefined }),
});
const d = await r.json();
if (d.plan) onChange(d.plan);
setTitle(""); setChoice(""); setWhy(""); setAdding(false);
} finally { setSaving(false); }
};
const remove = async (id: string) => {
const r = await fetch(`/api/projects/${projectId}/plan?kind=decision&id=${id}`, { method: "DELETE" });
const d = await r.json();
if (d.plan) onChange(d.plan);
};
return (
<div>
<PanelHeader
title="Decisions"
hint="Once a question is settled, log it here. The AI reads decisions so it stops asking you to re-decide."
/>
{adding ? (
<div style={decisionForm}>
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Question or topic (e.g. 'Database choice')" style={input} />
<input value={choice} onChange={(e) => setChoice(e.target.value)} placeholder="What we chose (e.g. 'Postgres')" style={input} />
<textarea value={why} onChange={(e) => setWhy(e.target.value)} placeholder="Why (optional)" rows={3} style={textarea} />
<div style={{ display: "flex", gap: 8 }}>
<button onClick={add} disabled={saving || !title.trim() || !choice.trim()} style={primaryBtn}>
{saving ? <Loader2 size={13} className="animate-spin" /> : <Check size={13} />} Save
</button>
<button onClick={() => { setAdding(false); setTitle(""); setChoice(""); setWhy(""); }} style={ghostBtn}>
<X size={13} /> Cancel
</button>
</div>
</div>
) : (
<button onClick={() => setAdding(true)} style={primaryBtn}>
<Plus size={13} /> Log a decision
</button>
)}
{plan.decisions.length === 0 ? (
<div style={{ ...emptyBox, marginTop: 14 }}>No decisions logged yet.</div>
) : (
<ul style={{ ...list, marginTop: 14 }}>
{plan.decisions.map((d) => (
<li key={d.id} style={decisionRow}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={decisionTitle}>{d.title}</div>
<div style={decisionChoice}> {d.choice}</div>
{d.why && <div style={decisionWhy}>{d.why}</div>}
<div style={{ ...timestampStyle, marginTop: 4 }}>{relativeTime(d.createdAt)}</div>
</div>
<button onClick={() => remove(d.id)} style={iconBtn} title="Delete">
<Trash2 size={13} />
</button>
</li>
))}
</ul>
)}
</div>
);
}
// ──────────────────────────────────────────────────
// Bits
// ──────────────────────────────────────────────────
function PanelHeader({ title, hint }: { title: string; hint: string }) {
return (
<div style={{ marginBottom: 14 }}>
<h3 style={panelTitle}>{title}</h3>
<p style={panelHint}>{hint}</p>
</div>
);
}
function relativeTime(iso: string): string {
const diff = (Date.now() - new Date(iso).getTime()) / 1000;
if (diff < 60) return "just now";
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
if (diff < 86400 * 30) return `${Math.floor(diff / 86400)}d ago`;
return new Date(iso).toLocaleDateString();
}
// ──────────────────────────────────────────────────
// Tokens
// ──────────────────────────────────────────────────
const INK = {
ink: "#1a1a1a", mid: "#5f5e5a", muted: "#a09a90",
border: "#e8e4dc", borderSoft: "#efebe1",
cardBg: "#fff",
fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif',
fontSerif: '"Newsreader", "Lora", Georgia, serif',
} as const;
const pageWrap: React.CSSProperties = { padding: "28px 48px 48px", fontFamily: INK.fontSans, color: INK.ink };
const grid: React.CSSProperties = {
display: "grid",
gridTemplateColumns: "minmax(220px, 280px) minmax(0, 1fr)",
gap: 28, maxWidth: 1200, margin: "0 auto", alignItems: "stretch",
};
const leftCol: React.CSSProperties = { minWidth: 0, display: "flex", flexDirection: "column", gap: 4 };
const rightCol: React.CSSProperties = { minWidth: 0, display: "flex", flexDirection: "column" };
const heading: React.CSSProperties = {
fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.12em",
textTransform: "uppercase", color: INK.muted, margin: "0 0 10px 8px",
};
const tileButton: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 10,
width: "100%", padding: "10px 12px",
border: "1px solid transparent", borderRadius: 8,
cursor: "pointer", font: "inherit", color: "inherit",
transition: "background 0.12s, border-color 0.12s",
};
const tileLabel: React.CSSProperties = { fontSize: "0.85rem", fontWeight: 600, flex: 1, textAlign: "left" };
const countPill: React.CSSProperties = {
fontSize: "0.7rem", fontWeight: 600, color: INK.mid,
padding: "1px 7px", borderRadius: 999, background: "#f3eee4",
};
const panel: React.CSSProperties = {
background: INK.cardBg, border: `1px solid ${INK.border}`, borderRadius: 10,
padding: 22, flex: 1, minHeight: 0, overflowY: "auto",
};
const panelTitle: React.CSSProperties = {
fontFamily: INK.fontSerif, fontSize: "1.4rem", fontWeight: 400,
letterSpacing: "-0.02em", margin: 0,
};
const panelHint: React.CSSProperties = {
fontSize: "0.82rem", color: INK.mid, marginTop: 4, lineHeight: 1.5,
};
const subHeading: React.CSSProperties = {
fontSize: "0.68rem", fontWeight: 600, letterSpacing: "0.12em",
textTransform: "uppercase", color: INK.muted, margin: "14px 0 8px",
};
const visionText: React.CSSProperties = {
fontSize: "1rem", lineHeight: 1.55, color: INK.ink, margin: "0 0 14px",
};
const addRow: React.CSSProperties = { display: "flex", gap: 8, marginBottom: 14 };
const input: React.CSSProperties = {
flex: 1, padding: "9px 12px", fontSize: "0.88rem",
border: `1px solid ${INK.border}`, borderRadius: 8,
fontFamily: "inherit", background: "#fff", color: INK.ink,
};
const textarea: React.CSSProperties = {
width: "100%", padding: "10px 12px", fontSize: "0.9rem",
border: `1px solid ${INK.border}`, borderRadius: 8,
fontFamily: "inherit", background: "#fff", color: INK.ink,
resize: "vertical", lineHeight: 1.5,
};
const list: React.CSSProperties = {
listStyle: "none", padding: 0, margin: 0,
display: "flex", flexDirection: "column",
border: `1px solid ${INK.borderSoft}`, borderRadius: 8, overflow: "hidden",
};
const listRow: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 10,
padding: "11px 14px", fontSize: "0.88rem",
borderBottom: `1px solid ${INK.borderSoft}`, background: "#fff",
};
const decisionRow: React.CSSProperties = {
display: "flex", alignItems: "flex-start", gap: 10,
padding: "12px 14px",
borderBottom: `1px solid ${INK.borderSoft}`, background: "#fff",
};
const decisionTitle: React.CSSProperties = {
fontSize: "0.88rem", fontWeight: 600, color: INK.ink,
};
const decisionChoice: React.CSSProperties = {
fontSize: "0.88rem", color: INK.ink, marginTop: 2,
};
const decisionWhy: React.CSSProperties = {
fontSize: "0.82rem", color: INK.mid, marginTop: 6, lineHeight: 1.5,
};
const decisionForm: React.CSSProperties = {
display: "flex", flexDirection: "column", gap: 8, marginBottom: 14,
};
const timestampStyle: React.CSSProperties = {
fontSize: "0.7rem", color: INK.muted, flexShrink: 0,
};
const primaryBtn: React.CSSProperties = {
display: "inline-flex", alignItems: "center", gap: 6,
padding: "8px 14px", fontSize: "0.82rem", fontWeight: 600,
background: "#1a1a1a", color: "#fff", border: "none",
borderRadius: 8, cursor: "pointer", fontFamily: "inherit",
};
const ghostBtn: React.CSSProperties = {
display: "inline-flex", alignItems: "center", gap: 6,
padding: "8px 12px", fontSize: "0.82rem", fontWeight: 500,
background: "transparent", color: INK.mid,
border: `1px solid ${INK.border}`, borderRadius: 8,
cursor: "pointer", fontFamily: "inherit",
};
const iconBtn: React.CSSProperties = {
display: "inline-flex", alignItems: "center", justifyContent: "center",
width: 28, height: 28, padding: 0,
background: "transparent", color: INK.muted,
border: "none", borderRadius: 6, cursor: "pointer", flexShrink: 0,
};
const checkbox: React.CSSProperties = {
width: 22, height: 22, padding: 0,
display: "inline-flex", alignItems: "center", justifyContent: "center",
background: "transparent", border: "none", cursor: "pointer", flexShrink: 0,
color: INK.mid,
};
const emptyBox: React.CSSProperties = {
padding: "16px 18px", fontSize: "0.85rem", color: INK.mid,
background: "#fafaf6", border: `1px dashed ${INK.borderSoft}`, borderRadius: 8,
lineHeight: 1.5, textAlign: "center",
};
const errorBox: React.CSSProperties = {
padding: "12px 14px", fontSize: "0.85rem", color: "#7a1f15",
background: "#fbe9e7", border: `1px solid #f4c2bc`, borderRadius: 8,
};

View File

@@ -74,6 +74,32 @@ export function buildSystemPrompt(
// at the top so the model treats `projectId` as resolved without the
// user having to name it. Falls through to the workspace-level mode
// (browse all projects) when activeProject is undefined.
// Pull plan artifacts (decisions + open tasks) so the AI doesn't ask
// the user to re-decide settled questions and knows what's queued up.
// Decisions are first-class: they encode the founder's intent and
// should be honored unless the user explicitly revisits one.
const plan = (activeProject?.plan ?? {}) as {
decisions?: { title: string; choice: string; why?: string }[];
tasks?: { text: string; status: "open" | "done" }[];
ideas?: { text: string }[];
};
const decisionsBlock = plan.decisions?.length
? `\n**Decisions already made for this project (DO NOT re-litigate unless the user asks):**\n${plan.decisions
.slice(0, 20)
.map((d) => `- ${d.title}${d.choice}${d.why ? ` (because: ${d.why})` : ''}`)
.join('\n')}\n`
: '';
const openTasks = (plan.tasks ?? []).filter((t) => t.status === 'open').slice(0, 15);
const tasksBlock = openTasks.length
? `\n**Open tasks the user has captured:**\n${openTasks.map((t) => `- ${t.text}`).join('\n')}\n`
: '';
const ideasBlock = plan.ideas?.length
? `\n**Ideas parked (not commitments — surface only if relevant):**\n${plan.ideas
.slice(0, 10)
.map((i) => `- ${i.text}`)
.join('\n')}\n`
: '';
const activeBlock = activeProject
? `\n## ACTIVE PROJECT — assume this for every tool call unless the user explicitly says otherwise
@@ -84,7 +110,7 @@ The user is currently looking at:
- Audience: ${activeProject.audience ?? 'unspecified'}
- Vision: ${activeProject.productVision ? activeProject.productVision.slice(0, 240) : '(not yet captured)'}
${activeProject.kickoff ? `- Created via: ${activeProject.kickoff.mode} (${JSON.stringify(activeProject.kickoff.sourceData).slice(0, 200)})` : ''}
${decisionsBlock}${tasksBlock}${ideasBlock}
When you call tools that take a \`projectId\`, USE this id (\`${activeProject.id}\`) without asking. When the user says "this project" / "the app" / "deploy it" — they mean THIS project. Switch to a different project only if the user names one explicitly.\n`
: '';

View File

@@ -0,0 +1,220 @@
/**
* /api/projects/[projectId]/plan
*
* Project-level "thinking" surface — the home of the Plan tab.
* Stores everything that happens BEFORE building:
* - vision — one-line elevator pitch (mirrored to data.productVision)
* - ideas — unstructured capture (the "park-it" bin)
* - tasks — what needs to happen next (status: open | done)
* - decisions — log of "we chose X over Y because Z"
*
* Storage lives under fs_projects.data.plan.{ideas,tasks,decisions}
* so we don't need a schema migration. Items are appended-and-mutated
* in place; we never load the full document back to the client without
* an ownership check.
*
* Methods:
* GET → full plan snapshot
* POST { kind: "vision", text } → set vision (also mirrors to productVision)
* POST { kind: "idea", text } → add idea
* POST { kind: "task", text } → add task (status="open")
* POST { kind: "decision", title, choice, why? } → add decision
* PATCH { kind, id, ... } → update one item (toggle task, edit text)
* DELETE?kind=…&id=… → remove one item
*/
import { NextResponse } from "next/server";
import { authSession } from "@/lib/auth/session-server";
import { query } from "@/lib/db-postgres";
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 PlanShape {
vision?: string;
ideas: Idea[];
tasks: Task[];
decisions: Decision[];
}
function emptyPlan(): PlanShape {
return { ideas: [], tasks: [], decisions: [] };
}
function newId(): string {
return Math.random().toString(36).slice(2, 11);
}
async function loadOwnedProject(projectId: string, email: string) {
const rows = await query<{ id: string; data: any }>(
`SELECT p.id, p.data FROM fs_projects p
JOIN fs_users u ON u.id = p.user_id
WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`,
[projectId, email],
);
return rows[0] ?? null;
}
function readPlan(data: any): PlanShape {
const raw = (data?.plan ?? {}) as Partial<PlanShape>;
return {
vision: data?.productVision ?? raw.vision,
ideas: Array.isArray(raw.ideas) ? raw.ideas : [],
tasks: Array.isArray(raw.tasks) ? raw.tasks : [],
decisions: Array.isArray(raw.decisions) ? raw.decisions : [],
};
}
async function writePlan(projectId: string, plan: PlanShape, alsoVision?: string) {
// Use a single jsonb_set call so we don't race with other writers.
// When the vision changes we also mirror it to productVision since
// it's the canonical field elsewhere in the app.
if (alsoVision !== undefined) {
await query(
`UPDATE fs_projects
SET data = jsonb_set(
jsonb_set(data, '{plan}', $2::jsonb, true),
'{productVision}', to_jsonb($3::text), true
)
WHERE id = $1`,
[projectId, JSON.stringify({ ...plan, vision: undefined }), alsoVision],
);
} else {
await query(
`UPDATE fs_projects
SET data = jsonb_set(data, '{plan}', $2::jsonb, true)
WHERE id = $1`,
[projectId, JSON.stringify({ ...plan, vision: undefined })],
);
}
}
export async function GET(
_req: Request,
ctx: { params: Promise<{ projectId: string }> },
) {
const { projectId } = await ctx.params;
const session = await authSession();
if (!session?.user?.email) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const project = await loadOwnedProject(projectId, session.user.email);
if (!project) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json({ plan: readPlan(project.data) });
}
export async function POST(
req: Request,
ctx: { params: Promise<{ projectId: string }> },
) {
const { projectId } = await ctx.params;
const session = await authSession();
if (!session?.user?.email) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const project = await loadOwnedProject(projectId, session.user.email);
if (!project) return NextResponse.json({ error: "Not found" }, { status: 404 });
const body = await req.json().catch(() => ({}));
const kind = String(body.kind ?? "");
const plan = readPlan(project.data);
const now = new Date().toISOString();
if (kind === "vision") {
const text = String(body.text ?? "").trim();
plan.vision = text;
await writePlan(projectId, plan, text);
return NextResponse.json({ plan });
}
if (kind === "idea") {
const text = String(body.text ?? "").trim();
if (!text) return NextResponse.json({ error: "text required" }, { status: 400 });
plan.ideas.unshift({ id: newId(), text, createdAt: now });
await writePlan(projectId, plan);
return NextResponse.json({ plan });
}
if (kind === "task") {
const text = String(body.text ?? "").trim();
if (!text) return NextResponse.json({ error: "text required" }, { status: 400 });
plan.tasks.unshift({ id: newId(), text, status: "open", createdAt: now });
await writePlan(projectId, plan);
return NextResponse.json({ plan });
}
if (kind === "decision") {
const title = String(body.title ?? "").trim();
const choice = String(body.choice ?? "").trim();
const why = body.why ? String(body.why).trim() : undefined;
if (!title || !choice) return NextResponse.json({ error: "title and choice required" }, { status: 400 });
plan.decisions.unshift({ id: newId(), title, choice, why, createdAt: now });
await writePlan(projectId, plan);
return NextResponse.json({ plan });
}
return NextResponse.json({ error: "unknown kind" }, { status: 400 });
}
export async function PATCH(
req: Request,
ctx: { params: Promise<{ projectId: string }> },
) {
const { projectId } = await ctx.params;
const session = await authSession();
if (!session?.user?.email) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const project = await loadOwnedProject(projectId, session.user.email);
if (!project) return NextResponse.json({ error: "Not found" }, { status: 404 });
const body = await req.json().catch(() => ({}));
const { kind, id } = body;
const plan = readPlan(project.data);
if (kind === "task" && id) {
const t = plan.tasks.find((x) => x.id === id);
if (!t) return NextResponse.json({ error: "task not found" }, { status: 404 });
if (typeof body.text === "string") t.text = body.text.trim();
if (body.status === "open" || body.status === "done") {
t.status = body.status;
t.doneAt = body.status === "done" ? new Date().toISOString() : undefined;
}
await writePlan(projectId, plan);
return NextResponse.json({ plan });
}
if (kind === "idea" && id) {
const i = plan.ideas.find((x) => x.id === id);
if (!i) return NextResponse.json({ error: "idea not found" }, { status: 404 });
if (typeof body.text === "string") i.text = body.text.trim();
await writePlan(projectId, plan);
return NextResponse.json({ plan });
}
if (kind === "decision" && id) {
const d = plan.decisions.find((x) => x.id === id);
if (!d) return NextResponse.json({ error: "decision not found" }, { status: 404 });
if (typeof body.title === "string") d.title = body.title.trim();
if (typeof body.choice === "string") d.choice = body.choice.trim();
if (typeof body.why === "string") d.why = body.why.trim();
await writePlan(projectId, plan);
return NextResponse.json({ plan });
}
return NextResponse.json({ error: "unsupported patch" }, { status: 400 });
}
export async function DELETE(
req: Request,
ctx: { params: Promise<{ projectId: string }> },
) {
const { projectId } = await ctx.params;
const session = await authSession();
if (!session?.user?.email) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const project = await loadOwnedProject(projectId, session.user.email);
if (!project) return NextResponse.json({ error: "Not found" }, { status: 404 });
const { searchParams } = new URL(req.url);
const kind = searchParams.get("kind") || "";
const id = searchParams.get("id") || "";
const plan = readPlan(project.data);
if (kind === "task") plan.tasks = plan.tasks.filter((x) => x.id !== id);
if (kind === "idea") plan.ideas = plan.ideas.filter((x) => x.id !== id);
if (kind === "decision") plan.decisions = plan.decisions.filter((x) => x.id !== id);
await writePlan(projectId, plan);
return NextResponse.json({ plan });
}

View File

@@ -10,12 +10,13 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Box, Cloud, Server } from "lucide-react";
import { Box, Cloud, Server, NotebookPen } from "lucide-react";
const TABS = [
{ id: "product", label: "Product", icon: Box, blurb: "Custom code, design, and content built for this vision." },
{ id: "infrastructure", label: "Infrastructure", icon: Server, blurb: "Swappable services this product depends on." },
{ id: "hosting", label: "Hosting", icon: Cloud, blurb: "Where it runs and how people reach it." },
{ id: "plan", label: "Plan", icon: NotebookPen, blurb: "Vision, ideas, tasks, and decisions for this project." },
{ id: "product", label: "Product", icon: Box, blurb: "Custom code, design, and content built for this vision." },
{ id: "infrastructure", label: "Infrastructure", icon: Server, blurb: "Swappable services this product depends on." },
{ id: "hosting", label: "Hosting", icon: Cloud, blurb: "Where it runs and how people reach it." },
] as const;
export function ProjectTabBar({
@@ -28,7 +29,7 @@ export function ProjectTabBar({
const pathname = usePathname() ?? "";
const activeTab =
TABS.find(t => pathname.includes(`/project/${projectId}/${t.id}`))?.id ??
"product";
"plan";
return (
<nav style={tabBar} aria-label="Project sections">