diff --git a/src/features/store-admin/dispatchModel.test.ts b/src/features/store-admin/dispatchModel.test.ts index d15f012..03e306b 100644 --- a/src/features/store-admin/dispatchModel.test.ts +++ b/src/features/store-admin/dispatchModel.test.ts @@ -5,6 +5,7 @@ import type { DeliveryRow, OrderRow, TenantLocation } from '@/api/types'; import { dayTotals, groupByCustomer, + groupByWindow, groupByRider, groupByStore, isToday, @@ -12,6 +13,7 @@ import { stopKey, stopValue, toStops, + NO_WINDOW, UNASSIGNED, WAITING, ymd, @@ -407,3 +409,93 @@ test('today is today', () => { assert.equal(isToday(ymd(new Date())), true); assert.equal(isToday('2020-01-01'), false); }); + +/* ── Grouping by delivery window ──────────────────────────────────────────── */ + +/* +The board that answers "what has to leave next". + +Two rules carry the weight, and both are about what happens to work nobody +wants to see: a window that has already closed, and the large pile of stops +that chose no window at all. Getting either wrong hides work rather than +ordering it. +*/ + +const windowed = (slotid: number, date: string, key: string, start: string, end: string) => + order({ + orderheaderid: slotid * 1000 + Number(date.slice(-2)), + deliveryslotid: slotid, + deliveryslotdate: date, + slotkey: key, + deliveryslotname: key[0]!.toUpperCase() + key.slice(1), + deliveryslotstart: start, + deliveryslotend: end, + } as Partial); + +test('groupByWindow sorts by date then start time', () => { + const stops = toStops( + [], + [ + windowed(3, '2026-10-08', 'evening', '17:00', '20:00'), + windowed(1, '2026-10-09', 'morning', '08:00', '10:00'), + windowed(1, '2026-10-08', 'morning', '08:00', '10:00'), + ], + ); + + const names = groupByWindow(stops).map((g) => g.name); + assert.ok(names[0]?.startsWith('Morning'), `first was ${names[0]}`); + assert.ok(names[1]?.startsWith('Evening'), `second was ${names[1]}`); + // Tomorrow's morning sorts after tonight's evening, not beside this + // morning's — the date leads, so the board reads as a timeline. + assert.ok(names[2]?.startsWith('Morning'), `third was ${names[2]}`); + assert.notEqual(names[0], names[2], 'two different days collapsed into one group'); +}); + +test('groupByWindow keeps the same window on different days apart', () => { + const stops = toStops( + [], + [ + windowed(1, '2026-10-08', 'morning', '08:00', '10:00'), + windowed(1, '2026-10-09', 'morning', '08:00', '10:00'), + ], + ); + + // Same slot id, different days. Grouping on the id alone would put two days' + // work under one heading with one deadline. + assert.equal(groupByWindow(stops).length, 2); +}); + +test('groupByWindow puts stops with no window last', () => { + const stops = toStops( + [], + [order({ orderheaderid: 1 }), windowed(3, '2026-10-08', 'evening', '17:00', '20:00')], + ); + + const groups = groupByWindow(stops); + // It is the biggest group by far today. First, it would push every group + // with an actual deadline off the screen. + assert.equal(groups.at(-1)?.id, NO_WINDOW); + assert.equal(groups.at(-1)?.name, 'No delivery window'); +}); + +test('groupByWindow keeps a window that has already closed', () => { + const stops = toStops([], [windowed(1, '2020-01-01', 'morning', '08:00', '10:00')]); + + // A morning group still holding undelivered stops in the afternoon is the + // most important thing on this screen. Dropping it to tidy the board would + // hide the one group somebody needs to act on. + const groups = groupByWindow(stops); + assert.equal(groups.length, 1); + assert.equal(groups[0]?.stops.length, 1); +}); + +test('groupByWindow treats a window with no date as no window', () => { + const stops = toStops( + [], + [order({ orderheaderid: 7, deliveryslotid: 2, deliveryslotdate: '' } as Partial)], + ); + + // Without a date it cannot be placed on the timeline, and inventing one + // would file somebody's order under the wrong deadline. + assert.equal(groupByWindow(stops)[0]?.id, NO_WINDOW); +}); diff --git a/src/features/store-admin/dispatchModel.ts b/src/features/store-admin/dispatchModel.ts index 42fe5ef..ad304b8 100644 --- a/src/features/store-admin/dispatchModel.ts +++ b/src/features/store-admin/dispatchModel.ts @@ -35,7 +35,7 @@ import { isRealName } from './orderStatus'; * Pure, so every rule below is testable without a map or a network. */ -export type ViewMode = 'stores' | 'customers' | 'riders'; +export type ViewMode = 'stores' | 'customers' | 'riders' | 'windows'; /** A job somebody is carrying, or an order still waiting for a rider. */ export type Stop = @@ -481,3 +481,94 @@ export function isToday(day: string): boolean { } + + +/** Stops with no delivery window, which is most of them while this rolls out. */ +export const NO_WINDOW = 'no-window'; + +/** + * Grouped by the delivery window the customer asked for. + * + * ── Why this board exists ─────────────────────────────────────────────────── + * + * The other three answer "who is carrying this" and "where is it going". This + * one answers "what has to leave next", which is the question a window creates + * and the only one the rider, store and customer boards cannot be asked. + * + * ── Order ─────────────────────────────────────────────────────────────────── + * + * By date then start time, so the soonest window is at the top and tomorrow's + * sits below today's. "No window" goes LAST rather than first: it is the + * largest group by far today and would otherwise push every dated group off + * the screen, burying the work that actually has a deadline. + * + * ── Windows that have closed are kept ─────────────────────────────────────── + * + * A morning group still holding undelivered stops at 2pm is the most important + * thing on this screen. It stays, in place, in time order — dropping or + * collapsing it would hide exactly the group somebody needs to act on. The + * caller decides how to mark it; this function only refuses to lose it. + */ +export function groupByWindow(stops: readonly Stop[]): Group[] { + const map = new Map(); + // The sort key per group, kept aside so the Group shape stays unchanged. + const sortKeys = new Map(); + + for (const stop of stops) { + const row = stop.row as { + deliveryslotid?: number; + deliveryslotdate?: string; + slotkey?: string; + deliveryslotname?: string; + deliveryslotstart?: string; + deliveryslotend?: string; + }; + + const slotId = Number(row.deliveryslotid ?? 0); + const date = (row.deliveryslotdate ?? '').trim().slice(0, 10); + + // Both are needed. A window without its date cannot be told from the same + // window tomorrow, and grouping them together would put two days' work + // under one heading with one deadline. + const id = slotId > 0 && date ? `${date}#${slotId}` : NO_WINDOW; + + let group = map.get(id); + if (!group) { + if (id === NO_WINDOW) { + group = blank(NO_WINDOW, 'No delivery window'); + // Sorts last: 'z' beats every date string. + sortKeys.set(id, 'zzzz'); + } else { + const name = (row.deliveryslotname ?? '').trim() || titleCase(row.slotkey ?? 'Window'); + const hours = + row.deliveryslotstart && row.deliveryslotend + ? `${row.deliveryslotstart}–${row.deliveryslotend}` + : undefined; + group = blank(id, `${name} · ${readableDate(date)}`, hours); + sortKeys.set(id, `${date} ${row.deliveryslotstart ?? '00:00'}`); + } + map.set(id, group); + } + fold(group, stop); + } + + for (const group of map.values()) { + group.stops.sort((a, b) => stopTime(a).localeCompare(stopTime(b))); + } + + return [...map.values()].sort((a, b) => + (sortKeys.get(a.id) ?? '').localeCompare(sortKeys.get(b.id) ?? ''), + ); +} + +function titleCase(value: string): string { + return value ? value[0]!.toUpperCase() + value.slice(1) : ''; +} + +/** "2026-10-08" → "8 Oct", or the raw value if it will not parse — better a + heading somebody can match against the data than a silent "Invalid Date". */ +function readableDate(date: string): string { + const parsed = new Date(`${date}T00:00:00`); + if (Number.isNaN(parsed.getTime())) return date; + return parsed.toLocaleDateString(undefined, { day: 'numeric', month: 'short' }); +} diff --git a/src/features/store-admin/pages/DispatchPage.tsx b/src/features/store-admin/pages/DispatchPage.tsx index f2818d0..a044083 100644 --- a/src/features/store-admin/pages/DispatchPage.tsx +++ b/src/features/store-admin/pages/DispatchPage.tsx @@ -4,6 +4,7 @@ import { Text } from '@astryxdesign/core/Text'; import { VStack } from '@astryxdesign/core/VStack'; import { Bike, + Clock, IndianRupee, MapPin, Radio, @@ -22,6 +23,7 @@ import { } from 'lucide-react'; import type { DeliveryRow, OrderRow, RiderInfo, TenantLocation } from '@/api/types'; import { WINDOW_FILTERS, matchesWindow, type WindowFilterKey } from '../deliveryWindow'; +import { groupByWindow } from '../dispatchModel'; import { KpiCard } from '@/components/KpiCard'; import { Tab, TabBar } from '@/components/TabBar'; import { StickyRow } from '@/components/StickyRow'; @@ -70,7 +72,7 @@ import './dispatch.css'; const DISPATCH_STATUS: Record = { ...DELIVERY_STATUS, [WAITING]: '#ef4444' }; type Board = ViewMode | 'timing' | 'active' | 'profit'; -const GROUPINGS = new Set(['riders', 'stores', 'customers']); +const GROUPINGS = new Set(['riders', 'stores', 'customers', 'windows']); const isGrouping = (board: Board): board is ViewMode => GROUPINGS.has(board); /** @@ -186,6 +188,7 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) { if (!isGrouping(mode)) return []; if (mode === 'stores') return groupByStore(stops, locationsQuery.data ?? branches); if (mode === 'customers') return groupByCustomer(stops, customers.data ?? [], branchName); + if (mode === 'windows') return groupByWindow(stops); return groupByRider(stops); }, [mode, stops, locationsQuery.data, branches, customers.data, branchName]); @@ -287,6 +290,14 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) { isActive={mode === 'stores'} onClick={() => changeMode('stores')} /> + {/* What has to leave next — the question a delivery window creates, and + the one the rider, store and customer boards cannot be asked. */} + } + isActive={mode === 'windows'} + onClick={() => changeMode('windows')} + /> {/* A shop's customer book is its own. Grouping a partner's rounds by customer would mean reading every merchant's book to put a name to a phone number, so the tab is not offered on a partner board. */}