@burdenoff/microfe-movethewheels 2026.802.1 → 2026.805.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,228 @@
1
+ /**
2
+ * UpcomingDemandPanel
3
+ *
4
+ * Surfaces next-week demand forecasts by lane / customer alongside the
5
+ * most recent closed-period variance (BOFF-4393). Pure read-side view
6
+ * over `wspace-movethewheels-svc`'s DemandForecast surface.
7
+ */
8
+
9
+ import { Calendar, TrendingUp, TrendingDown, AlertCircle } from 'lucide-react';
10
+
11
+ import { Card, CardContent, CardHeader, CardTitle, Skeleton } from './ui';
12
+
13
+ import type {
14
+ ForecastPoint,
15
+ ForecastVariance,
16
+ } from '../hooks/useDemandForecast';
17
+
18
+ interface UpcomingDemandPanelProps {
19
+ laneForecast: ForecastPoint[];
20
+ customerForecast: ForecastPoint[];
21
+ laneVariance: ForecastVariance[];
22
+ customerVariance: ForecastVariance[];
23
+ isLoading: boolean;
24
+ error: Error | null;
25
+ onRetry: () => void;
26
+ }
27
+
28
+ const formatWeek = (iso: string): string => {
29
+ const date = new Date(iso);
30
+ if (Number.isNaN(date.getTime())) return iso;
31
+ return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
32
+ };
33
+
34
+ const topByVolume = (rows: ForecastPoint[], weekStart: string): ForecastPoint[] => {
35
+ const filtered = rows.filter((row) => row.periodStart === weekStart);
36
+ return [...filtered].sort((a, b) => b.predictedVolume - a.predictedVolume).slice(0, 3);
37
+ };
38
+
39
+ const topByRevenue = (rows: ForecastPoint[], weekStart: string): ForecastPoint[] => {
40
+ const filtered = rows.filter((row) => row.periodStart === weekStart);
41
+ return [...filtered].sort((a, b) => b.predictedRevenue - a.predictedRevenue).slice(0, 3);
42
+ };
43
+
44
+ const totalVolume = (rows: ForecastPoint[], weekStart: string): number =>
45
+ rows.filter((row) => row.periodStart === weekStart).reduce((sum, row) => sum + row.predictedVolume, 0);
46
+
47
+ const formatConfidence = (confidence: number): string => {
48
+ if (confidence <= 0) return 'Not enough history';
49
+ return `${Math.round(confidence * 100)}% confidence`;
50
+ };
51
+
52
+ export function UpcomingDemandPanel({
53
+ laneForecast,
54
+ customerForecast,
55
+ laneVariance,
56
+ customerVariance,
57
+ isLoading,
58
+ error,
59
+ onRetry,
60
+ }: UpcomingDemandPanelProps) {
61
+ if (error && !isLoading) {
62
+ return (
63
+ <Card className="border-status-error-border">
64
+ <CardContent className="p-6 text-center space-y-3">
65
+ <p className="text-status-error-text font-medium">Couldn&apos;t load demand forecast</p>
66
+ <p className="text-sm text-text-secondary">{error.message}</p>
67
+ <button
68
+ type="button"
69
+ onClick={onRetry}
70
+ className="text-sm font-medium text-primary hover:underline"
71
+ >
72
+ Retry
73
+ </button>
74
+ </CardContent>
75
+ </Card>
76
+ );
77
+ }
78
+
79
+ if (isLoading) {
80
+ return (
81
+ <Card>
82
+ <CardHeader>
83
+ <CardTitle>Upcoming demand</CardTitle>
84
+ </CardHeader>
85
+ <CardContent className="space-y-4">
86
+ <Skeleton className="h-16 w-full" />
87
+ <Skeleton className="h-16 w-full" />
88
+ <Skeleton className="h-16 w-full" />
89
+ </CardContent>
90
+ </Card>
91
+ );
92
+ }
93
+
94
+ const nextWeekStart = laneForecast[0]?.periodStart ?? customerForecast[0]?.periodStart ?? null;
95
+
96
+ if (!nextWeekStart || laneForecast.length === 0) {
97
+ return (
98
+ <Card>
99
+ <CardHeader>
100
+ <CardTitle className="flex items-center gap-2">
101
+ <Calendar size={18} aria-hidden="true" />
102
+ Upcoming demand
103
+ </CardTitle>
104
+ </CardHeader>
105
+ <CardContent>
106
+ <div className="flex items-start gap-2 rounded-lg border border-border/60 bg-muted/40 p-4 text-sm text-text-secondary">
107
+ <AlertCircle size={16} aria-hidden="true" className="mt-0.5 shrink-0" />
108
+ <p>
109
+ Not enough history to forecast next week yet. Once you have at least 4 weeks of
110
+ completed orders, this panel will surface demand by lane and customer.
111
+ </p>
112
+ </div>
113
+ </CardContent>
114
+ </Card>
115
+ );
116
+ }
117
+
118
+ const topLanes = topByVolume(laneForecast, nextWeekStart);
119
+ const topLanesByRevenue = topByRevenue(laneForecast, nextWeekStart);
120
+ const topCustomers = topByVolume(customerForecast, nextWeekStart);
121
+ const total = totalVolume(laneForecast, nextWeekStart);
122
+
123
+ // Most-recent variance row per scope key — show the lane that overshot /
124
+ // undershot the most in absolute volume terms.
125
+ const mostRecentVariance = [...laneVariance, ...customerVariance]
126
+ .sort((a, b) => Math.abs(b.volumeVariance) - Math.abs(a.volumeVariance))
127
+ .slice(0, 3);
128
+
129
+ return (
130
+ <Card>
131
+ <CardHeader>
132
+ <CardTitle className="flex items-center gap-2">
133
+ <Calendar size={18} aria-hidden="true" />
134
+ Upcoming demand — week of {formatWeek(nextWeekStart)}
135
+ </CardTitle>
136
+ </CardHeader>
137
+ <CardContent className="space-y-4">
138
+ <div className="flex items-baseline justify-between rounded-lg border border-border/60 bg-muted/40 px-4 py-3">
139
+ <div>
140
+ <p className="text-xs uppercase tracking-wide text-text-secondary">
141
+ Predicted jobs (all lanes)
142
+ </p>
143
+ <p className="text-2xl font-bold">{total}</p>
144
+ </div>
145
+ <p className="text-xs text-text-secondary">
146
+ {topLanes[0] ? formatConfidence(topLanes[0].confidence) : ''}
147
+ </p>
148
+ </div>
149
+
150
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
151
+ <ForecastList title="Top lanes (volume)" rows={topLanes} />
152
+ <ForecastList title="Top lanes (revenue)" rows={topLanesByRevenue} />
153
+ <ForecastList title="Top customers (volume)" rows={topCustomers} />
154
+ <VarianceList rows={mostRecentVariance} />
155
+ </div>
156
+ </CardContent>
157
+ </Card>
158
+ );
159
+ }
160
+
161
+ interface ForecastListProps {
162
+ title: string;
163
+ rows: ForecastPoint[];
164
+ }
165
+
166
+ function ForecastList({ title, rows }: ForecastListProps) {
167
+ if (rows.length === 0) {
168
+ return (
169
+ <div className="rounded-lg border border-border/60 p-4">
170
+ <p className="text-sm font-medium mb-2">{title}</p>
171
+ <p className="text-xs text-text-secondary">No forecasts yet for this week.</p>
172
+ </div>
173
+ );
174
+ }
175
+ return (
176
+ <div className="rounded-lg border border-border/60 p-4">
177
+ <p className="text-sm font-medium mb-3">{title}</p>
178
+ <ul className="space-y-2">
179
+ {rows.map((row) => (
180
+ <li key={`${row.scopeType}-${row.scopeKey}`} className="flex items-center justify-between text-sm">
181
+ <span className="truncate font-medium">{row.scopeKey}</span>
182
+ <span className="ml-2 shrink-0 text-text-secondary">
183
+ {row.predictedVolume} jobs ·{' '}
184
+ {new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(
185
+ row.predictedRevenue
186
+ )}
187
+ </span>
188
+ </li>
189
+ ))}
190
+ </ul>
191
+ </div>
192
+ );
193
+ }
194
+
195
+ interface VarianceListProps {
196
+ rows: ForecastVariance[];
197
+ }
198
+
199
+ function VarianceList({ rows }: VarianceListProps) {
200
+ return (
201
+ <div className="rounded-lg border border-border/60 p-4">
202
+ <p className="text-sm font-medium mb-3">Recent forecast variance</p>
203
+ {rows.length === 0 ? (
204
+ <p className="text-xs text-text-secondary">No closed periods to compare yet.</p>
205
+ ) : (
206
+ <ul className="space-y-2">
207
+ {rows.map((row) => {
208
+ const isOvershoot = row.volumeVariance > 0;
209
+ const Icon = isOvershoot ? TrendingUp : TrendingDown;
210
+ const tone = isOvershoot ? 'text-emerald-600 dark:text-emerald-400' : 'text-rose-600 dark:text-rose-400';
211
+ return (
212
+ <li key={`${row.scopeType}-${row.scopeKey}-${row.periodStart}`} className="flex items-center justify-between text-sm">
213
+ <span className="flex items-center gap-2 truncate">
214
+ <Icon size={14} className={tone} aria-hidden="true" />
215
+ <span className="truncate font-medium">{row.scopeKey}</span>
216
+ </span>
217
+ <span className={`ml-2 shrink-0 ${tone}`}>
218
+ {isOvershoot ? '+' : ''}
219
+ {row.volumeVariance} jobs
220
+ </span>
221
+ </li>
222
+ );
223
+ })}
224
+ </ul>
225
+ )}
226
+ </div>
227
+ );
228
+ }
@@ -0,0 +1,155 @@
1
+ import { useCallback, useEffect, useMemo, useState } from 'react';
2
+
3
+ import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
4
+ import { executeGraphQL } from '../utils/graphqlClient';
5
+
6
+ /**
7
+ * Demand forecasting hook (BOFF-4393). Pulls next-N-week forecasts by lane
8
+ * and customer plus the recent closed-period variance. Read-side only — the
9
+ * admin console exposes `recalculateDemandForecasts` separately.
10
+ */
11
+
12
+ export interface ForecastPoint {
13
+ scopeType: 'LANE' | 'CUSTOMER';
14
+ scopeKey: string;
15
+ periodStart: string;
16
+ periodEnd: string;
17
+ predictedVolume: number;
18
+ predictedRevenue: number;
19
+ actualVolume: number | null;
20
+ actualRevenue: number | null;
21
+ confidence: number;
22
+ }
23
+
24
+ export interface ForecastVariance {
25
+ scopeType: 'LANE' | 'CUSTOMER';
26
+ scopeKey: string;
27
+ periodStart: string;
28
+ periodEnd: string;
29
+ predictedVolume: number;
30
+ actualVolume: number;
31
+ volumeVariance: number;
32
+ predictedRevenue: number;
33
+ actualRevenue: number;
34
+ revenueVariance: number;
35
+ }
36
+
37
+ interface UseDemandForecastResult {
38
+ laneForecast: ForecastPoint[];
39
+ customerForecast: ForecastPoint[];
40
+ laneVariance: ForecastVariance[];
41
+ customerVariance: ForecastVariance[];
42
+ isLoading: boolean;
43
+ error: Error | null;
44
+ refetch: () => void;
45
+ }
46
+
47
+ const FORECAST_QUERY = `
48
+ query GetMovethewheelsDemandForecast($weeks: Int!, $varianceLimit: Int!) {
49
+ demandForecastByLane(weeks: $weeks) {
50
+ scopeType
51
+ scopeKey
52
+ periodStart
53
+ periodEnd
54
+ predictedVolume
55
+ predictedRevenue
56
+ actualVolume
57
+ actualRevenue
58
+ confidence
59
+ }
60
+ demandForecastByCustomer(weeks: $weeks) {
61
+ scopeType
62
+ scopeKey
63
+ periodStart
64
+ periodEnd
65
+ predictedVolume
66
+ predictedRevenue
67
+ actualVolume
68
+ actualRevenue
69
+ confidence
70
+ }
71
+ laneVariance: demandForecastVariance(scopeType: LANE, limit: $varianceLimit) {
72
+ scopeType
73
+ scopeKey
74
+ periodStart
75
+ periodEnd
76
+ predictedVolume
77
+ actualVolume
78
+ volumeVariance
79
+ predictedRevenue
80
+ actualRevenue
81
+ revenueVariance
82
+ }
83
+ customerVariance: demandForecastVariance(scopeType: CUSTOMER, limit: $varianceLimit) {
84
+ scopeType
85
+ scopeKey
86
+ periodStart
87
+ periodEnd
88
+ predictedVolume
89
+ actualVolume
90
+ volumeVariance
91
+ predictedRevenue
92
+ actualRevenue
93
+ revenueVariance
94
+ }
95
+ }
96
+ `;
97
+
98
+ export function useDemandForecast(weeks = 4, varianceLimit = 8): UseDemandForecastResult {
99
+ const { apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser } =
100
+ useMoveTheWheels();
101
+ const [laneForecast, setLaneForecast] = useState<ForecastPoint[]>([]);
102
+ const [customerForecast, setCustomerForecast] = useState<ForecastPoint[]>([]);
103
+ const [laneVariance, setLaneVariance] = useState<ForecastVariance[]>([]);
104
+ const [customerVariance, setCustomerVariance] = useState<ForecastVariance[]>([]);
105
+ const [isLoading, setIsLoading] = useState(false);
106
+ const [error, setError] = useState<Error | null>(null);
107
+
108
+ const graphQLOptions = useMemo(
109
+ () => ({
110
+ apiGatewayUrl,
111
+ authToken,
112
+ workspaceToken,
113
+ workspaceId,
114
+ tenantId,
115
+ actorId: currentUser?.id,
116
+ }),
117
+ [apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser?.id]
118
+ );
119
+
120
+ const load = useCallback(async () => {
121
+ setIsLoading(true);
122
+ setError(null);
123
+ try {
124
+ const data = await executeGraphQL<{
125
+ demandForecastByLane: ForecastPoint[];
126
+ demandForecastByCustomer: ForecastPoint[];
127
+ laneVariance: ForecastVariance[];
128
+ customerVariance: ForecastVariance[];
129
+ }>(graphQLOptions, FORECAST_QUERY, { weeks, varianceLimit });
130
+
131
+ setLaneForecast(data.demandForecastByLane ?? []);
132
+ setCustomerForecast(data.demandForecastByCustomer ?? []);
133
+ setLaneVariance(data.laneVariance ?? []);
134
+ setCustomerVariance(data.customerVariance ?? []);
135
+ } catch (fetchError) {
136
+ setError(
137
+ fetchError instanceof Error
138
+ ? fetchError
139
+ : new Error('Failed to load demand forecast')
140
+ );
141
+ } finally {
142
+ setIsLoading(false);
143
+ }
144
+ }, [graphQLOptions, weeks, varianceLimit]);
145
+
146
+ useEffect(() => {
147
+ void load();
148
+ }, [load]);
149
+
150
+ const refetch = useCallback(() => {
151
+ void load();
152
+ }, [load]);
153
+
154
+ return { laneForecast, customerForecast, laneVariance, customerVariance, isLoading, error, refetch };
155
+ }
@@ -12,8 +12,14 @@ import { PageLayout } from '../components/PageLayout';
12
12
  import { MetricCard, MetricGrid, MetricCardSkeleton } from '../components/MetricCard';
