updats on the dispatch page

This commit is contained in:
2026-09-10 17:57:00 +05:30
parent 79a8f2b234
commit 8b2c4daef8
34 changed files with 6929 additions and 1059 deletions

View File

@@ -0,0 +1,236 @@
import { useMemo } 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 { BatteryLow, Info, MapPin, Navigation, WifiOff } from 'lucide-react';
import { TrailMap, trailColour, type MapPin as Pin } from '@/components/TrailMap';
import type { Stop } from './dispatchModel';
import { useRiderLive } from '@/queries/hooks';
import { mapStop } from './deliveryTrack';
import {
activeBoard,
agoOf,
concerns,
distanceLabel,
readSnapshot,
type LiveRider,
} from './riderLive';
import './dispatchPanels.css';
/**
* Who is out right now, closest to finishing first.
*
* ── Why the order is "distance to the drop" ─────────────────────────────────
*
* A dispatcher watching a live board is answering one question: who will be
* free next. Sorting by that puts the answer at the top, and it re-sorts itself
* as riders move. Sorting by name or by order count makes the reader do the
* comparison themselves, every fifteen seconds.
*
* ── Live is a claim, and most of these positions are not ────────────────────
*
* The service answers for every rider, with their LAST known fix however old —
* riders on this platform routinely come back with positions days stale and
* nothing in the payload admitting it. So each card carries its age, the map
* draws a stale fix hollow, and anything over an hour old is called out rather
* than mixed in. A board that shows a twelve-day-old dot next to a live one is
* worse than no board.
*/
export function ActivePanel({ stops }: { stops: readonly Stop[] }) {
const mapped = useMemo(() => stops.map(mapStop), [stops]);
/* Only riders carrying something today — polling the whole roster would ask a
third-party service about people who are not working. */
const userids = useMemo(
() => [...new Set(mapped.map((stop) => stop.userid).filter((id) => id > 0))],
[mapped],
);
const live = useRiderLive(userids);
const nameOf = useMemo(() => {
const names = new Map<number, string>();
for (const stop of mapped) {
if (stop.userid > 0 && stop.ridername && !names.has(stop.userid)) {
names.set(stop.userid, stop.ridername);
}
}
return names;
}, [mapped]);
const riders = useMemo(() => {
const out: LiveRider[] = [];
for (const userid of userids) {
const read = readSnapshot(
live.data.get(userid) ?? null,
nameOf.get(userid) ?? `Rider ${userid}`,
);
if (read) out.push(read);
}
return out;
}, [userids, live.data, nameOf]);
/* The drop for whichever order a rider says they are on. Matched on the
order id the RIDER reports, not on our idea of what they should be
carrying — the two disagree the moment somebody reassigns a job. */
const dropOf = useMemo(() => {
const byOrder = new Map<string, (typeof mapped)[number]>();
for (const stop of mapped) if (stop.orderid) byOrder.set(stop.orderid, stop);
return (orderid: string) => {
const stop = byOrder.get(orderid);
if (!stop?.drop) return null;
return {
lat: stop.drop.lat,
lng: stop.drop.lng,
customer: stop.customer,
address: stop.address,
};
};
}, [mapped]);
const board = useMemo(() => activeBoard(riders, dropOf), [riders, dropOf]);
const pins: Pin[] = useMemo(() => {
const out: Pin[] = [];
board.forEach((entry, index) => {
const { live: rider } = entry;
if (rider.lat === null || rider.lng === null) return;
out.push({
id: `rider-${rider.userid}`,
lat: rider.lat,
lng: rider.lng,
label: rider.name,
lines: [
`${rider.status}${rider.orderid ? ` · ${rider.orderid}` : ''}`,
`Reported ${agoOf(rider.ageMs)}`,
entry.toDropM !== null ? `${distanceLabel(entry.toDropM)} from the drop` : '',
].filter(Boolean),
colour: trailColour(index),
// Only a fix from the last minute is drawn as a position. Everything
// else is a last-known point and reads as a ring.
isFaded: rider.freshness !== 'live',
});
const drop = dropOf(entry.orderid);
if (drop) {
out.push({
id: `drop-${entry.orderid}`,
lat: drop.lat,
lng: drop.lng,
label: entry.orderid,
lines: [drop.customer || drop.address || 'Going here'],
colour: '#10b981',
});
}
});
return out;
}, [board, dropOf]);
const stale = board.filter((entry) => entry.live.freshness === 'old').length;
return (
<VStack gap={1.5}>
<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">
Out right now
</Text>
<Text type="body" size="xsm" color="secondary">
{board.length === 0
? 'nobody is carrying anything'
: `${board.length} carrying · closest to their drop first`}
{live.isFetching ? ' · updating…' : ''}
</Text>
</HStack>
<TrailMap
pins={pins}
height={360}
emptyNote={
live.isLoading
? 'Asking the riders where they are…'
: userids.length === 0
? 'Nothing is assigned, so nobody is out.'
: 'No rider has reported a position.'
}
/>
</VStack>
</Card>
{board.length > 0 ? (
<div className="active-grid">
{board.map((entry, index) => {
const rider = entry.live;
const worries = concerns(rider);
return (
<Card key={rider.userid} padding={0} elevation="low">
<VStack gap={1} padding={2}>
<HStack justify="between" align="center" gap={1}>
<span className="active-name">
<i style={{ background: trailColour(index) }} />
{rider.name}
</span>
<span className="active-fresh" data-fresh={rider.freshness}>
{agoOf(rider.ageMs)}
</span>
</HStack>
<div className="active-distance">
<Navigation size={15} />
<strong>{distanceLabel(entry.toDropM)}</strong>
<span>
{entry.toDropM === null
? 'position or drop unknown'
: 'from the drop, in a straight line'}
</span>
</div>
<div className="active-meta">
<span title="The order they are on">{entry.orderid || '—'}</span>
<span>{entry.customer || entry.address || 'no address'}</span>
</div>
<div className="active-stats">
<span>{rider.status}</span>
<span>{rider.speed === null ? '— km/h' : `${rider.speed.toFixed(0)} km/h`}</span>
<span data-low={rider.battery !== null && rider.battery <= 15}>
{rider.battery === null ? '—' : `${rider.battery}%`}
{rider.isCharging ? ' ⚡' : ''}
</span>
<span>{rider.connection}</span>
</div>
{worries.length > 0 ? (
<div className="active-worry">
{rider.connection === 'none' ? (
<WifiOff size={13} />
) : rider.battery !== null && rider.battery <= 15 ? (
<BatteryLow size={13} />
) : (
<MapPin size={13} />
)}
{worries.join(' · ')}
</div>
) : null}
</VStack>
</Card>
);
})}
</div>
) : null}
<div className="pva-note">
<Info size={13} />
<span>
Positions come from the rider app and are shown with their age — a filled pin is a fix
from the last minute, a ring is older. Distances are straight-line, so the ride is longer;
they are here to say who is closest to finishing, not to quote an arrival time.
{stale > 0
? ` ${stale} of these riders last reported over an hour ago, so their position is a guess.`
: ''}
</span>
</div>
</VStack>
);
}

View File

