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