feat(roadmap): planning page with bands, timeline and stats
This commit is contained in:
parent
ecf5f0c173
commit
9a5f6ca3f7
5 changed files with 175 additions and 2 deletions
43
src/app/plan/page.tsx
Normal file
43
src/app/plan/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
src/components/roadmap/roadmap-stats.tsx
Normal file
57
src/components/roadmap/roadmap-stats.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
src/components/roadmap/roadmap-tabs.tsx
Normal file
72
src/components/roadmap/roadmap-tabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -25,5 +25,5 @@ export function middleware(request: NextRequest) {
|
|||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/", "/p/:path*", "/admin/:path*"],
|
||||
matcher: ["/", "/p/:path*", "/plan", "/admin/:path*"],
|
||||
};
|
||||
Loading…
Reference in a new issue