diff --git a/src/components/TrailMap.tsx b/src/components/TrailMap.tsx
index 8e6595e..6b765ef 100644
--- a/src/components/TrailMap.tsx
+++ b/src/components/TrailMap.tsx
@@ -40,6 +40,12 @@ export interface MapPin {
colour?: string;
/** A hollow ring rather than a filled pin — for a last-known, not-live point. */
isFaded?: boolean;
+ /**
+ * A square marks a place rather than an event — a shop, a depot. Round pins
+ * are the things that happened there, so a branch never reads as one more
+ * customer among its own drops.
+ */
+ shape?: 'round' | 'square';
}
/** Tamil Nadu, so an empty map still shows the right part of the world. */
@@ -119,7 +125,7 @@ export function TrailMap({
title: pin.label,
icon: L.divIcon({
className: 'trail-pin-wrap',
- html: ``,
+ html: ``,
iconSize: [16, 16],
iconAnchor: [8, 8],
}),
diff --git a/src/components/trailMap.css b/src/components/trailMap.css
index 222a600..c19fb8e 100644
--- a/src/components/trailMap.css
+++ b/src/components/trailMap.css
@@ -52,6 +52,14 @@
box-shadow: 0 1px 4px rgb(15 23 42 / 45%);
}
+/* A place, not an event. Squared off and a touch larger so a shop reads as the
+ thing the round starts from rather than as one more drop on it. */
+.trail-pin[data-shape='square'] {
+ width: 15px;
+ height: 15px;
+ border-radius: 3px;
+}
+
/* A last-known position, not a live one. Hollow, so the difference between
"here now" and "here when they last reported" is visible on the map itself
and not only in the popup. */
diff --git a/src/features/store-admin/DispatchMap.tsx b/src/features/store-admin/DispatchMap.tsx
deleted file mode 100644
index 8fd5b5f..0000000
--- a/src/features/store-admin/DispatchMap.tsx
+++ /dev/null
@@ -1,156 +0,0 @@
-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(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 (
-
-
-
-
- Where the riders were
-
-
- {coverage.positioned} of {coverage.total} deliver
- {coverage.total === 1 ? 'y' : 'ies'} reported a position
-
-
-
- {paths.length > 1 ? (
-
- setFocused(null)}
- />
- {paths.map((path, index) => (
- setFocused((prev) => (prev === path.userid ? null : path.userid))}
- />
- ))}
-
- ) : null}
-
-
-
-
-
-
- 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.
-
-
-
-
- );
-}
-
-function RiderChip({
- label,
- colour,
- isActive,
- onClick,
-}: {
- label: string;
- colour: string;
- isActive: boolean;
- onClick: () => void;
-}) {
- return (
-
- );
-}
-
-function clock(at: number): string {
- return new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
-}
diff --git a/src/features/store-admin/GroupMap.tsx b/src/features/store-admin/GroupMap.tsx
new file mode 100644
index 0000000..f7bc597
--- /dev/null
+++ b/src/features/store-admin/GroupMap.tsx
@@ -0,0 +1,202 @@
+import { useMemo, useState } from 'react';
+import { HStack } from '@astryxdesign/core/HStack';
+import { Text } from '@astryxdesign/core/Text';
+import { VStack } from '@astryxdesign/core/VStack';
+import { Store } from 'lucide-react';
+import { TrailMap, trailColour, type MapPin, type MapTrail } from '@/components/TrailMap';
+import type { Stop } from './dispatchModel';
+import { coverageOf, mapStop, roundsOf, shopsOf } from './deliveryTrack';
+import { DELIVERY_STATUS, statusColor } from './orderStatus';
+import { moneyExact } from './format';
+
+/**
+ * A selected group's stops, on the map instead of in a table.
+ *
+ * ── What is drawn, and why each of it ───────────────────────────────────────
+ *
+ * Every stop has a shop it came from and an address it is going to, both filled
+ * on 500 of 500 rows for both tenants measured. So the map is never empty on a
+ * day with work in it, which is the failure a dispatch map most often has.
+ *
+ * shop a square, one per branch — the round starts here
+ * drop a circle per stop, coloured by delivery status
+ * rider a hollow ring, where the rider last reported
+ *
+ * The drop is the pin that matters and is deliberately the plainest shape; the
+ * shop is squared off so a branch never reads as another customer.
+ *
+ * ── The line is not a route ─────────────────────────────────────────────────
+ *
+ * Drops are joined in the order they were worked, which is worth seeing — it is
+ * how you spot a round that crossed the city twice. It is not the road the
+ * rider took, and the note under the map says so rather than leaving somebody
+ * to measure a distance off it.
+ *
+ * ── Scope ───────────────────────────────────────────────────────────────────
+ *
+ * Fed entirely from the group already on screen, so a shop sees its own work
+ * and nobody else's, and choosing a different rider or branch in the rail
+ * redraws this without another request.
+ */
+export function GroupMap({ stops, groupName }: { stops: readonly Stop[]; groupName: string }) {
+ const mapped = useMemo(() => stops.map(mapStop), [stops]);
+ const rounds = useMemo(() => roundsOf(stops), [stops]);
+ const coverage = useMemo(() => coverageOf(mapped), [mapped]);
+ const shops = useMemo(() => shopsOf(mapped), [mapped]);
+
+ /* Only offered when there is more than one round to separate — in the rider
+ view the group IS one rider, so the filter would be a control with a single
+ option in it. */
+ const [focused, setFocused] = useState(null);
+ const shown = focused === null ? rounds : rounds.filter((round) => round.userid === focused);
+
+ const trails: MapTrail[] = useMemo(
+ () =>
+ shown.flatMap((round) => {
+ const drops = round.stops.flatMap((stop) => (stop.drop ? [stop.drop] : []));
+ if (drops.length < 2) return [];
+ return [
+ {
+ id: round.userid,
+ label: `${round.rider} — ${drops.length} drops, in the order they were worked`,
+ points: drops,
+ colour: trailColour(rounds.findIndex((r) => r.userid === round.userid)),
+ },
+ ];
+ }),
+ [shown, rounds],
+ );
+
+ const pins: MapPin[] = useMemo(() => {
+ const out: MapPin[] = [];
+
+ // The shop first, so a drop pin drawn on top of it stays clickable.
+ for (const [index, shop] of shops.entries()) {
+ out.push({
+ id: `shop-${index}`,
+ lat: shop.lat,
+ lng: shop.lng,
+ label: 'Shop',
+ lines: ['Where these orders were collected'],
+ colour: 'var(--color-brand)',
+ shape: 'square',
+ });
+ }
+
+ for (const round of shown) {
+ for (const [index, stop] of round.stops.entries()) {
+ if (stop.drop) {
+ out.push({
+ id: `${stop.key}-drop`,
+ lat: stop.drop.lat,
+ lng: stop.drop.lng,
+ label: `${index + 1}. ${stop.orderid}`,
+ lines: [
+ stop.customer || stop.address || 'No address on the row',
+ `${stop.status}${stop.at ? ` · ${clock(stop.at)}` : ''}`,
+ `${round.rider} · ${moneyExact(stop.value)}`,
+ ],
+ colour: statusColor(DELIVERY_STATUS, stop.status),
+ });
+ }
+ if (stop.rider) {
+ out.push({
+ id: `${stop.key}-rider`,
+ lat: stop.rider.lat,
+ lng: stop.rider.lng,
+ label: round.rider,
+ lines: [`Last reported on ${stop.orderid}`, stop.at ? clock(stop.at) : ''],
+ colour: trailColour(rounds.findIndex((r) => r.userid === round.userid)),
+ isFaded: true,
+ });
+ }
+ }
+ }
+ return out;
+ }, [shown, shops, rounds]);
+
+ return (
+
+
+
+ {coverage.placed} of {coverage.total} stop{coverage.total === 1 ? '' : 's'} placed
+ {coverage.withRider > 0 ? ` · ${coverage.withRider} with a rider position` : ''}
+
+
+
+ shop
+
+ drop
+
+ rider
+
+
+
+ {/* In the store and customer views a group holds several riders' work, so
+ the round filter earns its place. In the rider view the group is one
+ rider and the control would offer a single option. */}
+ {rounds.length > 1 ? (
+
+ setFocused(null)}
+ />
+ {rounds.map((round, index) => (
+ setFocused((prev) => (prev === round.userid ? null : round.userid))}
+ />
+ ))}
+
+ ) : null}
+
+
+
+
+
+
+ Drops are joined in the order they were worked, not along the road the rider rode — the
+ length of that line is not the distance covered. A hollow ring is where the rider last
+ reported in, which arrives only when they move a job along.
+
+
+
+ );
+}
+
+function RoundChip({
+ label,
+ colour,
+ isActive,
+ onClick,
+}: {
+ label: string;
+ colour: string;
+ isActive: boolean;
+ onClick: () => void;
+}) {
+ return (
+
+ );
+}
+
+function clock(at: number): string {
+ return new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+}
diff --git a/src/features/store-admin/deliveryTrack.test.ts b/src/features/store-admin/deliveryTrack.test.ts
index 9d0d305..c23b6cb 100644
--- a/src/features/store-admin/deliveryTrack.test.ts
+++ b/src/features/store-admin/deliveryTrack.test.ts
@@ -1,168 +1,243 @@
/**
- * Rider positions, from the only column that actually varies.
+ * The three places a delivery happens.
*
- * 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.
+ * The awkward rows here are copied from production: `pickuplon` on the delivery
+ * endpoint against `pickuplong` on the orders one, `droplat` filled on
+ * deliveries and empty on orders, and a `ridername` of "delivered".
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
-import type { DeliveryRow } from '@/api/types';
-import { coverageOf, fixesOf, pathsOf } from './deliveryTrack';
+import type { DeliveryRow, OrderRow } from '@/api/types';
+import type { Stop } from './dispatchModel';
+import { coverageOf, mapStop, roundsOf, shopsOf } from './deliveryTrack';
+import { isRealName } from './orderStatus';
-function row(over: Partial): DeliveryRow {
+/** A delivery as `getdeliveries` sends one. */
+function delivery(over: Record = {}): Stop {
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;
+ kind: 'delivery',
+ row: {
+ 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',
+ pickuplat: '11.043236',
+ pickuplon: '77.0006758',
+ droplat: '11.082948',
+ droplon: '77.005914',
+ deliverylat: '11.082948',
+ deliverylong: '77.005914',
+ riderslat: '11.082815',
+ riderslon: '77.007213',
+ deliverycustomer: 'A customer',
+ deliveryaddress: 'Peelamedu',
+ deliveryamt: 36,
+ ...over,
+ } as unknown 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');
+/** An order as `getorders` sends one — different spellings, no drop columns. */
+function order(over: Record = {}): Stop {
+ return {
+ kind: 'order',
+ row: {
+ orderheaderid: 55,
+ orderid: '916-55',
+ orderstatus: 'pending',
+ orderdate: '2026-08-29T14:48:42+05:30',
+ pickuplat: '11.0432363',
+ pickuplong: '77.0006758',
+ droplat: '',
+ droplon: '',
+ deliverylat: '11.082948',
+ deliverylong: '77.005914',
+ ...over,
+ } as unknown as OrderRow,
+ };
+}
+
+/* ── Reading one stop ────────────────────────────────────────────────────── */
+
+test('a delivery gives up all three of its places', () => {
+ const stop = mapStop(delivery());
+ assert.deepEqual(stop.shop, { lat: 11.043236, lng: 77.0006758 });
+ assert.deepEqual(stop.drop, { lat: 11.082948, lng: 77.005914 });
+ assert.deepEqual(stop.rider, { lat: 11.082815, lng: 77.007213 });
});
-// 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 })]), []);
+// One letter apart, and reading the wrong one puts every shop on the prime
+// meridian off the coast of Ghana.
+test('pickuplon and pickuplong are both read, because the two endpoints differ', () => {
+ assert.deepEqual(mapStop(delivery()).shop, { lat: 11.043236, lng: 77.0006758 });
+ assert.deepEqual(mapStop(order()).shop, { lat: 11.0432363, lng: 77.0006758 });
});
-// 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' })]), []);
+// `droplat` is filled on deliveries and empty on orders, where `deliverylat`
+// carries the same value. A waiting order still has an address to place.
+test('an order with no droplat still gets its drop from deliverylat', () => {
+ const stop = mapStop(order());
+ assert.deepEqual(stop.drop, { lat: 11.082948, lng: 77.005914 });
});
-// `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 stop with no rider position has none, rather than a pin at zero', () => {
+ assert.equal(mapStop(delivery({ riderslat: '', riderslon: '' })).rider, null);
+ assert.equal(mapStop(order()).rider, null);
});
-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'));
+// A half-written pair reads as 0, and a pin on the equator is a confident lie
+// where a missing pin is an honest gap.
+test('half a coordinate is no coordinate', () => {
+ assert.equal(mapStop(delivery({ droplon: '', deliverylong: '' })).drop, null);
+ assert.equal(mapStop(delivery({ riderslat: '0' })).rider, null);
});
-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', () => {
+test('a stop is timed by the newest stamp on it', () => {
assert.equal(
- pathsOf([row({ userid: 1111, ridername: 'cancelled' })])[0]!.rider,
- 'Rider 1111',
+ mapStop(delivery()).at,
+ Date.parse('2026-08-29T13:16:57'),
+ 'the delivery stamp, not the assign one',
);
});
-// Better a plain id than a confident wrong name.
+test('a waiting order falls back to when it was placed', () => {
+ assert.equal(mapStop(order()).at, Date.parse('2026-08-29T14:48:42+05:30'));
+});
+
+test('orders and deliveries never collide on a key', () => {
+ assert.notEqual(mapStop(delivery()).key, mapStop(order()).key);
+});
+
+/* ── Names ───────────────────────────────────────────────────────────────── */
+
+// `ridername` holds a delivery status on more rows than it holds a name, for
+// two riders in five on tenant 916.
+test('a status is never mistaken for a name', () => {
+ assert.equal(isRealName('Varun'), true);
+ assert.equal(isRealName('delivered'), false);
+ assert.equal(isRealName('cancelled'), false);
+ assert.equal(isRealName(' '), false);
+ assert.equal(isRealName(undefined), false);
+});
+
+/* ── Rounds ──────────────────────────────────────────────────────────────── */
+
+test("a rider's stops are put in the order they were worked", () => {
+ const [round] = roundsOf([
+ delivery({ deliveryid: 2, deliverytime: '2026-08-29 14:00:00' }),
+ delivery({ deliveryid: 1, deliverytime: '2026-08-29 13:00:00' }),
+ delivery({ deliveryid: 3, deliverytime: '2026-08-29 15:00:00' }),
+ ]);
+ assert.deepEqual(round!.stops.map((stop) => stop.orderid), ['916-1', '916-1', '916-1']);
+ assert.deepEqual(
+ round!.stops.map((stop) => stop.at),
+ [
+ Date.parse('2026-08-29T13:00:00'),
+ Date.parse('2026-08-29T14:00:00'),
+ Date.parse('2026-08-29T15:00:00'),
+ ],
+ );
+});
+
+test('riders are grouped on their id, never on the name', () => {
+ const rounds = roundsOf([
+ delivery({ deliveryid: 1, userid: 883, ridername: 'Rajan' }),
+ delivery({ deliveryid: 2, userid: 883, ridername: 'delivered' }),
+ ]);
+ assert.equal(rounds.length, 1);
+ assert.equal(rounds[0]!.rider, 'Rajan');
+ assert.equal(rounds[0]!.stops.length, 2);
+});
+
+// Rider 897 is "Varun" on 69 rows and "delivered" on 75. Most-common-wins
+// would name them "delivered".
+test('a status is not chosen as the name even when it is the common value', () => {
+ const rounds = roundsOf([
+ ...Array.from({ length: 3 }, (_, i) =>
+ delivery({ deliveryid: i + 1, userid: 897, ridername: 'Varun' }),
+ ),
+ ...Array.from({ length: 7 }, (_, i) =>
+ delivery({ deliveryid: i + 10, userid: 897, ridername: 'delivered' }),
+ ),
+ ]);
+ assert.equal(rounds[0]!.rider, 'Varun');
+});
+
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' }),
+ const rounds = roundsOf([delivery({ userid: 950, ridername: 'delivered' })]);
+ assert.equal(rounds[0]!.rider, 'Rider 950');
+});
+
+// Waiting work has an address and belongs on the map — seeing where it clusters
+// is often the reason to look — but it is not a round and must not lead.
+test('work nobody is carrying is its own group, and comes last', () => {
+ const rounds = roundsOf([
+ order(),
+ delivery({ deliveryid: 1, userid: 883 }),
+ delivery({ deliveryid: 2, userid: 883 }),
]);
- assert.equal(paths[0]!.rider, 'Rider 950');
+ assert.deepEqual(rounds.map((round) => round.rider), ['Rajan', 'Not assigned']);
});
-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' }),
+test('the busiest round leads, so the legend starts with what matters', () => {
+ const rounds = roundsOf([
+ delivery({ deliveryid: 1, userid: 1, ridername: 'A' }),
+ delivery({ deliveryid: 2, userid: 2, ridername: 'B' }),
+ delivery({ deliveryid: 3, userid: 2, ridername: 'B' }),
]);
- assert.deepEqual(paths.map((path) => path.rider), ['B', 'A']);
+ assert.deepEqual(rounds.map((round) => round.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');
+test('a round carries only the rider fixes it actually has', () => {
+ const [round] = roundsOf([
+ delivery({ deliveryid: 1, deliverytime: '2026-08-29 13:00:00' }),
+ delivery({ deliveryid: 2, deliverytime: '2026-08-29 14:00:00', riderslat: '', riderslon: '' }),
+ ]);
+ assert.equal(round!.stops.length, 2);
+ assert.equal(round!.fixes.length, 1);
});
-// 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);
+/* ── Shops ───────────────────────────────────────────────────────────────── */
+
+// The same branch arrives as both 11.016327 and 11.0163268 in production. Two
+// pins on one shop looks like two shops.
+test('one branch is one pin, however many decimals it arrived with', () => {
+ const shops = shopsOf([
+ mapStop(delivery({ pickuplat: '11.016327', pickuplon: '77.0147763' })),
+ mapStop(delivery({ pickuplat: '11.0163268', pickuplon: '77.0147763' })),
+ ]);
+ assert.equal(shops.length, 1);
+});
+
+test('two genuinely different branches stay two pins', () => {
+ const shops = shopsOf([
+ mapStop(delivery({ pickuplat: '11.043236', pickuplon: '77.0006758' })),
+ mapStop(delivery({ pickuplat: '11.0388679', pickuplon: '76.9300797' })),
+ ]);
+ assert.equal(shops.length, 2);
+});
+
+test('a stop with no shop coordinate contributes no pin', () => {
+ assert.deepEqual(shopsOf([mapStop(delivery({ pickuplat: '', pickuplon: '' }))]), []);
});
/* ── 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('coverage says how much of the group the map can place', () => {
+ const stops = [
+ mapStop(delivery({ deliveryid: 1 })),
+ mapStop(delivery({ deliveryid: 2, riderslat: '', riderslon: '' })),
+ mapStop(delivery({ deliveryid: 3, droplat: '', droplon: '', deliverylat: '', deliverylong: '' })),
+ ];
+ assert.deepEqual(coverageOf(stops), { placed: 2, total: 3, withRider: 2 });
});
-test('a day with no positions at all reports zero rather than throwing', () => {
- assert.deepEqual(coverageOf([]), { positioned: 0, total: 0, riders: 0 });
+test('an empty group reports zeroes rather than throwing', () => {
+ assert.deepEqual(coverageOf([]), { placed: 0, total: 0, withRider: 0 });
+ assert.deepEqual(roundsOf([]), []);
});
diff --git a/src/features/store-admin/deliveryTrack.ts b/src/features/store-admin/deliveryTrack.ts
index 09e21a9..3e9d8c9 100644
--- a/src/features/store-admin/deliveryTrack.ts
+++ b/src/features/store-admin/deliveryTrack.ts
@@ -1,200 +1,242 @@
/**
- * Where riders actually were, from the delivery rows.
+ * The three places a delivery happens, and where the rider was.
*
- * ── Why this and not the rider log ──────────────────────────────────────────
+ * ── What is actually on the row ─────────────────────────────────────────────
*
- * `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.
+ * Measured on 2026-09-09 over 500 rows each for tenants 916 and 908:
*
- * `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.
+ * pickup (the shop) 500/500 8 distinct branches on 916, 6 on 908
+ * drop (the customer) 500/500 145 distinct on 916, 341 on 908
+ * rider (last reported) 461/500 on 916, 324/500 on 908
*
- * ── These are event locations, not samples ──────────────────────────────────
+ * Shop and drop are on every row, so the map is never empty on a day with
+ * deliveries in it. The rider position is the sparse one, and it is real: on
+ * delivered rows it sits a median 134 m from the drop, which is a GPS fix taken
+ * at the customer's door.
*
- * 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`.
+ * Not to be confused with `riderlogs`, which carries a coordinate on every ping
+ * and repeats the SAME one for a rider all day — see `riderShifts`.
+ *
+ * ── The two endpoints spell the columns differently ─────────────────────────
+ *
+ * getdeliveries pickuplat / pickuplon droplat / droplon deliverylat / deliverylong
+ * getorders pickuplat / pickuplong (both empty) deliverylat / deliverylong
+ *
+ * `pickuplon` versus `pickuplong` is one letter, and reading the wrong one puts
+ * every shop on the prime meridian off the coast of Ghana. `droplat`/`droplon`
+ * are filled on deliveries and empty on orders, so the delivery coordinates are
+ * the fallback rather than the primary. `coordOf` accepts every spelling, which
+ * is why a stop can be an order or a delivery without the caller caring.
*/
-import type { DeliveryRow } from '@/api/types';
-import { DELIVERY_STATUS, ORDER_STATUS } from './orderStatus';
+import type { DeliveryRow, OrderRow } from '@/api/types';
+import type { Stop } from './dispatchModel';
+import { isRealName } from './orderStatus';
-/** One place a rider was known to be, and why we know. */
-export interface Fix {
- deliveryid: number;
- orderid: string;
+/** A point that is actually a point. */
+export interface Point {
lat: number;
lng: number;
- /** The delivery's status when the position was written. */
+}
+
+export type PlaceKind = 'shop' | 'drop' | 'rider';
+
+/** One stop, as far as the map is concerned. */
+export interface MappedStop {
+ key: string;
+ orderid: string;
status: string;
- /** The most recent stamp on the row — when the position was most likely taken. */
- at: number | null;
+ /** The shop it came from. */
+ shop: Point | null;
+ /** Where it is going. The pin that matters. */
+ drop: Point | null;
+ /** Where the rider was when the job last moved. Absent on most waiting orders. */
+ rider: Point | null;
customer: string;
address: string;
-}
-
-export interface RiderPath {
+ value: number;
+ /** The rider's `app_users.userid`, or 0 when nobody is carrying it. */
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;
+ ridername: string;
+ /** For ordering a round: the newest lifecycle stamp on the row. */
+ at: number | null;
}
/**
- * When a delivery's position was most likely written.
+ * One coordinate, from whichever of its spellings the endpoint used.
*
- * `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.
+ * Zero is rejected as well as blank: a half-written pair reads as 0, and a pin
+ * on the equator is a confident lie where a missing pin is an honest gap.
*/
-function fixedAt(row: DeliveryRow): number | null {
- const stamps = [
- row.assigntime,
- row.arrivaltime,
- row.pickuptime,
- row.starttime,
- row.deliverytime,
- row.canceltime,
+function coordOf(row: Record, ...names: string[]): number | null {
+ for (const name of names) {
+ const raw = row[name];
+ if (raw === undefined || raw === null || raw === '') continue;
+ const n = Number(raw);
+ if (Number.isFinite(n) && n !== 0) return n;
+ }
+ return null;
+}
+
+function pointOf(
+ row: Record,
+ lats: string[],
+ lngs: string[],
+): Point | null {
+ const lat = coordOf(row, ...lats);
+ const lng = coordOf(row, ...lngs);
+ // Both, or neither.
+ return lat !== null && lng !== null ? { lat, lng } : null;
+}
+
+/**
+ * When this stop last moved.
+ *
+ * The ladder's order — assign, arrive, pickup, start, deliver — with the latest
+ * present stamp winning, because `updatedelivery` writes the rider position
+ * alongside whichever stamp the new status sets. An order that nobody has
+ * touched falls back to when it was placed.
+ */
+function stampOf(row: Record): number | null {
+ const candidates = [
+ 'assigntime',
+ 'arrivaltime',
+ 'pickuptime',
+ 'starttime',
+ 'deliverytime',
+ 'canceltime',
+ 'orderdate',
];
let latest: number | null = null;
- for (const stamp of stamps) {
- if (!stamp) continue;
- const at = Date.parse(stamp.replace(' ', 'T'));
+ for (const name of candidates) {
+ const raw = row[name];
+ if (typeof raw !== 'string' || raw === '') continue;
+ const at = Date.parse(raw.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;
-}
+/** One stop, read for the map. */
+export function mapStop(stop: Stop): MappedStop {
+ const row = stop.row as unknown as Record;
+ const delivery = stop.kind === 'delivery' ? (stop.row as DeliveryRow) : undefined;
+ const order = stop.kind === 'order' ? (stop.row as OrderRow) : undefined;
-/**
- * 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();
- 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();
- 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,
+ key: delivery ? `d${delivery.deliveryid}` : `o${order?.orderheaderid ?? 0}`,
+ orderid: (row['orderid'] as string) || `#${delivery?.deliveryid ?? order?.orderheaderid ?? 0}`,
+ status: (row['orderstatus'] as string)?.trim().toLowerCase() || 'unknown',
+ shop: pointOf(row, ['pickuplat'], ['pickuplon', 'pickuplong']),
+ // `droplat` on deliveries, `deliverylat` on orders — and both carry the
+ // same value where both are filled.
+ drop: pointOf(row, ['droplat', 'deliverylat'], ['droplon', 'deliverylong']),
+ rider: pointOf(row, ['riderslat'], ['riderslon']),
+ customer: ((row['deliverycustomer'] as string) ?? '').trim(),
+ address:
+ ((row['deliveryaddress'] as string) ?? '').trim() ||
+ ((row['deliverysuburb'] as string) ?? '').trim(),
+ value: Number(row['deliveryamt'] ?? row['orderamount'] ?? 0) || 0,
+ userid: Number(row['userid'] ?? 0) || 0,
+ ridername: (row['ridername'] as string) ?? '',
+ at: stampOf(row),
+ };
+}
+
+/** One rider's round: their stops, in the order they were worked. */
+export interface Round {
+ userid: number;
+ rider: string;
+ stops: MappedStop[];
+ /** The rider positions along the round, for the trail. */
+ fixes: Point[];
+}
+
+/**
+ * The day's stops, grouped into rounds.
+ *
+ * Grouped on `userid`, never on `ridername` — see `isRealName` for why that
+ * column cannot identify anybody. Work nobody is carrying lands under userid 0
+ * as "Not assigned", which is a legitimate group on this map: those drops still
+ * have an address, and seeing where the unassigned work is clustered is the
+ * point of looking.
+ */
+export function roundsOf(stops: readonly Stop[]): Round[] {
+ const mapped = stops.map(mapStop);
+ const byRider = new Map();
+ for (const stop of mapped) {
+ const list = byRider.get(stop.userid);
+ if (list) list.push(stop);
+ else byRider.set(stop.userid, [stop]);
+ }
+
+ const nameOf = (userid: number) => {
+ if (userid === 0) return 'Not assigned';
+ const counts = new Map();
+ for (const stop of mapped) {
+ if (stop.userid !== userid || !isRealName(stop.ridername)) continue;
+ const name = stop.ridername.trim();
+ 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;
+ }
+ }
+ // Better a plain id than a confident wrong name.
+ return best || `Rider ${userid}`;
+ };
+
+ return [...byRider.entries()]
+ .map(([userid, list]) => {
+ const ordered = list.sort((a, b) => (a.at ?? 0) - (b.at ?? 0));
+ return {
+ userid,
+ rider: nameOf(userid),
+ stops: ordered,
+ fixes: ordered.flatMap((stop) => (stop.rider ? [stop.rider] : [])),
+ };
+ })
+ .sort((a, b) => {
+ // Unassigned work last: it is a real group, but it is not a round and it
+ // should not lead the legend.
+ if (a.userid === 0) return 1;
+ if (b.userid === 0) return -1;
+ return b.stops.length - a.stops.length;
+ });
+}
+
+/** The distinct shops a set of stops was collected from. */
+export function shopsOf(stops: readonly MappedStop[]): Point[] {
+ const seen = new Map();
+ for (const stop of stops) {
+ if (!stop.shop) continue;
+ // Rounded to about a metre before de-duplicating: the same branch arrives
+ // as both 11.016327 and 11.0163268, and two pins on one shop look like two
+ // shops.
+ const key = `${stop.shop.lat.toFixed(5)},${stop.shop.lng.toFixed(5)}`;
+ if (!seen.has(key)) seen.set(key, stop.shop);
+ }
+ return [...seen.values()];
+}
+
+/**
+ * How much of the group the map can actually place.
+ *
+ * Stated on the page rather than implied by a sparse map: "6 of 29 stops can be
+ * placed" is the difference between a quiet day and a reporting gap, and a map
+ * with six pins looks the same either way.
+ */
+export function coverageOf(stops: readonly MappedStop[]): {
+ placed: number;
+ total: number;
+ withRider: number;
+} {
+ return {
+ placed: stops.filter((stop) => stop.drop !== null).length,
+ total: stops.length,
+ withRider: stops.filter((stop) => stop.rider !== null).length,
};
}
diff --git a/src/features/store-admin/dispatchModel.test.ts b/src/features/store-admin/dispatchModel.test.ts
index e4efa3c..d15f012 100644
--- a/src/features/store-admin/dispatchModel.test.ts
+++ b/src/features/store-admin/dispatchModel.test.ts
@@ -297,6 +297,33 @@ test('the waiting queue is oldest first — it is a work queue, not a log', () =
assert.deepEqual(groups[0]?.stops.map((s) => s.row.orderheaderid), [1, 2]);
});
+// The rail is the list an operator picks a rider from, so a wrong label there
+// is a phantom rider standing next to the real ones. `ridername` holds a
+// delivery status on more rows than it holds a name for two riders in five on
+// tenant 916, and naming the group from its first stop put "delivered" in the
+// rail carrying 100 of the day's stops.
+test('a rider is named from the whole round, so a status never labels the rail', () => {
+ const groups = groupByRider(
+ toStops(
+ [
+ delivery({ deliveryid: 1, userid: 897, ridername: 'delivered' }),
+ delivery({ deliveryid: 2, userid: 897, ridername: 'delivered' }),
+ delivery({ deliveryid: 3, userid: 897, ridername: 'Varun' }),
+ ],
+ [],
+ ),
+ );
+ assert.equal(groups.length, 1, 'grouping is on userid and was never the problem');
+ assert.equal(groups[0]?.name, 'Varun');
+});
+
+test('a rider whose every row carried a status keeps their id as the label', () => {
+ const groups = groupByRider(
+ toStops([delivery({ userid: 950, ridername: 'cancelled' })], []),
+ );
+ assert.equal(groups[0]?.name, 'Rider 950');
+});
+
test('riders are keyed on userid, which survives a missing name', () => {
// `ridername` comes from a join and is blank whenever that join misses.
const groups = groupByRider(toStops([delivery({ userid: 1427, ridername: '' })], []));
diff --git a/src/features/store-admin/dispatchModel.ts b/src/features/store-admin/dispatchModel.ts
index 204e17e..93c6e03 100644
--- a/src/features/store-admin/dispatchModel.ts
+++ b/src/features/store-admin/dispatchModel.ts
@@ -1,6 +1,7 @@
import type { CustomerInfo } from '@/api/customers';
import type { DeliveryRow, OrderRow, TenantLocation } from '@/api/types';
import { awaitingRider, assignedFrom } from './assignDelivery';
+import { isRealName } from './orderStatus';
/**
* A day's dispatch, grouped three ways.
@@ -180,6 +181,31 @@ function fold(group: Group, stop: Stop): void {
if (rider) group.riders.add(String(rider));
}
+/**
+ * The most common value in `ridername` that is not a delivery status.
+ *
+ * Returns undefined when the column held nothing but statuses, so the caller
+ * keeps whatever fallback it had rather than being handed a wrong name.
+ */
+function commonRiderName(stops: readonly Stop[]): string | undefined {
+ const counts = new Map();
+ for (const stop of stops) {
+ if (stop.kind !== 'delivery') continue;
+ const name = stop.row.ridername?.trim();
+ if (!name || !isRealName(name)) continue;
+ counts.set(name, (counts.get(name) ?? 0) + 1);
+ }
+ let best: string | undefined;
+ let most = 0;
+ for (const [name, count] of counts) {
+ if (count > most) {
+ best = name;
+ most = count;
+ }
+ }
+ return best;
+}
+
/** Busiest first, then alphabetical, so the board does not reshuffle randomly. */
function order(groups: Group[]): Group[] {
return groups.sort((a, b) => b.stops.length - a.stops.length || a.name.localeCompare(b.name));
@@ -298,19 +324,39 @@ export function groupByRider(stops: readonly Stop[]): Group[] {
const id = rider ? String(rider) : UNASSIGNED;
let group = map.get(id);
if (!group) {
- const name =
- id === UNASSIGNED
- ? 'Waiting for a rider'
- : (stop.kind === 'delivery' ? stop.row.ridername : '') || `Rider ${id}`;
const detail = stop.kind === 'delivery' ? stop.row.ridercontact : undefined;
- group = blank(id, name, detail);
+ // Named once the group is complete — see below.
+ group = blank(id, id === UNASSIGNED ? 'Waiting for a rider' : `Rider ${id}`, detail);
map.set(id, group);
}
fold(group, stop);
}
+ /*
+ Named from the whole group, not from whichever stop arrived first.
+
+ `ridername` is not reliably a name: every rider on tenant 916 has BOTH their
+ name and a delivery status in that column, 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 }
+
+ Taking the first stop's value put a rider called "delivered" in the rail, next
+ to the real riders, carrying 100 of the day's stops. Grouping was never
+ affected — that is on `userid` — but the label was, and the rail is the list
+ an operator picks from. So statuses are excluded by vocabulary and the most
+ common of what survives is the name; a rider whose every row carried a status
+ keeps their id, which is at least honest about being an id.
+ */
for (const group of map.values()) {
group.stops.sort((a, b) => stopTime(a).localeCompare(stopTime(b)));
+ if (group.id !== UNASSIGNED) {
+ const name = commonRiderName(group.stops);
+ if (name) group.name = name;
+ }
}
const groups = order([...map.values()]);
diff --git a/src/features/store-admin/orderStatus.ts b/src/features/store-admin/orderStatus.ts
index b223041..d9acd0d 100644
--- a/src/features/store-admin/orderStatus.ts
+++ b/src/features/store-admin/orderStatus.ts
@@ -37,6 +37,39 @@ export const DELIVERY_STATUS: Record = {
cancelled: '#ef4444',
};
+/**
+ * `waiting` is not in either map — it is the dispatch board's own word for work
+ * nobody has touched — but it is just as wrong as a name.
+ */
+const WAITING_WORD = 'waiting';
+
+/**
+ * True when a value is a person's name and not a status wearing one.
+ *
+ * `deliveries.ridername` is not reliably a name. Every rider on tenant 916 has
+ * BOTH their name and a delivery status in that column, and for two of the five
+ * the status is the MORE common value (measured 2026-09-09):
+ *
+ * 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 on its own. The
+ * statuses have to be excluded first, and they are excluded by VOCABULARY — the
+ * two status maps above — rather than by a hand-written list, so a status added
+ * to the ladder is excluded here the same day.
+ *
+ * It lives beside the vocabularies rather than with either caller: the dispatch
+ * rail and the map both need it, and one importing it from the other would put
+ * a cycle between them.
+ */
+export function isRealName(value: string | undefined): boolean {
+ const name = value?.trim().toLowerCase();
+ if (!name) return false;
+ return !(name in DELIVERY_STATUS) && !(name in ORDER_STATUS) && name !== WAITING_WORD;
+}
+
/** Unknown statuses fall to meta grey rather than to a colour that means something. */
export const statusColor = (map: Record, status: string | undefined): string =>
map[(status ?? '').trim().toLowerCase()] ?? 'var(--color-ink-3)';
diff --git a/src/features/store-admin/pages/DispatchPage.tsx b/src/features/store-admin/pages/DispatchPage.tsx
index d4418df..d7da91e 100644
--- a/src/features/store-admin/pages/DispatchPage.tsx
+++ b/src/features/store-admin/pages/DispatchPage.tsx
@@ -6,7 +6,6 @@ import { VStack } from '@astryxdesign/core/VStack';
import {
Bike,
IndianRupee,
- Map,
MapPin,
Package,
Store,
@@ -29,7 +28,7 @@ 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 { GroupMap } from '../GroupMap';
import { OrderDetailDrawer } from '../OrderDetailDrawer';
import { PlanVsActualPanel } from '../PlanVsActualPanel';
import {
@@ -74,13 +73,21 @@ import './dispatch.css';
* Deliveries tab could never answer, since it lists jobs flat with a rider
* column and no way to see a round.
*
- * ── What this page deliberately is not ──────────────────────────────────────
+ * ── Why two of the three views draw a map instead of a table ────────────────
*
- * There is no map yet. Every drop coordinate in production today sits within
- * about four metres of every other — one test customer ordering repeatedly —
- * so a map would render one pin with six underneath and imply a precision the
- * data does not have. The grouping, the rounds and the rider positions all work
- * without one, and the map is worth adding the day real drops spread out.
+ * A round and a shop's day are both SHAPES: where the work is, in what order,
+ * how far apart. A table of addresses cannot show either — you read twenty rows
+ * and still cannot see that a rider crossed the city twice. So picking a rider
+ * or a shop opens the map, and the table stays where a list is the right
+ * answer: the waiting queue, whose rows are ticked to assign them, and the
+ * customer view, where one customer's drops are all at one address.
+ *
+ * The coordinates support it. `pickuplat`/`pickuplon` and `droplat`/`droplon`
+ * are filled on 500 of 500 rows for both tenants measured, so the map is never
+ * empty on a day with work in it; the rider's own position is the sparse one,
+ * at 461 and 324 of 500. (An earlier note here said drops all sat within four
+ * metres of each other. That was one quiet test tenant: the live spread is 145
+ * distinct drops on tenant 916 and 341 on 908.)
*/
/**
* The delivery ladder plus the one status that is not on it.
@@ -102,7 +109,7 @@ const DISPATCH_STATUS: Record = { ...DELIVERY_STATUS, [WAITING]:
* fourth `ViewMode`, so the grouping functions never have to answer for a value
* that is not a grouping.
*/
-type Board = ViewMode | 'timing' | 'map';
+type Board = ViewMode | 'timing';
export function DispatchPage() {
const { branches, selected, tenantid } = useBranchScope();
@@ -157,7 +164,7 @@ export function DispatchPage() {
);
const groups = useMemo(() => {
- if (mode === 'timing' || mode === 'map') return [];
+ if (mode === 'timing') return [];
if (mode === 'stores') return groupByStore(stops, locations.data ?? branches);
if (mode === 'customers') return groupByCustomer(stops, customers.data ?? [], branchName);
return groupByRider(stops);
@@ -246,12 +253,6 @@ export function DispatchPage() {
isActive={mode === 'timing'}
onClick={() => changeMode('timing')}
/>
- }
- isActive={mode === 'map'}
- onClick={() => changeMode('map')}
- />
}
/>
@@ -287,12 +288,7 @@ export function DispatchPage() {
/>
- {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. */
-
- ) : mode === 'timing' ? (
+ {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. */
@@ -491,6 +487,21 @@ function GroupDetail({
// rider's round means nothing on this one.
const paged = usePaged(group.stops, { resetKey: group.id });
+ /*
+ A round and a shop's day are both SHAPES — where the work is, in what order,
+ how far apart — and a table of addresses cannot show either. So those two
+ views draw the map instead of the list.
+
+ Never on the waiting queue. That is the only group whose rows are ticked to
+ assign them, a checkbox cannot live on a map pin, and nothing in it has been
+ worked yet so there is no shape to see. `selection` is passed only for that
+ group, so its presence is the test.
+
+ "By customer" keeps its table too: one customer's drops are all at one
+ address, so the map would be a single pin with the rest hidden underneath it.
+ */
+ const showMap = (mode === 'riders' || mode === 'stores') && !selection;
+
return (
@@ -512,13 +523,10 @@ function GroupDetail({
- {/* Rider position, only in the rider view and only when reported.
- Labelled with its age and the status that produced it, because it
- is a last-known point and not a live feed — drawing it as a dot on
- a map would claim otherwise. */}
- {/* Never on the waiting queue: nobody is carrying that work, so
+ {/* The rider's last reported point, but only when the map is not
+ drawing it. On the waiting queue nobody is carrying the work, so
"No position reported" states the obvious and reads as a fault. */}
- {mode === 'riders' && group.id !== UNASSIGNED ? (
+ {!showMap && mode === 'riders' && group.id !== UNASSIGNED ? (
seen ? (
@@ -561,98 +562,106 @@ function GroupDetail({
is free, not on a screen you have to leave the list for. */}
{assignBar}
-
-
-
-
-
- {selection ? (
-
- {
- // Some can go and some cannot: neither ticked nor
- // empty, and the box should say so rather than pick one.
- if (node) node.indeterminate = selection.someChosen;
- }}
- onChange={selection.toggleAll}
- />
-
onOpen(stop)}>
- {selection ? (
- /* The click is stopped here so ticking a row does not
- also open its drawer — two different intentions on the
- same row, and the drawer would bury the bar. */
-
event.stopPropagation()}>
- order && selection.toggle(order.orderheaderid)}
- />
-
+ {
+ // Some can go and some cannot: neither ticked nor
+ // empty, and the box should say so rather than pick one.
+ if (node) node.indeterminate = selection.someChosen;
}}
- >
- {status}
-
-
-
onOpen(stop)}>
+ {selection ? (
+ /* The click is stopped here so ticking a row does not
+ also open its drawer — two different intentions on the
+ same row, and the drawer would bury the bar. */
+
event.stopPropagation()}>
+ order && selection.toggle(order.orderheaderid)}
+ />
+