map the selected rider and shop, drop the map tab

The separate Map tab is gone. "By rider" and "By store" now draw the map
where the stops table was, so picking a rider from the rail shows their
round on a map and picking a branch shows its day.

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 shows neither. You can read twenty
rows and still not see that a rider crossed the city twice.

Two views keep the table, on purpose: the waiting queue, whose rows are
ticked to assign them (a checkbox cannot live on a map pin, and nothing
there has been worked yet), and "By customer", where one customer's drops
are all at one address.

The map draws all three places a delivery has, not just the rider:

  shop   a square, one per branch — pickuplat/pickuplon, 500/500 rows
  drop   a circle per stop, by status — droplat/droplon, 500/500 rows
  rider  a hollow ring — riderslat/riderslon, 461/500 and 324/500

Shop and drop are on every row, so the map is never empty on a day with
work in it. The rider fix is real: on delivered rows it sits a median 134 m
from the drop, a reading taken at the customer's door.

The endpoints spell the columns differently — `pickuplon` on getdeliveries
against `pickuplong` on getorders, one letter, and reading the wrong one
puts every shop off the coast of Ghana. `droplat` is filled on deliveries
and empty on orders. `coordOf` takes every spelling so a waiting order maps
the same as a delivered one.

Also fixes the rail itself: it named each group from its first stop's
`ridername`, and that column holds a delivery status on more rows than a
name for two riders in five. The board was listing a phantom rider called
"delivered" carrying 100 of the day's stops, next to the real riders.
Grouping was never wrong — that is on userid — only the label. `isRealName`
now excludes the status vocabulary and both the rail and the map take the
most common name that survives.

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:47:40 +05:30
parent 34bf7989f7
commit 35250717fc
11 changed files with 915 additions and 585 deletions

View File

@@ -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: `<span class="trail-pin" data-faded="${pin.isFaded ? 'true' : 'false'}" style="--pin:${escapeAttr(colour)}"></span>`,
html: `<span class="trail-pin" data-faded="${pin.isFaded ? 'true' : 'false'}" data-shape="${pin.shape ?? 'round'}" style="--pin:${escapeAttr(colour)}"></span>`,
iconSize: [16, 16],
iconAnchor: [8, 8],
}),

View File

@@ -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. */

View File

@@ -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<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,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<number | null>(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 (
<VStack gap={1}>
<HStack justify="between" align="center" gap={2} wrap="wrap">
<Text type="body" size="xsm" color="secondary">
{coverage.placed} of {coverage.total} stop{coverage.total === 1 ? '' : 's'} placed
{coverage.withRider > 0 ? ` · ${coverage.withRider} with a rider position` : ''}
</Text>
<span className="map-key">
<i data-key="shop" />
shop
<i data-key="drop" />
drop
<i data-key="rider" />
rider
</span>
</HStack>
{/* 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 ? (
<HStack gap={0.5} wrap="wrap">
<RoundChip
label="Everyone"
colour="var(--color-ink-4)"
isActive={focused === null}
onClick={() => setFocused(null)}
/>
{rounds.map((round, index) => (
<RoundChip
key={round.userid}
label={`${round.rider} · ${round.stops.length}`}
colour={trailColour(index)}
isActive={focused === round.userid}
onClick={() => setFocused((prev) => (prev === round.userid ? null : round.userid))}
/>
))}
</HStack>
) : null}
<TrailMap
trails={trails}
pins={pins}
height={440}
emptyNote={
coverage.total === 0
? `Nothing out for ${groupName}.`
: 'None of these stops carries an address the map can place.'
}
/>
<div className="pva-note">
<Store size={13} />
<span>
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.
</span>
</div>
</VStack>
);
}
function RoundChip({
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

@@ -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>): DeliveryRow {
/** A delivery as `getdeliveries` sends one. */
function delivery(over: Record<string, unknown> = {}): 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<string, unknown> = {}): 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([]), []);
});

View File

@@ -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<string, unknown>, ...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<string, unknown>,
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<string, unknown>): 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<string, unknown>;
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<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,
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<number, MappedStop[]>();
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<string, number>();
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<string, Point>();
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,
};
}

View File

@@ -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: '' })], []));

View File

@@ -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<string, number>();
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()]);

View File

@@ -37,6 +37,39 @@ export const DELIVERY_STATUS: Record<string, string> = {
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<string, string>, status: string | undefined): string =>
map[(status ?? '').trim().toLowerCase()] ?? 'var(--color-ink-3)';

View File

