feat(roadmap): planning page with bands, timeline and stats

This commit is contained in:
Dave 2026-09-17 07:19:00 +02:00
parent ecf5f0c173
commit 9a5f6ca3f7
5 changed files with 175 additions and 2 deletions

43
src/app/plan/page.tsx Normal file
View file

@ -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 (
<AppShell role={session.role} currentPath="/plan">
<TopBar
title="Planning"
subtitle={
session.role === "owner"
? "Everything in flight, across every client"
: "What is underway and what is queued"
}
/>
<div className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-6xl space-y-6 p-6">
<RoadmapStats cards={banded} doneCount={doneCount} />
<RoadmapTabs
cards={cards}
inboxCount={inboxCount}
canReschedule={session.role === "owner"}
/>
</div>
</div>
</AppShell>
);
}

View file

@ -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 (
<div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-5">
{tiles.map((tile) => (
<div
key={tile.label}
className="rounded-[var(--radius-tile)] border border-border-subtle bg-surface p-4"
>
<p
className={`tabular text-2xl font-semibold tracking-tight ${
tile.accent ? "text-accent" : "text-text-primary"
}`}
>
{tile.value}
</p>
<p className="mt-1 text-xs text-text-muted">{tile.label}</p>
</div>
))}
</div>
);
}

View file

@ -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<string | null>(null);
// Bands exclude Inbox and Done; the timeline shows everything schedulable.
const banded = cards.filter((card) => card.band !== null);
return (
<div className="space-y-5">
<div className="inline-flex rounded-lg border border-border-subtle bg-surface p-0.5">
{(["bands", "timeline"] as const).map((key) => (
<button
key={key}
type="button"
aria-pressed={tab === key}
onClick={() => setTab(key)}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
tab === key
? "bg-panel text-text-primary shadow-[var(--shadow-tile)]"
: "text-text-muted hover:text-text-secondary"
}`}
>
{key === "bands" ? "Now / Next / Later" : "Timeline"}
</button>
))}
</div>
{error && (
<p role="alert" className="text-xs text-accent">
{error}
</p>
)}
{tab === "bands" ? (
<BandView cards={banded} inboxCount={inboxCount} />
) : (
<TimelineView
cards={banded}
canReschedule={canReschedule}
onReschedule={(cardId, date) =>
startTransition(async () => {
setError(null);
const result = await rescheduleCardAction({ cardId, date });
if (!result.ok) setError(result.message ?? "Could not move that card");
else router.refresh();
})
}
/>
)}
</div>
);
}

View file

@ -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 },

View file

@ -25,5 +25,5 @@ export function middleware(request: NextRequest) {
}
export const config = {
matcher: ["/", "/p/:path*", "/admin/:path*"],
matcher: ["/", "/p/:path*", "/plan", "/admin/:path*"],
};