dispatch map, plan vs actual, and a fleet page

Three of the features the old console had and ours did not, built on what
the data can actually support rather than on what the column names imply.

Two findings changed the shape of the work:

`riderlogs` is not a GPS trail. Every ping a rider sends carries the SAME
coordinate — one rider's 2,404 pings on 14 August all read 11.052998,
76.929958, and the same holds on every day and region checked. Distance,
speed and "time moving" cannot come from it. The Fleet page therefore
reports presence only: who was online and for how long, inferred from the
gaps between check-ins, because `login`, `logout` and `workhours` are empty
on all 320,132 August rows. It says out loud that it cannot tell a rider
parked all day from one who crossed the city.

The delivery ladder is not the order its columns are in. `starttime` is
later than `arrivaltime` on 316 of 316 rows, which looks corrupt and is not:
`starttime` is per-DROP, stamped when the rider sets off for that address
having finished the last one. Read as assign -> arrive -> pickup -> start ->
deliver, every duration is positive. On tenant 916 that shows the bottleneck
is not the riding: a median 69.5 minutes passes between handing an order to
a rider and that rider reaching the shop, against 0.6 minutes at the counter.

- Map tab on dispatch, fed by `deliveries.riderslat/lon` — the only rider
  positions that move (353 distinct across 461 rows). Tenant-scoped, so a
  shop sees its own rounds. The line joins stops in worked order and says
  it is not a route.
- Plan vs actual tab: promised against delivered, and a step breakdown of
  where the hours go. `actualkms` is excluded — it equals the planned `kms`
  to the decimal on every delivered row, so it is a copy, not a measurement.
- Fleet page in the platform console: a presence gantt and a map of where
  each rider is registered.
- `ridername` holds a delivery status on more rows than it holds a name for
  two riders in five, so names are resolved by excluding the status
  vocabulary first.
- leaflet, wrapped directly rather than via react-leaflet, lazy-loaded so
  only the pages with a map pay for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JYEsb8PNZ19G9R8gUjTU7n
This commit is contained in:
2026-09-09 17:02:21 +05:30
parent b760c1a078
commit 34bf7989f7
21 changed files with 3412 additions and 92 deletions

View File

@@ -0,0 +1,156 @@
import { useMemo, useState } from 'react';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { Info } from 'lucide-react';
import type { DeliveryRow } from '@/api/types';
import { TrailMap, trailColour, type MapPin, type MapTrail } from '@/components/TrailMap';
import { coverageOf, pathsOf } from './deliveryTrack';
import { statusColor } from './orderStatus';
import { DELIVERY_STATUS } from './orderStatus';
/**
* The day's rounds, on a map.
*
* ── Why this is fed by delivery rows and not the rider log ──────────────────
*
* `riderlogs` is the obvious source and is useless for position: every ping a
* rider sends carries the same coordinate, so a trail drawn from it is a single
* dot. `deliveries.riderslat` / `riderslon` are written when a rider moves a
* job along and do vary — 353 distinct positions across 461 positioned rows on
* one tenant. They are also tenant-scoped, so this belongs on the shop's board.
*
* ── The line is not a route ─────────────────────────────────────────────────
*
* Each point is where the rider stood when a delivery changed status, minutes
* apart. Joining them shows the ORDER a round was worked, which is worth
* seeing; it is not the road they took, and the note under the map says so
* rather than leaving the reader to assume a route they can act on.
*
* ── Coverage is stated ──────────────────────────────────────────────────────
*
* A map with four pins looks the same whether the day was quiet or the app
* stopped reporting. The count above it tells them apart.
*/
export function DispatchMap({ rows }: { rows: readonly DeliveryRow[] }) {
const paths = useMemo(() => pathsOf(rows), [rows]);
const coverage = useMemo(() => coverageOf(rows), [rows]);
const [focused, setFocused] = useState<number | null>(null);
const shown = focused === null ? paths : paths.filter((path) => path.userid === focused);
const trails: MapTrail[] = useMemo(
() =>
shown
.filter((path) => path.fixes.length > 1)
.map((path) => ({
id: path.userid,
label: `${path.rider} — ${path.fixes.length} stops, in order`,
points: path.fixes.map((fix) => ({ lat: fix.lat, lng: fix.lng })),
colour: trailColour(paths.findIndex((p) => p.userid === path.userid)),
})),
[shown, paths],
);
const pins: MapPin[] = useMemo(
() =>
shown.flatMap((path) =>
path.fixes.map((fix) => ({
id: `${path.userid}-${fix.deliveryid}`,
lat: fix.lat,
lng: fix.lng,
label: fix.orderid,
lines: [
path.rider,
fix.customer || fix.address || 'No address on the row',
`${fix.status}${fix.at ? ` · ${clock(fix.at)}` : ''}`,
],
// Coloured by the delivery's status, not the rider's: on a map the
// question is which stops are still open, and a round already reads
// as one shape from its line.
colour: statusColor(DELIVERY_STATUS, fix.status),
})),
),
[shown],
);
return (
<Card padding={0} elevation="low">
<VStack gap={1.5} padding={2}>
<HStack justify="between" align="center" gap={2} wrap="wrap">
<Text type="label" size="sm" weight="semibold">
Where the riders were
</Text>
<Text type="body" size="xsm" color="secondary">
{coverage.positioned} of {coverage.total} deliver
{coverage.total === 1 ? 'y' : 'ies'} reported a position
</Text>
</HStack>
{paths.length > 1 ? (
<HStack gap={0.5} wrap="wrap">
<RiderChip
label="Everyone"
colour="var(--color-ink-4)"
isActive={focused === null}
onClick={() => setFocused(null)}
/>
{paths.map((path, index) => (
<RiderChip
key={path.userid}
label={`${path.rider} · ${path.fixes.length}`}
colour={trailColour(index)}
isActive={focused === path.userid}
onClick={() => setFocused((prev) => (prev === path.userid ? null : path.userid))}
/>
))}
</HStack>
) : null}
<TrailMap
trails={trails}
pins={pins}
height={400}
emptyNote={
coverage.total === 0
? 'No deliveries in this range.'
: 'None of these deliveries carries a rider position. One is written each time a rider moves a job along, so a round nobody has touched yet has none.'
}
/>
<div className="pva-note">
<Info size={13} />
<span>
Each pin is where the rider stood when that delivery last changed status, coloured by
the status. The line joins one rider's stops in the order they were worked — it is not
the route they rode, and the distance along it is not the distance they covered.
</span>
</div>
</VStack>
</Card>
);
}
function RiderChip({
label,
colour,
isActive,
onClick,
}: {
label: string;
colour: string;
isActive: boolean;
onClick: () => void;
}) {
return (
<button type="button" className="rider-chip" data-active={isActive} onClick={onClick}>
<i style={{ background: colour }} />
{label}
</button>
);
}
function clock(at: number): string {
return new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}