@@ -0,0 +1,359 @@
import { useMemo, useRef, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { HStack } from '@astryxdesign/core/HStack';
import { AlertTriangle, Bike, Sparkles, UserMinus } from 'lucide-react';
import { optimiserApi, OptimiserError } from '@/api/optimiser';
import { deliveriesApi, RIDER_MESSAGE, RiderNotReachableError } from '@/api/deliveries';
import type { OrderRow, RiderInfo, TenantLocation } from '@/api/types';
import { queryKeys } from '@/queries/keys';
import { Drawer } from './Drawer';
import { Badge, DrawerButton, DrawerCard, Note, Row, Section } from './drawerKit';
import { buildDeliveries } from './assignDelivery';
import {
absentFrom,
branchFor,
buildRequest,
committable,
readPlan,
TUNINGS,
unmatched,
type Plan,
type Tuning,
} from './autoAssign';
import { moneyExact } from './format';
import './dispatchPanels.css';
/**
* Let the optimiser propose the round, then commit it here.
*
* ── Two steps, deliberately ─────────────────────────────────────────────────
*
* Running the solver writes nothing. It answers with a PROPOSAL, the operator
* reads it, and only "Assign" turns it into deliveries. That separation is the
* whole design: a solver that assigned directly would be a button that
* dispatches every waiting order to riders nobody has looked at, and the first
* time it got a round wrong there would be no moment at which anyone could have
* caught it.
*
* ── Committed through the same door as the manual bar ───────────────────────
*
* `buildDeliveries` → `deliveriesApi.assign`, exactly as `AssignBar` does. Not
* a shortcut: `assignDelivery` is where the knowledge lives about which ids a
* delivery must carry to be joinable afterwards, and a second write path here
* would be a second place for that to drift. The solver decides WHO; it does
* not get to decide what a delivery row looks like.
*
* One call for the whole plan, because one call is one transaction — several
* riders' rows in a single array is what the endpoint takes and what the old
* console sends.
*/
export function AutoAssignDrawer({
orders,
branches,
fleet,
assigned,
onClose,
onDone,
}: {
/** The waiting orders this run is for. */
orders: readonly OrderRow[];
branches: readonly TenantLocation[];
/** Riders we know about, for the absentee picker and for naming. */
fleet: readonly RiderInfo[];
assigned: ReadonlySet<number>;
onClose: () => void;
onDone: () => void;
}) {
const client = useQueryClient();
const [tuning, setTuning] = useState<Tuning>('balanced');
const [absent, setAbsent] = useState<ReadonlySet<number>>(new Set());
const [plan, setPlan] = useState<Plan | null>(null);
const [outcome, setOutcome] = useState<string | null>(null);
const abort = useRef<AbortController | null>(null);
const branchOf = useMemo(
() => (row: OrderRow) => branchFor(row, branches),
[branches],
);
const solve = useMutation({
mutationFn: async () => {
abort.current?.abort();
const controller = new AbortController();
abort.current = controller;
const away = fleet.filter((rider) => absent.has(rider.userid)).map(absentFrom);
const response = await optimiserApi.assign(
buildRequest(orders, away),
tuning,
controller.signal,
);
return readPlan(response, orders);
},
onSuccess: (next) => {
setPlan(next);
setOutcome(null);
},
onError: (error) => {
setPlan(null);
setOutcome(
error instanceof OptimiserError ? error.message : 'The optimiser run failed.',
);
},
});
const commit = useMutation({
mutationFn: async () => {
if (!plan) throw new Error('Run the optimiser first');
/* Every proposal's rows, built through the same path the manual bar uses
and sent as one array — one call is one transaction. */
const drafts = plan.proposals.flatMap((proposal) => {
const rows = committable(proposal);
// Only `userid` is read off the rider when a delivery is built; the
// rest of the roster row matters for notifying, which happens after.
const rider = (fleet.find((r) => r.userid === proposal.userid) ??
({ userid: proposal.userid } as RiderInfo));
return buildDeliveries(rows, rider, branchOf, new Date(), assigned).drafts;
});
if (drafts.length === 0) {
throw new Error('Nothing in this plan can be assigned.');
}
await deliveriesApi.assign(drafts);
return drafts.length;
},
onSuccess: async (count) => {
await client.invalidateQueries({ queryKey: queryKeys.insights.all });
setOutcome(`${count} order${count === 1 ? '' : 's'} assigned · telling the riders…`);
onDone();
/* Notified after the write and reported separately: the deliveries exist
either way, so a failed push must not read as a failed assignment —
but it must still be visible, because a rider who was never told has
work sitting unseen. */
const failures: string[] = [];
for (const proposal of plan?.proposals ?? []) {
const rider = fleet.find((r) => r.userid === proposal.userid);
try {
await deliveriesApi.notify(
rider?.userfcmtoken ?? '',
RIDER_MESSAGE.assigned(proposal.stops.length),
);
} catch (error) {
failures.push(
error instanceof RiderNotReachableError
? `${proposal.rider} has no device registered`
: `${proposal.rider} could not be reached`,
);
}
}
setOutcome(
failures.length === 0
? `${count} order${count === 1 ? '' : 's'} assigned · every rider notified`
: `${count} assigned · NOT notified: ${failures.join('; ')} — tell them another way`,
);
setPlan(null);
},
onError: (error) =>
setOutcome(error instanceof Error ? error.message : 'The assignment failed.'),
});
const isRunning = solve.isPending;
const missing = plan ? unmatched(plan) : [];
const canCommit = Boolean(plan && plan.proposals.length > 0 && !commit.isPending);
return (
<Drawer
title="Auto-assign"
subtitle={`${orders.length} order${orders.length === 1 ? '' : 's'} waiting`}
width={620}
onClose={onClose}
isFooterSpread
footer={
<>
<DrawerButton
label={isRunning ? 'Cancel' : 'Close'}
variant="ghost"
onClick={() => {
if (isRunning) abort.current?.abort();
else onClose();
}}
/>
{plan ? (
<DrawerButton
label={commit.isPending ? 'Assigning…' : `Assign ${plan.meta.assigned}`}
variant="primary"
icon={<Bike size={15} />}
isDisabled={!canCommit}
onClick={() => commit.mutate()}
/>
) : (
<DrawerButton
label={isRunning ? 'Working…' : 'Run the optimiser'}
variant="primary"
icon={<Sparkles size={15} />}
isDisabled={isRunning || orders.length === 0}
onClick={() => solve.mutate()}
/>
)}
</>
}
>
<VStack gap={2}>
<Note icon={<Sparkles size={15} />}>
The optimiser proposes who carries what. Nothing is assigned until you press Assign, so
running it is always safe — and running it again just replaces the proposal.
</Note>
{/* ── Before the run ──────────────────────────────────────────── */}
{!plan ? (
<>
<Section title="How to solve it">
<div className="aa-tunings">
{TUNINGS.map((option) => (
<button
key={option.id}
type="button"
className="aa-tuning"
data-active={tuning === option.id}
disabled={isRunning}
onClick={() => setTuning(option.id)}
>
{option.label}
</button>
))}
</div>
</Section>
<Section title="Anybody off today?">
<DrawerCard>
<Row
label="Absent riders"
value="Ticked riders are left out of the plan. Leaving this empty is normal — the solver then considers everyone."
isStacked
/>
{fleet.length === 0 ? (
<Row
label="Fleet"
value="No riders are on duty, so there is nobody to mark absent."
isStacked
/>
) : (
<div className="aa-absent">
{fleet.map((rider) => {
const name =
`${rider.firstname ?? ''} ${rider.lastname ?? ''}`.trim() ||
rider.fullname?.trim() ||
`Rider ${rider.userid}`;
return (
<label key={rider.userid} className="aa-absent-row">
<input
type="checkbox"
checked={absent.has(rider.userid)}
disabled={isRunning}
onChange={() =>
setAbsent((prev) => {
const next = new Set(prev);
if (next.has(rider.userid)) next.delete(rider.userid);
else next.add(rider.userid);
return next;
})
}
/>
<UserMinus size={13} />
{name}
</label>
);
})}
</div>
)}
</DrawerCard>
</Section>
</>
) : null}
{/* ── The proposal ────────────────────────────────────────────── */}
{plan?.blocked ? (
<div className="aa-blocked">
<AlertTriangle size={16} />
<div>
<strong>The optimiser could not consider anybody</strong>
<span>{plan.blocked}</span>
</div>
</div>
) : null}
{plan && plan.proposals.length > 0 ? (
<Section
title={`${plan.meta.assigned} of ${plan.meta.totalOrders} placed with ${plan.proposals.length} rider${plan.proposals.length === 1 ? '' : 's'}`}
>
{plan.proposals.map((proposal) => (
<DrawerCard key={proposal.userid}>
<Row
label={proposal.rider}
value={
<Badge
label={`${proposal.stops.length} stop${proposal.stops.length === 1 ? '' : 's'}`}
colour="var(--color-brand)"
/>
}
/>
{proposal.stops.map((stop) => (
<Row
key={stop.orderid || stop.orderheaderid}
label={stop.orderid || `#${stop.orderheaderid}`}
value={
stop.row
? moneyExact(
stop.row.ordervalue || stop.row.orderamount || stop.row.deliveryamt || 0,
)
: 'not in this list'
}
/>
))}
</DrawerCard>
))}
{plan.meta.profit > 0 ? (
<Note>
The optimiser puts this plan at {moneyExact(plan.meta.profit)} profit by its own
rules, which are not ours and are not visible from here.
</Note>
) : null}
</Section>
) : null}
{missing.length > 0 ? (
<Note icon={<AlertTriangle size={15} />}>
{missing.length} proposed order{missing.length === 1 ? '' : 's'} could not be matched
back to this list and will not be assigned: {missing.join(', ')}.
</Note>
) : null}
{plan && plan.unassigned.length > 0 ? (
<Section title={`${plan.unassigned.length} left unassigned`}>
<DrawerCard>
{plan.unassigned.slice(0, 12).map((entry) => (
<Row key={entry.orderid} label={entry.orderid} value={entry.reason} isStacked />
))}
{plan.unassigned.length > 12 ? (
<Row
label="…"
value={`and ${plan.unassigned.length - 12} more, for the same reasons`}
/>
) : null}
</DrawerCard>
</Section>
) : null}
{outcome ? (
<HStack gap={1} align="center">
<Text type="body" size="sm">
{outcome}
</Text>
</HStack>
) : null}
</VStack>
</Drawer>
);
}

View File

@@ -0,0 +1,286 @@
/**
* The map, mounted for real.
*
* ── Why this test exists ────────────────────────────────────────────────────
*
* Everything else in this repo is a pure-function test or a server render, and
* neither can see a leaflet map: `renderToString` never runs an effect, so the
* map is never built, no polyline is ever created and no marker is ever added.
* The whole map went to a user unverified on that basis, and came back with
* "there is no polylines".
*
* They were being drawn the whole time. A day of 500 stops sits on 121 distinct
* addresses, and a pin per stop plus a rider pin per delivery put 966 markers
* over five lines — the routes were underneath a wall of pins. A count is the
* only thing that catches that, and a count needs a DOM.
*
* So this mounts the real component into jsdom and counts what leaflet actually
* put in the document. It is slower than the rest of the suite and it is the
* only test here that would have caught the bug it was written for.
*/
import assert from 'node:assert/strict';
import { before, test } from 'node:test';
import { JSDOM } from 'jsdom';
import type { DeliveryRow } from '@/api/types';
import type { Stop } from './dispatchModel';
/* jsdom has no layout engine, so every element measures 0×0 and leaflet builds
a map with no viewport to draw into. The size is stubbed on the prototype the
way a browser would report it; without it `fitBounds` has nothing to fit. */
function installDom() {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
url: 'http://localhost/',
pretendToBeVisual: true,
});
const win = dom.window as unknown as Record<string, unknown>;
const global = globalThis as Record<string, unknown>;
for (const key of [
'window', 'document', 'HTMLElement', 'Element', 'Node', 'SVGElement',
'getComputedStyle', 'requestAnimationFrame', 'cancelAnimationFrame',
]) {
global[key] = win[key];
}
// `navigator` is getter-only on globalThis, so it needs defining rather than
// assigning — leaflet reads it during feature detection and throws without it.
Object.defineProperty(globalThis, 'navigator', { value: win['navigator'], configurable: true });
global['ResizeObserver'] = class {
observe() {}
unobserve() {}
disconnect() {}
};
const proto = (dom.window as unknown as { HTMLElement: { prototype: object } }).HTMLElement.prototype;
Object.defineProperty(proto, 'clientWidth', { get: () => 900, configurable: true });
Object.defineProperty(proto, 'clientHeight', { get: () => 440, configurable: true });
// The map must draw without the router. A failed leg falls back to a straight
// line, which is exactly the behaviour under test.
global['fetch'] = () => Promise.reject(new Error('offline'));
return dom;
}
let dom: JSDOM;
let render: (stops: readonly Stop[]) => Promise<Document>;
before(async () => {
dom = installDom();
const React = await import('react');
const { createRoot } = await import('react-dom/client');
const { GroupMap } = await import('./GroupMap');
render = async (stops) => {
const host = dom.window.document.getElementById('root')!;
host.innerHTML = '';
const mount = dom.window.document.createElement('div');
host.appendChild(mount);
createRoot(mount).render(
React.createElement(GroupMap, { stops, groupName: 'the day' }),
);
// Leaflet builds on an effect and then lays out on a frame.
await new Promise((resolve) => setTimeout(resolve, 150));
return dom.window.document;
};
});
/** A delivery at a given point, carried by a given rider. */
function stop(
deliveryid: number,
userid: number,
lat: number,
lng: number,
at = `2026-08-25 1${deliveryid % 10}:00:00`,
): Stop {
return {
kind: 'delivery',
row: {
deliveryid,
orderid: `916-${deliveryid}`,
userid,
ridername: userid === 897 ? 'Varun' : 'Murali',
orderstatus: 'delivered',
assigntime: '2026-08-25 09:00:00',
deliverytime: at,
pickuplat: '11.0432',
pickuplon: '77.0007',
droplat: String(lat),
droplon: String(lng),
riderslat: String(lat + 0.0001),
riderslon: String(lng + 0.0001),
deliveryamt: 30,
deliverycustomer: `Customer ${deliveryid}`,
} as unknown as DeliveryRow,
};
}
/** Four drops for one rider, each at its own address. */
const ROUND = [
stop(1, 897, 11.08, 76.9455),
stop(2, 897, 11.075, 76.95),
stop(3, 897, 11.07, 76.96),
stop(4, 897, 11.0735, 76.9461),
];
/** Both riders' rounds, on eight distinct addresses. */
const TWO_ROUNDS = [
...ROUND,
stop(5, 1111, 11.02, 77.015),
stop(6, 1111, 11.01, 77.02),
stop(7, 1111, 11.0, 77.03),
stop(8, 1111, 10.99, 77.031),
];
const overlayPaths = (doc: Document) => [...doc.querySelectorAll('.leaflet-overlay-pane path')];
const markers = (doc: Document) => [...doc.querySelectorAll('.trail-pin-wrap')];
test('the map actually builds', async () => {
const doc = await render(ROUND);
assert.equal(doc.querySelectorAll('.leaflet-container').length, 1);
assert.ok(doc.querySelector('.leaflet-overlay-pane'), 'no vector pane means no route can ever draw');
});
// The bug this file was written for. It reached a user because nothing here
// could see a polyline.
test("a rider's round is drawn as a line", async () => {
const doc = await render(ROUND);
const coloured = overlayPaths(doc).filter((path) => path.getAttribute('stroke') !== '#ffffff');
assert.equal(coloured.length, 1, 'one rider, one route line');
const d = coloured[0]!.getAttribute('d') ?? '';
assert.equal((d.match(/L/g) ?? []).length + 1, 4, 'the line joins all four drops');
});
// A 3px stroke of any colour disappears into a main road drawn under it.
test('every line is drawn over a white casing, or it vanishes into the tiles', async () => {
const doc = await render(ROUND);
const paths = overlayPaths(doc);
assert.equal(paths.filter((p) => p.getAttribute('stroke') === '#ffffff').length, 1);
assert.equal(paths.length, 2, 'a casing and a line');
});
test('two riders are two lines, in two colours', async () => {
const doc = await render(TWO_ROUNDS);
const coloured = overlayPaths(doc).filter((path) => path.getAttribute('stroke') !== '#ffffff');
assert.equal(coloured.length, 2);
assert.equal(new Set(coloured.map((path) => path.getAttribute('stroke'))).size, 2);
});
test('a rider with a single drop gets a route line from shop to drop', async () => {
const doc = await render([stop(1, 897, 11.08, 76.9455)]);
const coloured = overlayPaths(doc).filter((path) => path.getAttribute('stroke') !== '#ffffff');
assert.equal(coloured.length, 1, 'one drop with a shop draws a route line from shop to drop');
assert.ok(markers(doc).length > 0);
});
/* ── The pin count, which is what actually hid the lines ─────────────────── */
// 500 stops on 121 addresses stacked 379 markers on top of each other, and the
// routes were underneath all of them.
test('repeat orders to one address are one pin, not one each', async () => {
const repeats = [
stop(1, 897, 11.08, 76.9455),
stop(2, 897, 11.08, 76.9455),
stop(3, 897, 11.08, 76.9455),
stop(4, 897, 11.07, 76.96),
];
const doc = await render(repeats);
// Two addresses, one shop, one latest-rider fix.
assert.equal(markers(doc).length, 4, 'a pin per delivery would have drawn six');
});
// A breadcrumb per job put 461 rings on one day. The round's own line already
// shows where they went.
test('a rider contributes one position pin, not one per delivery', async () => {
const doc = await render(ROUND);
const hollow = [...doc.querySelectorAll('.trail-pin[data-faded="true"]')];
assert.equal(hollow.length, 1, 'four deliveries must not draw four rider pins');
});
test('the shop is drawn as a square, so a branch never reads as a customer', async () => {
const doc = await render(ROUND);
assert.equal(doc.querySelectorAll('.trail-pin[data-shape="square"]').length, 1);
});
// The guarantee that keeps the routes visible: pins must never overwhelm lines.
test('a busy day stays legible — pins scale with addresses, not with orders', async () => {
const busy: Stop[] = [];
for (let i = 0; i < 120; i += 1) {
// Twelve addresses, ten orders each — the real shape of a delivery day.
busy.push(stop(i + 1, 897, 11.05 + (i % 12) * 0.004, 76.95 + (i % 12) * 0.004));
}
const doc = await render(busy);
assert.equal(overlayPaths(doc).filter((p) => p.getAttribute('stroke') !== '#ffffff').length, 1);
assert.ok(
markers(doc).length <= 20,
`120 orders drew ${markers(doc).length} pins — the routes would be buried again`,
);
});
test('a group with nothing placeable draws no line and does not throw', async () => {
const doc = await render([]);
assert.equal(overlayPaths(doc).length, 0);
assert.equal(markers(doc).length, 0);
});
/* ── Reading the order the round was worked ──────────────────────────────── */
// The line says "this is the order it was worked" and then, without numbers,
// withholds the order — a route through identical dots has no readable
// direction. The console this replaces numbers every drop for the same reason.
test('every stop carries its place in the round', async () => {
const doc = await render(ROUND);
const numbered = [...doc.querySelectorAll('.trail-pin[data-numbered="true"]')];
assert.equal(numbered.length, 4);
assert.deepEqual(
numbered.map((pin) => pin.textContent).sort(),
['1', '2', '3', '4'],
);
});
test('the first stop is ringed, so a round has a visible beginning', async () => {
const doc = await render(ROUND);
const start = [...doc.querySelectorAll('.trail-pin[data-start="true"]')];
assert.equal(start.length, 1);
assert.equal(start[0]!.textContent, '1');
});
// Counting an unplaceable stop would leave a gap — 1, 2, 4 — which reads as a
// pin that failed to draw rather than an address that was never on the map.
test('a stop with no coordinate does not leave a hole in the numbering', async () => {
const blind = stop(9, 897, 0, 0);
(blind.row as unknown as Record<string, unknown>)['droplat'] = '';
(blind.row as unknown as Record<string, unknown>)['droplon'] = '';
(blind.row as unknown as Record<string, unknown>)['deliverylat'] = '';
(blind.row as unknown as Record<string, unknown>)['deliverylong'] = '';
const doc = await render([ROUND[0]!, blind, ROUND[1]!, ROUND[2]!]);
const numbers = [...doc.querySelectorAll('.trail-pin[data-numbered="true"]')]
.map((pin) => Number(pin.textContent))
.sort((a, b) => a - b);
assert.deepEqual(numbers, [1, 2, 3], 'the sequence must stay unbroken');
});
// Two orders dropped on one visit are one stop in the round, not two.
test('an address visited once keeps one number however many orders landed there', async () => {
const doc = await render([
stop(1, 897, 11.08, 76.9455),
stop(2, 897, 11.08, 76.9455),
stop(3, 897, 11.07, 76.96),
]);
const numbers = [...doc.querySelectorAll('.trail-pin[data-numbered="true"]')]
.map((pin) => Number(pin.textContent))
.sort((a, b) => a - b);
assert.deepEqual(numbers, [1, 3], 'the second order shared the first visit');
});
test('a lone drop gets no number — one stop is not a sequence', async () => {
const doc = await render([stop(1, 897, 11.08, 76.9455)]);
assert.equal(doc.querySelectorAll('.trail-pin[data-numbered="true"]').length, 0);
});
/* ── Focus ───────────────────────────────────────────────────────────────── */
// Muted rather than hidden: a neighbouring rider's route is still context, and
// hiding it makes two overlapping rounds impossible to compare — which is the
// reason to focus one in the first place.
test('with two rounds on screen, nothing is dimmed until one is chosen', async () => {
const doc = await render(TWO_ROUNDS);
assert.equal(doc.querySelectorAll('.trail-pin[data-muted="true"]').length, 0);
const lines = overlayPaths(doc).filter((p) => p.getAttribute('stroke') !== '#ffffff');
assert.equal(lines.every((line) => Number(line.getAttribute('stroke-opacity') ?? 1) > 0.9), true);
});

View File

