From 1dfbfc338212e57dc68f65099a2936125f72adca Mon Sep 17 00:00:00 2001 From: iamasx Date: Fri, 24 Apr 2026 20:49:56 +0530 Subject: [PATCH] feat: add forecast desk route --- .../forecast-desk/_data/forecast-desk-data.ts | 133 ++++++++ src/app/forecast-desk/page.test.tsx | 95 ++++++ src/app/forecast-desk/page.tsx | 307 ++++++++++++++++++ 3 files changed, 535 insertions(+) create mode 100644 src/app/forecast-desk/_data/forecast-desk-data.ts create mode 100644 src/app/forecast-desk/page.test.tsx create mode 100644 src/app/forecast-desk/page.tsx diff --git a/src/app/forecast-desk/_data/forecast-desk-data.ts b/src/app/forecast-desk/_data/forecast-desk-data.ts new file mode 100644 index 0000000..e662d0d --- /dev/null +++ b/src/app/forecast-desk/_data/forecast-desk-data.ts @@ -0,0 +1,133 @@ +export const forecastDeskOverview = { + eyebrow: "Forecast Desk", + title: "Read the next operating window through trend snapshots instead of a single blended forecast.", + description: + "This route stages short-horizon signals for inbound volume, dwell, exceptions, and reserve coverage. Snapshot cards show what is moving, short notes translate that movement into operating guidance, and the comparison band keeps the current shift anchored to the next two forecast windows.", + primaryAction: "Review trend snapshots", + secondaryAction: "Open forecast notes", + stats: [ + { + label: "Trend windows", + value: "3", + detail: "Signals tracked across the next handoff", + }, + { + label: "Forecast notes", + value: "3", + detail: "Short reads for desk and floor leads", + }, + { + label: "Comparison lenses", + value: "3", + detail: "Shared metrics across current, next 6h, and next day", + }, + ], +} as const; + +export const comparisonMetrics = [ + { + label: "Inbound volume", + current: "18.4k", + nextWindow: "20.1k", + nextDay: "17.6k", + note: "The peak lands before midnight, then cools into the morning reset.", + }, + { + label: "Dock dwell", + current: "42 min", + nextWindow: "49 min", + nextDay: "38 min", + note: "Weather pressure widens the turn window tonight but clears by the next day.", + }, + { + label: "Crew reserve", + current: "14 teams", + nextWindow: "11 teams", + nextDay: "16 teams", + note: "Reserve capacity stays usable, but most of it is already committed to contingencies.", + }, +] as const; + +export const trendSnapshots = [ + { + id: "metro-east", + title: "Metro east inbound lift", + tone: "accelerating", + metricValue: "20.1k parcels", + change: "+9.4% vs. current", + confidence: "High confidence", + window: "18:00-23:00 local", + summary: + "Late pickups compress the first sort block above the usual evening baseline.", + drivers: [ + "Retail release cutoffs moved 30 minutes later.", + "Linehaul timing recovered after the noon disruption.", + ], + action: "Pre-stage overflow sort support before 19:30.", + }, + { + id: "coastal-dwell", + title: "Coastal dwell pressure", + tone: "watch", + metricValue: "49 minutes", + change: "+7 minutes", + confidence: "Moderate confidence", + window: "20:00-01:00 local", + summary: + "A narrow rain band slows trailer turns on the coastal lanes and raises congestion risk.", + drivers: [ + "Southbound relay swaps stack inside one 90-minute window.", + "Only one spare dock team is free before midnight rotation.", + ], + action: "Protect one flex dock team for the coastal handoff.", + }, + { + id: "returns-slack", + title: "Returns slack stays available", + tone: "steady", + metricValue: "11.8%", + change: "-1.6 pts", + confidence: "High confidence", + window: "Current through 06:00", + summary: + "The lighter returns mix leaves usable capacity that can absorb routine variance.", + drivers: [ + "Weekend reversals cleared earlier than expected.", + "Secondary inspection backlog is already under threshold.", + ], + action: "Use returns capacity before drawing down reserve teams.", + }, +] as const; + +export const forecastNotes = [ + { + id: "east-overflow", + title: "Pre-stage east overflow support", + state: "ready", + owner: "Desk lead", + window: "By 19:30", + summary: + "Move one overflow crew toward the east sort block before the compression wave arrives.", + trigger: "Keep this move if inbound volume stays above 19.6k at 18:45.", + }, + { + id: "coastal-flex", + title: "Hold a flex dock team for coastal turns", + state: "watch", + owner: "Floor coordinator", + window: "20:00-23:30", + summary: + "Avoid spending the last spare dock team until the weather-linked dwell trend confirms.", + trigger: "Release the team only if dwell remains under 45 minutes after 20:30.", + }, + { + id: "reserve-handoff", + title: "Protect reserve coverage into handoff", + state: "follow-up", + owner: "Shift manager", + window: "Review at 23:15", + summary: + "Tonight's reserve coverage is adequate, but the margin disappears if dwell and exceptions rise together.", + trigger: "Escalate staffing review if exception share crosses 3.2% before handoff.", + }, +] as const; diff --git a/src/app/forecast-desk/page.test.tsx b/src/app/forecast-desk/page.test.tsx new file mode 100644 index 0000000..6705229 --- /dev/null +++ b/src/app/forecast-desk/page.test.tsx @@ -0,0 +1,95 @@ +import { cleanup, render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + comparisonMetrics, + forecastDeskOverview, + forecastNotes, + trendSnapshots, +} from "./_data/forecast-desk-data"; +import ForecastDeskPage from "./page"; + +afterEach(() => { + cleanup(); +}); + +describe("ForecastDeskPage", () => { + it("renders the hero content and route actions", () => { + render(); + + expect( + screen.getByRole("heading", { + name: /read the next operating window through trend snapshots instead of a single blended forecast\./i, + }), + ).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: forecastDeskOverview.primaryAction }), + ).toHaveAttribute("href", "#trend-snapshots"); + expect( + screen.getByRole("link", { name: forecastDeskOverview.secondaryAction }), + ).toHaveAttribute("href", "#forecast-notes"); + expect( + screen.getByRole("link", { name: /back to route index/i }), + ).toHaveAttribute("href", "/"); + }); + + it("renders every metric in the comparison band", () => { + render(); + + const comparisonBand = screen.getByLabelText(/metric comparison band/i); + + for (const metric of comparisonMetrics) { + const card = within(comparisonBand).getByText(metric.label).closest("article"); + + expect(card).toBeTruthy(); + expect(card?.textContent).toContain(metric.current); + expect(card?.textContent).toContain(metric.nextWindow); + expect(card?.textContent).toContain(metric.nextDay); + expect(card?.textContent).toContain(metric.note); + } + }); + + it("renders each trend snapshot with drivers and action guidance", () => { + render(); + + const snapshotList = screen.getByRole("list", { name: /trend snapshots/i }); + + expect(snapshotList.querySelectorAll(':scope > [role="listitem"]')).toHaveLength( + trendSnapshots.length, + ); + + for (const snapshot of trendSnapshots) { + const card = within(snapshotList) + .getByRole("heading", { name: snapshot.title }) + .closest('[role="listitem"]'); + + expect(card).toBeTruthy(); + expect(card?.textContent).toContain(snapshot.metricValue); + expect(card?.textContent).toContain(snapshot.confidence); + expect(card?.textContent).toContain(snapshot.drivers[0]); + expect(card?.textContent).toContain(snapshot.action); + } + }); + + it("renders the forecast notes with owners, windows, and triggers", () => { + render(); + + const notesList = screen.getByRole("list", { name: /forecast notes/i }); + + expect(notesList.querySelectorAll(':scope > [role="listitem"]')).toHaveLength( + forecastNotes.length, + ); + + for (const note of forecastNotes) { + const card = within(notesList) + .getByRole("heading", { name: note.title }) + .closest('[role="listitem"]'); + + expect(card).toBeTruthy(); + expect(card?.textContent).toContain(note.owner); + expect(card?.textContent).toContain(note.window); + expect(card?.textContent).toContain(note.summary); + expect(card?.textContent).toContain(note.trigger); + } + }); +}); diff --git a/src/app/forecast-desk/page.tsx b/src/app/forecast-desk/page.tsx new file mode 100644 index 0000000..b8df30a --- /dev/null +++ b/src/app/forecast-desk/page.tsx @@ -0,0 +1,307 @@ +import type { Metadata } from "next"; +import Link from "next/link"; + +import { + comparisonMetrics, + forecastDeskOverview, + forecastNotes, + trendSnapshots, +} from "./_data/forecast-desk-data"; + +export const metadata: Metadata = { + title: "Forecast Desk", + description: + "Standalone forecast desk route with trend snapshots, short forecast notes, and a compact metric comparison band.", +}; + +const snapshotToneStyles = { + accelerating: "border-amber-300/30 bg-amber-300/12 text-amber-100", + watch: "border-rose-300/30 bg-rose-300/12 text-rose-100", + steady: "border-emerald-300/30 bg-emerald-300/12 text-emerald-100", +} as const; + +const noteStateStyles = { + ready: "border-emerald-300/25 bg-emerald-300/10 text-emerald-100", + watch: "border-amber-300/25 bg-amber-300/10 text-amber-100", + "follow-up": "border-cyan-300/25 bg-cyan-300/10 text-cyan-100", +} as const; + +export default function ForecastDeskPage() { + return ( +
+
+
+
+ +
+
+
+
+

+ {forecastDeskOverview.eyebrow} +

+

+ {forecastDeskOverview.title} +

+

+ {forecastDeskOverview.description} +

+ + +
+ + +
+
+ +
+
+
+

+ Comparison band +

+

+ Compare current, next 6h, and next day +

+
+

+ Keep the desk focused on shifts that materially change staffing or + dock posture. +

+
+ +
+ {comparisonMetrics.map((metric) => ( +
+

+ {metric.label} +

+
+
+

+ Current +

+

+ {metric.current} +

+
+
+

+ Next 6h +

+

+ {metric.nextWindow} +

+
+
+

+ Next day +

+

+ {metric.nextDay} +

+
+
+

+ {metric.note} +

+
+ ))} +
+
+ +
+
+
+

+ Trend snapshots +

+

+ Read the movements that actually change the desk +

+
+

+ Each card pairs a short signal read with the action that follows + from it. +

+
+ +
+ {trendSnapshots.map((snapshot) => ( +
+
+

+ {snapshot.title} +

+ + {snapshot.tone} + +
+ +

+ {snapshot.metricValue} +

+

+ {snapshot.change} · {snapshot.confidence} +

+

{snapshot.window}

+

+ {snapshot.summary} +

+ +
    + {snapshot.drivers.map((driver) => ( +
  • + {driver} +
  • + ))} +
+ +
+

+ Forecast action +

+

+ {snapshot.action} +

+
+
+ ))} +
+
+ +
+
+
+

+ Forecast notes +

+

+ Keep the desk narrative short enough to act on +

+
+

+ These notes translate the snapshot set into decisions the active + desk can pick up immediately. +

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

+ {note.title} +

+

+ {note.owner} · {note.window} +

+
+ + {note.state} + +
+ +

+ {note.summary} +

+
+

+ Trigger +

+

+ {note.trigger} +

+
+
+ ))} +
+
+
+
+ ); +}