Add Stackless right panel to project shell

Shows Discovery phase tracker (Big Picture → Risks), Captured data
from Atlas, and Project Info (created, last active, features).
Data flows from DB via layout server component.

Made-with: Cursor
This commit is contained in:
2026-03-02 16:11:58 -08:00
parent aaa3f51592
commit 94bb9dbeb4
2 changed files with 169 additions and 32 deletions

View File

@@ -5,20 +5,30 @@ interface ProjectData {
name: string; name: string;
status?: string; status?: string;
progress?: number; progress?: number;
discoveryPhase?: number;
capturedData?: Record<string, string>;
createdAt?: string;
updatedAt?: string;
featureCount?: number;
} }
async function getProjectData(projectId: string): Promise<ProjectData> { async function getProjectData(projectId: string): Promise<ProjectData> {
try { try {
const rows = await query<{ data: any }>( const rows = await query<{ data: any; created_at?: string; updated_at?: string }>(
`SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`, `SELECT data, created_at, updated_at FROM fs_projects WHERE id = $1 LIMIT 1`,
[projectId] [projectId]
); );
if (rows.length > 0) { if (rows.length > 0) {
const data = rows[0].data; const { data, created_at, updated_at } = rows[0];
return { return {
name: data?.productName || data?.name || "Project", name: data?.productName || data?.name || "Project",
status: data?.status, status: data?.status,
progress: data?.progress ?? 0, progress: data?.progress ?? 0,
discoveryPhase: data?.discoveryPhase ?? 0,
capturedData: data?.capturedData ?? {},
createdAt: created_at,
updatedAt: updated_at,
featureCount: Array.isArray(data?.features) ? data.features.length : (data?.featureCount ?? 0),
}; };
} }
} catch (error) { } catch (error) {
@@ -44,6 +54,11 @@ export default async function ProjectLayout({
projectName={project.name} projectName={project.name}
projectStatus={project.status} projectStatus={project.status}
projectProgress={project.progress} projectProgress={project.progress}
discoveryPhase={project.discoveryPhase}
capturedData={project.capturedData}
createdAt={project.createdAt}
updatedAt={project.updatedAt}
featureCount={project.featureCount}
> >
{children} {children}
</ProjectShell> </ProjectShell>

View File

@@ -13,27 +13,60 @@ interface ProjectShellProps {
projectName: string; projectName: string;
projectStatus?: string; projectStatus?: string;
projectProgress?: number; projectProgress?: number;
discoveryPhase?: number;
capturedData?: Record<string, string>;
createdAt?: string;
updatedAt?: string;
featureCount?: number;
} }
const TABS = [ const TABS = [
{ id: "overview", label: "Atlas", path: "overview" }, { id: "overview", label: "Atlas", path: "overview" },
{ id: "prd", label: "PRD", path: "prd" }, { id: "prd", label: "PRD", path: "prd" },
{ id: "design", label: "Design", path: "design" }, { id: "design", label: "Design", path: "design" },
{ id: "build", label: "Build", path: "build" }, { id: "build", label: "Build", path: "build" },
{ id: "deployment", label: "Deploy", path: "deployment" }, { id: "deployment", label: "Deploy", path: "deployment" },
{ id: "settings", label: "Settings", path: "settings" }, { id: "settings", label: "Settings", path: "settings" },
]; ];
const DISCOVERY_PHASES = [
"Big Picture",
"Users & Personas",
"Features",
"Business Model",
"Screens",
"Risks",
];
function timeAgo(dateStr?: string): string {
if (!dateStr) return "—";
const date = new Date(dateStr);
if (isNaN(date.getTime())) return "—";
const diff = (Date.now() - date.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`;
const days = Math.floor(diff / 86400);
if (days === 1) return "Yesterday";
if (days < 7) return `${days}d ago`;
return new Date(dateStr).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}
function SectionLabel({ children }: { children: ReactNode }) {
return (
<div style={{
fontSize: "0.6rem", fontWeight: 600, color: "#a09a90",
letterSpacing: "0.1em", textTransform: "uppercase", marginBottom: 12,
}}>
{children}
</div>
);
}
function StatusTag({ status }: { status?: string }) { function StatusTag({ status }: { status?: string }) {
const label = status === "live" ? "Live" const label = status === "live" ? "Live" : status === "building" ? "Building" : "Defining";
: status === "building" ? "Building" const color = status === "live" ? "#2e7d32" : status === "building" ? "#3d5afe" : "#9a7b3a";
: "Defining"; const bg = status === "live" ? "#2e7d3210" : status === "building" ? "#3d5afe10" : "#d4a04a12";
const color = status === "live" ? "#2e7d32"
: status === "building" ? "#3d5afe"
: "#9a7b3a";
const bg = status === "live" ? "#2e7d3210"
: status === "building" ? "#3d5afe10"
: "#d4a04a12";
return ( return (
<span style={{ <span style={{
display: "inline-flex", alignItems: "center", gap: 5, display: "inline-flex", alignItems: "center", gap: 5,
@@ -53,22 +86,27 @@ export function ProjectShell({
projectName, projectName,
projectStatus, projectStatus,
projectProgress, projectProgress,
discoveryPhase = 0,
capturedData = {},
createdAt,
updatedAt,
featureCount = 0,
}: ProjectShellProps) { }: ProjectShellProps) {
const pathname = usePathname(); const pathname = usePathname();
// Determine which tab is active
const activeTab = TABS.find((t) => pathname?.includes(`/${t.path}`))?.id ?? "overview"; const activeTab = TABS.find((t) => pathname?.includes(`/${t.path}`))?.id ?? "overview";
const progress = projectProgress ?? 0; const progress = projectProgress ?? 0;
const capturedEntries = Object.entries(capturedData);
return ( return (
<> <>
<div style={{ display: "flex", height: "100vh", background: "#f6f4f0", overflow: "hidden" }}> <div style={{ display: "flex", height: "100vh", background: "#f6f4f0", overflow: "hidden" }}>
{/* Sidebar */} {/* Left sidebar */}
<VIBNSidebar workspace={workspace} /> <VIBNSidebar workspace={workspace} />
{/* Main content */} {/* Main column */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}> <div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
{/* Project header */} {/* Project header */}
<div style={{ <div style={{
padding: "18px 32px", padding: "18px 32px",
@@ -85,10 +123,7 @@ export function ProjectShell({
background: "#1a1a1a12", background: "#1a1a1a12",
display: "flex", alignItems: "center", justifyContent: "center", display: "flex", alignItems: "center", justifyContent: "center",
}}> }}>
<span style={{ <span style={{ fontFamily: "Newsreader, serif", fontSize: "1rem", fontWeight: 500, color: "#1a1a1a" }}>
fontFamily: "Newsreader, serif",
fontSize: "1rem", fontWeight: 500, color: "#1a1a1a",
}}>
{projectName[0]?.toUpperCase() ?? "P"} {projectName[0]?.toUpperCase() ?? "P"}
</span> </span>
</div> </div>
@@ -96,8 +131,7 @@ export function ProjectShell({
<div style={{ display: "flex", alignItems: "center", gap: 8 }}> <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<h2 style={{ <h2 style={{
fontSize: "1.05rem", fontWeight: 600, color: "#1a1a1a", fontSize: "1.05rem", fontWeight: 600, color: "#1a1a1a",
letterSpacing: "-0.02em", fontFamily: "Outfit, sans-serif", letterSpacing: "-0.02em", fontFamily: "Outfit, sans-serif", margin: 0,
margin: 0,
}}> }}>
{projectName} {projectName}
</h2> </h2>
@@ -120,7 +154,6 @@ export function ProjectShell({
padding: "0 32px", padding: "0 32px",
borderBottom: "1px solid #e8e4dc", borderBottom: "1px solid #e8e4dc",
display: "flex", display: "flex",
gap: 0,
background: "#fff", background: "#fff",
flexShrink: 0, flexShrink: 0,
}}> }}>
@@ -130,8 +163,6 @@ export function ProjectShell({
href={`/${workspace}/project/${projectId}/${t.path}`} href={`/${workspace}/project/${projectId}/${t.path}`}
style={{ style={{
padding: "12px 18px", padding: "12px 18px",
border: "none",
background: "none",
fontSize: "0.8rem", fontSize: "0.8rem",
fontWeight: 500, fontWeight: 500,
color: activeTab === t.id ? "#1a1a1a" : "#a09a90", color: activeTab === t.id ? "#1a1a1a" : "#a09a90",
@@ -153,6 +184,97 @@ export function ProjectShell({
{children} {children}
</div> </div>
</div> </div>
{/* Right panel */}
<div style={{
width: 230,
borderLeft: "1px solid #e8e4dc",
background: "#fff",
padding: "22px 18px",
overflow: "auto",
flexShrink: 0,
fontFamily: "Outfit, sans-serif",
}}>
{/* Discovery phases */}
<SectionLabel>Discovery</SectionLabel>
{DISCOVERY_PHASES.map((phase, i) => {
const isDone = i < discoveryPhase;
const isActive = i === discoveryPhase;
return (
<div
key={i}
style={{
display: "flex", alignItems: "center", gap: 10,
padding: "9px 0",
borderBottom: i < DISCOVERY_PHASES.length - 1 ? "1px solid #f0ece4" : "none",
}}
>
<div style={{
width: 20, height: 20, borderRadius: 5, flexShrink: 0,
background: isDone ? "#2e7d3210" : isActive ? "#d4a04a12" : "#f6f4f0",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "0.58rem", fontWeight: 700,
color: isDone ? "#2e7d32" : isActive ? "#9a7b3a" : "#c5c0b8",
}}>
{isDone ? "✓" : isActive ? "→" : i + 1}
</div>
<span style={{
fontSize: "0.78rem",
fontWeight: isActive ? 600 : 400,
color: isDone ? "#6b6560" : isActive ? "#1a1a1a" : "#b5b0a6",
}}>
{phase}
</span>
</div>
);
})}
<div style={{ height: 1, background: "#f0ece4", margin: "16px 0" }} />
{/* Captured data */}
<SectionLabel>Captured</SectionLabel>
{capturedEntries.length > 0 ? (
capturedEntries.map(([k, v], i) => (
<div key={i} style={{ marginBottom: 14 }}>
<div style={{
fontSize: "0.62rem", color: "#b5b0a6",
textTransform: "uppercase", letterSpacing: "0.05em",
marginBottom: 3, fontWeight: 600,
}}>
{k}
</div>
<div style={{ fontSize: "0.8rem", color: "#4a4640", lineHeight: 1.45 }}>
{v}
</div>
</div>
))
) : (
<p style={{ fontSize: "0.78rem", color: "#c5c0b8", lineHeight: 1.5, margin: 0 }}>
Atlas will capture key details here as you chat.
</p>
)}
<div style={{ height: 1, background: "#f0ece4", margin: "16px 0" }} />
{/* Project info */}
<SectionLabel>Project Info</SectionLabel>
{[
{ k: "Created", v: timeAgo(createdAt) },
{ k: "Last active", v: timeAgo(updatedAt) },
{ k: "Features", v: featureCount > 0 ? `${featureCount} defined` : "None yet" },
].map((item, i) => (
<div key={i} style={{ marginBottom: 12 }}>
<div style={{
fontSize: "0.62rem", color: "#b5b0a6",
textTransform: "uppercase", letterSpacing: "0.05em",
marginBottom: 3, fontWeight: 600,
}}>
{item.k}
</div>
<div style={{ fontSize: "0.8rem", color: "#4a4640" }}>{item.v}</div>
</div>
))}
</div>
</div> </div>
<Toaster position="top-center" /> <Toaster position="top-center" />
</> </>