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:
@@ -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`);
|
||||
}
|
||||
|
||||
645
app/[workspace]/project/[projectId]/(home)/plan/page.tsx
Normal file
645
app/[workspace]/project/[projectId]/(home)/plan/page.tsx
Normal 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,
|
||||
};
|
||||
@@ -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`
|
||||
: '';
|
||||
|
||||
|
||||
220
app/api/projects/[projectId]/plan/route.ts
Normal file
220
app/api/projects/[projectId]/plan/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user