-
-
- {mode === 'riders'
- ? isOneDay
- ? 'Rounds'
- : 'Riders'
- : mode === 'stores'
- ? 'Shops'
- : 'Customers'}
-
- setFocused((prev) => (prev === id ? null : id))}
- />
-
-
-
- {open ? (
-
+ ) : mode === 'timing' ? (
+ /* A different question of the same rows, so it replaces the board
+ rather than sitting beside it: promised against delivered, and where
+ the hours between accepting an order and dropping it actually went. */
+
+ ) : (
+
+
+
+ {mode === 'riders'
+ ? isOneDay
+ ? 'Rounds'
+ : 'Riders'
+ : mode === 'stores'
+ ? 'Shops'
+ : 'Customers'}
+
+
assignability(row, branchOf(row), assigned),
- assignBar:
- picked.count > 0 ? (
-
- ) : null,
- }
- : {})}
+ isLoading={deliveries.isLoading || (mode === 'customers' && customers.isLoading)}
+ focused={focused}
+ onFocus={(id) => setFocused((prev) => (prev === id ? null : id))}
/>
- ) : (
-
-
-
-
-
-
- {groups.length > 0
- ? 'Pick one to see its stops'
- : isOneDay
- ? 'Nothing out on this day'
- : 'Nothing out in this range'}
-
-
- {groups.length > 0
- ? 'Every stop, in the order it was assigned, with where the rider last reported in.'
- : 'Deliveries appear here once orders are assigned to a rider.'}
-
-
-
- )}
+
+
+
+ {open ? (
+
assignability(row, branchOf(row), assigned),
+ assignBar:
+ picked.count > 0 ? (
+
+ ) : null,
+ }
+ : {})}
+ />
+ ) : (
+
+
+
+
+
+
+ {groups.length > 0
+ ? 'Pick one to see its stops'
+ : isOneDay
+ ? 'Nothing out on this day'
+ : 'Nothing out in this range'}
+
+
+ {groups.length > 0
+ ? 'Every stop, in the order it was assigned, with where the rider last reported in.'
+ : 'Deliveries appear here once orders are assigned to a rider.'}
+
+
+
+ )}
+
-
+ )}
{detail ? (
setDetail(null)} />
diff --git a/src/features/store-admin/pages/dispatch.css b/src/features/store-admin/pages/dispatch.css
index 8d1928d..f3509d2 100644
--- a/src/features/store-admin/pages/dispatch.css
+++ b/src/features/store-admin/pages/dispatch.css
@@ -253,3 +253,221 @@
text-transform: capitalize;
white-space: nowrap;
}
+
+/* ── Plan vs actual ──────────────────────────────────────────────────────── */
+
+/* One colour per step, reused by the stacked bar and the legend swatches so the
+ two read as the same object. Ordered as the journey runs — cool at the shop,
+ warm on the road — rather than by a palette's own sequence. */
+.pva-bar {
+ display: flex;
+ height: 14px;
+ overflow: hidden;
+ border-radius: 7px;
+ background: var(--color-surface-sunken);
+}
+
+.pva-bar i,
+.pva-swatch {
+ display: block;
+}
+
+.pva-bar i[data-step='toShop'],
+.pva-swatch[data-step='toShop'] {
+ background: #662582;
+}
+
+.pva-bar i[data-step='counter'],
+.pva-swatch[data-step='counter'] {
+ background: #8b6bab;
+}
+
+.pva-bar i[data-step='inRound'],
+.pva-swatch[data-step='inRound'] {
+ background: #c2410c;
+}
+
+.pva-bar i[data-step='onRoad'],
+.pva-swatch[data-step='onRoad'] {
+ background: #0f8a5f;
+}
+
+.pva-swatch {
+ width: 9px;
+ height: 9px;
+ border-radius: 2px;
+}
+
+.pva-steps {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
+ gap: 4px 16px;
+}
+
+.pva-step {
+ display: grid;
+ grid-template-columns: 9px minmax(0, 1fr) auto;
+ gap: 4px 8px;
+ align-items: center;
+ padding: 6px 8px;
+ border: 1px solid transparent;
+ border-radius: 7px;
+}
+
+/* The step costing the most is the finding. Outlined rather than recoloured, so
+ the swatch keeps naming its own segment of the bar above. */
+.pva-step[data-bottleneck='true'] {
+ border-color: var(--color-border);
+ background: var(--color-surface-subtle);
+}
+
+.pva-step-label {
+ font-size: 12.5px;
+ color: var(--color-ink-1);
+}
+
+.pva-step-label em {
+ margin-left: 6px;
+ font-size: 10.5px;
+ font-style: normal;
+ font-weight: 600;
+ letter-spacing: 0.03em;
+ color: #b45309;
+ text-transform: uppercase;
+}
+
+.pva-step strong {
+ font-size: 13px;
+ font-variant-numeric: tabular-nums;
+ color: var(--color-ink-1);
+}
+
+.pva-step-note {
+ grid-column: 2 / -1;
+ font-size: 11px;
+ font-variant-numeric: tabular-nums;
+ color: var(--color-ink-4);
+}
+
+.pva-figure {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ padding-left: 10px;
+ border-left: 3px solid var(--color-border);
+}
+
+.pva-figure[data-tone='good'] {
+ border-left-color: #0f8a5f;
+}
+
+.pva-figure[data-tone='watch'] {
+ border-left-color: #b45309;
+}
+
+.pva-figure[data-tone='act'] {
+ border-left-color: #b91c1c;
+}
+
+.pva-figure-label {
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.03em;
+ color: var(--color-ink-3);
+ text-transform: uppercase;
+}
+
+.pva-figure strong {
+ font-size: 19px;
+ font-variant-numeric: tabular-nums;
+ line-height: 1.2;
+ color: var(--color-ink-1);
+}
+
+.pva-figure-note {
+ font-size: 11.5px;
+ color: var(--color-ink-4);
+}
+
+.pva-note {
+ display: flex;
+ gap: 7px;
+ align-items: flex-start;
+ padding: 8px 10px;
+ border-radius: 7px;
+ background: var(--color-surface-subtle);
+ font-size: 11.5px;
+ line-height: 1.5;
+ color: var(--color-ink-3);
+}
+
+.pva-note svg {
+ flex: none;
+ margin-top: 2px;
+}
+
+.pva-toggle {
+ display: inline-flex;
+ gap: 6px;
+ align-items: center;
+ font-size: 12.5px;
+ color: var(--color-ink-2);
+ cursor: pointer;
+}
+
+/* Rows here are read, not opened — nothing lies behind one — so the pointer and
+ hover lift the stops table uses would promise a click that does nothing. */
+.pva-table tbody tr {
+ cursor: default;
+}
+
+.pva-late[data-late='yes'] {
+ color: #b91c1c;
+}
+
+.pva-late[data-late='no'] {
+ color: #0f8a5f;
+}
+
+.pva-late[data-late='none'] {
+ color: var(--color-ink-4);
+}
+
+/* ── The map's rider filter ──────────────────────────────────────────────── */
+
+.rider-chip {
+ display: inline-flex;
+ gap: 6px;
+ align-items: center;
+ padding: 4px 10px;
+ border: 1px solid var(--color-border);
+ border-radius: 999px;
+ background: var(--color-surface);
+ font: inherit;
+ font-size: 12px;
+ color: var(--color-ink-2);
+ cursor: pointer;
+}
+
+.rider-chip:hover {
+ background: var(--color-surface-subtle);
+}
+
+.rider-chip[data-active='true'] {
+ border-color: var(--color-brand);
+ background: var(--color-brand-tint);
+ color: var(--color-ink-1);
+}
+
+.rider-chip:focus-visible {
+ outline: 2px solid var(--color-brand);
+ outline-offset: 1px;
+}
+
+/* The swatch matches this rider's line on the map, which is the only thing
+ connecting a chip to the shape it filters to. */
+.rider-chip i {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+}
diff --git a/src/features/store-admin/plannedVsActual.test.ts b/src/features/store-admin/plannedVsActual.test.ts
new file mode 100644
index 0000000..136fbcb
--- /dev/null
+++ b/src/features/store-admin/plannedVsActual.test.ts
@@ -0,0 +1,250 @@
+/**
+ * Promised against happened.
+ *
+ * The rows below are copied from production (tenant 916, 29 August 2026) rather
+ * than invented, because every awkward thing this module exists to handle is
+ * something the live data does and a hand-written fixture would not: a
+ * `starttime` later than `arrivaltime`, an `actualkms` identical to `kms`, a
+ * `riderkms` of 0.0026, and a promise written in twelve-hour time.
+ */
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import type { DeliveryRow } from '@/api/types';
+import { compare, journeyOf, lateness, parsePromise, span } from './plannedVsActual';
+
+/** One batch of four, assigned together — the rows that revealed the ladder. */
+const BATCH: DeliveryRow[] = [
+ {
+ deliveryid: 1,
+ orderid: '916-1',
+ ridername: 'Varun',
+ orderstatus: 'delivered',
+ assigntime: '2026-08-29 11:22:36',
+ arrivaltime: '2026-08-29 12:37:26',
+ pickuptime: '2026-08-29 12:41:20',
+ starttime: '2026-08-29 13:16:23',
+ deliverytime: '2026-08-29 13:16:57',
+ expecteddeliverytime: '2026-08-29 01:00 PM',
+ kms: '6',
+ actualkms: '6.0',
+ riderkms: '4.2',
+ },
+ {
+ deliveryid: 2,
+ orderid: '916-2',
+ ridername: 'Varun',
+ orderstatus: 'delivered',
+ assigntime: '2026-08-29 11:22:36',
+ arrivaltime: '2026-08-29 12:37:29',
+ pickuptime: '2026-08-29 12:41:21',
+ starttime: '2026-08-29 13:17:19',
+ deliverytime: '2026-08-29 13:38:26',
+ expecteddeliverytime: '2026-08-29 01:30 PM',
+ kms: '8',
+ actualkms: '8.0',
+ riderkms: '6.1',
+ },
+ {
+ deliveryid: 3,
+ orderid: '916-3',
+ ridername: 'Varun',
+ orderstatus: 'delivered',
+ assigntime: '2026-08-29 11:22:36',
+ arrivaltime: '2026-08-29 12:37:30',
+ pickuptime: '2026-08-29 12:41:23',
+ starttime: '2026-08-29 13:38:37',
+ deliverytime: '2026-08-29 13:44:14',
+ expecteddeliverytime: '2026-08-29 01:20 PM',
+ kms: '2',
+ actualkms: '2.0',
+ // A failed GPS reading, exactly as production sends it.
+ riderkms: '0.0026',
+ },
+ {
+ deliveryid: 4,
+ orderid: '916-4',
+ ridername: 'Varun',
+ orderstatus: 'delivered',
+ assigntime: '2026-08-29 11:22:36',
+ arrivaltime: '2026-08-29 12:37:33',
+ pickuptime: '2026-08-29 12:41:26',
+ starttime: '2026-08-29 13:44:56',
+ deliverytime: '2026-08-29 13:59:19',
+ expecteddeliverytime: '2026-08-29 02:10 PM',
+ kms: '6',
+ actualkms: '6.0',
+ riderkms: '5.5',
+ },
+];
+
+/* ── Reading one delivery ────────────────────────────────────────────────── */
+
+// The whole point. Read in column order the ladder gives negative steps; read
+// as assign → arrive → pickup → start → deliver every step is positive.
+test('every step of the ladder is a positive duration', () => {
+ for (const row of BATCH) {
+ for (const step of journeyOf(row).steps) {
+ assert.ok(step.ms !== null && step.ms >= 0, `${row.orderid} ${step.key} was ${step.ms}`);
+ }
+ }
+});
+
+test('the steps mean what the ladder says they mean', () => {
+ const journey = journeyOf(BATCH[1] as DeliveryRow);
+ const of = (key: string) => journey.steps.find((step) => step.key === key)!.ms!;
+ // 11:22:36 → 12:37:29
+ assert.equal(Math.round(of('toShop') / 60_000), 75);
+ // 12:37:29 → 12:41:21
+ assert.equal(Math.round(of('counter') / 60_000), 4);
+ // 12:41:21 → 13:17:19, waiting while the first drop was done
+ assert.equal(Math.round(of('inRound') / 60_000), 36);
+ // 13:17:19 → 13:38:26
+ assert.equal(Math.round(of('onRoad') / 60_000), 21);
+});
+
+// A round's first drop gets its `starttime` written moments before delivery.
+test("a round's first drop is flagged rather than believed", () => {
+ assert.equal(journeyOf(BATCH[0] as DeliveryRow).isFirstLeg, true, '34 seconds is not a ride');
+ assert.equal(journeyOf(BATCH[1] as DeliveryRow).isFirstLeg, false);
+});
+
+// `actualkms` equals `kms` to the decimal on 498 of 498 production rows. Showing
+// it as "actual" would draw two identical bars and call the round perfect.
+test('the measured distance is riderkms, never actualkms', () => {
+ const journey = journeyOf(BATCH[1] as DeliveryRow);
+ assert.equal(journey.plannedKm, 8);
+ assert.equal(journey.riddenKm, 6.1, 'actualkms (8.0) was used as the measurement');
+});
+
+test('a GPS reading of three metres is treated as absent, not as a short trip', () => {
+ const journey = journeyOf(BATCH[2] as DeliveryRow);
+ assert.equal(journey.riddenKm, null);
+ assert.equal(journey.plannedKm, 2, 'the plan is still known');
+});
+
+test('a stamp written out of order leaves a blank, not a negative bar', () => {
+ const journey = journeyOf({
+ deliveryid: 9,
+ assigntime: '2026-08-29 13:00:00',
+ arrivaltime: '2026-08-29 12:00:00',
+ pickuptime: '2026-08-29 12:05:00',
+ } as DeliveryRow);
+ assert.equal(journey.steps.find((step) => step.key === 'toShop')!.ms, null);
+ assert.equal(journey.steps.find((step) => step.key === 'counter')!.ms, 5 * 60_000);
+});
+
+test('a delivery nobody has finished has a blank total, not a running one', () => {
+ const journey = journeyOf({
+ deliveryid: 9,
+ assigntime: '2026-08-29 13:00:00',
+ orderstatus: 'pending',
+ } as DeliveryRow);
+ assert.equal(journey.totalMs, null);
+ assert.equal(journey.lateMs, null);
+ assert.equal(journey.steps.every((step) => step.ms === null), true);
+});
+
+/* ── The promise ─────────────────────────────────────────────────────────── */
+
+// The fourth timestamp format Fiesta sends, and the only twelve-hour one. Only
+// the ISO format is specified; a meridiem string is implementation-defined, so
+// this is pinned against an explicitly constructed instant rather than against
+// whatever the running engine happens to do with the same string.
+test('a twelve-hour promise is read as the instant it names', () => {
+ assert.equal(parsePromise('2026-08-29 07:27 PM'), Date.parse('2026-08-29T19:27:00'));
+});
+
+test('midnight and noon do not swap', () => {
+ assert.equal(parsePromise('2026-08-29 12:15 AM'), Date.parse('2026-08-29T00:15:00'));
+ assert.equal(parsePromise('2026-08-29 12:15 PM'), Date.parse('2026-08-29T12:15:00'));
+});
+
+test('a twenty-four-hour promise is read too, so a format change does not blank the panel', () => {
+ assert.equal(parsePromise('2026-08-29 19:27:30'), Date.parse('2026-08-29T19:27:30'));
+});
+
+test('no promise, or an unreadable one, is null rather than a guess', () => {
+ assert.equal(parsePromise(''), null);
+ assert.equal(parsePromise(undefined), null);
+ assert.equal(parsePromise('soon'), null);
+});
+
+test('lateness is measured against the promise, either way', () => {
+ // Promised 01:00 PM, delivered 13:16:57.
+ assert.ok(journeyOf(BATCH[0] as DeliveryRow).lateMs! > 16 * 60_000);
+ // Promised 02:10 PM, delivered 13:59:19 — early.
+ assert.ok(journeyOf(BATCH[3] as DeliveryRow).lateMs! < 0);
+});
+
+/* ── Many journeys ───────────────────────────────────────────────────────── */
+
+test('the summary counts what was promised and what landed on time', () => {
+ const result = compare(BATCH);
+ assert.equal(result.journeys, 4);
+ assert.equal(result.promised, 4);
+ assert.equal(result.onTime, 1, 'only the last drop beat its promise');
+});
+
+// Getting to the shop was 75 minutes; everything else was minutes. Naming the
+// bottleneck is the one thing this panel is for.
+test('the bottleneck is the step that actually costs the time', () => {
+ assert.equal(compare(BATCH).bottleneck, 'toShop');
+});
+
+test("the first drop's artefact leg is kept out of the on-road figure", () => {
+ const result = compare(BATCH);
+ const onRoad = result.steps.find((step) => step.key === 'onRoad')!;
+ assert.equal(result.firstLegs, 1);
+ assert.equal(onRoad.measured, 3, "the 34-second leg was averaged in");
+ assert.ok(
+ onRoad.medianMs! > 5 * 60_000,
+ `a real ride, not ${Math.round(onRoad.medianMs! / 1000)}s`,
+ );
+});
+
+test('shares add up to the whole, so the bars fill the bar', () => {
+ const total = compare(BATCH).steps.reduce((sum, step) => sum + step.share, 0);
+ assert.ok(Math.abs(total - 1) < 0.0001);
+});
+
+test('a step nobody stamped is measured zero times rather than counted as instant', () => {
+ const result = compare([
+ { deliveryid: 1, assigntime: '2026-08-29 11:00:00', arrivaltime: '2026-08-29 11:30:00' } as DeliveryRow,
+ ]);
+ const counter = result.steps.find((step) => step.key === 'counter')!;
+ assert.equal(counter.measured, 0);
+ assert.equal(counter.medianMs, null);
+ assert.equal(counter.share, 0);
+});
+
+test('distance compares plan against measurement, over the rows that have both', () => {
+ const { distance } = compare(BATCH);
+ assert.equal(distance.measured, 3, 'the 0.0026 km row has no measurement');
+ assert.equal(distance.medianPlannedKm, 6);
+ assert.equal(distance.medianRiddenKm, 5.5);
+});
+
+test('an empty day summarises to blanks, not zeroes', () => {
+ const result = compare([]);
+ assert.equal(result.journeys, 0);
+ assert.equal(result.medianLateMs, null);
+ assert.equal(result.bottleneck, null);
+ assert.equal(result.distance.medianRiddenKm, null);
+ assert.equal(result.steps.every((step) => step.medianMs === null), true);
+});
+
+/* ── Wording ─────────────────────────────────────────────────────────────── */
+
+test('spans read at the scale they are', () => {
+ assert.equal(span(null), '—');
+ assert.equal(span(34_000), '34s');
+ assert.equal(span(42 * 60_000), '42m');
+ assert.equal(span(69 * 60_000), '1h 09m');
+});
+
+test('lateness reads as a sentence, not a signed number', () => {
+ assert.equal(lateness(null), '—');
+ assert.equal(lateness(30_000), 'on time');
+ assert.equal(lateness(48 * 60_000), '48m late');
+ assert.equal(lateness(-12 * 60_000), '12m early');
+});
diff --git a/src/features/store-admin/plannedVsActual.ts b/src/features/store-admin/plannedVsActual.ts
new file mode 100644
index 0000000..4a609ff
--- /dev/null
+++ b/src/features/store-admin/plannedVsActual.ts
@@ -0,0 +1,298 @@
+/**
+ * What was promised against what happened.
+ *
+ * ── The ladder is not the one the column names imply ────────────────────────
+ *
+ * `deliveries` carries five stamps — assign, start, arrival, pickup, delivery —
+ * and read in that order they are nonsense: `starttime` is later than
+ * `arrivaltime` on 316 of 316 rows measured on tenant 916. That looks like
+ * corrupt data and is not. Reading a batch out in sequence shows what the app
+ * is actually doing (tenant 916, 29 August, four drops assigned together):
+ *
+ * assign 11:22:36 all four handed over at once
+ * arrive 12:37:2x the rider reached the SHOP — once, for all four
+ * pickup 12:41:2x collected all four at the counter
+ * start 13:16:23 → deliver 13:16:57
+ * start 13:17:19 → deliver 13:38:26
+ * start 13:38:37 → deliver 13:44:14
+ * start 13:44:56 → deliver 13:59:19
+ *
+ * Each `starttime` lands twenty to forty seconds after the PREVIOUS drop was
+ * delivered. So `starttime` is per-DROP, not per-round: it is when the rider
+ * set off for this particular address, having finished the last one. The ladder
+ * is assign → arrive → pickup → start → deliver, and read that way every
+ * duration is positive and every step means something:
+ *
+ * assign → arrive getting to the shop median 69.5 min (tenant 916)
+ * arrive → pickup at the counter median 0.6 min
+ * pickup → start waiting its turn median 28.0 min
+ * start → deliver on the road median 0.5 min
+ *
+ * That last median is not a half-minute ride. A round's FIRST drop gets a
+ * `starttime` written moments before its delivery — the app stamps it late —
+ * so the first leg of every round reads as near-zero. It is flagged rather than
+ * averaged away; see `Journey.isFirstLeg`.
+ *
+ * ── `actualkms` is not the actual kilometres ────────────────────────────────
+ *
+ * It equals `kms` to the decimal on 498 of 498 delivered rows for tenant 916
+ * and 464 of 474 for tenant 908. It is a copy of the plan, and showing it
+ * beside `kms` as "planned vs actual" would draw two identical numbers and call
+ * the round perfectly executed. `riderkms` is the only measured distance — GPS
+ * derived, present on about two thirds of rows, and lossy: a quarter of the
+ * values it carries are under 3 metres. Below `MEASURED_KM_FLOOR` it is treated
+ * as absent, because "0.0026 km" is a failed reading, not a short trip.
+ */
+import type { DeliveryRow } from '@/api/types';
+
+/** Under this, `riderkms` is a failed GPS reading rather than a short trip. */
+const MEASURED_KM_FLOOR = 0.1;
+
+/** A leg this short is the app stamping late, not a ride. See the note above. */
+const IMPLAUSIBLE_LEG_MS = 60_000;
+
+export type StepKey = 'toShop' | 'counter' | 'inRound' | 'onRoad';
+
+export interface Step {
+ key: StepKey;
+ label: string;
+ /** Null when either stamp is missing — a blank, never a zero. */
+ ms: number | null;
+}
+
+export const STEP_LABEL: Record = {
+ toShop: 'Getting to the shop',
+ counter: 'At the counter',
+ inRound: 'Waiting its turn',
+ onRoad: 'On the road',
+};
+
+export interface Journey {
+ deliveryid: number;
+ orderid: string;
+ rider: string;
+ steps: Step[];
+ /** assign → deliver, the whole thing. Null unless both ends are stamped. */
+ totalMs: number | null;
+ /** `kms` — what the shop was quoted. */
+ plannedKm: number | null;
+ /** `riderkms` — the only measured distance, and only when it reads plausibly. */
+ riddenKm: number | null;
+ /** The promise made to the customer. */
+ promisedAt: number | null;
+ deliveredAt: number | null;
+ /** Positive is late. Null when nothing was promised or nothing was delivered. */
+ lateMs: number | null;
+ /**
+ * True when this drop's `onRoad` leg is too short to be a ride — the round's
+ * first drop, whose `starttime` the app writes moments before delivery.
+ * Excluded from the on-the-road median rather than dragging it to zero.
+ */
+ isFirstLeg: boolean;
+}
+
+/** A naive `2026-08-29 16:22:59`, read in the viewer's zone. */
+function stamp(value: string | undefined): number | null {
+ if (!value) return null;
+ const at = Date.parse(value.replace(' ', 'T'));
+ return Number.isFinite(at) ? at : null;
+}
+
+/**
+ * `expecteddeliverytime`, which arrives as `2026-08-29 07:27 PM`.
+ *
+ * The fourth timestamp format Fiesta sends and the only twelve-hour one, so it
+ * gets its own parser rather than being handed to `Date.parse`. ECMAScript
+ * mandates only the ISO format; everything else is implementation-defined, and
+ * a twelve-hour string with a meridiem is squarely in that territory. V8 does
+ * happen to read it correctly — so relying on `Date.parse` would work in Chrome
+ * and in the test runner, and could silently return Invalid Date in another
+ * engine, turning "45 minutes late" into "no promise recorded" on every row
+ * that has one. Parsed here so the answer does not depend on the browser.
+ */
+export function parsePromise(value: string | undefined): number | null {
+ if (!value) return null;
+ const match = value.match(/(\d{4})-(\d{2})-(\d{2})[T\s]+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM)?/i);
+ if (!match) return null;
+ const [, year, month, day, rawHour, minute, second, meridiem] = match;
+ let hour = Number(rawHour);
+ if (meridiem) {
+ hour %= 12;
+ if (/pm/i.test(meridiem)) hour += 12;
+ }
+ const at = Date.parse(
+ `${year}-${month}-${day}T${String(hour).padStart(2, '0')}:${minute}:${second ?? '00'}`,
+ );
+ return Number.isFinite(at) ? at : null;
+}
+
+function gap(from: number | null, to: number | null): number | null {
+ if (from === null || to === null) return null;
+ const ms = to - from;
+ // A negative step is a stamp written out of order. Blank, not a negative
+ // duration on the bar — the ladder above is what the stamps mean, and a row
+ // that contradicts it is a row this cannot describe.
+ return ms >= 0 ? ms : null;
+}
+
+/** One delivery, read as the journey it was. */
+export function journeyOf(row: DeliveryRow): Journey {
+ const assigned = stamp(row.assigntime);
+ const arrived = stamp(row.arrivaltime);
+ const picked = stamp(row.pickuptime);
+ const started = stamp(row.starttime);
+ const delivered = stamp(row.deliverytime);
+
+ const onRoad = gap(started, delivered);
+ const promisedAt = parsePromise(row.expecteddeliverytime);
+
+ const ridden = Number(row.riderkms);
+ const planned = Number(row.kms);
+
+ return {
+ deliveryid: row.deliveryid,
+ orderid: row.orderid ?? `#${row.deliveryid}`,
+ rider: row.ridername?.trim() || '—',
+ steps: [
+ { key: 'toShop', label: STEP_LABEL.toShop, ms: gap(assigned, arrived) },
+ { key: 'counter', label: STEP_LABEL.counter, ms: gap(arrived, picked) },
+ { key: 'inRound', label: STEP_LABEL.inRound, ms: gap(picked, started) },
+ { key: 'onRoad', label: STEP_LABEL.onRoad, ms: onRoad },
+ ],
+ totalMs: gap(assigned, delivered),
+ plannedKm: Number.isFinite(planned) && planned > 0 ? planned : null,
+ riddenKm: Number.isFinite(ridden) && ridden >= MEASURED_KM_FLOOR ? ridden : null,
+ promisedAt,
+ deliveredAt: delivered,
+ lateMs: promisedAt !== null && delivered !== null ? delivered - promisedAt : null,
+ isFirstLeg: onRoad !== null && onRoad < IMPLAUSIBLE_LEG_MS,
+ };
+}
+
+/** A step's shape across many journeys. */
+export interface StepSummary {
+ key: StepKey;
+ label: string;
+ /** Journeys with both stamps. The rest contribute nothing rather than a zero. */
+ measured: number;
+ medianMs: number | null;
+ p90Ms: number | null;
+ /** This step's share of the median total, 0–1, for the bar widths. */
+ share: number;
+}
+
+export interface Comparison {
+ journeys: number;
+ /** Journeys carrying a promise AND a delivery — the on-time denominator. */
+ promised: number;
+ onTime: number;
+ /** Median lateness across `promised`. Positive is late. Null when none. */
+ medianLateMs: number | null;
+ steps: StepSummary[];
+ /** The step with the largest median. Where the time actually goes. */
+ bottleneck: StepKey | null;
+ /** Journeys with a usable `riderkms`, and the medians to compare. */
+ distance: {
+ measured: number;
+ medianPlannedKm: number | null;
+ medianRiddenKm: number | null;
+ };
+ /** Rounds' first drops, excluded from the on-the-road figure. */
+ firstLegs: number;
+}
+
+function median(values: number[]): number | null {
+ if (values.length === 0) return null;
+ const sorted = [...values].sort((a, b) => a - b);
+ return sorted[sorted.length >> 1] as number;
+}
+
+function percentile(values: number[], p: number): number | null {
+ if (values.length === 0) return null;
+ const sorted = [...values].sort((a, b) => a - b);
+ return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] as number;
+}
+
+/**
+ * Many journeys, summarised.
+ *
+ * Medians rather than means throughout. One order assigned in the morning and
+ * delivered at closing drags a mean into uselessness, and the whole point of
+ * this panel is to say where the typical hour goes.
+ */
+export function compare(rows: readonly DeliveryRow[]): Comparison {
+ const journeys = rows.map(journeyOf);
+
+ const promised = journeys.filter(
+ (journey) => journey.lateMs !== null,
+ );
+ const lateness = promised.map((journey) => journey.lateMs as number);
+
+ const steps: StepSummary[] = (['toShop', 'counter', 'inRound', 'onRoad'] as StepKey[]).map(
+ (key) => {
+ const values = journeys
+ // The first drop of a round has a `starttime` written moments before
+ // its delivery, so its on-road leg is an artefact. Included, it pulls
+ // the median to half a minute and the panel reports that riders spend
+ // no time riding.
+ .filter((journey) => !(key === 'onRoad' && journey.isFirstLeg))
+ .map((journey) => journey.steps.find((step) => step.key === key)?.ms)
+ .filter((ms): ms is number => ms !== null && ms !== undefined);
+ return {
+ key,
+ label: STEP_LABEL[key],
+ measured: values.length,
+ medianMs: median(values),
+ p90Ms: percentile(values, 0.9),
+ share: 0,
+ };
+ },
+ );
+
+ const totalMedian = steps.reduce((total, step) => total + (step.medianMs ?? 0), 0);
+ for (const step of steps) {
+ step.share = totalMedian > 0 ? (step.medianMs ?? 0) / totalMedian : 0;
+ }
+
+ const withDistance = journeys.filter((journey) => journey.riddenKm !== null);
+
+ const bottleneck =
+ steps
+ .filter((step) => step.medianMs !== null)
+ .sort((a, b) => (b.medianMs as number) - (a.medianMs as number))[0]?.key ?? null;
+
+ return {
+ journeys: journeys.length,
+ promised: promised.length,
+ onTime: lateness.filter((ms) => ms <= 0).length,
+ medianLateMs: median(lateness),
+ steps,
+ bottleneck,
+ distance: {
+ measured: withDistance.length,
+ medianPlannedKm: median(
+ withDistance
+ .map((journey) => journey.plannedKm)
+ .filter((km): km is number => km !== null),
+ ),
+ medianRiddenKm: median(withDistance.map((journey) => journey.riddenKm as number)),
+ },
+ firstLegs: journeys.filter((journey) => journey.isFirstLeg).length,
+ };
+}
+
+/** "1h 09m", "42m", "38s". Durations here span seconds to hours. */
+export function span(ms: number | null): string {
+ if (ms === null) return '—';
+ if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
+ const minutes = Math.round(ms / 60_000);
+ if (minutes < 60) return `${minutes}m`;
+ return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m`;
+}
+
+/** "48m late", "12m early", "on time". Reads as a sentence, not a signed number. */
+export function lateness(ms: number | null): string {
+ if (ms === null) return '—';
+ if (Math.abs(ms) < 60_000) return 'on time';
+ return ms > 0 ? `${span(ms)} late` : `${span(-ms)} early`;
+}
diff --git a/src/queries/hooks.ts b/src/queries/hooks.ts
index df62458..c260616 100644
--- a/src/queries/hooks.ts
+++ b/src/queries/hooks.ts
@@ -216,6 +216,38 @@ export function usePartnerRiderCounts(partnerids: readonly number[]) {
return counts;
}
+/**
+ * Every GPS ping a partner's riders sent over a window.
+ *
+ * ── Why the window is small by default ────────────────────────────────────
+ *
+ * The response is one row per ping and the riders ping constantly: August 2026
+ * returned 320,132 rows for eight riders. A month is several megabytes of JSON
+ * to fetch, parse and smooth, so the fleet view asks for a day or two and says
+ * which day it is showing.
+ *
+ * Cached longer than `stable` allows because it is history: yesterday's pings
+ * do not change, and a refetch on every focus would re-download the day.
+ */
+export function usePartnerRiderLogs(
+ partnerid: number | undefined,
+ range: { fromdate: string; todate: string },
+) {
+ return useQuery({
+ queryKey: queryKeys.partners.logs(partnerid ?? 0, range.fromdate, range.todate),
+ queryFn: () =>
+ partnersApi.riderLogs({
+ partnerid: partnerid as number,
+ fromdate: range.fromdate,
+ todate: range.todate,
+ }),
+ enabled:
+ typeof partnerid === 'number' && partnerid > 0 && Boolean(range.fromdate && range.todate),
+ ...stable,
+ staleTime: 5 * 60_000,
+ });
+}
+
/** The regions one partner covers. */
export function usePartnerLocations(partnerid: number | undefined) {
return useQuery({
diff --git a/src/queries/keys.ts b/src/queries/keys.ts
index faf0b14..2177025 100644
--- a/src/queries/keys.ts
+++ b/src/queries/keys.ts
@@ -13,6 +13,8 @@ export const queryKeys = {
all: ['partners'] as const,
inRegion: (applocationid: number) => ['partners', 'region', applocationid] as const,
locations: (partnerid: number) => ['partners', 'locations', partnerid] as const,
+ logs: (partnerid: number, fromdate: string, todate: string) =>
+ ['partners', 'logs', partnerid, fromdate, todate] as const,
},
/** The delivery regions a partner can cover. */
regions: { all: ['regions'] as const },