13
13
  import { Card, CardContent, CardHeader, CardTitle, Progress, Button } from '../components/ui';
14
14
  import { TrendChart, VolumeBar, StatusDonut, type DonutSlice } from '../components/charts';
15
+ import { UpcomingDemandPanel } from '../components/UpcomingDemandPanel';
15
16
  import { EmphasisPanel, PagePurpose } from '@burdenoff/fe-libs/ui';
16
- import { useAnalytics, useOrderAnalytics, useFleetAnalytics } from '../hooks/useAnalytics';
17
+ import {
18
+ useAnalytics,
19
+ useOrderAnalytics,
20
+ useFleetAnalytics,
21
+ } from '../hooks/useAnalytics';
22
+ import { useDemandForecast } from '../hooks/useDemandForecast';
17
23
  import { formatCurrency } from '../utils/formatters';
18
24
 
19
25
  const statusLabel = (status: string) =>
@@ -23,6 +29,15 @@ export default function AnalyticsPage() {
23
29
  const { analytics, isLoading, error, refetch } = useAnalytics();
24
30
  const { orderData } = useOrderAnalytics();
25
31
  const { fleetData } = useFleetAnalytics();
32
+ const {
33
+ laneForecast,
34
+ customerForecast,
35
+ laneVariance,
36
+ customerVariance,
37
+ isLoading: forecastLoading,
38
+ error: forecastError,
39
+ refetch: refetchForecast,
40
+ } = useDemandForecast(4, 8);
26
41
 
27
42
  const orderSpark = useMemo(
28
43
  () => analytics.trends.dailyOrders.map((d) => d.orders),
@@ -129,6 +144,18 @@ export default function AnalyticsPage() {
129
144
  </CardContent>
130
145
  </Card>
131
146
 
147
+ {/* Upcoming demand (BOFF-4393) — heuristic forecast, surfaces only
148
+ when the workspace has enough history (>=4 weeks). */}
149
+ <UpcomingDemandPanel
150
+ laneForecast={laneForecast}
151
+ customerForecast={customerForecast}
152
+ laneVariance={laneVariance}
153
+ customerVariance={customerVariance}
154
+ isLoading={forecastLoading}
155
+ error={forecastError}
156
+ onRetry={refetchForecast}
157
+ />
158
+
132
159
  <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
133
160
  <Card>
134
161
  <CardHeader>
@@ -0,0 +1,47 @@
1
+ # Demand forecasting (BOFF-4393) — query operations against the
2
+ # wspace-movethewheels-svc DemandForecast surface. Read-side only on the
3
+ # UI; recalculation is triggered from the admin console via a separate
4
+ # mutation that is not wired into the MFE yet.
5
+
6
+ query GetMovethewheelsDemandForecastByLane($weeks: Int!) {
7
+ demandForecastByLane(weeks: $weeks) {
8
+ scopeType
9
+ scopeKey
10
+ periodStart
11
+ periodEnd
12
+ predictedVolume
13
+ predictedRevenue
14
+ actualVolume
15
+ actualRevenue
16
+ confidence
17
+ }
18
+ }
19
+
20
+ query GetMovethewheelsDemandForecastByCustomer($weeks: Int!) {
21
+ demandForecastByCustomer(weeks: $weeks) {
22
+ scopeType
23
+ scopeKey
24
+ periodStart
25
+ periodEnd
26
+ predictedVolume
27
+ predictedRevenue
28
+ actualVolume
29
+ actualRevenue
30
+ confidence
31
+ }
32
+ }
33
+
34
+ query GetMovethewheelsDemandForecastVariance($scopeType: DemandForecastScope!, $limit: Int!) {
35
+ demandForecastVariance(scopeType: $scopeType, limit: $limit) {
36
+ scopeType
37
+ scopeKey
38
+ periodStart
39
+ periodEnd
40
+ predictedVolume
41
+ actualVolume
42
+ volumeVariance
43
+ predictedRevenue
44
+ actualRevenue
45
+ revenueVariance
46
+ }
47
+ }