@@ -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<string, string> = { ...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')}
/>
<ModeTab
label="Map"
icon={<Map size={14} />}
isActive={mode === 'map'}
onClick={() => changeMode('map')}
/>
</HStack>
}
/>
@@ -287,12 +288,7 @@ export function DispatchPage() {
/>
</div>
{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' ? (
{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 (
<VStack gap={1.5}>
<Card padding={0} elevation="low">
@@ -512,13 +523,10 @@ function GroupDetail({
</Text>
</HStack>
{/* 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 ? (
<div className="last-seen">
<MapPin size={14} />
@@ -532,13 +540,6 @@ function GroupDetail({
{seen.orderid ? ` · ${seen.orderid}` : ''}
</span>
</div>
<a
href={`https://www.openstreetmap.org/?mlat=${seen.lat}&mlon=${seen.lon}#map=16/${seen.lat}/${seen.lon}`}
target="_blank"
rel="noreferrer"
>
Open map
</a>
</div>
) : (
<div className="last-seen" data-empty="true">
@@ -561,98 +562,106 @@ function GroupDetail({
is free, not on a screen you have to leave the list for. */}
{assignBar}
<Card padding={0} elevation="low">
<div className="table-scroll">
<table className="stops-table">
<thead>
<tr>
{selection ? (
<th className="tick-cell">
<input
type="checkbox"
aria-label="Select every order that can be assigned"
checked={selection.allChosen}
ref={(node) => {
// 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}
/>
</th>
) : null}
<th>#</th>
<th>Order</th>
<th>Going to</th>
{mode !== 'riders' ? <th>Rider</th> : null}
<th>KM</th>
<th>Value</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{paged.rows.map((stop, rowIndex) => {
const status = stopStatus(stop);
const tint = statusColor(DISPATCH_STATUS, status);
const order = stop.kind === 'order' ? stop.row : undefined;
const verdict = order && verdictOf ? verdictOf(order) : undefined;
const pickable = Boolean(verdict?.canAssign);
return (
<tr key={stopKey(stop)} onClick={() => 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. */
<td className="tick-cell" onClick={(event) => event.stopPropagation()}>
<input
type="checkbox"
aria-label={`Select order ${stopOrderId(stop)}`}
checked={pickable && selection.has(order!.orderheaderid)}
disabled={!pickable}
title={pickable ? undefined : (verdict?.reason ?? 'Already with a rider')}
onChange={() => order && selection.toggle(order.orderheaderid)}
/>
</td>
) : null}
<td className="num">{paged.from + rowIndex}</td>
<td>
<strong>{stopOrderId(stop) || `#${stop.row.orderheaderid}`}</strong>
<span>{stopTime(stop) ? shortTime(stopTime(stop)) : ''}</span>
</td>
<td>
<strong>{stopCustomer(stop) || '—'}</strong>
<span>{stopAddress(stop) || stop.row.deliverysuburb}</span>
</td>
{mode !== 'riders' ? (
<td>
{stop.kind === 'delivery' && stop.row.ridername ? (
stop.row.ridername
) : (
<em className="muted">not assigned</em>
)}
</td>
) : null}
<td className="num">{stop.kind === 'delivery' ? km(stop.row) : '—'}</td>
<td className="num">{moneyExact(stopValue(stop))}</td>
<td>
<span
className="stop-status"
style={{
color: tint,
background: `color-mix(in oklab, ${tint} 12%, transparent)`,
{showMap ? (
<Card padding={0} elevation="low">
<VStack gap={1.5} padding={2}>
<GroupMap stops={group.stops} groupName={group.name} />
</VStack>
</Card>
) : (
<Card padding={0} elevation="low">
<div className="table-scroll">
<table className="stops-table">
<thead>
<tr>
{selection ? (
<th className="tick-cell">
<input
type="checkbox"
aria-label="Select every order that can be assigned"
checked={selection.allChosen}
ref={(node) => {
// 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}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<TablePager paged={paged} label="stops" />
</Card>
onChange={selection.toggleAll}
/>
</th>
) : null}
<th>#</th>
<th>Order</th>
<th>Going to</th>
{mode !== 'riders' ? <th>Rider</th> : null}
<th>KM</th>
<th>Value</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{paged.rows.map((stop, rowIndex) => {
const status = stopStatus(stop);
const tint = statusColor(DISPATCH_STATUS, status);
const order = stop.kind === 'order' ? stop.row : undefined;
const verdict = order && verdictOf ? verdictOf(order) : undefined;
const pickable = Boolean(verdict?.canAssign);
return (
<tr key={stopKey(stop)} onClick={() => 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. */
<td className="tick-cell" onClick={(event) => event.stopPropagation()}>
<input
type="checkbox"
aria-label={`Select order ${stopOrderId(stop)}`}
checked={pickable && selection.has(order!.orderheaderid)}
disabled={!pickable}
title={pickable ? undefined : (verdict?.reason ?? 'Already with a rider')}
onChange={() => order && selection.toggle(order.orderheaderid)}
/>
</td>
) : null}
<td className="num">{paged.from + rowIndex}</td>
<td>
<strong>{stopOrderId(stop) || `#${stop.row.orderheaderid}`}</strong>
<span>{stopTime(stop) ? shortTime(stopTime(stop)) : ''}</span>
</td>
<td>
<strong>{stopCustomer(stop) || '—'}</strong>
<span>{stopAddress(stop) || stop.row.deliverysuburb}</span>
</td>
{mode !== 'riders' ? (
<td>
{stop.kind === 'delivery' && stop.row.ridername ? (
stop.row.ridername
) : (
<em className="muted">not assigned</em>
)}
</td>
) : null}
<td className="num">{stop.kind === 'delivery' ? km(stop.row) : '—'}</td>
<td className="num">{moneyExact(stopValue(stop))}</td>
<td>
<span
className="stop-status"
style={{
color: tint,
background: `color-mix(in oklab, ${tint} 12%, transparent)`,
}}
>
{status}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<TablePager paged={paged} label="stops" />
</Card>
)}
</VStack>
);
}

View File

@@ -471,3 +471,41 @@
height: 8px;
border-radius: 50%;
}
/* ── The map key ─────────────────────────────────────────────────────────── */
/* Three shapes, named. Without it a square and a hollow ring are two more
things to work out; with it the map needs no other explanation. */
.map-key {
display: inline-flex;
gap: 5px;
align-items: center;
font-size: 11px;
color: var(--color-ink-4);
}
.map-key i {
width: 9px;
height: 9px;
margin-left: 6px;
border-radius: 50%;
background: var(--color-ink-4);
}
.map-key i:first-child {
margin-left: 0;
}
.map-key i[data-key='shop'] {
border-radius: 2px;
background: var(--color-brand);
}
.map-key i[data-key='drop'] {
background: #10b981;
}
.map-key i[data-key='rider'] {
background: transparent;
border: 2px solid var(--color-ink-3);
}