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

View File

@@ -13,6 +13,11 @@ interface ProjectShellProps {
projectName: string;
projectStatus?: string;
projectProgress?: number;
discoveryPhase?: number;
capturedData?: Record<string, string>;
createdAt?: string;
updatedAt?: string;
featureCount?: number;
}
const TABS = [
@@ -24,16 +29,44 @@ const TABS = [
{ 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 }) {
const label = status === "live" ? "Live"
: status === "building" ? "Building"
: "Defining";
const color = status === "live" ? "#2e7d32"
: status === "building" ? "#3d5afe"
: "#9a7b3a";
const bg = status === "live" ? "#2e7d3210"
: status === "building" ? "#3d5afe10"
: "#d4a04a12";
const label = status === "live" ? "Live" : status === "building" ? "Building" : "Defining";
const color = status === "live" ? "#2e7d32" : status === "building" ? "#3d5afe" : "#9a7b3a";
const bg = status === "live" ? "#2e7d3210" : status === "building" ? "#3d5afe10" : "#d4a04a12";
return (
<span style={{
display: "inline-flex", alignItems: "center", gap: 5,
@@ -53,22 +86,27 @@ export function ProjectShell({
projectName,
projectStatus,
projectProgress,
discoveryPhase = 0,
capturedData = {},
createdAt,
updatedAt,
featureCount = 0,
}: ProjectShellProps) {
const pathname = usePathname();
// Determine which tab is active
const activeTab = TABS.find((t) => pathname?.includes(`/${t.path}`))?.id ?? "overview";
const progress = projectProgress ?? 0;
const capturedEntries = Object.entries(capturedData);
return (
<>
<div style={{ display: "flex", height: "100vh", background: "#f6f4f0", overflow: "hidden" }}>
{/* Sidebar */}
{/* Left sidebar */}
<VIBNSidebar workspace={workspace} />
{/* Main content */}
{/* Main column */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
{/* Project header */}
<div style={{
padding: "18px 32px",
@@ -85,10 +123,7 @@ export function ProjectShell({
background: "#1a1a1a12",
display: "flex", alignItems: "center", justifyContent: "center",
}}>
<span style={{
fontFamily: "Newsreader, serif",
fontSize: "1rem", fontWeight: 500, color: "#1a1a1a",
}}>
<span style={{ fontFamily: "Newsreader, serif", fontSize: "1rem", fontWeight: 500, color: "#1a1a1a" }}>
{projectName[0]?.toUpperCase() ?? "P"}
</span>
</div>
@@ -96,8 +131,7 @@ export function ProjectShell({
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<h2 style={{
fontSize: "1.05rem", fontWeight: 600, color: "#1a1a1a",
letterSpacing: "-0.02em", fontFamily: "Outfit, sans-serif",
margin: 0,
letterSpacing: "-0.02em", fontFamily: "Outfit, sans-serif", margin: 0,
}}>
{projectName}
</h2>
@@ -120,7 +154,6 @@ export function ProjectShell({
padding: "0 32px",
borderBottom: "1px solid #e8e4dc",
display: "flex",
gap: 0,
background: "#fff",
flexShrink: 0,
}}>
@@ -130,8 +163,6 @@ export function ProjectShell({
href={`/${workspace}/project/${projectId}/${t.path}`}
style={{
padding: "12px 18px",
border: "none",
background: "none",
fontSize: "0.8rem",
fontWeight: 500,
color: activeTab === t.id ? "#1a1a1a" : "#a09a90",
@@ -153,6 +184,97 @@ export function ProjectShell({
{children}
</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>
<Toaster position="top-center" />
</>