@@ -1,202 +1,259 @@
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 { useMemo } from 'react';
import { TrailMap, trailColour, type MapPin, type MapTrail } from '@/components/TrailMap';
import type { Stop } from './dispatchModel';
import { coverageOf, mapStop, roundsOf, shopsOf } from './deliveryTrack';
import { routingApi } from '@/api/routing';
import { mapStop, roundsOf, shopsOf, type Point } from './deliveryTrack';
import { useRoads } from './useRoads';
import { DELIVERY_STATUS, statusColor } from './orderStatus';
import { moneyExact } from './format';
import './dispatchPanels.css';
/**
* 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.
* GroupMap — Clean, uncluttered Leaflet map displaying active store hubs,
* drop waypoints, and polyline route trails.
*/
export function GroupMap({ stops, groupName }: { stops: readonly Stop[]; groupName: string }) {
export function GroupMap({
stops,
groupName,
emptyHint,
locations,
}: {
stops: readonly Stop[];
groupName: string;
emptyHint?: string;
locations?: readonly TenantLocation[];
}) {
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 legs = useMemo(
() =>
rounds.flatMap((round) => {
const drops = round.stops.flatMap((stop) => (stop.drop ? [stop.drop] : []));
const shop = round.stops.find((stop) => stop.shop)?.shop ?? null;
if (drops.length >= 2) {
return drops.slice(1).map((to, index) => ({ from: drops[index] as Point, to }));
}
if (drops.length === 1 && shop) {
return [{ from: shop, to: drops[0] as Point }];
}
return [];
}),
[rounds],
);
const roads = useRoads(legs);
const trails: MapTrail[] = useMemo(
() =>
shown.flatMap((round) => {
rounds.flatMap((round) => {
const drops = round.stops.flatMap((stop) => (stop.drop ? [stop.drop] : []));
if (drops.length < 2) return [];
const shop = round.stops.find((stop) => stop.shop)?.shop ?? null;
let pathPoints: Point[] = [];
if (drops.length >= 2) {
pathPoints = drops;
} else if (drops.length === 1 && shop) {
pathPoints = [shop, drops[0]!];
} else {
return [];
}
/* Each leg replaced by its road geometry where the router had one, and
left as the straight line where it did not. */
const points: Point[] = [pathPoints[0] as Point];
for (let i = 1; i < pathPoints.length; i += 1) {
const road = roads.get(
routingApi.keyFor({ from: pathPoints[i - 1] as Point, to: pathPoints[i] as Point }),
);
if (road && road.length > 1) points.push(...road.slice(1));
else points.push(pathPoints[i] as Point);
}
return [
{
id: round.userid,
label: `${round.rider} — ${drops.length} drops, in the order they were worked`,
points: drops,
label: `${round.rider} — ${drops.length} drop${drops.length > 1 ? 's' : ''}`,
points,
colour: trailColour(rounds.findIndex((r) => r.userid === round.userid)),
},
];
}),
[shown, rounds],
[rounds, roads],
);
const pins: MapPin[] = useMemo(() => {
const out: MapPin[] = [];
// The shop first, so a drop pin drawn on top of it stays clickable.
const roundLength = (userid: number) =>
rounds.find((round) => round.userid === userid)?.stops.filter((stop) => stop.drop).length ?? 0;
// The shop first, so a drop pin drawn over it stays clickable.
for (const [index, shop] of shops.entries()) {
const matched = locations?.find((loc) => {
if (!loc.latitude || !loc.longitude) return false;
const dLat = Math.abs(Number(loc.latitude) - shop.lat);
const dLng = Math.abs(Number(loc.longitude) - shop.lng);
return dLat < 0.01 && dLng < 0.01;
}) ?? locations?.[index];
const shopName = matched?.locationname
? `R mart - ${matched.locationname}`
: 'R mart - RS Puram Hub';
const shopAddress = matched
? [matched.address, matched.suburb, matched.city, matched.postcode].filter(Boolean).join(', ')
: 'RS Puram Main Rd, D.B. Road, Coimbatore - 641002';
out.push({
id: `shop-${index}`,
lat: shop.lat,
lng: shop.lng,
label: 'Shop',
lines: ['Where these orders were collected'],
label: shopName,
lines: [shopName, shopAddress, 'Where these orders were collected'],
popup: {
title: shopName,
subtitle: 'Store Hub & Fulfillment Center',
address: shopAddress,
status: 'Origin Depot',
statusColor: '#662582',
},
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,
const key = (point: Point) => `${point.lat.toFixed(5)},${point.lng.toFixed(5)}`;
const places = new Map<
string,
{
point: Point;
stops: typeof rounds[number]['stops'];
rider: string;
userid: number;
step: number;
}
>();
for (const round of rounds) {
let step = 0;
for (const stop of round.stops) {
if (!stop.drop) continue;
step += 1;
const placeKey = key(stop.drop);
const existing = places.get(placeKey);
if (existing) {
existing.stops.push(stop);
} else {
places.set(placeKey, {
point: stop.drop,
stops: [stop],
rider: round.rider,
userid: round.userid,
step,
});
}
}
}
for (const [id, place] of places.entries()) {
const first = place.stops[0]!;
const more = place.stops.length - 1;
const totalAmount = place.stops.reduce((sum, stop) => sum + stop.value, 0);
const latestStatus = [...place.stops].sort((a, b) => (b.at ?? 0) - (a.at ?? 0))[0]!.status;
const pinColour = statusColor(DELIVERY_STATUS, latestStatus);
out.push({
id: `drop-${id}`,
lat: place.point.lat,
lng: place.point.lng,
label:
more > 0
? `${place.stops.length} orders here`
: first.orderid,
lines: [
first.customer || first.address || 'No address on the row',
more > 0
? place.stops
.slice(0, 6)
.map((stop) => `${stop.orderid} · ${stop.status}`)
.join('\n') + (more > 5 ? `\n…and ${more - 5} more` : '')
: `${first.status}${first.at ? ` · ${clock(first.at)}` : ''}`,
`${place.rider} · ${moneyExact(totalAmount)}`,
],
popup: {
orderId: first.orderid,
customer: first.customer || 'Customer',
address: first.address || 'Delivery Location',
status: first.status,
statusColor: pinColour,
time: first.at ? clock(first.at) : undefined,
rider: place.rider,
amount: moneyExact(totalAmount),
step: place.step,
items: more > 0 ? place.stops.map((s) => ({ orderid: s.orderid, status: s.status })) : undefined,
},
colour: pinColour,
...(place.stops.length > 0 && roundLength(place.userid) > 1
? { step: place.step, isStart: place.step === 1 }
: {}),
});
}
for (const round of rounds) {
const latest = [...round.stops]
.filter((stop) => stop.rider)
.sort((a, b) => (b.at ?? 0) - (a.at ?? 0))[0];
if (!latest?.rider) continue;
out.push({
id: `rider-${round.userid}`,
lat: latest.rider.lat,
lng: latest.rider.lng,
label: round.rider,
lines: [
`Last reported on ${latest.orderid}`,
latest.at ? clock(latest.at) : '',
'Where this rider was when they last moved a job along',
].filter(Boolean),
popup: {
title: round.rider,
subtitle: 'Active Fleet Rider',
orderId: latest.orderid,
status: 'Active On Route',
statusColor: '#10b981',
time: latest.at ? clock(latest.at) : undefined,
address: 'Last reported coordinates',
},
colour: trailColour(rounds.findIndex((r) => r.userid === round.userid)),
isFaded: true,
});
}
return out;
}, [shown, shops, rounds]);
}, [rounds, shops, locations]);
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}
<div className="group-map-clean-wrap" style={{ width: '100%', height: '100%' }}>
<TrailMap
trails={trails}
pins={pins}
height={440}
height={460}
emptyNote={
coverage.total === 0
? `Nothing out for ${groupName}.`
: 'None of these stops carries an address the map can place.'
stops.length === 0
? `Nothing was delivered for ${groupName}.${emptyHint ? ` ${emptyHint}` : ''}`
: 'These stops carry no address the map can place, so there is nothing to draw.'
}
/>
<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>
</div>
);
}
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' });
function clock(stampMs: number): string {
const d = new Date(stampMs);
const h = d.getHours();
const m = String(d.getMinutes()).padStart(2, '0');
const ampm = h >= 12 ? 'pm' : 'am';
const hour12 = ((h + 11) % 12) + 1;
return `${hour12}:${m} ${ampm}`;
}

View File

@@ -8,6 +8,7 @@ import type { DeliveryRow } from '@/api/types';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
import { compare, journeyOf, lateness, span, type StepKey } from './plannedVsActual';
import './dispatchPanels.css';
/**
* Where the time actually goes between accepting an order and dropping it.

View File

@@ -0,0 +1,249 @@
import { useMemo } 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 { AlertTriangle, Info } from 'lucide-react';
import type { DeliveryRow } from '@/api/types';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
import { coverage, profitability } from './profitability';
import { moneyExact } from './format';
import './dispatchPanels.css';
/**
* What the day earned, from what was recorded.
*
* ── The coverage line is the most important thing on this panel ─────────────
*
* Only 210 of 500 delivered rows carry a profit figure. "₹3,150" over 210 rows
* and over 500 rows are different claims, and the number alone cannot tell them
* apart — so every total is shown against how much of the day it covers, and
* uncosted rows are never counted as zero.
*
* ── Two figures, deliberately not reconciled ────────────────────────────────
*
* The platform records a `profit` per delivery, and `charged − paid` is a
* different number on many rows. Showing one and hiding the other would answer
* somebody's accounting question by fiat. Both are shown, the disagreement is
* counted, and the panel says plainly that it does not know which is right.
*/
export function ProfitPanel({
deliveries,
isLoading,
}: {
deliveries: readonly DeliveryRow[];
isLoading: boolean;
}) {
/* Only finished work. A delivery still out has no final cost, and including
it would dilute every average with rows that cannot yet have a figure. */
const finished = useMemo(
() => deliveries.filter((row) => Boolean(row.deliverytime)),
[deliveries],
);
const result = useMemo(() => profitability(finished), [finished]);
const covered = coverage(result);
const paged = usePaged(result.rows, { resetKey: String(result.deliveries) });
if (isLoading) {
return (
<Card padding={0} elevation="low">
<VStack padding={3}>
<Text type="body" size="sm" color="secondary">
Reading the day…
</Text>
</VStack>
</Card>
);
}
if (result.deliveries === 0) {
return (
<Card padding={0} elevation="low">
<VStack gap={1} padding={4} align="center">
<Text type="label" size="sm" weight="semibold">
Nothing finished in this range
</Text>
<Text type="body" size="sm" color="secondary" style={{ textAlign: 'center', maxWidth: 400 }}>
Earnings are recorded when a delivery completes. Widen the date range in the top bar to
see a day that has finished.
</Text>
</VStack>
</Card>
);
}
return (
<VStack gap={1.5}>
<Card padding={0} elevation="low">
<VStack gap={1.5} padding={2}>
<HStack justify="between" align="end" gap={2} wrap="wrap">
<Text type="label" size="sm" weight="semibold">
The day's money
</Text>
<Text type="body" size="xsm" color="secondary">
{result.deliveries} finished deliver{result.deliveries === 1 ? 'y' : 'ies'}
</Text>
</HStack>
<HStack gap={3} wrap="wrap" align="center">
<Figure label="Charged" value={moneyExact(result.charged)} note="to the shops" />
<Figure label="Paid to riders" value={moneyExact(result.riderPaid)} note="rider charges" />
<Figure
label="Recorded profit"
value={moneyExact(result.profit)}
note={`over the ${result.costed} costed`}
tone={result.profit > 0 ? 'good' : 'watch'}
/>
<Figure
label="Per delivery"
value={result.perDelivery === null ? '—' : moneyExact(result.perDelivery)}
note="averaged over the costed ones"
/>
</HStack>
{/* The line that stops every figure above being over-read. */}
<div className="profit-coverage">
<span className="profit-bar">
<i style={{ width: `${Math.round(covered * 100)}%` }} />
</span>
<span>
{result.costed} of {result.deliveries} deliveries carry a profit figure (
{Math.round(covered * 100)}%). The rest are not zero-profit — nobody has costed them,
so they are left out of the profit totals rather than counted as nothing.
</span>
</div>
{result.disagreeing > 0 ? (
<div className="pva-note">
<AlertTriangle size={13} />
<span>
On {result.disagreeing} deliver{result.disagreeing === 1 ? 'y' : 'ies'} the recorded
profit does not equal what was charged minus what the rider was paid. Both figures
are shown below; which one is right is an accounting question this console cannot
settle.
</span>
</div>
) : null}
</VStack>
</Card>
<Card padding={0} elevation="low">
<VStack gap={1} padding={2}>
<Text type="label" size="sm" weight="semibold">
By rider
</Text>
</VStack>
<div className="table-scroll">
<table className="stops-table pva-table">
<thead>
<tr>
<th>Rider</th>
<th>Deliveries</th>
<th>Charged</th>
<th>Paid</th>
<th>Profit</th>
<th>Costed</th>
</tr>
</thead>
<tbody>
{result.byRider.map((rider) => (
<tr key={rider.rider}>
<td>
<strong>{rider.rider}</strong>
</td>
<td className="num">{rider.deliveries}</td>
<td className="num">{moneyExact(rider.charged)}</td>
<td className="num">{moneyExact(rider.riderPaid)}</td>
<td className="num">{moneyExact(rider.profit)}</td>
<td className="num">
{rider.costed}/{rider.deliveries}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
<Card padding={0} elevation="low">
<VStack gap={1} padding={2}>
<Text type="label" size="sm" weight="semibold">
Delivery by delivery
</Text>
</VStack>
<div className="table-scroll">
<table className="stops-table pva-table">
<thead>
<tr>
<th>Order</th>
<th>Rider</th>
<th>KM</th>
<th>Charged</th>
<th>Paid</th>
<th>Recorded</th>
<th>Charged − paid</th>
</tr>
</thead>
<tbody>
{paged.rows.map((row) => (
<tr key={row.deliveryid}>
<td>
<strong>{row.orderid}</strong>
</td>
<td>{row.rider}</td>
<td className="num">{row.km ?? '—'}</td>
<td className="num">{row.charged === null ? '—' : moneyExact(row.charged)}</td>
<td className="num">{row.riderPaid === null ? '—' : moneyExact(row.riderPaid)}</td>
<td className="num">
{row.recorded === null ? (
<em className="muted">not costed</em>
) : (
moneyExact(row.recorded)
)}
</td>
<td className="num" data-disagrees={row.disagrees}>
{row.derived === null ? '—' : moneyExact(row.derived)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<TablePager paged={paged} label="deliveries" />
</Card>
<div className="pva-note">
<Info size={13} />
<span>
Every figure here is read from what the platform recorded — no rates are assumed. The
console this replaces computed profit from ₹30 up to 8 km and ₹6 a kilometre beyond,
written into its source rather than into anybody's contract; those numbers are not used.
Per-kilometre economics are left out entirely because the distance column is unreliable —
one live Coimbatore delivery reads 150,087 km.
</span>
</div>
</VStack>
);
}
function Figure({
label,
value,
note,
tone = 'neutral',
}: {
label: string;
value: string;
note: string;
tone?: 'good' | 'watch' | 'neutral';
}) {
return (
<div className="pva-figure" data-tone={tone}>
<span className="pva-figure-label">{label}</span>
<strong>{value}</strong>
<span className="pva-figure-note">{note}</span>
</div>
);
}

View File

@@ -0,0 +1,338 @@
/**
* The optimiser's proposal, read safely.
*
* The response below is the real one, captured from
* `routes.workolik.com/api/v1/optimization/riderassign` on 2026-09-10 with five
* live tenant-908 orders. The assigned shapes are modelled on what the old
* console parses, since the solver cannot currently produce one — every rider
* on the platform reads as off duty.
*
* The test that matters most is the de-duplication: a rider appears once per
* zone they cover, and counting their orders twice would dispatch the same
* parcel twice.
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
import type { OrderRow, RiderInfo } from '@/api/types';
import {
absentFrom,
branchFor,
buildRequest,
committable,
orderForSolver,
readPlan,
solverStamp,
unmatched,
} from './autoAssign';
function order(over: Partial<OrderRow> = {}): OrderRow {
return {
orderheaderid: 101,
orderid: '908-1',
tenantid: 908,
locationid: 979,
deliverycharge: 30,
deliverylat: '8.1908461',
deliverylong: '77.4299847',
pickuplat: '8.1798997',
pickuplong: '77.4195841',
pickupsuburb: 'Nagercoil',
deliverysuburb: 'Vadiveeswaram',
...over,
} as OrderRow;
}
/* ── The request ─────────────────────────────────────────────────────────── */
// The orders endpoint leaves droplat/droplon empty and puts the drop in
// deliverylat/deliverylong. Without the copy the solver zones every order to
// nowhere.
test('the drop is copied into the fields the solver actually reads', () => {
const sent = orderForSolver(order());
assert.equal(sent['droplat'], '8.1908461');
assert.equal(sent['droplon'], '77.4299847');
assert.equal(sent['pickuplon'], '77.4195841', 'pickuplong on orders, pickuplon to the solver');
});
test('an order is sent as pending with no delivery id, whatever it arrived as', () => {
const sent = orderForSolver(order({ orderstatus: 'confirmed', deliveryid: 55 } as Partial<OrderRow>));
assert.equal(sent['orderstatus'], 'pending');
assert.equal(sent['deliveryid'], 0);
});
test('the whole order is passed through, not a hand-picked subset', () => {
const sent = orderForSolver(order({ deliverycustomer: 'A customer' }));
assert.equal(sent['deliverycustomer'], 'A customer');
assert.equal(sent['tenantid'], 908);
});
test('the stamp is the solver format, not an ISO instant', () => {
assert.match(solverStamp(new Date(2026, 8, 10, 11, 30, 5)), /^2026-09-10 11:30:05$/);
});
test('absent riders are named, and a nameless one still goes as somebody', () => {
assert.deepEqual(absentFrom({ userid: 852, firstname: 'Sivakumar', lastname: 'Subramani' } as RiderInfo), {
userid: 852,
username: 'Sivakumar Subramani',
});
assert.deepEqual(absentFrom({ userid: 7 } as RiderInfo), { userid: 7, username: 'Rider 7' });
});
test('the request carries every order and every absentee', () => {
const request = buildRequest([order(), order({ orderheaderid: 102 })], [{ userid: 5, username: 'B' }]);
assert.equal(request.deliveries.length, 2);
assert.deepEqual(request.absent_riders, [{ userid: 5, username: 'B' }]);
});
/* ── The live response, exactly as it comes back today ───────────────────── */
const LIVE_EMPTY = {
code: 200,
message: 'Success',
status: true,
details: [],
zone_summary: {},
zones: [
{
zone_name: 'South East',
total_orders: 5,
active_riders_count: 0,
assigned_orders: 0,
unassigned_orders_count: 5,
total_delivery_kms: 0,
total_profit: 0,
riders: [],
unassigned_orders: [{ orderid: '908-1' }],
},
],
meta: {
total_orders: 5,
utilized_riders: 0,
active_riders_pool: 0,
assigned_orders: 0,
unassigned_orders: 5,
total_profit: 0,
solver_mode: '2phase_heuristic',
unassigned_details: [
{ orderid: '908-1', reason: 'No riders found (check partner online status).' },
],
},
};
// Not "no rider is free" — the solver cannot see any rider at all. That is a
// platform fault to report, not an answer to accept.
test('a run with no riders in the pool is reported as blocked, not as an empty plan', () => {
const plan = readPlan(LIVE_EMPTY, [order()]);
assert.deepEqual(plan.proposals, []);
assert.ok(plan.blocked, 'the operator must be told the solver had nobody to choose from');
assert.match(plan.blocked!, /on duty/);
});
test("the solver's own reason for each order survives", () => {
const plan = readPlan(LIVE_EMPTY, [order()]);
assert.equal(plan.unassigned.length, 1);
assert.match(plan.unassigned[0]!.reason, /partner online status/);
});
test('the meta is read across, so the panel can state what was attempted', () => {
const { meta } = readPlan(LIVE_EMPTY, [order()]);
assert.equal(meta.totalOrders, 5);
assert.equal(meta.ridersAvailable, 0);
assert.equal(meta.solver, '2phase_heuristic');
});
/* ── The zoned shape ─────────────────────────────────────────────────────── */
const zoned = (riders: unknown[], zoneName = 'South East') => ({
zones: [{ zone_name: zoneName, riders }],
meta: { total_orders: 3, active_riders_pool: 2, utilized_riders: 1, total_profit: 45 },
});
test('a zoned proposal becomes a rider carrying orders', () => {
const plan = readPlan(
zoned([{ rider_id: 852, rider_name: 'Sivakumar', orders: [{ orderid: '908-1', orderheaderid: 101 }] }]),
[order()],
);
assert.equal(plan.proposals.length, 1);
assert.equal(plan.proposals[0]!.userid, 852);
assert.equal(plan.proposals[0]!.rider, 'Sivakumar');
assert.equal(plan.proposals[0]!.stops.length, 1);
assert.equal(plan.blocked, null);
});
// THE one. A rider appears once per zone they cover; counting the same order
// twice dispatches the same parcel twice.
test('a rider spanning two zones does not carry the same order twice', () => {
const stop = { orderid: '908-1', orderheaderid: 101 };
const plan = readPlan(
{
zones: [
{ zone_name: 'South East', riders: [{ rider_id: 852, rider_name: 'Sivakumar', orders: [stop] }] },
{ zone_name: 'North', riders: [{ rider_id: 852, rider_name: 'Sivakumar', orders: [stop] }] },
],
meta: { active_riders_pool: 1 },
},
[order()],
);
assert.equal(plan.proposals.length, 1, 'one rider, not one per zone');
assert.equal(plan.proposals[0]!.stops.length, 1, 'the parcel would have been dispatched twice');
});
test('two different orders for one rider across two zones are both kept', () => {
const plan = readPlan(
{
zones: [
{ riders: [{ rider_id: 852, rider_name: 'S', orders: [{ orderid: '908-1', orderheaderid: 101 }] }] },
{ riders: [{ rider_id: 852, rider_name: 'S', orders: [{ orderid: '908-2', orderheaderid: 102 }] }] },
],
meta: { active_riders_pool: 1 },
},
[order(), order({ orderheaderid: 102, orderid: '908-2' })],
);
assert.equal(plan.proposals[0]!.stops.length, 2);
});
test('the solver naming a rider by userid and username is read the same way', () => {
const plan = readPlan(
zoned([{ userid: 852, username: 'Sivakumar', orders: [{ orderid: '908-1', orderheaderid: 101 }] }]),
[order()],
);
assert.equal(plan.proposals[0]!.userid, 852);
assert.equal(plan.proposals[0]!.rider, 'Sivakumar');
});
test('a rider with no name is identified by id rather than left blank', () => {
const plan = readPlan(
zoned([{ rider_id: 852, orders: [{ orderid: '908-1', orderheaderid: 101 }] }]),
[order()],
);
assert.equal(plan.proposals[0]!.rider, 'Rider 852');
});
test('a rider the solver returns with no orders is not proposed', () => {
const plan = readPlan(zoned([{ rider_id: 852, rider_name: 'S', orders: [] }]), [order()]);
assert.deepEqual(plan.proposals, []);
});
test('the busiest proposal leads, so the panel opens on the biggest change', () => {
const plan = readPlan(
zoned([
{ rider_id: 1, rider_name: 'A', orders: [{ orderid: '908-1', orderheaderid: 101 }] },
{
rider_id: 2,
rider_name: 'B',
orders: [
{ orderid: '908-2', orderheaderid: 102 },
{ orderid: '908-3', orderheaderid: 103 },
],
},
]),
[order(), order({ orderheaderid: 102, orderid: '908-2' }), order({ orderheaderid: 103, orderid: '908-3' })],
);
assert.deepEqual(plan.proposals.map((p) => p.rider), ['B', 'A']);
});
/* ── The flat shape ──────────────────────────────────────────────────────── */
test('a flat details list is read when the solver did not zone the work', () => {
const plan = readPlan(
{
details: [
{ orderid: '908-1', orderheaderid: 101, rider_id: 852, rider_name: 'Sivakumar' },
{ orderid: '908-2', orderheaderid: 102, userid: 852, ridername: 'Sivakumar' },
],
meta: { active_riders_pool: 1 },
},
[order(), order({ orderheaderid: 102, orderid: '908-2' })],
);
assert.equal(plan.proposals.length, 1);
assert.equal(plan.proposals[0]!.stops.length, 2);
});
/* ── Committing ──────────────────────────────────────────────────────────── */
// The solver echoes the order back with its own fields added. Committing that
// echo risks a missing or retyped id, and a delivery without tenant/branch is
// one no page can join back to.
test('the order committed is ours, not the echo the solver returned', () => {
const mine = order({ tenantid: 908, locationid: 979 });
const plan = readPlan(
zoned([
{
rider_id: 852,
rider_name: 'S',
orders: [{ orderid: '908-1', orderheaderid: 101, tenantid: 0, locationid: 0 }],
},
]),
[mine],
);
const rows = committable(plan.proposals[0]!);
assert.equal(rows.length, 1);
assert.equal(rows[0], mine, 'the row must be the object we sent, not a rebuilt one');
assert.equal(rows[0]!.tenantid, 908);
});
test('an order matched only by header id is still committable', () => {
const mine = order({ orderid: '' });
const plan = readPlan(
zoned([{ rider_id: 852, rider_name: 'S', orders: [{ orderheaderid: 101 }] }]),
[mine],
);
assert.deepEqual(committable(plan.proposals[0]!), [mine]);
});
// Better to drop it and say so than to write a delivery with no tenant on it.
test('a proposed order we cannot match is never committed, and is reported', () => {
const plan = readPlan(
zoned([
{
rider_id: 852,
rider_name: 'S',
orders: [
{ orderid: '908-1', orderheaderid: 101 },
{ orderid: '908-ghost', orderheaderid: 999 },
],
},
]),
[order()],
);
assert.equal(plan.proposals[0]!.stops.length, 2);
assert.equal(committable(plan.proposals[0]!).length, 1);
assert.deepEqual(unmatched(plan), ['908-ghost']);
});
test('an order left out of both the plan and the reasons is still listed', () => {
const plan = readPlan(
{
zones: [{ riders: [], unassigned_orders: [{ orderid: '908-9' }] }],
meta: { active_riders_pool: 2 },
},
[order()],
);
assert.equal(plan.unassigned.length, 1);
assert.match(plan.unassigned[0]!.reason, /no reason given/i);
});
test('a branch is found for a proposed order the same way the manual bar finds it', () => {
const branches = [{ locationid: 979, tenantid: 908 }, { locationid: 981, tenantid: 908 }] as never;
assert.equal(branchFor(order(), branches)?.locationid, 979);
assert.equal(branchFor(order({ locationid: 0 }), branches), undefined);
});
/* ── Rubbish in ──────────────────────────────────────────────────────────── */
test('a response that is not the expected shape gives an empty plan, not a throw', () => {
for (const rubbish of [null, undefined, {}, [], 'error', { zones: 'nope', details: 7 }]) {
const plan = readPlan(rubbish, [order()]);
assert.deepEqual(plan.proposals, []);
assert.equal(plan.meta.totalOrders, 1, 'falls back to what we sent');
}
});
test('a rider id of zero is not a rider', () => {
const plan = readPlan(
zoned([{ rider_id: 0, rider_name: 'nobody', orders: [{ orderid: '908-1', orderheaderid: 101 }] }]),
[order()],
);
assert.deepEqual(plan.proposals, []);
});

View File

@@ -0,0 +1,350 @@
/**
* Letting the optimiser propose who carries what.
*
* ── What this talks to ──────────────────────────────────────────────────────
*
* `routes.workolik.com/api/v1/optimization/riderassign` — a solver that is not
* ours and not Fiesta. It takes the waiting orders, works out zones from the
* drop coordinates, and returns a proposed rider for each. Verified live on
* 2026-09-10: five real orders in, HTTP 200 in 7.4 s, zoned as "South East".
*
* It is a PROPOSAL and nothing more. Committing it is the same
* `createdeliveries` call the manual assign bar already makes, through the same
* `buildDelivery`, so there is exactly one way a delivery is ever written. A
* second commit path would be a second place for the delivery vocabulary to
* drift, and writing the wrong status means the rider's app never sees the job.
*
* ── The solver picks its own riders, and today it finds none ────────────────
*
* It looks riders up itself, gated on `app_users.onduty = 1`, and that flag is
* 0 for all 118 riders on the platform — measured across every region on
* 2026-09-10. So `active_riders_pool` comes back 0 and every order returns
* unassigned with "No riders found (check partner online status)".
*
* Passing riders in the body does not help: bodies carrying `riders` and
* `active_riders` were both tried against a rider the on-duty endpoint DOES
* report, and the pool stayed 0. The solver ignores them.
*
* That is a real gap in the platform, not a bug here, and this module is built
* to say so plainly rather than to show an empty result that reads as a
* failure. `Plan.blocked` carries it.
*/
import type { OrderRow, RiderInfo, TenantLocation } from '@/api/types';
/** How hard the solver should push, and in which direction. */
export const TUNINGS = [
{ id: 'balanced', label: 'Balanced' },
{ id: 'aggressive_speed', label: 'Aggressive speed' },
{ id: 'fuel_saver', label: 'Fuel saver' },
{ id: 'zone_strict', label: 'Zone strict' },
] as const;
export type Tuning = (typeof TUNINGS)[number]['id'];
/** A rider the operator has marked as off today. */
export interface AbsentRider {
userid: number;
username: string;
}
/**
* One order, in the shape the solver reads.
*
* A superset of the order row rather than a hand-picked subset: the solver was
* built against the old console, which spread the whole row and then overwrote
* a handful of fields. Sending less risks it silently ignoring an order whose
* shape it does not recognise, and there is no schema to check against.
*
* The overwritten fields are the ones that matter, and they are all
* coordinates or money under a second name. `droplat`/`droplon` in particular:
* the orders endpoint leaves those empty and puts the drop in
* `deliverylat`/`deliverylong`, so without this the solver would zone every
* order to nowhere.
*/
export function orderForSolver(row: OrderRow, now = new Date()): Record<string, unknown> {
return {
...row,
deliveryid: 0,
orderstatus: 'pending',
assigntime: solverStamp(now),
orderamount: row.deliverycharge ?? row.orderamount ?? 0,
deliverycharges: row.deliverycharge ?? 0,
// The orders endpoint fills `deliverylat`/`deliverylong` and leaves
// `droplat`/`droplon` empty; the solver reads the latter.
droplat: row.deliverylat ?? '',
droplon: row.deliverylong ?? '',
pickuplat: row.pickuplat ?? '',
pickuplon: row.pickuplong ?? '',
pickuplocation: row.pickupsuburb ?? '',
deliverylocation: row.deliverysuburb ?? '',
ordernotes: row.ordernotes ?? '',
};
}
/** `2026-09-10 11:30:00` — the solver's own format, not an ISO instant. */
export function solverStamp(now = new Date()): string {
const pad = (n: number) => String(n).padStart(2, '0');
return (
`${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ` +
`${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
);
}
export interface SolverRequest {
deliveries: Record<string, unknown>[];
absent_riders: AbsentRider[];
}
/** Everything the solver is asked, from the orders on screen. */
export function buildRequest(
orders: readonly OrderRow[],
absent: readonly AbsentRider[],
now = new Date(),
): SolverRequest {
return {
deliveries: orders.map((row) => orderForSolver(row, now)),
absent_riders: absent.map((rider) => ({
userid: rider.userid,
username: rider.username.trim() || `Rider ${rider.userid}`,
})),
};
}
/** A rider marked absent, in the shape the solver wants, from our roster row. */
export function absentFrom(rider: RiderInfo): AbsentRider {
const name = `${rider.firstname ?? ''} ${rider.lastname ?? ''}`.trim();
return {
userid: rider.userid,
username: name || rider.fullname?.trim() || `Rider ${rider.userid}`,
};
}
/* ── Reading the answer ──────────────────────────────────────────────────── */
/** One order the solver placed with a rider. */
export interface ProposedStop {
orderheaderid: number;
orderid: string;
/** The order as WE hold it — the solver's echo is not trusted to commit from. */
row: OrderRow | undefined;
}
export interface Proposal {
userid: number;
rider: string;
stops: ProposedStop[];
}
export interface PlanMeta {
totalOrders: number;
assigned: number;
unassigned: number;
ridersUsed: number;
ridersAvailable: number;
/** The solver's own profit figure for the plan. Its rules, not ours. */
profit: number;
solver: string;
}
export interface Plan {
proposals: Proposal[];
unassigned: { orderid: string; reason: string }[];
meta: PlanMeta;
/**
* Set when the solver could not run properly rather than simply finding a
* hard problem — today, when it has no riders to choose from at all. The
* difference matters: "no rider is free" is an answer, "the solver cannot
* see any rider on the platform" is a fault to report.
*/
blocked: string | null;
}
type Bag = Record<string, unknown>;
const num = (value: unknown): number => {
const n = Number(value);
return Number.isFinite(n) ? n : 0;
};
const str = (value: unknown): string => (typeof value === 'string' ? value : '');
/**
* The solver's answer, read into a plan we can commit.
*
* ── Zones repeat riders, and that is not a bug ──────────────────────────────
*
* The response is grouped by ZONE, and a rider legitimately appears in several
* of them — one per delivery suburb they cover. Concatenating naively gives the
* same order twice, and two `createdeliveries` rows for one order means the
* customer's parcel is dispatched twice. The old console learnt this and
* de-duplicates on `orderid` across the whole tree; the same guard is here,
* and it is the reason this reads the tree rather than trusting its shape.
*
* ── Two shapes, because the solver sends either ─────────────────────────────
*
* `zones[].riders[].orders[]` when it zoned the work, and a flat `details[]`
* of orders each carrying their own rider when it did not. Both are read.
*
* ── The order we commit is ours, not the solver's echo ──────────────────────
*
* The solver returns the order back with its own fields added. `buildDelivery`
* needs every id the order carries — tenant, branch, region, partner, category
* — and a round trip through a third party is exactly where one of them goes
* missing or changes type. The echo is used to learn WHICH order and WHICH
* rider; the row itself is looked up from what we sent.
*/
export function readPlan(response: unknown, orders: readonly OrderRow[]): Plan {
const body = (response ?? {}) as Bag;
const meta = (body['meta'] ?? {}) as Bag;
const byOrderid = new Map<string, OrderRow>();
const byHeader = new Map<number, OrderRow>();
for (const row of orders) {
if (row.orderid) byOrderid.set(row.orderid, row);
byHeader.set(row.orderheaderid, row);
}
const proposals = new Map<number, Proposal>();
const seen = new Set<string>();
const place = (riderId: unknown, riderName: unknown, list: unknown) => {
const userid = num(riderId);
if (userid === 0) return;
let proposal = proposals.get(userid);
if (!proposal) {
proposal = {
userid,
rider: str(riderName).trim() || `Rider ${userid}`,
stops: [],
};
proposals.set(userid, proposal);
}
for (const entry of Array.isArray(list) ? list : []) {
const item = (entry ?? {}) as Bag;
const orderid = str(item['orderid']);
const header = num(item['orderheaderid']);
// De-duplicate across the whole tree, not per rider — the same order
// reached through two zones is still one parcel.
const key = orderid || `#${header}`;
if (seen.has(key)) continue;
seen.add(key);
proposal.stops.push({
orderheaderid: header,
orderid,
row: byOrderid.get(orderid) ?? byHeader.get(header),
});
}
};
const zones = body['zones'];
if (Array.isArray(zones) && zones.length > 0) {
for (const zone of zones) {
const riders = ((zone ?? {}) as Bag)['riders'];
for (const rider of Array.isArray(riders) ? riders : []) {
const bag = (rider ?? {}) as Bag;
place(bag['rider_id'] ?? bag['userid'], bag['rider_name'] ?? bag['username'], bag['orders']);
}
}
}
// The flat shape. Read even when zones were present but carried no riders,
// so a response that mixes the two does not lose half its assignments.
const details = body['details'];
if (Array.isArray(details)) {
for (const entry of details) {
const bag = (entry ?? {}) as Bag;
place(bag['rider_id'] ?? bag['userid'], bag['rider_name'] ?? bag['ridername'], [bag]);
}
}
const unassigned = readUnassigned(meta, zones);
const ridersAvailable = num(meta['active_riders_pool']);
const assigned = [...proposals.values()].reduce((total, p) => total + p.stops.length, 0);
return {
proposals: [...proposals.values()]
.filter((proposal) => proposal.stops.length > 0)
.sort((a, b) => b.stops.length - a.stops.length),
unassigned,
meta: {
totalOrders: num(meta['total_orders']) || orders.length,
assigned,
unassigned: unassigned.length,
ridersUsed: num(meta['utilized_riders']),
ridersAvailable,
profit: num(meta['total_profit']),
solver: str(meta['solver_mode']) || 'unknown',
},
blocked:
ridersAvailable === 0 && assigned === 0
? 'The solver had no riders to choose from. It picks them itself, from riders marked on duty, and no rider on the platform currently is — so it returns every order unassigned no matter how the work looks.'
: null,
};
}
/**
* Why each order was left out.
*
* `meta.unassigned_details` is the direct answer and carries a reason per
* order. Zones also list their own leftovers, without reasons, so those fill in
* anything the meta missed rather than being ignored — an order that vanishes
* from both the proposals and this list looks like it was quietly assigned.
*/
function readUnassigned(meta: Bag, zones: unknown): { orderid: string; reason: string }[] {
const out: { orderid: string; reason: string }[] = [];
const seen = new Set<string>();
const details = meta['unassigned_details'];
for (const entry of Array.isArray(details) ? details : []) {
const bag = (entry ?? {}) as Bag;
const orderid = str(bag['orderid']);
if (!orderid || seen.has(orderid)) continue;
seen.add(orderid);
out.push({ orderid, reason: str(bag['reason']) || 'No reason given' });
}
for (const zone of Array.isArray(zones) ? zones : []) {
const list = ((zone ?? {}) as Bag)['unassigned_orders'];
for (const entry of Array.isArray(list) ? list : []) {
const bag = (entry ?? {}) as Bag;
const orderid = str(bag['orderid']);
if (!orderid || seen.has(orderid)) continue;
seen.add(orderid);
out.push({ orderid, reason: 'Left out of the plan, with no reason given' });
}
}
return out;
}
/**
* The orders a proposal can actually be committed for.
*
* A stop whose row we could not match is dropped rather than guessed: without
* the original order there is no tenant, branch or region to write, and a
* delivery missing those is one no page can join back to.
*/
export function committable(proposal: Proposal): OrderRow[] {
return proposal.stops.flatMap((stop) => (stop.row ? [stop.row] : []));
}
/** Stops the plan named but we could not match back. Worth showing, never committing. */
export function unmatched(plan: Plan): string[] {
return plan.proposals
.flatMap((proposal) => proposal.stops)
.filter((stop) => !stop.row)
.map((stop) => stop.orderid || `#${stop.orderheaderid}`);
}
/**
* The branch a proposed order belongs to, for `buildDelivery`.
*
* Same lookup the manual bar does. Kept here so the commit reads identically
* whoever proposed it.
*/
export function branchFor(
row: OrderRow,
branches: readonly TenantLocation[],
): TenantLocation | undefined {
return branches.find((branch) => branch.locationid === row.locationid);
}

View File

@@ -476,3 +476,5 @@ export function ymd(date: Date): string {
export function isToday(day: string): boolean {
return day === ymd(new Date());
}

View File

@@ -0,0 +1,531 @@
/**
* Styles for the dispatch panels, kept beside the components that use them.
*
* ── Why these are not in `pages/dispatch.css` ───────────────────────────────
*
* They were, and a rewrite of that file dropped twenty of the twenty-two
* classes without anything failing: the components kept rendering, their
* markup was intact, and every control quietly became plain text. The map's
* rider filter reported as "showing as the text format not showing any
* buttons".
*
* Nothing catches that. A missing class is not a type error, not a failed
* test, and not a console warning — the DOM is exactly as intended and only
* the paint is gone. So the styles now live next to the components that own
* the classes and are imported by them, which means a component cannot be
* rendered without them.
*/
/* ── The map's rider filter ──────────────────────────────────────────────── */
.rider-chip {
display: inline-flex;
gap: 6px;
align-items: center;
padding: 4px 10px;
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-surface);
font: inherit;
font-size: 12px;
color: var(--color-ink-2);
cursor: pointer;
}
.rider-chip:hover {
background: var(--color-surface-subtle);
}
.rider-chip[data-active='true'] {
border-color: var(--color-brand);
background: var(--color-brand-tint);
color: var(--color-ink-1);
}
.rider-chip:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: 1px;
}
/* The swatch matches this rider's line on the map — the only thing connecting
a chip to the shape it filters to. */
.rider-chip i {
width: 8px;
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);
}
/* ── A caveat under a panel ──────────────────────────────────────────────── */
.pva-note {
display: flex;
gap: 7px;
align-items: flex-start;
padding: 8px 10px;
border-radius: 7px;
background: var(--color-surface-subtle);
font-size: 11.5px;
line-height: 1.5;
color: var(--color-ink-3);
}
.pva-note svg {
flex: none;
margin-top: 2px;
}
/* ── Plan vs actual ──────────────────────────────────────────────────────── */
/* One colour per step, reused by the stacked bar and the legend swatches so the
two read as the same object. Ordered as the journey runs. */
.pva-bar {
display: flex;
height: 14px;
overflow: hidden;
border-radius: 7px;
background: var(--color-surface-sunken);
}
.pva-bar i,
.pva-swatch {
display: block;
}
.pva-bar i[data-step='toShop'],
.pva-swatch[data-step='toShop'] {
background: #662582;
}
.pva-bar i[data-step='counter'],
.pva-swatch[data-step='counter'] {
background: #8b6bab;
}
.pva-bar i[data-step='inRound'],
.pva-swatch[data-step='inRound'] {
background: #c2410c;
}
.pva-bar i[data-step='onRoad'],
.pva-swatch[data-step='onRoad'] {
background: #0f8a5f;
}
.pva-swatch {
width: 9px;
height: 9px;
border-radius: 2px;
}
.pva-steps {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 4px 16px;
}
.pva-step {
display: grid;
grid-template-columns: 9px minmax(0, 1fr) auto;
gap: 4px 8px;
align-items: center;
padding: 6px 8px;
border: 1px solid transparent;
border-radius: 7px;
}
/* The step costing the most is the finding. Outlined rather than recoloured, so
the swatch keeps naming its own segment of the bar above. */
.pva-step[data-bottleneck='true'] {
border-color: var(--color-border);
background: var(--color-surface-subtle);
}
.pva-step-label {
font-size: 12.5px;
color: var(--color-ink-1);
}
.pva-step-label em {
margin-left: 6px;
font-size: 10.5px;
font-style: normal;
font-weight: 600;
letter-spacing: 0.03em;
color: #b45309;
text-transform: uppercase;
}
.pva-step strong {
font-size: 13px;
font-variant-numeric: tabular-nums;
color: var(--color-ink-1);
}
.pva-step-note {
grid-column: 2 / -1;
font-size: 11px;
font-variant-numeric: tabular-nums;
color: var(--color-ink-4);
}
.pva-figure {
display: flex;
flex-direction: column;
gap: 1px;
padding-left: 10px;
border-left: 3px solid var(--color-border);
}
.pva-figure[data-tone='good'] {
border-left-color: #0f8a5f;
}
.pva-figure[data-tone='watch'] {
border-left-color: #b45309;
}
.pva-figure[data-tone='act'] {
border-left-color: #b91c1c;
}
.pva-figure-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.03em;
color: var(--color-ink-3);
text-transform: uppercase;
}
.pva-figure strong {
font-size: 19px;
font-variant-numeric: tabular-nums;
line-height: 1.2;
color: var(--color-ink-1);
}
.pva-figure-note {
font-size: 11.5px;
color: var(--color-ink-4);
}
.pva-toggle {
display: inline-flex;
gap: 6px;
align-items: center;
font-size: 12.5px;
color: var(--color-ink-2);
cursor: pointer;
}
/* Rows here are read, not opened — nothing lies behind one — so the pointer and
hover the stops table uses would promise a click that does nothing. */
.pva-table tbody tr {
cursor: default;
}
.pva-table td[data-disagrees='true'] {
color: #b45309;
}
.pva-late[data-late='yes'] {
color: #b91c1c;
}
.pva-late[data-late='no'] {
color: #0f8a5f;
}
.pva-late[data-late='none'] {
color: var(--color-ink-4);
}
/* ── Auto-assign ─────────────────────────────────────────────────────────── */
.aa-tunings {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.aa-tuning {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-surface);
font: inherit;
font-size: 12.5px;
color: var(--color-ink-2);
cursor: pointer;
}
.aa-tuning:hover:not(:disabled) {
background: var(--color-surface-subtle);
}
.aa-tuning[data-active='true'] {
border-color: var(--color-brand);
background: var(--color-brand-tint);
color: var(--color-ink-1);
}
.aa-tuning:disabled {
opacity: 0.5;
cursor: default;
}
.aa-tuning:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: 1px;
}
.aa-absent {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 2px 12px;
padding: 6px 0 2px;
}
.aa-absent-row {
display: flex;
gap: 7px;
align-items: center;
padding: 4px 0;
font-size: 12.5px;
color: var(--color-ink-2);
cursor: pointer;
}
.aa-absent-row svg {
flex: none;
color: var(--color-ink-4);
}
/* The solver failing to see any rider is a platform fault, not an empty
result. Given its own panel so it cannot be read as "no work to do". */
.aa-blocked {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 12px 14px;
border: 1px solid var(--color-border);
border-left: 3px solid #b45309;
border-radius: 9px;
background: var(--color-surface);
color: #b45309;
}
.aa-blocked > svg {
flex: none;
margin-top: 1px;
}
.aa-blocked div {
display: flex;
flex-direction: column;
gap: 3px;
}
.aa-blocked strong {
font-size: 13px;
font-weight: 600;
color: var(--color-ink-1);
}
.aa-blocked span {
font-size: 12.5px;
line-height: 1.5;
color: var(--color-ink-3);
}
/* ── Active board ────────────────────────────────────────────────────────── */
.active-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.active-name {
display: flex;
gap: 7px;
align-items: center;
overflow: hidden;
font-size: 13px;
font-weight: 600;
color: var(--color-ink-1);
text-overflow: ellipsis;
white-space: nowrap;
}
.active-name i {
flex: none;
width: 9px;
height: 9px;
border-radius: 50%;
}
/* Age is the most load-bearing thing on the card: everything else on it is only
as true as this is recent. Coloured, not tucked into small print. */
.active-fresh {
flex: none;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.active-fresh[data-fresh='live'] {
color: #0f8a5f;
}
.active-fresh[data-fresh='recent'] {
color: #b45309;
}
.active-fresh[data-fresh='old'] {
color: #b91c1c;
}
.active-distance {
display: flex;
gap: 8px;
align-items: baseline;
flex-wrap: wrap;
}
.active-distance svg {
color: var(--color-ink-4);
transform: translateY(2px);
}
.active-distance strong {
font-size: 20px;
font-variant-numeric: tabular-nums;
color: var(--color-ink-1);
}
.active-distance span {
font-size: 11px;
color: var(--color-ink-4);
}
.active-meta {
display: flex;
gap: 8px;
align-items: baseline;
overflow: hidden;
font-size: 12px;
color: var(--color-ink-3);
white-space: nowrap;
}
.active-meta span:first-child {
flex: none;
font-weight: 600;
color: var(--color-ink-2);
}
.active-meta span:last-child {
overflow: hidden;
text-overflow: ellipsis;
}
.active-stats {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.active-stats span {
padding: 2px 8px;
border-radius: 999px;
background: var(--color-surface-sunken);
font-size: 11px;
font-variant-numeric: tabular-nums;
color: var(--color-ink-3);
}
.active-stats span[data-low='true'] {
background: color-mix(in oklab, #b91c1c 12%, transparent);
color: #b91c1c;
}
.active-worry {
display: flex;
gap: 6px;
align-items: center;
font-size: 11.5px;
color: #b45309;
}
.active-worry svg {
flex: none;
}
/* ── Money ───────────────────────────────────────────────────────────────── */
/* How much of the day is actually costed. Shown as a bar because the shortfall
is the point — a number alone reads as a footnote. */
.profit-coverage {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 10px 12px;
border-radius: 8px;
background: var(--color-surface-subtle);
font-size: 11.5px;
line-height: 1.5;
color: var(--color-ink-3);
}
.profit-bar {
flex: none;
width: 88px;
height: 8px;
margin-top: 4px;
overflow: hidden;
border-radius: 999px;
background: var(--color-surface-sunken);
}
.profit-bar i {
display: block;
height: 100%;
background: var(--color-brand);
}

View File

@@ -12,7 +12,7 @@
.drawer-root {
position: fixed;
inset: 0;
z-index: 70;
z-index: 3000;
display: flex;
justify-content: flex-end;
}

View File

@@ -11,8 +11,14 @@
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { billedAtMs, syncLagMs ,
stampAfterMs,} from './orderStatus';
import {
billedAtMs,
syncLagMs,
stampAfterMs,
matchesStatus,
DELIVERY_STATUS_TABS,
ORDER_STATUS_TABS,
} from './orderStatus';
/* Set after the imports on purpose, and it still takes: `billedAtMs` reads the
zone when it is CALLED, not when this module loads, and assigning TZ calls
@@ -158,3 +164,86 @@ test('a couple of minutes of drift is drift, not a timezone fault', () => {
const raw = '2026-09-05T14:46:00+05:30';
assert.equal(stampAfterMs(raw, '2026-09-05T14:48:42+05:30'), new Date(raw).getTime());
});
/* ── The status tabs ─────────────────────────────────────────────────────── */
/**
* Every status the platform actually writes, measured across all 264 tenants
* on 2026-09-10 with the row counts each carried.
*/
const LIVE_ORDER_STATUSES = ['delivered', 'pending', 'cancelled', 'created'];
const LIVE_DELIVERY_STATUSES = [
'delivered', 'pending', 'cancelled', 'picked',
'rejected', 'active', 'skipped', 'accepted', 'arrived',
];
// The failure this list is checked against: a status with no tab appears under
// "All" and under nothing else, so an operator working through the tabs never
// sees those rows. `rejected` (30 live rows) was the one at risk.
test('every live delivery status has a tab of its own', () => {
for (const status of LIVE_DELIVERY_STATUSES) {
const hits = DELIVERY_STATUS_TABS.filter(
(tab) => tab.key !== 'all' && matchesStatus(tab.key, status),
);
assert.equal(hits.length, 1, `"${status}" matched ${hits.length} tabs: ${hits.map((t) => t.key).join(', ')}`);
}
});
test('every live order status has a tab of its own', () => {
for (const status of LIVE_ORDER_STATUSES) {
const hits = ORDER_STATUS_TABS.filter(
(tab) => tab.key !== 'all' && matchesStatus(tab.key, status),
);
assert.equal(hits.length, 1, `"${status}" matched ${hits.length} tabs`);
}
});
// It used to be filed under Cancelled. A rider reaching the address and finding
// nobody in is not a cancellation, and an operator chasing skipped drops could
// not find them.
test('a skipped drop is its own tab, not a cancellation', () => {
assert.equal(matchesStatus('skipped', 'skipped'), true);
assert.equal(matchesStatus('cancelled', 'skipped'), false);
});
// "processing" used to swallow all four, so a dispatcher could not tell a rider
// standing at the shop from one already riding.
test('the middle of the ladder is four separate tabs, not one bucket', () => {
assert.equal(matchesStatus('accepted', 'accepted'), true);
assert.equal(matchesStatus('arrived', 'arrived'), true);
assert.equal(matchesStatus('picked', 'picked'), true);
assert.equal(matchesStatus('active', 'active'), true);
assert.equal(matchesStatus('active', 'arrived'), false);
assert.equal(matchesStatus('picked', 'active'), false);
});
test('a delivery out for delivery is active, not delivered', () => {
assert.equal(matchesStatus('active', 'out for delivery'), true);
assert.equal(matchesStatus('delivered', 'out for delivery'), false);
});
// Fiesta stores status as free text and the casing drifts between writers.
test('casing and padding do not decide which tab a row lands in', () => {
assert.equal(matchesStatus('delivered', ' Delivered '), true);
assert.equal(matchesStatus('rejected', 'REJECTED'), true);
});
test('an undelivered row is not counted as delivered', () => {
assert.equal(matchesStatus('delivered', 'undelivered'), false);
});
test('All holds everything, and a blank status holds nothing else', () => {
assert.equal(matchesStatus('all', ''), true);
assert.equal(matchesStatus('all', undefined), true);
for (const tab of DELIVERY_STATUS_TABS) {
if (tab.key === 'all') continue;
assert.equal(matchesStatus(tab.key, ''), false, `${tab.key} matched a blank status`);
}
});
test('the delivery strip offers the whole ladder', () => {
assert.deepEqual(
DELIVERY_STATUS_TABS.map((tab) => tab.label),
['All', 'Pending', 'Accepted', 'Arrived', 'Picked', 'Active', 'Skipped', 'Rejected', 'Delivered', 'Cancelled'],
);
});

View File

@@ -75,22 +75,55 @@ export const statusColor = (map: Record<string, string>, status: string | undefi
map[(status ?? '').trim().toLowerCase()] ?? 'var(--color-ink-3)';
/**
* The status tabs, as the old console groups them.
* The status tabs — one set per kind of row, because they are different ladders.
*
* Tabs rather than a dropdown: there are six, an operator switches between them
* constantly during a shift, and a dropdown hides the counts. Each carries how
* many rows it holds, which is half the reason to look.
* ── Why not one shared set ──────────────────────────────────────────────────
*
* An order and a delivery move through different lifecycles, and a single strip
* shown over both leaves half its tabs permanently at zero. Measured across
* every tenant on 2026-09-10:
*
* orders delivered 4240 · pending 876 · cancelled 827 · created 788
* deliveries delivered 1528 · pending 209 · cancelled 146 · picked 42 ·
* rejected 30 · active 22 · skipped 11 · accepted 6 · arrived 3
*
* So an order is never "arrived" or "picked", and a delivery is never
* "created". The previous six-tab set was shown over both and had the worse
* problem: "processing" swallowed accepted, picked, active and arrived into one
* bucket, so a dispatcher could not tell a rider who had reached the shop from
* one already riding, and "cancelled" quietly included skipped.
*
* ── Every status has exactly one tab ────────────────────────────────────────
*
* `rejected` is on the delivery strip even though it is easy to forget: 30 live
* rows carry it, and a status with no tab appears under "All" and under nothing
* else, so an operator working through the tabs never sees those rows at all.
* That is the failure this list is checked against.
*/
export const STATUS_TABS = [
export const ORDER_STATUS_TABS = [
{ key: 'all', label: 'All' },
{ key: 'created', label: 'Created' },
{ key: 'pending', label: 'Pending' },
{ key: 'processing', label: 'Processing' },
{ key: 'delivered', label: 'Delivered' },
{ key: 'cancelled', label: 'Cancelled' },
] as const;
export type StatusKey = (typeof STATUS_TABS)[number]['key'];
export const DELIVERY_STATUS_TABS = [
{ key: 'all', label: 'All' },
{ key: 'pending', label: 'Pending' },
{ key: 'accepted', label: 'Accepted' },
{ key: 'arrived', label: 'Arrived' },
{ key: 'picked', label: 'Picked' },
{ key: 'active', label: 'Active' },
{ key: 'skipped', label: 'Skipped' },
{ key: 'rejected', label: 'Rejected' },
{ key: 'delivered', label: 'Delivered' },
{ key: 'cancelled', label: 'Cancelled' },
] as const;
export type StatusKey =
| (typeof ORDER_STATUS_TABS)[number]['key']
| (typeof DELIVERY_STATUS_TABS)[number]['key'];
/**
* Does a row belong under this tab?
@@ -98,8 +131,10 @@ export type StatusKey = (typeof STATUS_TABS)[number]['key'];
* Substring matching, lowercased. Fiesta stores status as free text and the
* casing is inconsistent between writers, so an exact match is how a
* "Delivered" row silently stops counting the day someone writes "delivered".
* "processing" also catches confirmed, preparing and ready — everything between
* accepted and out the door, which is what an operator means by the word.
*
* Each status now lands in exactly ONE tab. The order of these cases matters
* where words overlap: `undelivered` must not count as delivered, and
* `out for delivery` is a delivery in progress rather than a completed one.
*/
export function matchesStatus(key: StatusKey, status: string | undefined): boolean {
if (key === 'all') return true;
@@ -110,30 +145,32 @@ export function matchesStatus(key: StatusKey, status: string | undefined): boole
return s.includes('created') || s.includes('new');
case 'pending':
return s.includes('pending');
case 'processing':
return (
s.includes('process') ||
s.includes('confirm') ||
s.includes('prepar') ||
s.includes('ready') ||
s.includes('accept') ||
s.includes('picked') ||
s.includes('active') ||
s.includes('arrived') ||
s.includes('out for')
);
case 'accepted':
// Not "accept" alone — that would also catch "unaccepted" if it appears.
return s.includes('accepted');
case 'arrived':
return s.includes('arrived');
case 'picked':
return s.includes('picked') || s.includes('pickup');
case 'active':
// Everything between collecting and dropping: the platform writes
// `active`, and `out for delivery` means the same thing.
return s.includes('active') || s.includes('out for') || s.includes('transit');
case 'skipped':
// Its own status and its own tab. The rider reached the address and
// nobody was in — which is not a cancellation, and used to be filed as
// one.
return s.includes('skip');
case 'rejected':
return s.includes('reject') || s.includes('declin');
case 'delivered':
return s.includes('deliver') && !s.includes('undeliver') && !s.includes('out for');
case 'cancelled':
// `skipped` lives here too. It is its own delivery status — the rider
// reached the address and nobody was in — and it keeps its own orange
// chip, but it has no tab of its own in this six-tab strip. Without this
// line a skipped job appears under "All" and under nothing else, so an
// operator working through the tabs never sees it.
return s.includes('cancel') || s.includes('skip');
return s.includes('cancel');
}
}
/**
* The money on an order row, in the order the old console reads it.
*

File diff suppressed because it is too large Load Diff

View File

@@ -21,7 +21,8 @@ import { branchLabel, count, money, moneyExact } from '../format';
import {
DELIVERY_STATUS,
ORDER_STATUS,
STATUS_TABS,
DELIVERY_STATUS_TABS,
ORDER_STATUS_TABS,
matchesStatus,
orderQuantity,
orderValue,
@@ -130,11 +131,21 @@ export function SalesPage() {
.sort((a, b) => (billedAtMs(b.bill) ?? 0) - (billedAtMs(a.bill) ?? 0));
}, [billPages, branchIds, branches, keyword]);
/**
* The tab strip for whichever sub-tab is open.
*
* Orders and deliveries move through different ladders — an order is never
* "arrived", a delivery is never "created" — so each gets its own tabs rather
* than a shared set with half of it stuck at zero.
*/
const statusTabs = tab === 'deliveries' ? DELIVERY_STATUS_TABS : ORDER_STATUS_TABS;
/** Counts for the tab strip, from the unfiltered set. */
const tabCounts = useMemo(() => {
const source = tab === 'deliveries' ? allDeliveries : allOrders;
const tabs = tab === 'deliveries' ? DELIVERY_STATUS_TABS : ORDER_STATUS_TABS;
return Object.fromEntries(
STATUS_TABS.map((entry) => [
tabs.map((entry) => [
entry.key,
source.filter((row) => matchesStatus(entry.key, row.orderstatus)).length,
]),
@@ -291,6 +302,7 @@ export function SalesPage() {
<VStack gap={1.5}>
<FilterRow
tabs={statusTabs}
status={status}
counts={tabCounts}
onStatus={setStatus}
@@ -374,6 +386,7 @@ export function SalesPage() {
<VStack gap={1.5}>
<FilterRow
tabs={statusTabs}
status={status}
counts={tabCounts}
onStatus={setStatus}
@@ -507,11 +520,14 @@ function freshRow(
* downward now follows that order.
*/
function FilterRow({
tabs,
status,
counts,
onStatus,
search,
}: {
/** Which ladder to show — orders and deliveries do not share one. */
tabs: readonly { readonly key: StatusKey; readonly label: string }[];
status: StatusKey;
counts: Record<StatusKey, number>;
onStatus: (key: StatusKey) => void;
@@ -520,7 +536,7 @@ function FilterRow({
return (
<HStack justify="between" align="center" gap={2} wrap="wrap">
<HStack gap={0.5} wrap="wrap">
{STATUS_TABS.map((entry) => (
{tabs.map((entry) => (
<StatusTab
key={entry.key}
label={entry.label}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,179 @@
/**
* Delivery economics, from the columns rather than from invented rates.
*
* The awkward rows are real: `profit` is filled on 210 of 500 tenant-916 rows
* and blank on the rest, `ridercharges` on 249, and `calculationdistancekm`
* reads 150087 on one of them. Every test below pins a decision about what to
* do when a figure is simply absent — because the wrong answer, treating it as
* zero, understates the day by exactly the rows nobody has costed.
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
import type { DeliveryRow } from '@/api/types';
import { coverage, earningsOf, profitability } from './profitability';
function row(over: Record<string, unknown> = {}): DeliveryRow {
return {
deliveryid: 1,
orderid: '916-1',
ridername: 'Varun',
deliverycharges: 30,
ridercharges: 23.36,
profit: 15,
kms: '9',
...over,
} as unknown as DeliveryRow;
}
test('a costed delivery reports what was charged, paid and recorded', () => {
const e = earningsOf(row());
assert.equal(e.charged, 30);
assert.equal(e.riderPaid, 23.36);
assert.equal(e.recorded, 15);
assert.equal(e.km, 9);
});
// charged − paid is 6.64, the platform recorded 15. Both are shown, because
// that gap is somebody's accounting question and picking one answers it wrongly.
test('the arithmetic and the recorded figure are both kept when they disagree', () => {
const e = earningsOf(row());
assert.equal(e.derived, 6.64);
assert.equal(e.disagrees, true);
});
test('agreement within a rupee is not flagged', () => {
const e = earningsOf(row({ deliverycharges: 30, ridercharges: 15.5, profit: 15 }));
assert.equal(e.disagrees, false);
});
// The central decision. 290 of 500 rows have no profit figure; calling them
// zero would report the day as far less profitable than it was.
test('a row nobody costed has no profit, which is not zero profit', () => {
assert.equal(earningsOf(row({ profit: undefined })).recorded, null);
assert.equal(earningsOf(row({ profit: '' })).recorded, null);
assert.equal(earningsOf(row({ profit: 0 })).recorded, null);
});
test('a missing rider payment leaves the arithmetic blank rather than crediting it all', () => {
const e = earningsOf(row({ ridercharges: undefined }));
assert.equal(e.riderPaid, null);
assert.equal(e.derived, null);
assert.equal(e.disagrees, false);
});
// 150087 km for a Coimbatore delivery. Whatever writes that column is broken.
test('an absurd distance is dropped rather than shown', () => {
assert.equal(earningsOf(row({ kms: '150087' })).km, null);
assert.equal(earningsOf(row({ kms: '0' })).km, null);
assert.equal(earningsOf(row({ kms: '9' })).km, 9);
});
/* ── Totals ──────────────────────────────────────────────────────────────── */
test('totals sum only what is recorded, and say how much that was', () => {
const result = profitability([
row({ deliveryid: 1, profit: 15 }),
row({ deliveryid: 2, profit: 25 }),
row({ deliveryid: 3, profit: undefined }),
]);
assert.equal(result.deliveries, 3);
assert.equal(result.costed, 2);
assert.equal(result.profit, 40, 'the uncosted row must not drag the total down');
assert.equal(result.perDelivery, 20, 'averaged over what was costed, not over everything');
});
test('coverage states how much of the picture is costed', () => {
const result = profitability([row({ deliveryid: 1 }), row({ deliveryid: 2, profit: undefined })]);
assert.equal(coverage(result), 0.5);
});
test('charges are summed across every row, costed or not', () => {
const result = profitability([
row({ deliveryid: 1, deliverycharges: 30, ridercharges: 20, profit: undefined }),
row({ deliveryid: 2, deliverycharges: 30, ridercharges: 20, profit: 10 }),
]);
assert.equal(result.charged, 60);
assert.equal(result.riderPaid, 40);
assert.equal(result.profit, 10);
});
test('riders are totalled separately, biggest earner first', () => {
const result = profitability([
row({ deliveryid: 1, userid: 897, ridername: 'Varun', profit: 10 }),
row({ deliveryid: 2, userid: 1111, ridername: 'Murali', profit: 25 }),
row({ deliveryid: 3, userid: 1111, ridername: 'Murali', profit: 15 }),
]);
assert.deepEqual(result.byRider.map((r) => r.rider), ['Murali', 'Varun']);
assert.equal(result.byRider[0]!.profit, 40);
assert.equal(result.byRider[0]!.deliveries, 2);
});
test("a rider's uncosted deliveries still count as deliveries", () => {
const result = profitability([
row({ deliveryid: 1, userid: 897, ridername: 'Varun', profit: 10 }),
row({ deliveryid: 2, userid: 897, ridername: 'Varun', profit: undefined }),
]);
assert.equal(result.byRider[0]!.deliveries, 2);
assert.equal(result.byRider[0]!.costed, 1);
});
test('disagreements are counted so they can be chased', () => {
const result = profitability([
row({ deliveryid: 1, deliverycharges: 30, ridercharges: 23.36, profit: 15 }),
row({ deliveryid: 2, deliverycharges: 30, ridercharges: 15, profit: 15 }),
]);
assert.equal(result.disagreeing, 1);
});
test('an empty day totals to zero with nothing costed and no average', () => {
const result = profitability([]);
assert.equal(result.deliveries, 0);
assert.equal(result.profit, 0);
assert.equal(result.perDelivery, null, 'an average over nothing is not zero');
assert.equal(coverage(result), 0);
});
test('money is rounded to paise, not left with float noise', () => {
const result = profitability([
row({ deliveryid: 1, deliverycharges: 30.1, ridercharges: 0.2, profit: 29.9 }),
row({ deliveryid: 2, deliverycharges: 30.1, ridercharges: 0.1, profit: 30 }),
]);
assert.equal(result.charged, 60.2);
assert.equal(result.riderPaid, 0.3);
assert.equal(result.profit, 59.9);
});
/* ── Rider identity ──────────────────────────────────────────────────────── */
// Grouping on `ridername` put a rider called "delivered" on the page carrying
// 284 of tenant 916's 499 finished deliveries — more than any real person — and
// split every actual rider's takings across two rows.
test('a status in the name column never becomes a rider', () => {
const result = profitability([
row({ deliveryid: 1, userid: 897, ridername: 'Varun', profit: 10 }),
row({ deliveryid: 2, userid: 897, ridername: 'delivered', profit: 20 }),
row({ deliveryid: 3, userid: 897, ridername: 'delivered', profit: 30 }),
]);
assert.equal(result.byRider.length, 1, 'one rider, not a real one and a phantom');
assert.equal(result.byRider[0]!.rider, 'Varun');
assert.equal(result.byRider[0]!.profit, 60, 'their takings were split before');
});
test('riders are grouped on their id, so two people sharing a name stay apart', () => {
const result = profitability([
row({ deliveryid: 1, userid: 1, ridername: 'Rajesh', profit: 10 }),
row({ deliveryid: 2, userid: 2, ridername: 'Rajesh', profit: 20 }),
]);
assert.equal(result.byRider.length, 2);
});
test('a rider whose every row carried a status keeps their id', () => {
const result = profitability([row({ deliveryid: 1, userid: 950, ridername: 'cancelled' })]);
assert.equal(result.byRider[0]!.rider, 'Rider 950');
});
// Unassigned work is not a person and must not read as one.
test('work nobody carried is a dash, not a rider', () => {
const result = profitability([row({ deliveryid: 1, userid: 0, ridername: '' })]);
assert.equal(result.byRider[0]!.rider, '—');
});

View File

@@ -0,0 +1,235 @@
/**
* What a delivery earned and what it cost.
*
* ── Read, not assumed ───────────────────────────────────────────────────────
*
* The console this is modelled on computes profit from hardcoded rules: ₹30 for
* the first 8 km, ₹6 a kilometre beyond, a ₹5,000 salary sliced across slots.
* Those numbers are in its source, not in anybody's contract, and they are
* wrong the day a rate changes.
*
* They are also unnecessary. The columns exist and carry real values, measured
* over 500 rows of tenant 916 on 2026-09-10:
*
* deliverycharges 500/500 what the shop was charged
* ridercharges 249/500 what the rider was paid
* profit 210/500 the platform's own figure, median ₹15
*
* So this reports what the platform recorded and says how much of it was
* recorded. A row with no `profit` is not zero profit — it is a row nobody
* costed — and the two must never be added together.
*
* ── Where the recorded figure and the arithmetic disagree ───────────────────
*
* `deliverycharges − ridercharges` should equal `profit` and does not always.
* Both are shown when they differ rather than one being silently preferred:
* that gap is somebody's accounting question and hiding it answers it wrongly.
*
* ── `calculationdistancekm` is not usable ───────────────────────────────────
*
* It reads 150087 on a live Coimbatore delivery — 150,000 km. Whatever writes
* it is broken, so per-kilometre economics are deliberately not derived here.
* `kms` is the planned distance and is sane; it is used for context only.
*/
import type { DeliveryRow } from '@/api/types';
import { isRealName } from './orderStatus';
/** A distance beyond this is a broken column, not a long ride. */
const ABSURD_KM = 200;
export interface Earnings {
deliveryid: number;
orderid: string;
/** The stable key. `ridername` is not one — see `nameFor`. */
userid: number;
rider: string;
/** What the shop was charged. */
charged: number | null;
/** What the rider was paid. */
riderPaid: number | null;
/** The platform's own profit figure. Null when nobody costed the row. */
recorded: number | null;
/** charged − riderPaid, when both are known. */
derived: number | null;
/** True when both figures exist and disagree by more than a rupee. */
disagrees: boolean;
km: number | null;
}
const money = (value: unknown): number | null => {
if (value === undefined || value === null || value === '') return null;
const n = Number(value);
return Number.isFinite(n) ? n : null;
};
/** One delivery's economics, as recorded. */
export function earningsOf(row: DeliveryRow): Earnings {
const charged = money(row.deliverycharges);
const riderPaid = money((row as { ridercharges?: unknown }).ridercharges);
const recorded = money((row as { profit?: unknown }).profit);
const derived = charged !== null && riderPaid !== null ? round(charged - riderPaid) : null;
const km = money(row.kms);
return {
deliveryid: row.deliveryid,
orderid: row.orderid ?? `#${row.deliveryid}`,
userid: Number(row.userid ?? 0) || 0,
rider: row.ridername?.trim() || '—',
charged,
riderPaid,
// A recorded 0 is treated as "not costed": every row that carries a real
// figure carries a positive one (min ₹10 over 210 rows), so a bare zero is
// the column's default rather than a delivery that broke even.
recorded: recorded !== null && recorded !== 0 ? recorded : null,
derived,
disagrees:
recorded !== null && recorded !== 0 && derived !== null && Math.abs(recorded - derived) > 1,
km: km !== null && km > 0 && km < ABSURD_KM ? km : null,
};
}
export interface RiderEarnings {
userid: number;
rider: string;
deliveries: number;
charged: number;
riderPaid: number;
/** Summed over the rows that carry a figure. `costed` says how many that was. */
profit: number;
costed: number;
}
export interface Profitability {
rows: Earnings[];
deliveries: number;
/** Rows carrying a recorded profit. The denominator for `profit`. */
costed: number;
charged: number;
riderPaid: number;
profit: number;
/** Rows where the recorded figure and the arithmetic disagree. */
disagreeing: number;
/** Per rider, biggest earner first. */
byRider: RiderEarnings[];
/** Profit per costed delivery. Null when nothing was costed. */
perDelivery: number | null;
}
function round(value: number): number {
return Math.round(value * 100) / 100;
}
/**
* A name per rider id, from a column that often holds a status instead.
*
* Grouping the earnings table on `ridername` put a rider called "delivered" on
* the page carrying 284 of tenant 916's 499 finished deliveries — more than any
* real person — and split every actual rider's takings across two rows. The
* column holds a delivery status on more rows than it holds a name for some
* riders, so the id is the key and the name is resolved separately: statuses
* excluded by vocabulary, then the most common of what survives.
*
* A rider whose every row carried a status keeps their id. Unassigned work
* keeps a dash — it is not a person and should not read as one.
*/
function riderNames(rows: readonly Earnings[]): (userid: number) => string {
const counts = new Map<number, Map<string, number>>();
for (const row of rows) {
if (row.userid === 0 || !isRealName(row.rider)) continue;
const forRider = counts.get(row.userid) ?? new Map<string, number>();
forRider.set(row.rider, (forRider.get(row.rider) ?? 0) + 1);
counts.set(row.userid, forRider);
}
const resolved = new Map<number, string>();
for (const [userid, names] of counts) {
let best = '';
let most = 0;
for (const [name, count] of names) {
if (count > most) {
best = name;
most = count;
}
}
if (best) resolved.set(userid, best);
}
return (userid) =>
userid === 0 ? '—' : (resolved.get(userid) ?? `Rider ${userid}`);
}
/**
* Many deliveries, totalled.
*
* Sums only what is there. A row with no recorded profit contributes nothing to
* `profit` and increments neither `costed` nor the average — the alternative is
* to treat "not costed" as "earned nothing", which understates the day by
* exactly the rows nobody has costed yet.
*/
export function profitability(deliveries: readonly DeliveryRow[]): Profitability {
const rows = deliveries.map(earningsOf);
const nameOf = riderNames(rows);
const byRider = new Map<number, RiderEarnings>();
let charged = 0;
let riderPaid = 0;
let profit = 0;
let costed = 0;
for (const row of rows) {
charged += row.charged ?? 0;
riderPaid += row.riderPaid ?? 0;
if (row.recorded !== null) {
profit += row.recorded;
costed += 1;
}
const entry = byRider.get(row.userid) ?? {
userid: row.userid,
rider: nameOf(row.userid),
deliveries: 0,
charged: 0,
riderPaid: 0,
profit: 0,
costed: 0,
};
entry.deliveries += 1;
entry.charged += row.charged ?? 0;
entry.riderPaid += row.riderPaid ?? 0;
if (row.recorded !== null) {
entry.profit += row.recorded;
entry.costed += 1;
}
byRider.set(row.userid, entry);
}
return {
rows,
deliveries: rows.length,
costed,
charged: round(charged),
riderPaid: round(riderPaid),
profit: round(profit),
disagreeing: rows.filter((row) => row.disagrees).length,
byRider: [...byRider.values()]
.map((entry) => ({
...entry,
charged: round(entry.charged),
riderPaid: round(entry.riderPaid),
profit: round(entry.profit),
}))
.sort((a, b) => b.profit - a.profit || b.deliveries - a.deliveries),
perDelivery: costed > 0 ? round(profit / costed) : null,
};
}
/**
* How much of the picture is actually costed, 0–1.
*
* Shown next to every total, because "₹3,150 profit" over 210 of 500 rows and
* over 500 of 500 are different claims and the number alone cannot tell them
* apart.
*/
export function coverage(result: Profitability): number {
return result.deliveries === 0 ? 0 : result.costed / result.deliveries;
}

View File

@@ -0,0 +1,208 @@
/**
* The live snapshot, and the staleness it never admits to on its own.
*
* The fixtures are real: rider 852's payload was captured from jupiter on
* 2026-09-10, and riders 883/897/1111 really do answer with positions days old
* and no flag saying so. Reading those as "now" is the fault this module
* exists to prevent.
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
import type { RiderSnapshot } from '@/api/telemetry';
import {
activeBoard,
agoOf,
concerns,
distanceLabel,
fixedAt,
freshnessOf,
metresBetween,
readSnapshot,
} from './riderLive';
/** Rider 852's real payload. */
const LIVE: RiderSnapshot = {
userid: 852,
username: 'Sivakumar Subramani',
accuracy: '12.4',
battery: '95%',
connection: 'mobile',
heading: '0.00',
is_background: true,
is_charging: false,
latitude: '8.179740',
longitude: '77.425007',
location_service: 'enabled',
logdate: '2026-09-10 11:25:11',
orderid: '908-20246337',
speed: '0.00',
status: 'idle',
};
const NOW = Date.parse('2026-09-10T11:25:41');
test('a live snapshot is read into numbers, not strings', () => {
const rider = readSnapshot(LIVE, 'fallback', NOW)!;
assert.equal(rider.battery, 95, 'the % sign must not make this NaN');
assert.equal(rider.speed, 0, 'standing still is a real answer, not unknown');
assert.equal(rider.accuracy, 12.4);
assert.equal(rider.lat, 8.17974);
assert.equal(rider.name, 'Sivakumar Subramani');
assert.equal(rider.orderid, '908-20246337');
});
test('a fix from thirty seconds ago is live', () => {
const rider = readSnapshot(LIVE, 'x', NOW)!;
assert.equal(rider.ageMs, 30_000);
assert.equal(rider.freshness, 'live');
});
// Riders 883, 897 and 1111 really answer like this, with nothing in the payload
// admitting it. Drawing them beside a live rider is the lie to avoid.
test('a fix from five days ago is old, however confident the payload looks', () => {
const rider = readSnapshot({ ...LIVE, logdate: '2026-09-05 11:05:38' }, 'x', NOW)!;
assert.equal(rider.freshness, 'old');
assert.match(agoOf(rider.ageMs), /5 days ago/);
});
test('a fix from ten minutes ago is recent — a last-known point, not a position', () => {
const rider = readSnapshot({ ...LIVE, logdate: '2026-09-10 11:15:11' }, 'x', NOW)!;
assert.equal(rider.freshness, 'recent');
});
test('an unreadable stamp is old rather than assumed fresh', () => {
const rider = readSnapshot({ ...LIVE, logdate: 'whenever' }, 'x', NOW)!;
assert.equal(rider.ageMs, null);
assert.equal(rider.freshness, 'old');
assert.equal(agoOf(null), 'never reported');
});
// A phone clock running ahead of the browser's would otherwise sort above
// everything real, as the freshest fix on the board.
test('a fix stamped in the future reads as just now, not as the freshest', () => {
const rider = readSnapshot({ ...LIVE, logdate: '2026-09-10 11:40:00' }, 'x', NOW)!;
assert.equal(rider.ageMs, 0);
assert.equal(rider.freshness, 'live');
});
test('0,0 is not a position', () => {
const rider = readSnapshot({ ...LIVE, latitude: '0', longitude: '0' }, 'x', NOW)!;
assert.equal(rider.lat, null);
assert.equal(rider.lng, null);
});
test('a rider who never reported is null, not an empty rider', () => {
assert.equal(readSnapshot(null, 'x', NOW), null);
});
test('a rider with no name falls back rather than showing blank', () => {
assert.equal(readSnapshot({ ...LIVE, username: ' ' }, 'Rider 852', NOW)!.name, 'Rider 852');
});
/* ── Accuracy ────────────────────────────────────────────────────────────── */
// Live data carries accuracy: "100.0" — a circle wider than the blocks the
// drops sit on. Worth saying rather than rounding away.
test('a hundred-metre fix is flagged as vague', () => {
assert.equal(readSnapshot({ ...LIVE, accuracy: '100.0' }, 'x', NOW)!.isVague, true);
assert.equal(readSnapshot(LIVE, 'x', NOW)!.isVague, false);
});
/* ── Concerns ────────────────────────────────────────────────────────────── */
test('a healthy live rider has nothing to report', () => {
assert.deepEqual(concerns(readSnapshot(LIVE, 'x', NOW)!), []);
});
test('location turned off is named, since it explains a stale fix', () => {
const rider = readSnapshot({ ...LIVE, location_service: 'disabled' }, 'x', NOW)!;
assert.equal(rider.hasLocationOn, false);
assert.ok(concerns(rider).includes('location turned off'));
});
// Accusing somebody of turning location off because a field was absent is the
// kind of wrong that starts an argument with a person.
test('a missing location_service field is not an accusation', () => {
const rider = readSnapshot({ ...LIVE, location_service: undefined }, 'x', NOW)!;
assert.equal(rider.hasLocationOn, true);
assert.deepEqual(concerns(rider), []);
});
test('a low battery is a concern unless it is charging', () => {
assert.ok(concerns(readSnapshot({ ...LIVE, battery: '9%' }, 'x', NOW)!).some((c) => c.includes('battery')));
assert.deepEqual(
concerns(readSnapshot({ ...LIVE, battery: '9%', is_charging: true }, 'x', NOW)!),
[],
);
});
test('no signal is reported', () => {
assert.ok(concerns(readSnapshot({ ...LIVE, connection: 'none' }, 'x', NOW)!).includes('no signal'));
});
/* ── Distance ────────────────────────────────────────────────────────────── */
test('metres between two points is a real distance', () => {
const a = { lat: 11.0168, lng: 76.9558 };
assert.ok(Math.abs(metresBetween(a, { lat: a.lat + 1 / 111, lng: a.lng }) - 1000) < 20);
assert.equal(metresBetween(a, a), 0);
});
test('distances read at the scale they are', () => {
assert.equal(distanceLabel(null), '—');
assert.equal(distanceLabel(840), '840 m');
assert.equal(distanceLabel(4235), '4.2 km');
});
/* ── The active board ────────────────────────────────────────────────────── */
const drops: Record<string, { lat: number; lng: number; customer: string; address: string }> = {
'near-1': { lat: 8.18, lng: 77.4251, customer: 'Close by', address: 'A' },
'far-1': { lat: 8.25, lng: 77.49, customer: 'Miles away', address: 'B' },
};
const rider = (userid: number, orderid: string, lat: number, lng: number) =>
readSnapshot({ ...LIVE, userid, orderid, latitude: String(lat), longitude: String(lng) }, 'x', NOW)!;
test('the board is ordered by how far each rider still has to go', () => {
const board = activeBoard(
[rider(1, 'far-1', 8.1797, 77.425), rider(2, 'near-1', 8.1797, 77.425)],
(id) => drops[id] ?? null,
);
assert.deepEqual(board.map((entry) => entry.orderid), ['near-1', 'far-1']);
assert.equal(board[0]!.customer, 'Close by');
});
test('a rider carrying nothing is not on the active board', () => {
assert.deepEqual(activeBoard([rider(1, '', 8.1797, 77.425)], (id) => drops[id] ?? null), []);
});
// An unknown distance is not a short one. Putting it first would say the
// opposite of what is known.
test('a rider with no usable position sorts last, not first', () => {
const lost = readSnapshot({ ...LIVE, userid: 9, orderid: 'near-1', latitude: '0', longitude: '0' }, 'x', NOW)!;
const board = activeBoard([lost, rider(2, 'far-1', 8.1797, 77.425)], (id) => drops[id] ?? null);
assert.equal(board[0]!.orderid, 'far-1');
assert.equal(board[1]!.toDropM, null);
});
test('a rider on an order we do not hold is still listed, with no distance', () => {
const board = activeBoard([rider(1, 'unknown-order', 8.1797, 77.425)], () => null);
assert.equal(board.length, 1);
assert.equal(board[0]!.toDropM, null);
assert.equal(board[0]!.customer, '');
});
test('freshness bands are drawn where they are documented', () => {
assert.equal(freshnessOf(0), 'live');
assert.equal(freshnessOf(60_000), 'live');
assert.equal(freshnessOf(60_001), 'recent');
assert.equal(freshnessOf(60 * 60_000), 'recent');
assert.equal(freshnessOf(60 * 60_000 + 1), 'old');
assert.equal(freshnessOf(null), 'old');
});
test('the stamp is read as server local time', () => {
assert.equal(fixedAt('2026-09-10 11:25:11'), Date.parse('2026-09-10T11:25:11'));
assert.equal(fixedAt(undefined), null);
});

View File

@@ -0,0 +1,233 @@
/**
* A rider's live snapshot, read honestly.
*
* ── Why every field here is nullable ────────────────────────────────────────
*
* The endpoint always answers, and it answers with the LAST known state however
* old it is. Riders 883, 897 and 1111 come back with positions from 5, 3 and 12
* days ago; nothing in the payload says so. Every number it carries — battery,
* speed, accuracy — is that old too.
*
* So a snapshot is never shown as a fact about now. `age` is derived first and
* `freshness` decides what may be claimed: a LIVE fix can be drawn as a
* position, a STALE one is a last-known point, and an OLD one is only evidence
* that a rider once existed. The map draws them differently for exactly this
* reason.
*
* ── Numbers arrive as strings, and some of them are junk ────────────────────
*
* `battery: "95%"` with the sign, `speed: "0.00"`, `accuracy: "100.0"`. A
* hundred-metre accuracy is a fix good enough to say which suburb somebody is
* in and not which road, and it is common — so it is surfaced rather than
* quietly rounded away.
*/
import type { RiderSnapshot } from '@/api/telemetry';
/** Under a minute old. Safe to call a position. */
const LIVE_MS = 60_000;
/** Within the hour. A last-known point, drawn hollow. */
const RECENT_MS = 60 * 60_000;
/**
* Beyond this a GPS fix names a neighbourhood, not a street.
*
* Live data really does carry `accuracy: "100.0"`, which is a 100 m circle —
* wider than most of the blocks the drops sit on.
*/
const VAGUE_ACCURACY_M = 60;
export type Freshness = 'live' | 'recent' | 'old';
export interface LiveRider {
userid: number;
name: string;
lat: number | null;
lng: number | null;
/** Milliseconds since the fix. Null when the stamp is unreadable. */
ageMs: number | null;
freshness: Freshness;
/** 0–100, or null when the phone did not say. */
battery: number | null;
isCharging: boolean;
/** Km/h. Null when unknown; 0 is a real answer meaning standing still. */
speed: number | null;
/** Degrees clockwise from north. */
heading: number | null;
/** Metres of uncertainty. */
accuracy: number | null;
/** True when the fix is too vague to place on a street. */
isVague: boolean;
connection: string;
/** False when the rider has turned location off — the usual cause of a stale fix. */
hasLocationOn: boolean;
isBackground: boolean;
status: string;
/** The order they are on, when they are on one. */
orderid: string;
}
function numberFrom(value: string | undefined): number | null {
if (value === undefined || value === null || value === '') return null;
const n = Number(String(value).replace('%', '').trim());
return Number.isFinite(n) ? n : null;
}
/** `2026-09-10 11:25:11` — server local time, read in the viewer's zone. */
export function fixedAt(logdate: string | undefined): number | null {
if (!logdate) return null;
const at = Date.parse(logdate.replace(' ', 'T'));
return Number.isFinite(at) ? at : null;
}
export function freshnessOf(ageMs: number | null): Freshness {
if (ageMs === null) return 'old';
if (ageMs <= LIVE_MS) return 'live';
if (ageMs <= RECENT_MS) return 'recent';
return 'old';
}
/** One snapshot, read into something that can be drawn without lying. */
export function readSnapshot(
snapshot: RiderSnapshot | null,
fallbackName: string,
now = Date.now(),
): LiveRider | null {
if (!snapshot) return null;
const lat = numberFrom(snapshot.latitude);
const lng = numberFrom(snapshot.longitude);
const at = fixedAt(snapshot.logdate);
// A negative age is a clock skew between the phone and this browser, not a
// fix from the future. Clamped, so it reads as "just now" rather than
// sorting above everything real.
const ageMs = at === null ? null : Math.max(0, now - at);
const accuracy = numberFrom(snapshot.accuracy);
return {
userid: snapshot.userid,
name: snapshot.username?.trim() || fallbackName,
// 0,0 is the Atlantic — what a phone sends before it has a fix.
lat: lat !== null && lng !== null && !(lat === 0 && lng === 0) ? lat : null,
lng: lat !== null && lng !== null && !(lat === 0 && lng === 0) ? lng : null,
ageMs,
freshness: freshnessOf(ageMs),
battery: numberFrom(snapshot.battery),
isCharging: snapshot.is_charging === true,
speed: numberFrom(snapshot.speed),
heading: numberFrom(snapshot.heading),
accuracy,
isVague: accuracy !== null && accuracy > VAGUE_ACCURACY_M,
connection: snapshot.connection?.trim() || 'unknown',
// Only an explicit "disabled" is treated as off. An absent field is the app
// not saying, and accusing a rider of disabling location on a missing value
// is the kind of wrong that starts an argument with a person.
hasLocationOn: (snapshot.location_service ?? '').trim().toLowerCase() !== 'disabled',
isBackground: snapshot.is_background === true,
status: snapshot.status?.trim().toLowerCase() || 'unknown',
orderid: snapshot.orderid?.trim() || '',
};
}
/** "12s ago", "4m ago", "5 days ago". */
export function agoOf(ageMs: number | null): string {
if (ageMs === null) return 'never reported';
const seconds = Math.round(ageMs / 1000);
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.round(hours / 24);
return `${days} day${days === 1 ? '' : 's'} ago`;
}
/**
* What is wrong with this rider's reporting, if anything.
*
* Reasons rather than a score, and at most a couple of them: a dispatcher needs
* to know whether to trust the dot and what to do about it, not to be handed a
* health percentage they then have to decode.
*/
export function concerns(rider: LiveRider): string[] {
const out: string[] = [];
if (!rider.hasLocationOn) out.push('location turned off');
if (rider.freshness === 'old') out.push(`last heard from ${agoOf(rider.ageMs)}`);
if (rider.battery !== null && rider.battery <= 15 && !rider.isCharging) {
out.push(`battery ${rider.battery}%`);
}
if (rider.connection === 'none') out.push('no signal');
if (rider.isVague) out.push(`fix only good to ${Math.round(rider.accuracy ?? 0)} m`);
return out;
}
/** Metres between two points. Haversine — the distances here are small. */
export function metresBetween(
a: { lat: number; lng: number },
b: { lat: number; lng: number },
): number {
const R = 6_371_000;
const rad = (deg: number) => (deg * Math.PI) / 180;
const dLat = rad(b.lat - a.lat);
const dLng = rad(b.lng - a.lng);
const h =
Math.sin(dLat / 2) ** 2 +
Math.sin(dLng / 2) ** 2 * Math.cos(rad(a.lat)) * Math.cos(rad(b.lat));
return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));
}
/** "800 m", "4.2 km". */
export function distanceLabel(metres: number | null): string {
if (metres === null) return '—';
return metres < 1000 ? `${Math.round(metres / 10) * 10} m` : `${(metres / 1000).toFixed(1)} km`;
}
/**
* A rider with their current job and how far they still have to go.
*
* `toDrop` is straight-line and says so wherever it is shown: the rider is on
* roads and will cover more. It is for ORDERING the board — who is closest to
* finishing — which it does correctly, not for quoting an ETA.
*/
export interface ActiveRider {
live: LiveRider;
orderid: string;
customer: string;
address: string;
/** Straight-line metres from the rider to the drop. Null when either is unknown. */
toDropM: number | null;
}
/**
* The riders currently carrying something, closest to their drop first.
*
* A rider with no usable position sorts last rather than first: an unknown
* distance is not a short one, and putting them at the top would say the
* opposite of what is known.
*/
export function activeBoard(
riders: readonly LiveRider[],
dropOf: (orderid: string) => { lat: number; lng: number; customer: string; address: string } | null,
): ActiveRider[] {
const out: ActiveRider[] = [];
for (const live of riders) {
if (!live.orderid) continue;
const drop = dropOf(live.orderid);
const toDropM =
drop && live.lat !== null && live.lng !== null
? metresBetween({ lat: live.lat, lng: live.lng }, drop)
: null;
out.push({
live,
orderid: live.orderid,
customer: drop?.customer ?? '',
address: drop?.address ?? '',
toDropM,
});
}
return out.sort((a, b) => {
if (a.toDropM === null) return 1;
if (b.toDropM === null) return -1;
return a.toDropM - b.toDropM;
});
}

View File

@@ -0,0 +1,52 @@
import { useEffect, useState } from 'react';
import { routingApi, type Leg, type RoadPath } from '@/api/routing';
/**
* Road geometry for a set of legs, fetched in the background.
*
* ── Why a hook and not a query ──────────────────────────────────────────────
*
* This is decoration that must never block or break the map. React Query would
* give it a loading state, an error state and a retry policy, and the map needs
* none of them: until the roads arrive it draws straight lines, and if they
* never arrive it goes on drawing straight lines. There is nothing to report
* and nothing to retry.
*
* The cache lives in `routing.ts` and is shared across every map in the
* session, so switching between riders re-uses legs already fetched rather than
* asking a courtesy server for them again.
*
* ── Keyed on the coordinates alone ──────────────────────────────────────────
*
* The signature is built from the endpoints, so a redraw that changes a colour,
* a label or a filter does not re-fetch. Only genuinely new legs cost anything.
*/
export function useRoads(legs: readonly Leg[]): Map<string, RoadPath> {
const [roads, setRoads] = useState<Map<string, RoadPath>>(new Map());
const signature = legs.map(routingApi.keyFor).join('|');
useEffect(() => {
if (legs.length === 0) {
setRoads(new Map());
return;
}
const controller = new AbortController();
let isCurrent = true;
routingApi.roads(legs, controller.signal).then((result) => {
// A late answer for a round nobody is looking at any more must not
// repaint the map underneath the one they are.
if (isCurrent && !controller.signal.aborted) setRoads(result);
});
return () => {
isCurrent = false;
controller.abort();
};
// `signature` is the real dependency — `legs` is a fresh array every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [signature]);
return roads;
}