View File

@@ -0,0 +1,333 @@
import { useMemo, useState } from 'react';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { Info } from 'lucide-react';
import type { DeliveryRow } from '@/api/types';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
import { compare, journeyOf, lateness, span, type StepKey } from './plannedVsActual';
/**
* Where the time actually goes between accepting an order and dropping it.
*
* ── Why a step breakdown and not "planned vs actual minutes" ────────────────
*
* There is no planned duration to compare against. `transitminutes` is present
* on about a quarter of rows and matches neither the pickup-to-delivery gap nor
* the start-to-delivery gap on any tenant measured, so it describes something
* nobody here can name. The one genuine plan-versus-outcome pair the data holds
* is the customer promise (`expecteddeliverytime`) against the delivery stamp,
* and that is the headline.
*
* The rest of the panel answers the question the promise raises: given the
* orders ARE late, at which step. On tenant 916 the answer was not the riding —
* a median 69.5 minutes passed between the order being handed to a rider and
* that rider reaching the shop, against 0.6 minutes at the counter. No amount of
* faster riding fixes that, and only a step breakdown shows it.
*
* ── Distance ────────────────────────────────────────────────────────────────
*
* `actualkms` is not the actual kilometres — it equals the planned `kms` to the
* decimal on every delivered row measured. `riderkms` is the only measurement,
* so that is the pairing shown, over the rows that carry a usable one.
*/
export function PlanVsActualPanel({
rows,
isLoading,
}: {
rows: readonly DeliveryRow[];
isLoading: boolean;
}) {
/* Only finished journeys. A delivery still in progress has half its stamps,
and including it would drag every median toward "unknown" while looking
like a measurement. */
const finished = useMemo(
() => rows.filter((row) => Boolean(row.deliverytime)),
[rows],
);
const result = useMemo(() => compare(finished), [finished]);
const journeys = useMemo(
() =>
finished
.map(journeyOf)
.sort((a, b) => (b.deliveredAt ?? 0) - (a.deliveredAt ?? 0)),
[finished],
);
const [onlyLate, setOnlyLate] = useState(false);
const shown = onlyLate ? journeys.filter((journey) => (journey.lateMs ?? 0) > 60_000) : journeys;
const paged = usePaged(shown, { resetKey: onlyLate ? 'late' : 'all' });
if (isLoading) {
return (
<Card padding={0} elevation="low">
<VStack padding={3}>
<Text type="body" size="sm" color="secondary">
Reading the day…
</Text>
</VStack>
</Card>
);
}
if (finished.length === 0) {
return (
<Card padding={0} elevation="low">
<VStack gap={1} padding={4} align="center">
<Text type="label" size="sm" weight="semibold">
Nothing finished in this range
</Text>
<Text type="body" size="sm" color="secondary" style={{ textAlign: 'center', maxWidth: 400 }}>
This compares completed deliveries against what was promised. Widen the date range in
the top bar, or come back once today's round is done.
</Text>
</VStack>
</Card>
);
}
const onTimeShare = result.promised > 0 ? result.onTime / result.promised : null;
return (
<VStack gap={1.5}>
{/* ── The promise ────────────────────────────────────────────────── */}
<Card padding={0} elevation="low">
<VStack gap={1.5} padding={2}>
<Text type="label" size="sm" weight="semibold">
Against the promise
</Text>
{result.promised === 0 ? (
<Text type="body" size="sm" color="secondary">
None of these {result.journeys} deliveries carried a promised time, so there is
nothing to measure them against. The app writes{' '}
<code>expecteddeliverytime</code> when it quotes the customer; on this shop it is
not being written.
</Text>
) : (
<HStack gap={3} wrap="wrap" align="center">
<Figure
label="On time"
value={`${Math.round((onTimeShare ?? 0) * 100)}%`}
note={`${result.onTime} of ${result.promised} promised`}
tone={(onTimeShare ?? 0) >= 0.8 ? 'good' : (onTimeShare ?? 0) >= 0.5 ? 'watch' : 'act'}
/>
<Figure
label="Typically"
value={lateness(result.medianLateMs)}
note="median against the promise"
tone={(result.medianLateMs ?? 0) > 15 * 60_000 ? 'act' : 'watch'}
/>
{result.journeys > result.promised ? (
<Figure
label="No promise"
value={String(result.journeys - result.promised)}
note="delivered with nothing quoted"
tone="watch"
/>
) : null}
</HStack>
)}
</VStack>
</Card>
{/* ── Where the time goes ────────────────────────────────────────── */}
<Card padding={0} elevation="low">
<VStack gap={1.5} padding={2}>
<HStack justify="between" align="end" gap={2} wrap="wrap">
<Text type="label" size="sm" weight="semibold">
Where the time goes
</Text>
<Text type="body" size="xsm" color="secondary">
median of {result.journeys} finished deliver{result.journeys === 1 ? 'y' : 'ies'}
</Text>
</HStack>
{/* One bar, split by each step's share of the median journey. It
answers "which part of this is the problem" before any number is
read, which a table of four medians does not. */}
<div className="pva-bar">
{result.steps.map((step) =>
step.share > 0 ? (
<i
key={step.key}
data-step={step.key}
data-bottleneck={step.key === result.bottleneck}
style={{ width: `${step.share * 100}%` }}
title={`${step.label} · ${span(step.medianMs)}`}
/>
) : null,
)}
</div>
<div className="pva-steps">
{result.steps.map((step) => (
<div key={step.key} className="pva-step" data-bottleneck={step.key === result.bottleneck}>
<span className="pva-swatch" data-step={step.key} />
<span className="pva-step-label">
{step.label}
{step.key === result.bottleneck ? <em>the bottleneck</em> : null}
</span>
<strong>{span(step.medianMs)}</strong>
<span className="pva-step-note">
{step.measured === 0
? 'never stamped'
: `p90 ${span(step.p90Ms)} · ${step.measured} measured`}
</span>
</div>
))}
</div>
{result.firstLegs > 0 ? (
<Note>
{result.firstLegs} of these are the first drop of a round, whose “on the road” stamp
the rider app writes moments before delivery. They are counted everywhere else and
left out of the on-the-road figure, which would otherwise read as seconds.
</Note>
) : null}
</VStack>
</Card>
{/* ── Distance ───────────────────────────────────────────────────── */}
<Card padding={0} elevation="low">
<VStack gap={1} padding={2}>
<Text type="label" size="sm" weight="semibold">
Distance
</Text>
{result.distance.measured === 0 ? (
<Text type="body" size="sm" color="secondary">
No delivery in this range carries a measured distance. The rider app reports one
(<code>riderkms</code>) on roughly two thirds of deliveries platform-wide, and the
readings under a hundred metres are treated as failed rather than as short trips.
</Text>
) : (
<HStack gap={3} wrap="wrap" align="center">
<Figure
label="Quoted"
value={`${result.distance.medianPlannedKm ?? '—'} km`}
note="median planned"
tone="neutral"
/>
<Figure
label="Ridden"
value={`${result.distance.medianRiddenKm ?? '—'} km`}
note={`median measured, over ${result.distance.measured}`}
tone="neutral"
/>
</HStack>
)}
</VStack>
</Card>
{/* ── The deliveries themselves ──────────────────────────────────── */}
<Card padding={0} elevation="low">
<VStack gap={1} padding={2} align="stretch">
<HStack justify="between" align="center" gap={2} wrap="wrap">
<Text type="label" size="sm" weight="semibold">
Delivery by delivery
</Text>
<label className="pva-toggle">
<input
type="checkbox"
checked={onlyLate}
onChange={(event) => setOnlyLate(event.target.checked)}
/>
Only the late ones
</label>
</HStack>
</VStack>
<div className="table-scroll">
<table className="stops-table pva-table">
<thead>
<tr>
<th>Order</th>
<th>Rider</th>
<th>To the shop</th>
<th>Counter</th>
<th>Waiting</th>
<th>On the road</th>
<th>Total</th>
<th>Against promise</th>
</tr>
</thead>
<tbody>
{paged.rows.map((journey) => {
const of = (key: StepKey) =>
journey.steps.find((step) => step.key === key)?.ms ?? null;
const late = journey.lateMs;
return (
<tr key={journey.deliveryid}>
<td>
<strong>{journey.orderid}</strong>
<span>
{journey.deliveredAt
? new Date(journey.deliveredAt).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})
: ''}
</span>
</td>
<td>{journey.rider}</td>
<td className="num">{span(of('toShop'))}</td>
<td className="num">{span(of('counter'))}</td>
<td className="num">{span(of('inRound'))}</td>
<td className="num">
{journey.isFirstLeg ? (
<span title="First drop of a round — the app stamps this late">
<em className="muted">n/a</em>
</span>
) : (
span(of('onRoad'))
)}
</td>
<td className="num">{span(journey.totalMs)}</td>
<td className="num">
<span
className="pva-late"
data-late={late === null ? 'none' : late > 60_000 ? 'yes' : 'no'}
>
{lateness(late)}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<TablePager paged={paged} label="deliveries" />
</Card>
</VStack>
);
}
function Figure({
label,
value,
note,
tone,
}: {
label: string;
value: string;
note: string;
tone: 'good' | 'watch' | 'act' | 'neutral';
}) {
return (
<div className="pva-figure" data-tone={tone}>
<span className="pva-figure-label">{label}</span>
<strong>{value}</strong>
<span className="pva-figure-note">{note}</span>
</div>
);
}
function Note({ children }: { children: React.ReactNode }) {
return (
<div className="pva-note">
<Info size={13} />
<span>{children}</span>
</div>
);
}

View File

@@ -0,0 +1,168 @@
/**
* Rider positions, from the only column that actually varies.
*
* The awkward rows here are copied from production: a `ridername` of
* "delivered", positions written as empty strings, and the single-fix rider who
* still deserves a pin.
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
import type { DeliveryRow } from '@/api/types';
import { coverageOf, fixesOf, pathsOf } from './deliveryTrack';
function row(over: Partial<DeliveryRow>): DeliveryRow {
return {
deliveryid: 1,
orderid: '916-1',
userid: 883,
ridername: 'Rajan',
orderstatus: 'delivered',
assigntime: '2026-08-29 11:22:36',
deliverytime: '2026-08-29 13:16:57',
riderslat: '11.039621',
riderslon: '76.929141',
...over,
} as DeliveryRow;
}
test('a positioned delivery becomes a fix', () => {
const [fix] = fixesOf([row({})]);
assert.equal(fix!.lat, 11.039621);
assert.equal(fix!.lng, 76.929141);
assert.equal(fix!.orderid, '916-1');
assert.equal(fix!.status, 'delivered');
});
// 39 of 500 rows on tenant 916 carry no position at all.
test('a delivery with no position is left off the map rather than placed at zero', () => {
assert.deepEqual(fixesOf([row({ riderslat: '', riderslon: '' })]), []);
assert.deepEqual(fixesOf([row({ riderslat: undefined, riderslon: undefined })]), []);
});
// A row with one half of the pair would land on the equator or the prime
// meridian, which is a confident lie rather than a missing value.
test('half a position is no position', () => {
assert.deepEqual(fixesOf([row({ riderslon: '' })]), []);
assert.deepEqual(fixesOf([row({ riderslat: '0' })]), []);
});
// `updatedelivery` writes the position alongside whichever stamp the new status
// sets, so the newest stamp is when the position was taken.
test('a fix is timed by the latest stamp on its row', () => {
const [fix] = fixesOf([
row({
assigntime: '2026-08-29 11:22:36',
arrivaltime: '2026-08-29 12:37:26',
deliverytime: '2026-08-29 13:16:57',
}),
]);
assert.equal(fix!.at, Date.parse('2026-08-29T13:16:57'));
});
test('a delivery only just assigned is timed by its assign stamp', () => {
const [fix] = fixesOf([
row({ orderstatus: 'pending', deliverytime: '', arrivaltime: '', assigntime: '2026-08-29 11:22:36' }),
]);
assert.equal(fix!.at, Date.parse('2026-08-29T11:22:36'));
});
test('a fix with no readable stamp is still a place, just an undated one', () => {
const [fix] = fixesOf([row({ assigntime: '', deliverytime: '', arrivaltime: '' })]);
assert.equal(fix!.at, null);
assert.equal(fix!.lat, 11.039621);
});
/* ── Paths ───────────────────────────────────────────────────────────────── */
test("a rider's fixes are joined in the order they happened", () => {
const [path] = pathsOf([
row({ deliveryid: 2, deliverytime: '2026-08-29 14:00:00', riderslat: '11.05', riderslon: '76.95' }),
row({ deliveryid: 1, deliverytime: '2026-08-29 13:00:00', riderslat: '11.04', riderslon: '76.94' }),
row({ deliveryid: 3, deliverytime: '2026-08-29 15:00:00', riderslat: '11.06', riderslon: '76.96' }),
]);
assert.deepEqual(path!.fixes.map((fix) => fix.deliveryid), [1, 2, 3]);
});
// `ridername` holds a delivery status on many rows. Grouping on it would split
// one rider's round in two and invent a rider called "delivered".
test('riders are grouped on their id, never on the name', () => {
const paths = pathsOf([
row({ deliveryid: 1, userid: 883, ridername: 'Rajan' }),
row({ deliveryid: 2, userid: 883, ridername: 'delivered', riderslat: '11.04' }),
]);
assert.equal(paths.length, 1);
assert.equal(paths[0]!.rider, 'Rajan');
assert.equal(paths[0]!.fixes.length, 2);
});
// The real shape on tenant 916: rider 897 is "Varun" on 69 rows and "delivered"
// on 75. Taking the most common value would name them "delivered".
test('a status is never mistaken for a name, even when it is the common value', () => {
const rows = [
...Array.from({ length: 3 }, (_, i) =>
row({ deliveryid: i + 1, userid: 897, ridername: 'Varun', riderslat: `11.0${i + 1}` }),
),
...Array.from({ length: 7 }, (_, i) =>
row({ deliveryid: i + 10, userid: 897, ridername: 'delivered', riderslat: `11.1${i}` }),
),
];
assert.equal(pathsOf(rows)[0]!.rider, 'Varun');
});
test('an order status in the name column is rejected too, not just a delivery one', () => {
assert.equal(
pathsOf([row({ userid: 1111, ridername: 'cancelled' })])[0]!.rider,
'Rider 1111',
);
});
// Better a plain id than a confident wrong name.
test('a rider whose every row carried a status is named by their id', () => {
const paths = pathsOf([
row({ deliveryid: 1, userid: 950, ridername: 'delivered' }),
row({ deliveryid: 2, userid: 950, ridername: 'delivered', riderslat: '11.04' }),
]);
assert.equal(paths[0]!.rider, 'Rider 950');
});
test('a rider with one known position still gets a path, and so a pin', () => {
const [path] = pathsOf([row({})]);
assert.equal(path!.fixes.length, 1);
});
test('the busiest rider is first, so the map legend leads with the round that matters', () => {
const paths = pathsOf([
row({ deliveryid: 1, userid: 1, ridername: 'A' }),
row({ deliveryid: 2, userid: 2, ridername: 'B', riderslat: '11.04' }),
row({ deliveryid: 3, userid: 2, ridername: 'B', riderslat: '11.05' }),
]);
assert.deepEqual(paths.map((path) => path.rider), ['B', 'A']);
});
test('a delivery nobody is carrying is grouped as unassigned rather than dropped', () => {
const [path] = pathsOf([row({ userid: 0, ridername: '' })]);
assert.equal(path!.rider, 'Unassigned');
});
// The line between two fixes is minutes apart and is not a road. The flag is on
// the type so the map cannot quietly start calling it a route.
test('a path always declares that its line is between events, not along a road', () => {
assert.equal(pathsOf([row({})])[0]!.isSampled, true);
});
/* ── Coverage ────────────────────────────────────────────────────────────── */
// A map with six pins looks the same whether the day was quiet or the reporting
// failed. The count is what tells them apart.
test('coverage says how much of the day the map can show', () => {
const coverage = coverageOf([
row({ deliveryid: 1 }),
row({ deliveryid: 2, riderslat: '11.04' }),
row({ deliveryid: 3, riderslat: '', riderslon: '' }),
]);
assert.deepEqual(coverage, { positioned: 2, total: 3, riders: 1 });
});
test('a day with no positions at all reports zero rather than throwing', () => {
assert.deepEqual(coverageOf([]), { positioned: 0, total: 0, riders: 0 });
});

View File

@@ -0,0 +1,200 @@
/**
* Where riders actually were, from the delivery rows.
*
* ── Why this and not the rider log ──────────────────────────────────────────
*
* `riderlogs` looks like the obvious source and is useless for position: every
* ping a rider sends carries the same coordinate. One rider's 2,404 pings on
* 14 August 2026 all read 11.052998, 76.929958, and the same holds on every day
* and every region checked. The app stamps a position once and repeats it.
*
* `deliveries.riderslat` / `riderslon` are written by `updatedelivery` when a
* rider moves a job along, and they DO vary — 353 distinct positions across the
* 461 positioned rows for tenant 916. Sparse (one point per delivery, not a
* trail) but real, and tenant-scoped, so a shop may see them.
*
* ── These are event locations, not samples ──────────────────────────────────
*
* Each point is where the rider stood when a delivery reached its current
* status. Nothing is smoothed or interpolated: a smoother would slide a
* "delivered" pin off the customer's door to make a line look better, and the
* door is the only thing on this map anybody needs to trust. The points are
* joined in time order so a round's shape is visible, and the line between two
* of them is explicitly not a route — see `RiderPath.isSampled`.
*/
import type { DeliveryRow } from '@/api/types';
import { DELIVERY_STATUS, ORDER_STATUS } from './orderStatus';
/** One place a rider was known to be, and why we know. */
export interface Fix {
deliveryid: number;
orderid: string;
lat: number;
lng: number;
/** The delivery's status when the position was written. */
status: string;
/** The most recent stamp on the row — when the position was most likely taken. */
at: number | null;
customer: string;
address: string;
}
export interface RiderPath {
userid: number;
rider: string;
fixes: Fix[];
/**
* Always true, and named so it cannot be forgotten: the line joining these
* points is drawn between events minutes apart, not sampled along a road. It
* shows the order a round was worked, never the route it took.
*/
isSampled: true;
}
/** A coordinate that is actually a coordinate. */
function coord(value: string | undefined): number | null {
if (!value) return null;
const n = Number(value);
return Number.isFinite(n) && n !== 0 ? n : null;
}
/**
* When a delivery's position was most likely written.
*
* `updatedelivery` writes the position alongside whichever stamp the new status
* sets, so the latest stamp on the row is the best available answer. Read in
* the ladder's order — assign, arrive, pickup, start, deliver — and the last
* one present wins.
*/
function fixedAt(row: DeliveryRow): number | null {
const stamps = [
row.assigntime,
row.arrivaltime,
row.pickuptime,
row.starttime,
row.deliverytime,
row.canceltime,
];
let latest: number | null = null;
for (const stamp of stamps) {
if (!stamp) continue;
const at = Date.parse(stamp.replace(' ', 'T'));
if (Number.isFinite(at) && (latest === null || at > latest)) latest = at;
}
return latest;
}
/** Every positioned delivery in the batch, as a fix. */
export function fixesOf(rows: readonly DeliveryRow[]): Fix[] {
const fixes: Fix[] = [];
for (const row of rows) {
const lat = coord(row.riderslat);
const lng = coord(row.riderslon);
// Both, or neither. A row with one of the pair is a half-written position
// and placing it on the equator would be worse than leaving it out.
if (lat === null || lng === null) continue;
fixes.push({
deliveryid: row.deliveryid,
orderid: row.orderid ?? `#${row.deliveryid}`,
lat,
lng,
status: row.orderstatus ?? 'unknown',
at: fixedAt(row),
customer: row.deliverycustomer?.trim() || '',
address: row.deliveryaddress?.trim() || row.deliverysuburb?.trim() || '',
});
}
return fixes;
}
/**
* A rider's name, from a column that sometimes holds a status instead.
*
* `ridername` is not reliably a name. Every rider on tenant 916 has BOTH their
* name and a delivery status in it, and for two of the five the status is the
* more common value:
*
* 883 { "Rajan": 21, "delivered": 42 }
* 897 { "Varun": 69, "delivered": 75 }
* 1111 { "Murali": 33, "delivered": 74, "cancelled": 1 }
* 1114 { "Tamilazhagan": 37, "delivered": 63 }
*
* So neither "first non-empty" nor "most common" finds the name — the first
* picks whichever row happens to sort first, the second picks "delivered" for
* two riders out of five. Statuses are excluded by vocabulary first, and the
* most common of what survives is the name.
*/
const NOT_A_NAME = new Set([
...Object.keys(DELIVERY_STATUS),
...Object.keys(ORDER_STATUS),
]);
function nameFor(rows: readonly DeliveryRow[], userid: number): string {
const counts = new Map<string, number>();
for (const row of rows) {
if (row.userid !== userid) continue;
const name = row.ridername?.trim();
if (!name || NOT_A_NAME.has(name.toLowerCase())) continue;
counts.set(name, (counts.get(name) ?? 0) + 1);
}
let best = '';
let most = 0;
for (const [name, count] of counts) {
if (count > most) {
best = name;
most = count;
}
}
// A rider whose every row carried a status is nameless rather than called
// "delivered" — the id is at least honest about being an id.
return best || (userid > 0 ? `Rider ${userid}` : 'Unassigned');
}
/**
* Fixes grouped into one path per rider, in time order.
*
* Grouped on `userid`, never on `ridername` — see `nameFor` for why that column
* cannot be trusted to identify anybody. Riders with a single fix are kept: one
* known position is still worth a pin, it just has no line.
*/
export function pathsOf(rows: readonly DeliveryRow[]): RiderPath[] {
const byRider = new Map<number, { rider: string; fixes: Fix[] }>();
const fixes = fixesOf(rows);
const rowById = new Map(rows.map((row) => [row.deliveryid, row]));
for (const fix of fixes) {
const userid = rowById.get(fix.deliveryid)?.userid ?? 0;
const entry = byRider.get(userid);
if (entry) entry.fixes.push(fix);
else byRider.set(userid, { rider: nameFor(rows, userid), fixes: [fix] });
}
return [...byRider.entries()]
.map(([userid, entry]) => ({
userid,
rider: entry.rider,
fixes: entry.fixes.sort((a, b) => (a.at ?? 0) - (b.at ?? 0)),
isSampled: true as const,
}))
.sort((a, b) => b.fixes.length - a.fixes.length);
}
/**
* How much of the day the map can actually show.
*
* Stated on the page rather than implied by a sparse map: "6 of 29 deliveries
* carry a position" is the difference between a quiet day and a reporting gap,
* and a map with six pins on it looks the same either way.
*/
export function coverageOf(rows: readonly DeliveryRow[]): {
positioned: number;
total: number;
riders: number;
} {
const paths = pathsOf(rows);
return {
positioned: paths.reduce((total, path) => total + path.fixes.length, 0),
total: rows.length,
riders: paths.length,
};
}

View File

@@ -6,9 +6,11 @@ import { VStack } from '@astryxdesign/core/VStack';
import {
Bike,
IndianRupee,
Map,
MapPin,
Package,
Store,
Timer,
Truck,
UserX,
Users,
@@ -27,7 +29,9 @@ import { useBranchScope } from '../BranchScope';
import { count, money, moneyExact } from '../format';
import { DELIVERY_STATUS, statusColor } from '../orderStatus';
import { shortAge } from '../posStatus';
import { DispatchMap } from '../DispatchMap';
import { OrderDetailDrawer } from '../OrderDetailDrawer';
import { PlanVsActualPanel } from '../PlanVsActualPanel';
import {
dayTotals,
groupByCustomer,
@@ -89,10 +93,21 @@ import './dispatch.css';
*/
const DISPATCH_STATUS: Record<string, string> = { ...DELIVERY_STATUS, [WAITING]: '#ef4444' };
/**
* The fourth tab is not a fourth grouping.
*
* "By rider", "by store" and "by customer" are three orderings of the same
* rows; "Plan vs actual" asks a different question of them and replaces the
* rail-and-table body entirely. Kept as a separate piece of state rather than a
* fourth `ViewMode`, so the grouping functions never have to answer for a value
* that is not a grouping.
*/
type Board = ViewMode | 'timing' | 'map';
export function DispatchPage() {
const { branches, selected, tenantid } = useBranchScope();
const dates = useDateScope();
const [mode, setMode] = useState<ViewMode>('riders');
const [mode, setMode] = useState<Board>('riders');
const [focused, setFocused] = useState<string | null>(null);
const [detail, setDetail] = useState<Stop | null>(null);
@@ -142,6 +157,7 @@ export function DispatchPage() {
);
const groups = useMemo(() => {
if (mode === 'timing' || mode === 'map') return [];
if (mode === 'stores') return groupByStore(stops, locations.data ?? branches);
if (mode === 'customers') return groupByCustomer(stops, customers.data ?? [], branchName);
return groupByRider(stops);
@@ -191,7 +207,7 @@ export function DispatchPage() {
[waiting, picked],
);
const changeMode = (next: ViewMode) => {
const changeMode = (next: Board) => {
setMode(next);
// A group id means nothing across groupings — a branch id is not a rider
// id — so the focus is dropped rather than carried into nonsense.
@@ -224,6 +240,18 @@ export function DispatchPage() {
isActive={mode === 'customers'}
onClick={() => changeMode('customers')}
/>
<ModeTab
label="Plan vs actual"
icon={<Timer size={14} />}
isActive={mode === 'timing'}
onClick={() => changeMode('timing')}
/>
<ModeTab
label="Map"
icon={<Map size={14} />}
isActive={mode === 'map'}
onClick={() => changeMode('map')}
/>
</HStack>
}
/>
@@ -259,77 +287,89 @@ export function DispatchPage() {
/>
</div>
<div className="dispatch-body">
<div className="dispatch-rail">
<Text type="label" size="xsm" color="secondary">
{mode === 'riders'
? isOneDay
? 'Rounds'
: 'Riders'
: mode === 'stores'
? 'Shops'
: 'Customers'}
</Text>
<GroupList
groups={groups}
mode={mode}
isLoading={deliveries.isLoading || (mode === 'customers' && customers.isLoading)}
focused={focused}
onFocus={(id) => setFocused((prev) => (prev === id ? null : id))}
/>
</div>
<div className="dispatch-main">
{open ? (
<GroupDetail
group={open}
{mode === 'map' ? (
/* The one place on the platform where rider positions actually move.
Fed by the delivery rows, which are tenant-scoped, so a shop sees its
own rounds and nobody else's. */
<DispatchMap rows={deliveries.data ?? []} />
) : 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. */
<PlanVsActualPanel rows={deliveries.data ?? []} isLoading={deliveries.isLoading} />
) : (
<div className="dispatch-body">
<div className="dispatch-rail">
<Text type="label" size="xsm" color="secondary">
{mode === 'riders'
? isOneDay
? 'Rounds'
: 'Riders'
: mode === 'stores'
? 'Shops'
: 'Customers'}
</Text>
<GroupList
groups={groups}
mode={mode}
onOpen={setDetail}
{...(open.id === UNASSIGNED
? {
selection: picked,
verdictOf: (row: OrderRow) => assignability(row, branchOf(row), assigned),
assignBar:
picked.count > 0 ? (
<AssignBar
orders={pickedOrders}
branchOf={branchOf}
assigned={assigned}
onClear={picked.clear}
onDone={picked.clear}
/>
) : null,
}
: {})}
isLoading={deliveries.isLoading || (mode === 'customers' && customers.isLoading)}
focused={focused}
onFocus={(id) => setFocused((prev) => (prev === id ? null : id))}
/>
) : (
<Card padding={0} elevation="low">
<VStack gap={1} padding={4} align="center">
<span style={{ color: 'var(--color-ink-4)' }}>
<MapPin size={22} />
</span>
<Text type="label" size="sm" weight="semibold">
{groups.length > 0
? 'Pick one to see its stops'
: isOneDay
? 'Nothing out on this day'
: 'Nothing out in this range'}
</Text>
<Text
type="body"
size="sm"
color="secondary"
style={{ textAlign: 'center', maxWidth: 380 }}
>
{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.'}
</Text>
</VStack>
</Card>
)}
</div>
<div className="dispatch-main">
{open ? (
<GroupDetail
group={open}
mode={mode}
onOpen={setDetail}
{...(open.id === UNASSIGNED
? {
selection: picked,
verdictOf: (row: OrderRow) => assignability(row, branchOf(row), assigned),
assignBar:
picked.count > 0 ? (
<AssignBar
orders={pickedOrders}
branchOf={branchOf}
assigned={assigned}
onClear={picked.clear}
onDone={picked.clear}
/>
) : null,
}
: {})}
/>
) : (
<Card padding={0} elevation="low">
<VStack gap={1} padding={4} align="center">
<span style={{ color: 'var(--color-ink-4)' }}>
<MapPin size={22} />
</span>
<Text type="label" size="sm" weight="semibold">
{groups.length > 0
? 'Pick one to see its stops'
: isOneDay
? 'Nothing out on this day'
: 'Nothing out in this range'}
</Text>
<Text
type="body"
size="sm"
color="secondary"
style={{ textAlign: 'center', maxWidth: 380 }}
>
{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.'}
</Text>
</VStack>
</Card>
)}
</div>
</div>
</div>
)}
{detail ? (
<OrderDetailDrawer row={detail.row} kind={detail.kind} onClose={() => setDetail(null)} />

View File

@@ -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%;
}

View File

@@ -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');
});

View File

@@ -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<StepKey, string> = {
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`;
}