diff --git a/src/app/plan/page.tsx b/src/app/plan/page.tsx
new file mode 100644
index 0000000..c70118f
--- /dev/null
+++ b/src/app/plan/page.tsx
@@ -0,0 +1,43 @@
+import { redirect } from "next/navigation";
+
+import { AppShell } from "@/components/shell/app-shell";
+import { TopBar } from "@/components/shell/top-bar";
+import { RoadmapTabs } from "@/components/roadmap/roadmap-tabs";
+import { RoadmapStats } from "@/components/roadmap/roadmap-stats";
+import { getSession } from "@/lib/auth/session";
+import { loadRoadmap } from "@/lib/roadmap/load";
+
+export default async function PlanPage() {
+ const session = await getSession();
+ if (!session) redirect("/login");
+
+ const { cards, inboxCount, doneCount } = await loadRoadmap();
+
+ // Stats use the same banded set as the tabs so the counts agree with what
+ // is rendered. Done and Inbox cards are excluded by definition.
+ const banded = cards.filter((c) => c.band !== null);
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/components/roadmap/roadmap-stats.tsx b/src/components/roadmap/roadmap-stats.tsx
new file mode 100644
index 0000000..7fe9850
--- /dev/null
+++ b/src/components/roadmap/roadmap-stats.tsx
@@ -0,0 +1,57 @@
+import { classifyDueDate } from "@/lib/board/dates";
+import type { RoadmapCard as Card } from "@/lib/roadmap/load";
+
+/**
+ * The bottom strip from the Gantt reference, with one change.
+ *
+ * Its fifth tile reads "On Track 54%". "On track" is a judgement about work
+ * nobody has assessed. The honest equivalent is the share of DATED cards that
+ * are not overdue, labelled as exactly that.
+ */
+export function RoadmapStats({
+ cards,
+ doneCount,
+}: {
+ cards: Card[];
+ doneCount: number;
+}) {
+ const dated = cards.filter((c) => c.targetDate);
+ const overdue = dated.filter((c) => classifyDueDate(c.targetDate) === "overdue");
+ const now = cards.filter((c) => c.band === "now");
+
+ const notOverdue =
+ dated.length > 0
+ ? Math.round(((dated.length - overdue.length) / dated.length) * 100)
+ : null;
+
+ const tiles = [
+ { label: "Cards", value: String(cards.length) },
+ { label: "Done", value: String(doneCount) },
+ { label: "In flight", value: String(now.length) },
+ { label: "Overdue", value: String(overdue.length), accent: overdue.length > 0 },
+ {
+ label: "Dated cards not overdue",
+ value: notOverdue === null ? "—" : `${notOverdue}%`,
+ },
+ ];
+
+ return (
+
+ {tiles.map((tile) => (
+
+
+ {tile.value}
+
+
{tile.label}
+
+ ))}
+
+ );
+}
diff --git a/src/components/roadmap/roadmap-tabs.tsx b/src/components/roadmap/roadmap-tabs.tsx
new file mode 100644
index 0000000..db0962b
--- /dev/null
+++ b/src/components/roadmap/roadmap-tabs.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+
+import { BandView } from "@/components/roadmap/band-view";
+import { TimelineView } from "@/components/roadmap/timeline-view";
+import { rescheduleCardAction } from "@/app/actions/reschedule";
+import type { RoadmapCard as Card } from "@/lib/roadmap/load";
+
+export function RoadmapTabs({
+ cards,
+ inboxCount,
+ canReschedule,
+}: {
+ cards: Card[];
+ inboxCount: number;
+ canReschedule: boolean;
+}) {
+ const router = useRouter();
+ const [tab, setTab] = useState<"bands" | "timeline">("bands");
+ const [, startTransition] = useTransition();
+ const [error, setError] = useState(null);
+
+ // Bands exclude Inbox and Done; the timeline shows everything schedulable.
+ const banded = cards.filter((card) => card.band !== null);
+
+ return (
+
+
+ {(["bands", "timeline"] as const).map((key) => (
+
+ ))}
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {tab === "bands" ? (
+
+ ) : (
+
+ startTransition(async () => {
+ setError(null);
+ const result = await rescheduleCardAction({ cardId, date });
+ if (!result.ok) setError(result.message ?? "Could not move that card");
+ else router.refresh();
+ })
+ }
+ />
+ )}
+
+ );
+}
diff --git a/src/components/shell/sidebar.tsx b/src/components/shell/sidebar.tsx
index 1de5c90..410bf8e 100644
--- a/src/components/shell/sidebar.tsx
+++ b/src/components/shell/sidebar.tsx
@@ -1,5 +1,5 @@
import Link from "next/link";
-import { LayoutGrid, FolderKanban, Settings, Users } from "lucide-react";
+import { LayoutGrid, FolderKanban, Settings, Users, CalendarRange } from "lucide-react";
import { cn } from "@/lib/cn";
import type { Role } from "@/lib/pb/types";
@@ -13,6 +13,7 @@ interface NavItem {
const NAV: NavItem[] = [
{ href: "/", label: "Projects", icon: LayoutGrid },
+ { href: "/plan", label: "Plan", icon: CalendarRange },
{ href: "/admin/clients", label: "Clients", icon: Users, ownerOnly: true },
{ href: "/admin/projects", label: "Manage", icon: FolderKanban, ownerOnly: true },
{ href: "/admin/settings", label: "Settings", icon: Settings, ownerOnly: true },
diff --git a/src/middleware.ts b/src/middleware.ts
index 538d307..153cb28 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -25,5 +25,5 @@ export function middleware(request: NextRequest) {
}
export const config = {
- matcher: ["/", "/p/:path*", "/admin/:path*"],
+ matcher: ["/", "/p/:path*", "/plan", "/admin/:path*"],
};
\ No newline at end of file