diff --git a/src/app/capacity-planner/_components/demand-band.tsx b/src/app/capacity-planner/_components/demand-band.tsx new file mode 100644 index 0000000..733e877 --- /dev/null +++ b/src/app/capacity-planner/_components/demand-band.tsx @@ -0,0 +1,61 @@ +import type { DemandBand } from "../_data/capacity-planner-data"; +import styles from "../capacity-planner.module.css"; + +const levelLabels: Record = { + low: "Low", + moderate: "Moderate", + high: "High", + peak: "Peak", +}; + +const levelBadgeStyles: Record = { + low: "border-cyan-300/20 bg-cyan-300/10 text-cyan-50", + moderate: "border-emerald-300/20 bg-emerald-300/10 text-emerald-50", + high: "border-amber-300/20 bg-amber-300/10 text-amber-50", + peak: "border-rose-300/20 bg-rose-300/10 text-rose-50", +}; + +const levelSurfaceStyles: Record = { + low: styles.bandLow, + moderate: styles.bandModerate, + high: styles.bandHigh, + peak: styles.bandPeak, +}; + +type DemandBandCardProps = { + band: DemandBand; +}; + +export function DemandBandCard({ band }: DemandBandCardProps) { + return ( +
+
+
+

+ {band.label} +

+

{band.peakWindow}

+
+ + {levelLabels[band.level]} + +
+ +
+

+ Forecasted load +

+

+ {band.forecastedLoad} +

+
+ +

{band.detail}

+
+ ); +} diff --git a/src/app/capacity-planner/_components/planning-notes.tsx b/src/app/capacity-planner/_components/planning-notes.tsx new file mode 100644 index 0000000..db06edb --- /dev/null +++ b/src/app/capacity-planner/_components/planning-notes.tsx @@ -0,0 +1,60 @@ +import type { PlanningNote } from "../_data/capacity-planner-data"; +import styles from "../capacity-planner.module.css"; + +const priorityLabels: Record = { + info: "Info", + action: "Action", + warning: "Warning", +}; + +const priorityBadgeStyles: Record = { + info: "border-cyan-300/20 bg-cyan-300/10 text-cyan-50", + action: "border-emerald-300/20 bg-emerald-300/10 text-emerald-50", + warning: "border-amber-300/20 bg-amber-300/10 text-amber-50", +}; + +type PlanningNotesProps = { + notes: PlanningNote[]; +}; + +export function PlanningNotes({ notes }: PlanningNotesProps) { + return ( +
+
+

+ Review notes +

+

+ Planning notes from the team +

+

+ Notes, action items, and warnings captured during the current planning + cycle. Each note is tied to a team member and timestamped. +

+
+ +
+ {notes.map((note) => ( +
+
+
+

{note.author}

+

{note.timestamp}

+
+ + {priorityLabels[note.priority]} + +
+

{note.body}

+
+ ))} +
+
+ ); +} diff --git a/src/app/capacity-planner/_components/utilization-card.tsx b/src/app/capacity-planner/_components/utilization-card.tsx new file mode 100644 index 0000000..a739faf --- /dev/null +++ b/src/app/capacity-planner/_components/utilization-card.tsx @@ -0,0 +1,68 @@ +import type { UtilizationCard } from "../_data/capacity-planner-data"; +import styles from "../capacity-planner.module.css"; + +const statusLabels: Record = { + healthy: "Healthy", + elevated: "Elevated", + critical: "Critical", +}; + +const statusBadgeStyles: Record = { + healthy: "border-emerald-300/20 bg-emerald-300/10 text-emerald-50", + elevated: "border-amber-300/20 bg-amber-300/10 text-amber-50", + critical: "border-rose-300/20 bg-rose-300/10 text-rose-50", +}; + +const statusSurfaceStyles: Record = { + healthy: styles.utilHealthy, + elevated: styles.utilElevated, + critical: styles.utilCritical, +}; + +type UtilizationCardItemProps = { + card: UtilizationCard; +}; + +export function UtilizationCardItem({ card }: UtilizationCardItemProps) { + return ( +
+
+
+

+ {card.resource} +

+

{card.owner}

+
+ + {statusLabels[card.status]} + +
+ +
+
+

+ Utilization +

+

+ {card.currentUtilization} +

+
+
+

+ Capacity +

+

+ {card.capacity} +

+
+
+ +

{card.note}

+
+ ); +} diff --git a/src/app/capacity-planner/_data/capacity-planner-data.ts b/src/app/capacity-planner/_data/capacity-planner-data.ts new file mode 100644 index 0000000..f5b5570 --- /dev/null +++ b/src/app/capacity-planner/_data/capacity-planner-data.ts @@ -0,0 +1,188 @@ +export type DemandLevel = "low" | "moderate" | "high" | "peak"; +export type UtilizationStatus = "healthy" | "elevated" | "critical"; + +export interface DemandBand { + id: string; + label: string; + level: DemandLevel; + forecastedLoad: string; + peakWindow: string; + detail: string; +} + +export interface UtilizationCard { + id: string; + resource: string; + currentUtilization: string; + capacity: string; + status: UtilizationStatus; + owner: string; + note: string; +} + +export interface PlanningNote { + id: string; + author: string; + timestamp: string; + body: string; + priority: "info" | "action" | "warning"; +} + +export interface CapacityPlannerOverview { + eyebrow: string; + title: string; + description: string; + planCycle: string; + region: string; +} + +export interface CapacityStat { + label: string; + value: string; + detail: string; +} + +export const capacityPlannerOverview: CapacityPlannerOverview = { + eyebrow: "Capacity Planner", + title: "Forecast demand, track utilization, and align capacity before the next planning window.", + description: + "A consolidated view of demand bands, resource utilization, and planning notes that helps teams make informed capacity decisions ahead of load spikes.", + planCycle: "Q2 2026 — Sprint 14", + region: "US-West / Portland cluster", +}; + +export const capacityStats: CapacityStat[] = [ + { + label: "Active demand bands", + value: "4", + detail: "Covering overnight batch, morning API surge, midday steady-state, and evening wind-down", + }, + { + label: "Resources tracked", + value: "5", + detail: "Compute nodes, message queues, worker pools, storage I/O, and network egress", + }, + { + label: "Open planning notes", + value: "4", + detail: "Two action items, one warning, and one informational note from the last review cycle", + }, +]; + +export const demandBands: DemandBand[] = [ + { + id: "band-overnight", + label: "Overnight batch window", + level: "low", + forecastedLoad: "12%", + peakWindow: "01:00 — 05:00 PDT", + detail: + "Scheduled ETL jobs and index rebuilds run during this window. Load stays predictable unless a backfill is queued.", + }, + { + id: "band-morning", + label: "Morning API surge", + level: "high", + forecastedLoad: "78%", + peakWindow: "07:30 — 10:00 PDT", + detail: + "User-facing API traffic ramps sharply as west-coast teams come online. Auto-scaling usually absorbs this within 8 minutes.", + }, + { + id: "band-midday", + label: "Midday steady-state", + level: "moderate", + forecastedLoad: "45%", + peakWindow: "10:00 — 16:00 PDT", + detail: + "Traffic plateaus after the morning surge. This is the safest window for deploying capacity changes or running load tests.", + }, + { + id: "band-evening", + label: "Evening wind-down", + level: "peak", + forecastedLoad: "88%", + peakWindow: "16:00 — 19:00 PDT", + detail: + "End-of-day report generation and cross-region sync jobs push utilization to its daily peak. Pre-scale at 15:30.", + }, +]; + +export const utilizationCards: UtilizationCard[] = [ + { + id: "util-compute", + resource: "Compute nodes", + currentUtilization: "62%", + capacity: "48 / 80 vCPUs", + status: "healthy", + owner: "Platform team", + note: "Auto-scaler headroom is sufficient for the next two demand bands.", + }, + { + id: "util-queue", + resource: "Message queues", + currentUtilization: "74%", + capacity: "18.5k / 25k msg/s", + status: "elevated", + owner: "Messaging infra", + note: "Consumer lag has been climbing since the last deploy. Monitor closely during the evening peak.", + }, + { + id: "util-workers", + resource: "Worker pool", + currentUtilization: "55%", + capacity: "110 / 200 workers", + status: "healthy", + owner: "Job scheduler", + note: "Batch jobs are draining on schedule. No contention expected until the overnight window.", + }, + { + id: "util-storage", + resource: "Storage I/O", + currentUtilization: "83%", + capacity: "4.1k / 5k IOPS", + status: "critical", + owner: "Data platform", + note: "Index rebuild backlog is saturating the provisioned IOPS. Consider deferring non-critical writes.", + }, + { + id: "util-network", + resource: "Network egress", + currentUtilization: "39%", + capacity: "3.9 / 10 Gbps", + status: "healthy", + owner: "Network ops", + note: "Cross-region replication is well within budget. No action needed this cycle.", + }, +]; + +export const planningNotes: PlanningNote[] = [ + { + id: "note-001", + author: "M. Chen", + timestamp: "2026-04-25 09:14 PDT", + body: "Storage IOPS are running hot after the index rebuild was moved to the midday window. Recommend reverting to overnight scheduling.", + priority: "warning", + }, + { + id: "note-002", + author: "J. Okafor", + timestamp: "2026-04-25 08:42 PDT", + body: "Pre-scale compute nodes to 72 vCPUs by 15:30 to absorb the evening peak without relying solely on the auto-scaler.", + priority: "action", + }, + { + id: "note-003", + author: "S. Petrov", + timestamp: "2026-04-24 17:30 PDT", + body: "Consumer lag on the order-events topic cleared after partition rebalance. Queue utilization should stabilize by tomorrow morning.", + priority: "info", + }, + { + id: "note-004", + author: "A. Reyes", + timestamp: "2026-04-24 14:55 PDT", + body: "Add a second worker pool tier for low-priority batch jobs so they don't compete with real-time processing during peak bands.", + priority: "action", + }, +]; diff --git a/src/app/capacity-planner/capacity-planner.module.css b/src/app/capacity-planner/capacity-planner.module.css new file mode 100644 index 0000000..7d8ae50 --- /dev/null +++ b/src/app/capacity-planner/capacity-planner.module.css @@ -0,0 +1,121 @@ +.shell { + position: relative; + min-height: 100vh; + overflow: hidden; + background: + radial-gradient(circle at 18% 14%, rgba(99, 102, 241, 0.14), transparent 22%), + radial-gradient(circle at 82% 18%, rgba(34, 211, 238, 0.16), transparent 24%), + radial-gradient(circle at 50% 96%, rgba(168, 85, 247, 0.16), transparent 28%), + linear-gradient(180deg, #08111f 0%, #0e1c34 44%, #030712 100%); +} + +.shell::before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: + linear-gradient(125deg, rgba(255, 255, 255, 0.03), transparent 45%), + repeating-linear-gradient( + 90deg, + rgba(148, 163, 184, 0.06) 0, + rgba(148, 163, 184, 0.06) 1px, + transparent 1px, + transparent 72px + ); + mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.95), transparent 96%); +} + +.heroPanel { + position: relative; + background: + linear-gradient(140deg, rgba(15, 23, 42, 0.82), rgba(15, 23, 42, 0.58)), + linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(15, 23, 42, 0.12)); + box-shadow: 0 38px 120px rgba(2, 6, 23, 0.42); +} + +.heroPanel::after { + content: ""; + position: absolute; + right: -5rem; + top: -4rem; + width: 18rem; + height: 18rem; + border-radius: 9999px; + background: radial-gradient(circle, rgba(99, 102, 241, 0.22), transparent 66%); + filter: blur(20px); + pointer-events: none; +} + +.statCard { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(15, 23, 42, 0.28)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); +} + +.bandCard { + position: relative; + overflow: hidden; + background: + linear-gradient(180deg, rgba(15, 23, 42, 0.82), rgba(6, 11, 24, 0.94)); + box-shadow: 0 26px 70px rgba(2, 6, 23, 0.26); +} + +.bandLow { + border-color: rgba(34, 211, 238, 0.22); +} + +.bandModerate { + border-color: rgba(52, 211, 153, 0.22); +} + +.bandHigh { + border-color: rgba(251, 191, 36, 0.24); +} + +.bandPeak { + border-color: rgba(251, 113, 133, 0.28); +} + +.utilCard { + position: relative; + overflow: hidden; + background: + linear-gradient(180deg, rgba(15, 23, 42, 0.82), rgba(6, 11, 24, 0.94)); + box-shadow: 0 26px 70px rgba(2, 6, 23, 0.26); +} + +.utilHealthy { + border-color: rgba(52, 211, 153, 0.22); +} + +.utilElevated { + border-color: rgba(251, 191, 36, 0.24); +} + +.utilCritical { + border-color: rgba(251, 113, 133, 0.28); +} + +.noteCard { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(15, 23, 42, 0.32)); +} + +.statusBadge { + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08); +} + +.floatingInfo { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(15, 23, 42, 0.26)); +} + +@media (max-width: 640px) { + .heroPanel, + .bandCard, + .utilCard, + .noteCard, + .statCard { + border-radius: 1.5rem; + } +} diff --git a/src/app/capacity-planner/page.test.tsx b/src/app/capacity-planner/page.test.tsx new file mode 100644 index 0000000..ff66fb3 --- /dev/null +++ b/src/app/capacity-planner/page.test.tsx @@ -0,0 +1,89 @@ +import { cleanup, render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + capacityPlannerOverview, + capacityStats, + demandBands, + planningNotes, + utilizationCards, +} from "./_data/capacity-planner-data"; +import CapacityPlannerPage from "./page"; + +afterEach(() => { + cleanup(); +}); + +describe("CapacityPlannerPage", () => { + it("renders the hero with primary heading, overview metadata, and back link", () => { + render(); + + expect( + screen.getByText(capacityPlannerOverview.title, { selector: "h1" }), + ).toBeInTheDocument(); + expect(screen.getByText(capacityPlannerOverview.planCycle)).toBeInTheDocument(); + expect(screen.getByText(capacityPlannerOverview.region)).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: /back to overview/i }), + ).toHaveAttribute("href", "/"); + }); + + it("renders summary stats for capacity planner", () => { + render(); + + const summarySection = screen.getByLabelText(/capacity planner summary/i); + + for (const stat of capacityStats) { + const statEl = within(summarySection).getByText(stat.label).closest("article"); + + expect(statEl).toBeTruthy(); + expect(within(statEl as HTMLElement).getByText(stat.value)).toBeInTheDocument(); + } + }); + + it("renders all demand bands with their labels, levels, and forecasted loads", () => { + render(); + + const bandList = screen.getByRole("list", { name: /demand bands/i }); + const bandItems = within(bandList).getAllByRole("listitem"); + + expect(bandItems).toHaveLength(demandBands.length); + + for (const band of demandBands) { + expect(within(bandList).getByText(band.label)).toBeInTheDocument(); + expect(within(bandList).getByText(band.forecastedLoad)).toBeInTheDocument(); + expect(within(bandList).getByText(band.peakWindow)).toBeInTheDocument(); + } + }); + + it("renders all utilization cards with resource names, utilization, and status badges", () => { + render(); + + const utilList = screen.getByRole("list", { name: /utilization cards/i }); + const utilItems = within(utilList).getAllByRole("listitem"); + + expect(utilItems).toHaveLength(utilizationCards.length); + + for (const card of utilizationCards) { + expect(within(utilList).getByText(card.resource)).toBeInTheDocument(); + expect(within(utilList).getByText(card.currentUtilization)).toBeInTheDocument(); + expect(within(utilList).getByText(card.owner)).toBeInTheDocument(); + } + }); + + it("renders planning notes with authors, timestamps, and priority badges", () => { + render(); + + const notesSection = screen.getByLabelText(/planning notes$/i); + const notesList = within(notesSection).getByRole("list", { name: /planning notes list/i }); + const noteItems = within(notesList).getAllByRole("listitem"); + + expect(noteItems).toHaveLength(planningNotes.length); + + for (const note of planningNotes) { + expect(within(notesList).getByText(note.author)).toBeInTheDocument(); + expect(within(notesList).getByText(note.timestamp)).toBeInTheDocument(); + expect(within(notesList).getByText(note.body)).toBeInTheDocument(); + } + }); +}); diff --git a/src/app/capacity-planner/page.tsx b/src/app/capacity-planner/page.tsx new file mode 100644 index 0000000..5d64998 --- /dev/null +++ b/src/app/capacity-planner/page.tsx @@ -0,0 +1,139 @@ +import type { Metadata } from "next"; +import Link from "next/link"; + +import { DemandBandCard } from "./_components/demand-band"; +import { PlanningNotes } from "./_components/planning-notes"; +import { UtilizationCardItem } from "./_components/utilization-card"; +import styles from "./capacity-planner.module.css"; +import { + capacityPlannerOverview, + capacityStats, + demandBands, + planningNotes, + utilizationCards, +} from "./_data/capacity-planner-data"; + +export const metadata: Metadata = { + title: "Capacity Planner", + description: + "Demand bands, utilization cards, and planning notes for capacity forecasting and resource alignment.", +}; + +export default function CapacityPlannerPage() { + return ( +
+
+ {/* Hero */} +
+
+
+ + {capacityPlannerOverview.eyebrow} + +

+ {capacityPlannerOverview.title} +

+

+ {capacityPlannerOverview.description} +

+
+ +
+
+

+ Plan cycle +

+

+ {capacityPlannerOverview.planCycle} +

+

+ {capacityPlannerOverview.region} +

+
+ + + Back to overview + +
+
+
+ + {/* Stats */} +
+ {capacityStats.map((stat) => ( +
+

+ {stat.label} +

+

+ {stat.value} +

+

{stat.detail}

+
+ ))} +
+ + {/* Demand bands */} +
+
+
+

+ Demand forecast +

+

+ Demand bands across the daily cycle +

+
+

+ Each band represents a time window with a forecasted load level. + Use these bands to plan scaling actions and maintenance windows. +

+
+ +
+ {demandBands.map((band) => ( + + ))} +
+
+ + {/* Utilization cards */} +
+
+
+

+ Resource health +

+

+ Utilization across tracked resources +

+
+

+ Current utilization and capacity for each tracked resource. + Status reflects whether the resource is within safe operating margins. +

+
+ +
+ {utilizationCards.map((card) => ( + + ))} +
+
+ + {/* Planning notes */} + +
+
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 4611bce..6d9e0d3 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -33,6 +33,7 @@ const navLinks = [ { href: "/queue-monitor", label: "Queue Monitor" }, { href: "/parcel-hub", label: "Parcel Hub" }, { href: "/status-board", label: "Status Board" }, + { href: "/capacity-planner", label: "Capacity Planner" }, ] as const; export default function RootLayout({