Compare commits

...

2 Commits

Author SHA1 Message Date
35250717fc map the selected rider and shop, drop the map tab
The separate Map tab is gone. "By rider" and "By store" now draw the map
where the stops table was, so picking a rider from the rail shows their
round on a map and picking a branch shows its day.

A round and a shop's day are both shapes — where the work is, in what order,
how far apart — and a table of addresses shows neither. You can read twenty
rows and still not see that a rider crossed the city twice.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JYEsb8PNZ19G9R8gUjTU7n
2026-09-09 17:47:40 +05:30
34bf7989f7 dispatch map, plan vs actual, and a fleet page
Three of the features the old console had and ours did not, built on what
the data can actually support rather than on what the column names imply.

Two findings changed the shape of the work:

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JYEsb8PNZ19G9R8gUjTU7n
2026-09-09 17:02:21 +05:30
24 changed files with 3857 additions and 207 deletions

71
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"@astryxdesign/core": "^0.4.5",
"@stylexjs/stylex": "^0.19.0",
"@tanstack/react-query": "^5.101.4",
"leaflet": "^1.9.4",
"lucide-react": "^1.33.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
@@ -21,6 +22,7 @@
"devDependencies": {
"@astryxdesign/cli": "^0.4.5",
"@tailwindcss/vite": "^4.3.3",
"@types/leaflet": "^1.9.22",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
@@ -79,6 +81,7 @@
"integrity": "sha512-rbGaoAGZq1QImY2VWeWQNAh1ZqQa/KLWnmoOdy13lSjzMcAPDS6WrVv9fZfsNDx+DIG2oySLtMikwZk5WFl1Uw==",
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"intl-messageformat": "^11.2.9"
},
@@ -119,6 +122,7 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@@ -691,6 +695,17 @@
"node": ">=6.9.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
"integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
@@ -1519,6 +1534,7 @@
"resolved": "https://registry.npmjs.org/@stylexjs/stylex/-/stylex-0.19.0.tgz",
"integrity": "sha512-CnUFp7YMaDLDeemsWOfJgoC/gKM5P/yBNMcpJaE6ChJmXr7s0DJwSeGTTlHJcqqwN9OW1qGtmARWLFhGZN1pTA==",
"license": "MIT",
"peer": true,
"dependencies": {
"css-mediaquery": "^0.1.2",
"invariant": "^2.2.4",
@@ -1748,27 +1764,6 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"dev": true,
@@ -1997,12 +1992,30 @@
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/leaflet": {
"version": "1.9.22",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz",
"integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/node": {
"version": "26.2.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~8.3.0"
}
@@ -2013,6 +2026,7 @@
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2449,6 +2463,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.11.12",
"caniuse-lite": "^1.0.30001809",
@@ -3141,6 +3156,12 @@
"node": ">=0.10.0"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -3660,6 +3681,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -3669,6 +3691,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -3688,6 +3711,7 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@@ -3805,7 +3829,8 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/redux-thunk": {
"version": "3.1.0",
@@ -4027,6 +4052,7 @@
"integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.28.0"
},
@@ -4150,6 +4176,7 @@
"integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.5",

View File

@@ -22,6 +22,7 @@
"@astryxdesign/core": "^0.4.5",
"@stylexjs/stylex": "^0.19.0",
"@tanstack/react-query": "^5.101.4",
"leaflet": "^1.9.4",
"lucide-react": "^1.33.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
@@ -32,6 +33,7 @@
"devDependencies": {
"@astryxdesign/cli": "^0.4.5",
"@tailwindcss/vite": "^4.3.3",
"@types/leaflet": "^1.9.22",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",

View File

@@ -34,6 +34,9 @@ const OnboardTenantPage = named('OnboardTenantPage', () => import('@/features/ne
const GlobalCataloguePage = named('GlobalCataloguePage', () => import('@/features/nearle-admin/pages/GlobalCataloguePage'));
const PartnersPage = named('PartnersPage', () => import('@/features/nearle-admin/pages/PartnersPage'));
const NearleUploadsPage = named('UploadsPage', () => import('@/features/nearle-admin/pages/UploadsPage'));
/* Lazy like the rest, and it matters more here: this page pulls in leaflet and
its stylesheet, which nobody who never opens the fleet map should download. */
const FleetPage = named('FleetPage', () => import('@/features/nearle-admin/pages/FleetPage'));
/* One Console for both workspaces — it reads its own scope from BranchScope,
which pins a store user to their outlet and lets an admin choose. Both routes
@@ -108,6 +111,7 @@ export function App() {
{/* Delivery partners — the companies that supply riders. Platform-side
only: a merchant is assigned one, never allowed to create one. */}
<Route path="partners" element={<PartnersPage />} />
<Route path="fleet" element={<FleetPage />} />
<Route path="uploads" element={<NearleUploadsPage />} />
{/* Absorbed here rather than by the global `*`, so a wrong sub-path can
never bounce out to a HOME_ROUTE that points back into this

View File

@@ -388,4 +388,71 @@ export const partnersApi = {
/** The regions one partner covers. */
locations: (partnerid: number) =>
api.list<PartnerLocation>(`${WEB}/partners/getpartnerlocations`, { partnerid }),
/**
* Every GPS ping a partner's riders sent over a window.
*
* ── Scope, and why this is a platform endpoint ────────────────────────────
*
* It filters on `partnerid` or on the rider's `applocationid` — never on a
* tenant. A partner's riders serve every merchant that partner supplies, so
* there is no tenant this could be scoped to, and asking by region would hand
* one merchant every rider in the city. That is why the fleet view lives in
* the platform console and not in a shop's.
*
* ── What comes back, and what it is not ───────────────────────────────────
*
* One row per ping: rider, timestamp, latitude, longitude. Dense — 320,132
* rows across eight riders for August 2026 — so a month-wide window is a
* large response and callers ask for a day or two at a time.
*
* The coordinates are NOT a trail. Every row for a given rider carries the
* same pair: one rider's 2,404 pings on 14 August 2026 all read 11.052998,
* 76.929958, and the same holds on every day and region checked. The app
* stamps a location once and repeats it on each heartbeat, so distance, speed
* and "time moving" cannot be derived from this and anything of that shape
* would be invented. Rider positions that actually move are written on the
* `deliveries` rows; see `deliveryTrack`.
*
* The row also carries `login`, `logout`, `workhours`, `shorthours` and
* `breakhours`, and every one of them is empty or zero on every row measured.
* Nothing closes a shift. So the timestamps are what this endpoint is good
* for — who was online and for how long — and shifts are inferred from the
* gaps between pings; see `riderShifts`.
*/
riderLogs: (query: { partnerid?: number; applocationid?: number; fromdate: string; todate: string }) =>
api.list<RiderPingRow>(`${WEB}/partners/getriderlogs`, {
...(query.partnerid ? { partnerid: query.partnerid } : {}),
...(query.applocationid ? { applocationid: query.applocationid } : {}),
fromdate: query.fromdate,
todate: query.todate,
}),
};
/**
* One row of `getriderlogs`.
*
* The shift columns are typed because they are sent, and documented as empty
* because they are: nothing on the platform writes them. Reading `workhours`
* and believing it is the mistake this comment exists to prevent.
*/
export interface RiderPingRow {
logid?: number;
logdate: string;
userid: number;
username?: string;
partnerid?: number;
latitude?: string;
longitude?: string;
shiftid?: number;
shifthours?: number;
/** Always empty on production data. See `riderLogs`. */
login?: string;
/** Always empty on production data. See `riderLogs`. */
logout?: string;
/** Always 0 on production data. See `riderLogs`. */
workhours?: number;
shorthours?: number;
breakhours?: number;
logstatus?: number;
}

205
src/components/TrailMap.tsx Normal file
View File

@@ -0,0 +1,205 @@
import { useEffect, useRef } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import './trailMap.css';
/**
* A map, for things that have a position.
*
* ── Why leaflet directly and not react-leaflet ──────────────────────────────
*
* A leaflet map is an imperative object that owns a DOM node and must be torn
* down by hand — `remove()`, or the tile layer keeps fetching and the container
* keeps its `_leaflet_id` and refuses to be reused. React-leaflet wraps that in
* components and adds a second package that has to track React's major version
* forever. The wrapping is about forty lines; it is written here instead.
*
* ── Why the pins are divIcons ───────────────────────────────────────────────
*
* Leaflet's default marker is a PNG resolved relative to the stylesheet, which
* every bundler rewrites and breaks — the classic "markers are invisible"
* bug, usually patched by re-pointing the icon URLs at a CDN. A `divIcon` is
* markup, so it ships with the bundle, takes the console's brand colour and
* needs no image at all.
*/
export interface MapTrail {
id: string | number;
label: string;
points: readonly { lat: number; lng: number }[];
colour: string;
}
export interface MapPin {
id: string | number;
lat: number;
lng: number;
label: string;
/** Shown under the label in the popup. Plain text, one line per entry. */
lines?: string[];
colour?: string;
/** A hollow ring rather than a filled pin — for a last-known, not-live point. */
isFaded?: boolean;
/**
* A square marks a place rather than an event — a shop, a depot. Round pins
* are the things that happened there, so a branch never reads as one more
* customer among its own drops.
*/
shape?: 'round' | 'square';
}
/** Tamil Nadu, so an empty map still shows the right part of the world. */
const FALLBACK: L.LatLngExpression = [11.0168, 76.9558];
export function TrailMap({
trails = [],
pins = [],
height = 380,
emptyNote = 'Nothing to place on the map yet.',
}: {
trails?: readonly MapTrail[];
pins?: readonly MapPin[];
height?: number;
emptyNote?: string;
}) {
const host = useRef<HTMLDivElement | null>(null);
const map = useRef<L.Map | null>(null);
/* Everything drawn, kept together so a redraw clears exactly what it drew.
Clearing the map wholesale would take the tile layer with it. */
const drawn = useRef<L.LayerGroup | null>(null);
const isEmpty = trails.every((trail) => trail.points.length === 0) && pins.length === 0;
useEffect(() => {
if (!host.current || map.current) return;
const instance = L.map(host.current, {
center: FALLBACK,
zoom: 12,
// The console scrolls; a wheel over the map should scroll the page, not
// zoom. Ctrl+wheel and the +/− buttons still zoom, which is what people
// expect from a map embedded in a document.
scrollWheelZoom: false,
attributionControl: true,
});
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap contributors',
}).addTo(instance);
drawn.current = L.layerGroup().addTo(instance);
map.current = instance;
return () => {
instance.remove();
map.current = null;
drawn.current = null;
};
}, []);
useEffect(() => {
const instance = map.current;
const layer = drawn.current;
if (!instance || !layer) return;
layer.clearLayers();
const bounds = L.latLngBounds([]);
for (const trail of trails) {
if (trail.points.length < 2) continue;
const line = trail.points.map((point) => [point.lat, point.lng] as [number, number]);
L.polyline(line, {
color: trail.colour,
weight: 3,
opacity: 0.85,
// Rounded joins, or a dense GPS trail draws spikes at every turn.
lineJoin: 'round',
lineCap: 'round',
})
.bindTooltip(trail.label, { sticky: true })
.addTo(layer);
line.forEach((point) => bounds.extend(point));
}
for (const pin of pins) {
const colour = pin.colour ?? 'var(--color-brand)';
L.marker([pin.lat, pin.lng], {
title: pin.label,
icon: L.divIcon({
className: 'trail-pin-wrap',
html: `<span class="trail-pin" data-faded="${pin.isFaded ? 'true' : 'false'}" data-shape="${pin.shape ?? 'round'}" style="--pin:${escapeAttr(colour)}"></span>`,
iconSize: [16, 16],
iconAnchor: [8, 8],
}),
})
.bindPopup(
`<strong>${escapeHtml(pin.label)}</strong>` +
(pin.lines ?? []).map((line) => `<br>${escapeHtml(line)}`).join(''),
)
.addTo(layer);
bounds.extend([pin.lat, pin.lng]);
}
if (bounds.isValid()) {
// `maxZoom` matters: a single pin, or a rider who never left one street,
// otherwise zooms to building level and the map shows one grey rectangle
// with no landmarks to orient by.
instance.fitBounds(bounds, { padding: [28, 28], maxZoom: 16 });
}
}, [trails, pins]);
/* Leaflet measures its container once, at construction. Inside a drawer or a
tab the container is often zero-height at that moment, and the map renders
as a grey strip with one tile in the corner until something resizes the
window. Re-measuring whenever the box changes size fixes it for good,
including when the drawer animates open. */
useEffect(() => {
const node = host.current;
const instance = map.current;
if (!node || !instance || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => instance.invalidateSize());
observer.observe(node);
return () => observer.disconnect();
}, []);
return (
<div className="trail-map" style={{ height }}>
<div ref={host} className="trail-map-canvas" />
{isEmpty ? <div className="trail-map-empty">{emptyNote}</div> : null}
</div>
);
}
/**
* Distinct, legible line colours.
*
* Hand-picked rather than generated from a hue wheel: evenly spaced hues put
* two yellows next to each other on an OSM tile and both vanish. These are all
* dark enough to read over map detail and different enough to tell apart at the
* width of a polyline.
*/
export const TRAIL_COLOURS = [
'#662582',
'#0f8a5f',
'#c2410c',
'#1d4ed8',
'#b91c1c',
'#0e7490',
'#7c2d12',
'#4d7c0f',
] as const;
export function trailColour(index: number): string {
return TRAIL_COLOURS[index % TRAIL_COLOURS.length] as string;
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/** Popup and icon HTML is a string, so a rider named `<b>` must not become markup. */
function escapeAttr(value: string): string {
return value.replace(/["'<>]/g, '');
}

View File

@@ -0,0 +1,94 @@
/**
* The map's frame and its pins.
*
* Leaflet ships its own stylesheet for the tiles, controls and popups; this
* covers only what the console adds — the container, the divIcon pins and the
* empty state — plus the two places leaflet's defaults clash with the console.
*/
.trail-map {
position: relative;
width: 100%;
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: 10px;
background: var(--color-surface-sunken);
}
.trail-map-canvas {
width: 100%;
height: 100%;
}
/* Sits over the tiles rather than replacing them: an empty map still shows the
region, so "no positions here" reads as an absence in a real place instead of
a component that failed to load. */
.trail-map-empty {
position: absolute;
inset: auto 0 0 0;
z-index: 500;
padding: 10px 12px;
background: color-mix(in oklab, var(--color-surface) 92%, transparent);
border-top: 1px solid var(--color-border);
font-size: 12.5px;
color: var(--color-ink-3);
text-align: center;
}
/* The divIcon's own box — leaflet gives it a white background and a border by
default, which would frame every pin in a small white square. */
.trail-pin-wrap {
background: none;
border: 0;
}
.trail-pin {
display: block;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--pin, var(--color-brand));
border: 2px solid #fff;
box-shadow: 0 1px 4px rgb(15 23 42 / 45%);
}
/* A place, not an event. Squared off and a touch larger so a shop reads as the
thing the round starts from rather than as one more drop on it. */
.trail-pin[data-shape='square'] {
width: 15px;
height: 15px;
border-radius: 3px;
}
/* A last-known position, not a live one. Hollow, so the difference between
"here now" and "here when they last reported" is visible on the map itself
and not only in the popup. */
.trail-pin[data-faded='true'] {
background: transparent;
border-color: var(--pin, var(--color-brand));
border-width: 3px;
box-shadow: none;
}
/* Leaflet's controls and popups default to its own font stack and a blue link
colour; both look foreign next to the rest of the console. */
.trail-map .leaflet-container {
font: inherit;
background: var(--color-surface-sunken);
}
.trail-map .leaflet-popup-content {
margin: 10px 12px;
font-size: 12.5px;
line-height: 1.5;
color: var(--color-ink-1);
}
.trail-map .leaflet-control-attribution {
font-size: 10px;
background: color-mix(in oklab, var(--color-surface) 85%, transparent);
}
.trail-map .leaflet-control-attribution a {
color: var(--color-ink-3);
}

View File

@@ -13,6 +13,7 @@ const NAV: readonly NavEntry[] = [
{ to: '/nearle/onboard/tenant', label: 'Onboard tenant' },
{ to: '/nearle/catalogue', label: 'Global catalogue' },
{ to: '/nearle/partners', label: 'Rider partners' },
{ to: '/nearle/fleet', label: 'Fleet' },
{ to: '/nearle/uploads', label: 'Uploads' },
];

View File

@@ -0,0 +1,399 @@
import { useEffect, useMemo, useState } from 'react';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { AlertTriangle, Bike, CheckCircle2, Clock, Info, MapPin, Timer } from 'lucide-react';
import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader';
import { TrailMap, trailColour, type MapPin as Pin } from '@/components/TrailMap';
import { useAllPartners, usePartnerRiderLogs } from '@/queries/hooks';
import { duration, fleetDay, recommend, type RiderDay, type Severity } from '../riderShifts';
import './fleet.css';
/**
* Who was online, and for how long.
*
* ── Why this is a platform page and not a shop's ────────────────────────────
*
* `getriderlogs` filters on a partner or on a region — never on a tenant. A
* partner's riders serve every merchant that partner supplies (one covers 48
* shops, another 63), so there is no shop this could belong to, and scoping it
* by region would hand one merchant every rider in the city.
*
* ── Why there is no distance, speed or utilisation on this page ─────────────
*
* Because the data cannot support any of them. `riderlogs` carries a position
* on every row and it is the SAME position on every row for a given rider —
* 2,404 pings from one rider on 14 August 2026, all reading 11.052998,
* 76.929958, and the same on every day and region checked. The app stamps a
* location once and repeats it on each heartbeat. A "moved 62% of their shift"
* figure drawn from that would be invented, and it would be believed.
*
* The positions that do move are written on the delivery rows when a rider
* moves a job along, and they are tenant-scoped — so the shop's own dispatch
* board draws the map, and this page does not pretend to.
*
* ── What is real, and worth the page ────────────────────────────────────────
*
* The timestamps. A heartbeat means the app was open and the rider was
* reachable, so the pings say who was on and for how long. The columns built to
* answer exactly that — `login`, `logout`, `workhours`, `shorthours` — are
* empty or zero on all 320,132 rows read for August, so this is the only way to
* know, and it is inferred rather than recorded. The page says so out loud.
*/
export function FleetPage() {
const partners = useAllPartners();
const [partnerid, setPartnerid] = useState<number>(0);
const [day, setDay] = useState(() => yesterday());
// The first partner, once they load — an empty page with a dropdown on it
// makes the reader do a step the page could have done.
useEffect(() => {
if (partnerid === 0 && partners.data.length > 0) {
setPartnerid(partners.data[0]?.partnerid ?? 0);
}
}, [partnerid, partners.data]);
const logs = usePartnerRiderLogs(partnerid || undefined, { fromdate: day, todate: day });
const fleet = useMemo(() => fleetDay(logs.data ?? []), [logs.data]);
const advice = useMemo(() => recommend(fleet), [fleet]);
const [focused, setFocused] = useState<number | null>(null);
/* One pin per rider, and each is the single location that rider's app has
ever reported — hollow, because a filled pin says "here now" and this is
not that. It is worth drawing anyway: it shows how the fleet is spread
across the city, which nothing else on the platform answers. */
const pins: Pin[] = useMemo(
() =>
fleet.riders.flatMap((rider, index) =>
rider.place
? [
{
id: rider.userid,
lat: rider.place.lat,
lng: rider.place.lng,
label: rider.username,
lines: [
`${duration(rider.onlineMs)} online · ${rider.coverage.pings} check-ins`,
'The only location this rider’s app has reported',
],
colour: trailColour(index),
isFaded: true,
},
]
: [],
),
[fleet.riders],
);
return (
<VStack gap={3}>
<PageHeader
title="Fleet"
actions={
<HStack gap={1} align="center" wrap="wrap">
<select
className="fleet-select"
aria-label="Rider partner"
value={partnerid}
onChange={(event) => {
setPartnerid(Number(event.target.value));
setFocused(null);
}}
>
{partners.data.length === 0 ? <option value={0}>No partners</option> : null}
{partners.data.map((partner) => (
<option key={partner.partnerid} value={partner.partnerid}>
{partner.partnername ?? `Partner ${partner.partnerid}`}
</option>
))}
</select>
<input
type="date"
className="fleet-select"
aria-label="Day"
value={day}
max={today()}
onChange={(event) => {
setDay(event.target.value);
setFocused(null);
}}
/>
</HStack>
}
/>
<Banner advice={advice} />
<div className="kpi-grid">
<KpiCard
label="Riders online"
value={String(fleet.riders.length)}
note={
fleet.riders.length === 0
? 'nobody checked in on this day'
: fleet.measured === fleet.riders.length
? 'all with enough check-ins to time'
: `${fleet.measured} with enough check-ins to time`
}
tone="accent"
icon={<Bike size={15} />}
/>
<KpiCard
label="Hours online"
value={duration(fleet.onlineMs)}
note="inferred from check-ins, not clock-ins"
tone="neutral"
icon={<Clock size={15} />}
/>
<KpiCard
label="Average day"
value={duration(fleet.measured > 0 ? fleet.onlineMs / fleet.measured : null)}
note={`longest ${duration(fleet.longestMs)}`}
tone={toneOf(advice.severity)}
icon={<Timer size={15} />}
/>
<KpiCard
label="Reported places"
value={String(pins.length)}
note="one fixed point per rider — not a live position"
tone="neutral"
icon={<MapPin size={15} />}
/>
</div>
<div className="fleet-body">
<Card padding={0} elevation="low">
<VStack gap={1.5} padding={2}>
<Text type="label" size="sm" weight="semibold">
Where the fleet is registered
</Text>
<TrailMap
pins={pins}
height={420}
emptyNote={
logs.isLoading
? 'Reading the day…'
: 'No rider reported a location on this day.'
}
/>
<Note>
One pin per rider, and each is the only location that rider’s app has ever sent — it
does not change between check-ins, so this is where they are registered, not where
they are. Live positions are written when a rider moves an order along, and appear on
that shop’s own dispatch board.
</Note>
</VStack>
</Card>
<Card padding={0} elevation="low">
<VStack gap={1.5} padding={2}>
<Text type="label" size="sm" weight="semibold">
The day, rider by rider
</Text>
{logs.isLoading ? (
<Text type="body" size="sm" color="secondary">
Reading check-ins…
</Text>
) : fleet.riders.length === 0 ? (
<Text type="body" size="sm" color="secondary">
Nobody checked in on {day}.
</Text>
) : (
<div className="gantt">
{fleet.riders.map((rider, index) => (
<GanttRow
key={rider.userid}
rider={rider}
colour={trailColour(index)}
window={fleet.window}
isFocused={focused === rider.userid}
onClick={() =>
setFocused((prev) => (prev === rider.userid ? null : rider.userid))
}
/>
))}
{fleet.window ? (
<div className="gantt-axis">
<span>{clock(fleet.window.from)}</span>
<span>{clock(fleet.window.to)}</span>
</div>
) : null}
</div>
)}
{focused !== null ? <ShiftDetail rider={fleet.riders.find((r) => r.userid === focused)} /> : null}
</VStack>
</Card>
</div>
<Card padding={0} elevation="low">
<VStack gap={1} padding={2}>
<HStack gap={1} align="center">
<span className="fleet-note-icon">
<Info size={14} />
</span>
<Text type="label" size="xsm" weight="semibold">
What these numbers are, and are not
</Text>
</HStack>
<Text type="body" size="xsm" color="secondary">
Nothing here comes from a timesheet. The rider app sends a check-in every twenty
seconds or so and never closes a shift — <code>workhours</code>, <code>logout</code> and
the rest are empty on every row — so a shift is read as a run of check-ins with no
silence longer than forty minutes in it. That says who was reachable and when. It says
nothing about how hard anybody worked: the location on those rows never changes, so a
rider parked all day and one who crossed the city look identical from here. Riders who
checked in too rarely to time are listed with blanks rather than given a figure.
</Text>
</VStack>
</Card>
</VStack>
);
}
/* ── The gantt ───────────────────────────────────────────────────────────── */
function GanttRow({
rider,
colour,
window,
isFocused,
onClick,
}: {
rider: RiderDay;
colour: string;
window: { from: number; to: number } | null;
isFocused: boolean;
onClick: () => void;
}) {
const span = window ? Math.max(1, window.to - window.from) : 1;
const at = (time: number) => (window ? ((time - window.from) / span) * 100 : 0);
return (
<button type="button" className="gantt-row" data-active={isFocused} onClick={onClick}>
<span className="gantt-name" title={rider.username}>
<i style={{ background: colour }} />
{rider.username}
</span>
<span className="gantt-track">
{rider.shifts.map((shift) => (
<i
key={shift.from}
className="gantt-bar"
style={{
left: `${at(shift.from)}%`,
width: `${Math.max(0.4, at(shift.to) - at(shift.from))}%`,
background: colour,
}}
title={`Online ${clock(shift.from)}–${clock(shift.to)} · ${shift.pings} check-ins`}
/>
))}
</span>
<span className="gantt-figure">
{rider.onlineMs === null ? (
<em title={`Only ${rider.coverage.pings} check-ins — too few to time a shift`}>—</em>
) : (
duration(rider.onlineMs)
)}
</span>
<span className="gantt-figure gantt-quiet" title="Separate runs of check-ins">
{rider.shifts.length || '—'}
</span>
<span className="gantt-figure gantt-quiet">{rider.coverage.pings}</span>
</button>
);
}
function ShiftDetail({ rider }: { rider: RiderDay | undefined }) {
if (!rider) return null;
return (
<div className="shift-detail">
<strong>{rider.username}</strong>
{rider.shifts.length === 0 ? (
<span>
{rider.coverage.pings} check-in{rider.coverage.pings === 1 ? '' : 's'}, too few to make a
shift out of.
</span>
) : (
<ul>
{rider.shifts.map((shift) => (
<li key={shift.from}>
<span>
{clock(shift.from)} – {clock(shift.to)}
</span>
<em>{duration(shift.to - shift.from)}</em>
{/* A long silence inside a run means the shift is stitched across
it, so the total is an upper bound rather than a reading. */}
{shift.longestGapMs > 5 * 60_000 ? (
<span className="shift-gap">
quiet for {duration(shift.longestGapMs)} inside this run
</span>
) : null}
</li>
))}
</ul>
)}
</div>
);
}
/* ── Chrome ──────────────────────────────────────────────────────────────── */
function Banner({ advice }: { advice: ReturnType<typeof recommend> }) {
const Icon =
advice.severity === 'good' ? CheckCircle2 : advice.severity === 'watch' ? Info : AlertTriangle;
return (
<div className="fleet-banner" data-severity={advice.severity}>
<Icon size={17} />
<div>
<strong>{advice.headline}</strong>
<span>{advice.detail}</span>
</div>
</div>
);
}
function Note({ children }: { children: React.ReactNode }) {
return (
<div className="fleet-inline-note">
<Info size={13} />
<span>{children}</span>
</div>
);
}
function toneOf(severity: Severity): 'success' | 'warning' | 'error' {
return severity === 'good' ? 'success' : severity === 'watch' ? 'warning' : 'error';
}
/* ── Dates ───────────────────────────────────────────────────────────────── */
function iso(date: Date): string {
// Built from the local parts, not `toISOString()` — that converts to UTC and
// in IST hands back yesterday's date for anything before 05:30.
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function today(): string {
return iso(new Date());
}
/**
* Yesterday, as the default.
*
* A day that is still running is half a day, and "average shift 4h" read at
* eleven in the morning describes a morning. Yesterday is a whole one.
*/
function yesterday(): string {
const date = new Date();
date.setDate(date.getDate() - 1);
return iso(date);
}
function clock(at: number): string {
return new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}

View File

@@ -0,0 +1,263 @@
/**
* The fleet page: a banner, a map beside a gantt, and the gantt itself.
*/
.fleet-select {
height: 32px;
padding: 0 10px;
border: 1px solid var(--color-border);
border-radius: 7px;
background: var(--color-surface);
font: inherit;
font-size: 13px;
color: var(--color-ink-1);
}
.fleet-select:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: 1px;
}
/* ── The recommendation banner ───────────────────────────────────────────── */
/* Severity is carried by the left rail and the icon, not by a wash of colour
across the whole strip — a full red panel reads as an error the console has
hit, rather than a finding about the fleet. */
.fleet-banner {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 12px 14px;
border: 1px solid var(--color-border);
border-left-width: 3px;
border-radius: 9px;
background: var(--color-surface);
}
.fleet-banner > svg {
flex: none;
margin-top: 1px;
}
.fleet-banner div {
display: flex;
flex-direction: column;
gap: 2px;
}
.fleet-banner strong {
font-size: 13.5px;
font-weight: 600;
color: var(--color-ink-1);
}
.fleet-banner span {
font-size: 12.5px;
line-height: 1.5;
color: var(--color-ink-3);
}
.fleet-banner[data-severity='good'] {
border-left-color: #0f8a5f;
color: #0f8a5f;
}
.fleet-banner[data-severity='watch'] {
border-left-color: #b45309;
color: #b45309;
}
.fleet-banner[data-severity='act'] {
border-left-color: #b91c1c;
color: #b91c1c;
}
/* ── Map beside gantt ────────────────────────────────────────────────────── */
.fleet-body {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
gap: 16px;
align-items: start;
}
@media (max-width: 1100px) {
.fleet-body {
grid-template-columns: minmax(0, 1fr);
}
}
/* ── The gantt ───────────────────────────────────────────────────────────── */
.gantt {
display: flex;
flex-direction: column;
gap: 2px;
}
/* The track is the flexible column; the name and the three figures are fixed,
so every row's bars start and end at the same x and the day lines up down the
list. A shrink-to-fit name column would stagger them by rider. */
.gantt-row {
display: grid;
grid-template-columns: 132px minmax(0, 1fr) 62px 34px 54px;
gap: 10px;
align-items: center;
width: 100%;
padding: 6px 8px;
border: 1px solid transparent;
border-radius: 7px;
background: none;
font: inherit;
text-align: left;
cursor: pointer;
}
.gantt-row:hover {
background: var(--color-surface-subtle);
}
.gantt-row[data-active='true'] {
border-color: var(--color-brand);
background: var(--color-brand-tint);
}
.gantt-row:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: 1px;
}
.gantt-name {
display: flex;
gap: 7px;
align-items: center;
overflow: hidden;
font-size: 12.5px;
color: var(--color-ink-1);
text-overflow: ellipsis;
white-space: nowrap;
}
/* The same colour as this rider's line on the map — the only thing tying the
two panels together. */
.gantt-name i {
flex: none;
width: 8px;
height: 8px;
border-radius: 50%;
}
.gantt-track {
position: relative;
height: 16px;
border-radius: 4px;
background: var(--color-surface-sunken);
}
.gantt-bar {
position: absolute;
top: 3px;
height: 10px;
border-radius: 3px;
}
.gantt-figure {
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--color-ink-1);
text-align: right;
}
.gantt-quiet {
color: var(--color-ink-3);
}
.gantt-figure em {
color: var(--color-ink-4);
font-style: normal;
}
.gantt-axis {
display: flex;
justify-content: space-between;
/* Aligned to the track column, so the two clock readings sit under the ends
of the bars they label rather than under the whole row. */
padding: 4px 160px 0 150px;
font-size: 11px;
font-variant-numeric: tabular-nums;
color: var(--color-ink-4);
}
.fleet-note-icon {
display: inline-flex;
color: var(--color-ink-4);
}
/* ── One rider's runs, opened from the gantt ─────────────────────────────── */
.shift-detail {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface-subtle);
}
.shift-detail strong {
font-size: 12.5px;
color: var(--color-ink-1);
}
.shift-detail > span {
font-size: 12px;
color: var(--color-ink-3);
}
.shift-detail ul {
display: flex;
flex-direction: column;
gap: 3px;
margin: 0;
padding: 0;
list-style: none;
}
.shift-detail li {
display: flex;
gap: 10px;
align-items: baseline;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--color-ink-2);
}
.shift-detail em {
font-style: normal;
font-weight: 600;
color: var(--color-ink-1);
}
/* A long silence inside a run means the shift is stitched across it, so the
total above is an upper bound. Said on the row rather than in a footnote. */
.shift-gap {
color: #b45309;
}
.fleet-inline-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);
}
.fleet-inline-note svg {
flex: none;
margin-top: 2px;
}

View File

@@ -0,0 +1,251 @@
/**
* Presence, and the line between a reading and a guess.
*
* The refusals matter most: `riderlogs` really does return days with a handful
* of pings in them, and "0h 2m online" derived from two heartbeats would be
* read as a fact about somebody's working day.
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { duration, fleetDay, groupPings, recommend, riderDay, type RiderPing } from './riderShifts';
const DAY = Date.parse('2026-08-14T09:00:00+05:30');
/**
* Pings every 20 seconds — the live cadence (median gap 19 s, p90 30 s).
*
* The position is fixed on purpose: that is what production sends. One rider's
* 2,404 pings on 14 August all read 11.052998, 76.929958.
*/
function heartbeat(opts: {
from: number;
count: number;
userid?: number;
username?: string;
lat?: string;
lng?: string;
}): RiderPing[] {
return Array.from({ length: opts.count }, (_, i) => ({
userid: opts.userid ?? 7,
username: opts.username ?? 'Murali P',
logdate: new Date(opts.from + i * 20_000).toISOString(),
latitude: opts.lat ?? '11.052998',
longitude: opts.lng ?? '76.929958',
}));
}
/* ── Grouping ────────────────────────────────────────────────────────────── */
test('pings are grouped per rider and put in time order', () => {
const grouped = groupPings([
{ userid: 2, logdate: '2026-08-14T09:00:30+05:30' },
{ userid: 1, logdate: '2026-08-14T09:00:20+05:30' },
{ userid: 1, logdate: '2026-08-14T09:00:10+05:30' },
]);
assert.equal(grouped.size, 2);
assert.deepEqual(
grouped.get(1)!.map((ping) => ping.logdate),
['2026-08-14T09:00:10+05:30', '2026-08-14T09:00:20+05:30'],
);
});
test('a row with an unreadable date is skipped rather than sorted to the epoch', () => {
const grouped = groupPings([
{ userid: 1, logdate: '2026-08-14T09:00:00+05:30' },
{ userid: 1, logdate: 'not a date' },
]);
assert.equal(grouped.get(1)!.length, 1);
});
/* ── One rider's day ─────────────────────────────────────────────────────── */
test('a continuous run of heartbeats is one shift', () => {
const day = riderDay(7, 'Murali P', heartbeat({ from: DAY, count: 200 }));
assert.equal(day.shifts.length, 1);
// 199 gaps of 20 s.
assert.ok(Math.abs((day.onlineMs as number) - 199 * 20_000) < 1000);
});
// Forty minutes of silence is the app closed or the rider gone home. Counting
// it as time online would inflate every figure on the page.
test('a long silence splits the day and is not counted as time online', () => {
const day = riderDay(7, 'Murali P', [
...heartbeat({ from: DAY, count: 100 }),
...heartbeat({ from: DAY + 3 * 60 * 60 * 1000, count: 100 }),
]);
assert.equal(day.shifts.length, 2);
assert.ok(
(day.onlineMs as number) < 75 * 60 * 1000,
'the three-hour gap was counted as time online',
);
});
test('a ten-minute pause stays inside one shift', () => {
// The first run is 100 pings at 20 s, so it ends 33 minutes in; the second
// starts ten minutes after that.
const firstRunMs = 99 * 20_000;
const day = riderDay(7, 'M', [
...heartbeat({ from: DAY, count: 100 }),
...heartbeat({ from: DAY + firstRunMs + 10 * 60 * 1000, count: 100 }),
]);
assert.equal(day.shifts.length, 1);
assert.ok(day.shifts[0]!.longestGapMs > 8 * 60 * 1000, 'the pause is reported, not hidden');
});
test('opening the app once and closing it is not a shift', () => {
const day = riderDay(7, 'M', heartbeat({ from: DAY, count: 1 }));
assert.deepEqual(day.shifts, []);
assert.equal(day.onlineMs, null);
assert.equal(day.first, DAY, 'the ping still happened, and is still reported');
});
// The refusal that matters.
test('a day with four heartbeats states no hours', () => {
const day = riderDay(7, 'Murali P', heartbeat({ from: DAY, count: 4 }));
assert.equal(day.onlineMs, null);
assert.equal(day.coverage.isSound, false);
assert.equal(day.coverage.pings, 4);
assert.equal(day.shifts.length, 1, 'the run is still drawn, it just carries no figure');
});
test('no pings at all is an empty day rather than a throw', () => {
const day = riderDay(7, 'Murali P', []);
assert.deepEqual(day.shifts, []);
assert.equal(day.onlineMs, null);
assert.equal(day.first, null);
assert.equal(day.place, null);
});
/* ── The one position ────────────────────────────────────────────────────── */
// Not a trail. The whole module exists because this value never changes.
test("the rider's single reported position is read from the pings", () => {
const day = riderDay(7, 'M', heartbeat({ from: DAY, count: 50 }));
assert.deepEqual(day.place, { lat: 11.052998, lng: 76.929958 });
});
// 0,0 is the Atlantic and is what a phone sends before it has a fix. One of
// these becomes a map centred on the Gulf of Guinea.
test('a 0,0 fix is not taken as a place', () => {
const day = riderDay(7, 'M', [
...heartbeat({ from: DAY, count: 10 }),
...heartbeat({ from: DAY + 200_000, count: 5, lat: '0', lng: '0' }),
]);
assert.deepEqual(day.place, { lat: 11.052998, lng: 76.929958 });
});
test('a rider whose app never sent a position has none, rather than a zero', () => {
const day = riderDay(7, 'M', [
{ userid: 7, logdate: new Date(DAY).toISOString() },
{ userid: 7, logdate: new Date(DAY + 20_000).toISOString() },
]);
assert.equal(day.place, null);
});
/* ── The fleet ───────────────────────────────────────────────────────────── */
test('the fleet is every rider in the batch, longest day first', () => {
const day = fleetDay([
...heartbeat({ from: DAY, count: 60, userid: 9, username: 'Varun Edward' }),
...heartbeat({ from: DAY, count: 300, userid: 7, username: 'Murali P' }),
]);
assert.deepEqual(day.riders.map((rider) => rider.username), ['Murali P', 'Varun Edward']);
assert.equal(day.measured, 2);
});
test('a rider with too few check-ins is listed but left out of the totals', () => {
const day = fleetDay([
...heartbeat({ from: DAY, count: 300, userid: 7, username: 'A' }),
...heartbeat({ from: DAY, count: 3, userid: 9, username: 'B' }),
]);
assert.equal(day.riders.length, 2, 'a thin rider still belongs on the list');
assert.equal(day.measured, 1);
assert.ok(Math.abs(day.onlineMs - 299 * 20_000) < 1000, "B's three pings were added in");
});
test('the window spans every rider, for a shared gantt axis', () => {
const day = fleetDay([
...heartbeat({ from: DAY, count: 100, userid: 7 }),
...heartbeat({ from: DAY + 60 * 60 * 1000, count: 100, userid: 9 }),
]);
assert.equal(day.window!.from, DAY);
assert.ok(day.window!.to > DAY + 60 * 60 * 1000);
});
test('the longest day is reported, not just the total', () => {
const day = fleetDay([
...heartbeat({ from: DAY, count: 300, userid: 7 }),
...heartbeat({ from: DAY, count: 100, userid: 9 }),
]);
assert.ok(Math.abs(day.longestMs - 299 * 20_000) < 1000);
});
test('an empty batch gives an empty fleet with no window', () => {
const day = fleetDay([]);
assert.deepEqual(day.riders, []);
assert.equal(day.window, null);
assert.equal(day.onlineMs, 0);
});
/* ── The recommendation ──────────────────────────────────────────────────── */
test('nobody reporting in is stated as ambiguous, not as an idle fleet', () => {
const advice = recommend(fleetDay([]));
assert.match(advice.headline, /Nobody was online/);
assert.match(advice.detail, /app was not opened/);
});
test('thin coverage outranks any finding drawn from it', () => {
const advice = recommend(fleetDay(heartbeat({ from: DAY, count: 3 })));
assert.equal(advice.severity, 'watch');
assert.match(advice.headline, /Too few check-ins/);
});
test('an ordinary shift length is graded good', () => {
// Eight hours of heartbeats at one every 20 s.
const advice = recommend(fleetDay(heartbeat({ from: DAY, count: 1440 })));
assert.equal(advice.severity, 'good');
assert.match(advice.headline, /8h/);
});
test('a fifteen-hour day is flagged', () => {
const advice = recommend(fleetDay(heartbeat({ from: DAY, count: 2700 })));
assert.equal(advice.severity, 'act');
assert.match(advice.detail, /long day to be on call/);
});
test('very short days are flagged as either short shifts or a closed app', () => {
const advice = recommend(fleetDay(heartbeat({ from: DAY, count: 200 })));
assert.equal(advice.severity, 'watch');
assert.match(advice.detail, /closed between jobs/);
});
// The claim this module must never make. The position never changes, so a rider
// parked all day and one who crossed the city are indistinguishable here.
test('nothing in the advice claims to know how hard anybody worked', () => {
for (const count of [0, 3, 200, 1440, 2700]) {
const advice = recommend(fleetDay(heartbeat({ from: DAY, count })));
const text = `${advice.headline} ${advice.detail}`.toLowerCase();
for (const word of ['moving', 'idle', 'utilis', 'km', 'distance', 'productiv']) {
assert.ok(!text.includes(word), `advice for ${count} pings claimed "${word}": ${text}`);
}
}
});
test('riders left out for thin coverage are named in the advice', () => {
const advice = recommend(
fleetDay([
...heartbeat({ from: DAY, count: 1440, userid: 7, username: 'A' }),
...heartbeat({ from: DAY, count: 4, userid: 9, username: 'B' }),
]),
);
assert.match(advice.detail, /checked in too rarely/);
});
test('durations read the way a person says them', () => {
assert.equal(duration(null), '—');
assert.equal(duration(0), '—');
assert.equal(duration(45 * 60 * 1000), '45m');
assert.equal(duration(2 * 60 * 60 * 1000), '2h');
assert.equal(duration(2 * 60 * 60 * 1000 + 20 * 60 * 1000), '2h 20m');
});

View File

@@ -0,0 +1,324 @@
/**
* When a partner's riders were logged in.
*
* ── What `riderlogs` actually contains ──────────────────────────────────────
*
* It looks like a GPS trail and is not one. Every row carries a latitude and a
* longitude, and every row for a given rider carries the SAME latitude and
* longitude. Measured on 2026-09-09 across partner 44 and regions 1, 2 and 23,
* on four separate days:
*
* 14 Aug 12,976 pings, 5 riders — distinct positions per rider: 1, 1, 1, 1, 1
* 05 Aug 12,514 pings, 5 riders — 1, 1, 1, 1, 1
* 20 Aug 13,595 pings, 6 riders — 1, 1, 1, 1, 1, 1
* 28 Aug 8,948 pings, 4 riders — 1, 1, 1, 1
*
* One rider sent 2,404 pings on 14 August, all reading 11.052998, 76.929958.
* The app stamps a position once and repeats it on every heartbeat. So there is
* no trail to draw, no distance to total and no "moving versus idle" to compute
* — anything of that shape derived from this table would be invented.
*
* The rider positions that DO move live on the `deliveries` rows, written when
* a rider moves a job along; see `deliveryTrack`.
*
* ── What is real here, and worth having ─────────────────────────────────────
*
* The timestamps. A ping means the app was open and the rider was reachable, so
* the pings say when somebody was on and for how long — which is the one thing
* a platform operator cannot get anywhere else, and which the columns built to
* answer it (`login`, `logout`, `workhours`, `shorthours`) never carry: they are
* empty or zero on every row of all 320,132 read for August.
*
* So a shift is inferred: a run of pings with no long silence in it. That is a
* weaker claim than a clock-in, and everything below is shaped around saying so
* rather than rounding it up to a timesheet.
*/
/** One ping as `getriderlogs` returns it. */
export interface RiderPing {
userid: number;
username?: string;
logdate: string;
latitude?: string;
longitude?: string;
}
/**
* Silence that ends a shift.
*
* The app pings every twenty seconds or so — median gap 19 s, p90 30 s, measured
* over one rider's 2,404 pings. Forty minutes with nothing is the app closed,
* the phone out of signal, or the rider gone home; all three mean the same thing
* here. Set long deliberately: too short and a rider waiting out a quiet hour
* gets their day chopped into a dozen shifts, too long and two days run
* together. Forty minutes survives a long stop and never bridges a night.
*/
const SHIFT_GAP_MS = 40 * 60 * 1000;
/** Fewer pings than this in a day and the shift is a guess, not a reading. */
const MIN_PINGS_FOR_A_FIGURE = 30;
/** One unbroken run of pings. */
export interface Shift {
from: number;
to: number;
pings: number;
/** The longest silence inside it. A big one means the run is stitched. */
longestGapMs: number;
}
/** How much evidence stands behind a rider's day. */
export interface Coverage {
pings: number;
/**
* The longest silence anywhere in the day, INCLUDING the gaps between shifts.
* Distinct from `Shift.longestGapMs`, which is the longest silence inside one
* run: a rider with two shifts three hours apart has a three-hour figure here
* and a small one on each shift, and both readings are correct.
*/
longestGapMs: number;
/** False when there is too little data to state an hours figure at all. */
isSound: boolean;
}
/** The single position this rider's app has ever reported. */
export interface RiderPlace {
lat: number;
lng: number;
}
export interface RiderDay {
userid: number;
username: string;
shifts: Shift[];
/** Sum of shift spans. Blank when coverage is too thin to state one. */
onlineMs: number | null;
/** First ping of the day, and last. What the gantt draws between. */
first: number | null;
last: number | null;
coverage: Coverage;
/**
* Where the app says this rider is — one fixed point, not a position.
* Null when the app never sent a usable one.
*/
place: RiderPlace | null;
}
export interface FleetDay {
riders: RiderDay[];
/** Riders with enough pings to state hours for. The rest are listed, not timed. */
measured: number;
/** Total hours online across the measured riders. */
onlineMs: number;
/** The longest anyone was online. The busiest day on the page. */
longestMs: number;
/** Earliest and latest ping across everyone — the gantt's axis. */
window: { from: number; to: number } | null;
}
/** Pings grouped by rider, in time order, with unusable rows dropped. */
export function groupPings(logs: readonly RiderPing[]): Map<number, RiderPing[]> {
const byRider = new Map<number, RiderPing[]>();
for (const ping of logs) {
if (!Number.isFinite(Date.parse(ping.logdate))) continue;
const list = byRider.get(ping.userid);
if (list) list.push(ping);
else byRider.set(ping.userid, [ping]);
}
for (const list of byRider.values()) {
list.sort((a, b) => Date.parse(a.logdate) - Date.parse(b.logdate));
}
return byRider;
}
/**
* The one position a rider's app reports.
*
* Taken from the last ping that carries a usable pair. 0,0 is the Atlantic and
* is what a phone sends before it has a fix — the single most common way a map
* ends up centred on the Gulf of Guinea.
*/
function placeOf(pings: readonly RiderPing[]): RiderPlace | null {
for (let i = pings.length - 1; i >= 0; i -= 1) {
const lat = Number(pings[i]?.latitude);
const lng = Number(pings[i]?.longitude);
if (Number.isFinite(lat) && Number.isFinite(lng) && !(lat === 0 && lng === 0)) {
return { lat, lng };
}
}
return null;
}
/** One rider's day, from their pings. */
export function riderDay(userid: number, username: string, pings: readonly RiderPing[]): RiderDay {
const times = pings
.map((ping) => Date.parse(ping.logdate))
.filter((at) => Number.isFinite(at));
if (times.length === 0) {
return {
userid,
username,
shifts: [],
onlineMs: null,
first: null,
last: null,
coverage: { pings: 0, longestGapMs: 0, isSound: false },
place: placeOf(pings),
};
}
const shifts: Shift[] = [];
let start = times[0] as number;
let previous = start;
let count = 1;
let longestInShift = 0;
let longestOverall = 0;
const close = () => {
// A shift of one ping has no duration and nothing to draw — somebody
// opening the app and closing it. Counting it would add a zero-length shift
// to the day and a sliver to the gantt.
if (count > 1) {
shifts.push({ from: start, to: previous, pings: count, longestGapMs: longestInShift });
}
};
for (let i = 1; i < times.length; i += 1) {
const at = times[i] as number;
const gap = at - previous;
if (gap > longestOverall) longestOverall = gap;
if (gap > SHIFT_GAP_MS) {
close();
start = at;
count = 1;
longestInShift = 0;
} else {
count += 1;
if (gap > longestInShift) longestInShift = gap;
}
previous = at;
}
close();
const onlineMs = shifts.reduce((total, shift) => total + (shift.to - shift.from), 0);
const isSound = times.length >= MIN_PINGS_FOR_A_FIGURE && onlineMs > 0;
return {
userid,
username,
shifts,
onlineMs: isSound ? onlineMs : null,
first: times[0] as number,
last: times[times.length - 1] as number,
coverage: { pings: times.length, longestGapMs: longestOverall, isSound },
place: placeOf(pings),
};
}
/** The whole fleet's day, from one batch of `getriderlogs` rows. */
export function fleetDay(logs: readonly RiderPing[]): FleetDay {
const grouped = groupPings(logs);
const names = new Map<number, string>();
for (const ping of logs) {
if (ping.username && !names.has(ping.userid)) names.set(ping.userid, ping.username);
}
const riders = [...grouped.entries()]
.map(([userid, pings]) => riderDay(userid, names.get(userid) ?? `Rider ${userid}`, pings))
.sort((a, b) => (b.onlineMs ?? 0) - (a.onlineMs ?? 0));
const sound = riders.filter((rider) => rider.coverage.isSound);
let from = Infinity;
let to = -Infinity;
for (const rider of riders) {
if (rider.first !== null && rider.first < from) from = rider.first;
if (rider.last !== null && rider.last > to) to = rider.last;
}
return {
riders,
measured: sound.length,
onlineMs: sound.reduce((total, rider) => total + (rider.onlineMs ?? 0), 0),
longestMs: sound.reduce((most, rider) => Math.max(most, rider.onlineMs ?? 0), 0),
window: Number.isFinite(from) ? { from, to } : null,
};
}
export type Severity = 'good' | 'watch' | 'act';
export interface Recommendation {
severity: Severity;
headline: string;
detail: string;
}
/**
* What the day suggests, graded.
*
* Everything stated here is about PRESENCE — who was on and for how long. It is
* deliberately silent about how hard anybody worked, because this data cannot
* tell: the position never changes, so a rider parked all day and a rider who
* crossed the city look identical from here.
*
* Thin coverage outranks every other finding: hours drawn from four pings are
* worse than no hours, because they will be believed.
*/
export function recommend(day: FleetDay): Recommendation {
if (day.riders.length === 0) {
return {
severity: 'watch',
headline: 'Nobody was online on this day',
detail:
'No rider app reported in. Either no shifts ran, or the app was not opened — the two look identical from here.',
};
}
if (day.measured === 0) {
return {
severity: 'watch',
headline: 'Too few check-ins to state hours',
detail: `${day.riders.length} rider${day.riders.length === 1 ? '' : 's'} reported in, but none often enough to measure a shift. Hours are left blank rather than guessed.`,
};
}
const averageMs = day.onlineMs / day.measured;
const thin = day.riders.length - day.measured;
const thinNote =
thin > 0
? ` ${thin} of ${day.riders.length} checked in too rarely to be included.`
: '';
const who = `${day.measured} rider${day.measured === 1 ? '' : 's'}`;
if (averageMs > 13 * 60 * 60 * 1000) {
return {
severity: 'act',
headline: `${who} averaged ${duration(averageMs)} online`,
detail: `That is a very long day to be on call, and the longest was ${duration(day.longestMs)}. Worth checking against what the shifts are meant to be.${thinNote}`,
};
}
if (averageMs < 3 * 60 * 60 * 1000) {
return {
severity: 'watch',
headline: `${who} averaged only ${duration(averageMs)} online`,
detail: `Short days, or an app that is being closed between jobs. The order flow for the day says which is more likely.${thinNote}`,
};
}
return {
severity: 'good',
headline: `${who} averaged ${duration(averageMs)} online`,
detail: `Ordinary shift lengths, the longest being ${duration(day.longestMs)}.${thinNote}`,
};
}
/** "6h 20m" — the gantt's totals and the axis labels. */
export function duration(ms: number | null): string {
if (ms === null || ms <= 0) return '—';
const minutes = Math.round(ms / 60_000);
const hours = Math.floor(minutes / 60);
const rest = minutes % 60;
if (hours === 0) return `${rest}m`;
return rest === 0 ? `${hours}h` : `${hours}h ${rest}m`;
}

View File

@@ -0,0 +1,202 @@
import { useMemo, useState } from 'react';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { Store } from 'lucide-react';
import { TrailMap, trailColour, type MapPin, type MapTrail } from '@/components/TrailMap';
import type { Stop } from './dispatchModel';
import { coverageOf, mapStop, roundsOf, shopsOf } from './deliveryTrack';
import { DELIVERY_STATUS, statusColor } from './orderStatus';
import { moneyExact } from './format';
/**
* A selected group's stops, on the map instead of in a table.
*
* ── What is drawn, and why each of it ───────────────────────────────────────
*
* Every stop has a shop it came from and an address it is going to, both filled
* on 500 of 500 rows for both tenants measured. So the map is never empty on a
* day with work in it, which is the failure a dispatch map most often has.
*
* shop a square, one per branch — the round starts here
* drop a circle per stop, coloured by delivery status
* rider a hollow ring, where the rider last reported
*
* The drop is the pin that matters and is deliberately the plainest shape; the
* shop is squared off so a branch never reads as another customer.
*
* ── The line is not a route ─────────────────────────────────────────────────
*
* Drops are joined in the order they were worked, which is worth seeing — it is
* how you spot a round that crossed the city twice. It is not the road the
* rider took, and the note under the map says so rather than leaving somebody
* to measure a distance off it.
*
* ── Scope ───────────────────────────────────────────────────────────────────
*
* Fed entirely from the group already on screen, so a shop sees its own work
* and nobody else's, and choosing a different rider or branch in the rail
* redraws this without another request.
*/
export function GroupMap({ stops, groupName }: { stops: readonly Stop[]; groupName: string }) {
const mapped = useMemo(() => stops.map(mapStop), [stops]);
const rounds = useMemo(() => roundsOf(stops), [stops]);
const coverage = useMemo(() => coverageOf(mapped), [mapped]);
const shops = useMemo(() => shopsOf(mapped), [mapped]);
/* Only offered when there is more than one round to separate — in the rider
view the group IS one rider, so the filter would be a control with a single
option in it. */
const [focused, setFocused] = useState<number | null>(null);
const shown = focused === null ? rounds : rounds.filter((round) => round.userid === focused);
const trails: MapTrail[] = useMemo(
() =>
shown.flatMap((round) => {
const drops = round.stops.flatMap((stop) => (stop.drop ? [stop.drop] : []));
if (drops.length < 2) return [];
return [
{
id: round.userid,
label: `${round.rider} — ${drops.length} drops, in the order they were worked`,
points: drops,
colour: trailColour(rounds.findIndex((r) => r.userid === round.userid)),
},
];
}),
[shown, rounds],
);
const pins: MapPin[] = useMemo(() => {
const out: MapPin[] = [];
// The shop first, so a drop pin drawn on top of it stays clickable.
for (const [index, shop] of shops.entries()) {
out.push({
id: `shop-${index}`,
lat: shop.lat,
lng: shop.lng,
label: 'Shop',
lines: ['Where these orders were collected'],
colour: 'var(--color-brand)',
shape: 'square',
});
}
for (const round of shown) {
for (const [index, stop] of round.stops.entries()) {
if (stop.drop) {
out.push({
id: `${stop.key}-drop`,
lat: stop.drop.lat,
lng: stop.drop.lng,
label: `${index + 1}. ${stop.orderid}`,
lines: [
stop.customer || stop.address || 'No address on the row',
`${stop.status}${stop.at ? ` · ${clock(stop.at)}` : ''}`,
`${round.rider} · ${moneyExact(stop.value)}`,
],
colour: statusColor(DELIVERY_STATUS, stop.status),
});
}
if (stop.rider) {
out.push({
id: `${stop.key}-rider`,
lat: stop.rider.lat,
lng: stop.rider.lng,
label: round.rider,
lines: [`Last reported on ${stop.orderid}`, stop.at ? clock(stop.at) : ''],
colour: trailColour(rounds.findIndex((r) => r.userid === round.userid)),
isFaded: true,
});
}
}
}
return out;
}, [shown, shops, rounds]);
return (
<VStack gap={1}>
<HStack justify="between" align="center" gap={2} wrap="wrap">
<Text type="body" size="xsm" color="secondary">
{coverage.placed} of {coverage.total} stop{coverage.total === 1 ? '' : 's'} placed
{coverage.withRider > 0 ? ` · ${coverage.withRider} with a rider position` : ''}
</Text>
<span className="map-key">
<i data-key="shop" />
shop
<i data-key="drop" />
drop
<i data-key="rider" />
rider
</span>
</HStack>
{/* In the store and customer views a group holds several riders' work, so
the round filter earns its place. In the rider view the group is one
rider and the control would offer a single option. */}
{rounds.length > 1 ? (
<HStack gap={0.5} wrap="wrap">
<RoundChip
label="Everyone"
colour="var(--color-ink-4)"
isActive={focused === null}
onClick={() => setFocused(null)}
/>
{rounds.map((round, index) => (
<RoundChip
key={round.userid}
label={`${round.rider} · ${round.stops.length}`}
colour={trailColour(index)}
isActive={focused === round.userid}
onClick={() => setFocused((prev) => (prev === round.userid ? null : round.userid))}
/>
))}
</HStack>
) : null}
<TrailMap
trails={trails}
pins={pins}
height={440}
emptyNote={
coverage.total === 0
? `Nothing out for ${groupName}.`
: 'None of these stops carries an address the map can place.'
}
/>
<div className="pva-note">
<Store size={13} />
<span>
Drops are joined in the order they were worked, not along the road the rider rode — the
length of that line is not the distance covered. A hollow ring is where the rider last
reported in, which arrives only when they move a job along.
</span>
</div>
</VStack>
);
}
function RoundChip({
label,
colour,
isActive,
onClick,
}: {
label: string;
colour: string;
isActive: boolean;
onClick: () => void;
}) {
return (
<button type="button" className="rider-chip" data-active={isActive} onClick={onClick}>
<i style={{ background: colour }} />
{label}
</button>
);
}
function clock(at: number): string {
return new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}

View File

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

View File

@@ -0,0 +1,243 @@
/**
* The three places a delivery happens.
*
* The awkward rows here are copied from production: `pickuplon` on the delivery
* endpoint against `pickuplong` on the orders one, `droplat` filled on
* deliveries and empty on orders, and a `ridername` of "delivered".
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
import type { DeliveryRow, OrderRow } from '@/api/types';
import type { Stop } from './dispatchModel';
import { coverageOf, mapStop, roundsOf, shopsOf } from './deliveryTrack';
import { isRealName } from './orderStatus';
/** A delivery as `getdeliveries` sends one. */
function delivery(over: Record<string, unknown> = {}): Stop {
return {
kind: 'delivery',
row: {
deliveryid: 1,
orderid: '916-1',
userid: 883,
ridername: 'Rajan',
orderstatus: 'delivered',
assigntime: '2026-08-29 11:22:36',
deliverytime: '2026-08-29 13:16:57',
pickuplat: '11.043236',
pickuplon: '77.0006758',
droplat: '11.082948',
droplon: '77.005914',
deliverylat: '11.082948',
deliverylong: '77.005914',
riderslat: '11.082815',
riderslon: '77.007213',
deliverycustomer: 'A customer',
deliveryaddress: 'Peelamedu',
deliveryamt: 36,
...over,
} as unknown as DeliveryRow,
};
}
/** An order as `getorders` sends one — different spellings, no drop columns. */
function order(over: Record<string, unknown> = {}): Stop {
return {
kind: 'order',
row: {
orderheaderid: 55,
orderid: '916-55',
orderstatus: 'pending',
orderdate: '2026-08-29T14:48:42+05:30',
pickuplat: '11.0432363',
pickuplong: '77.0006758',
droplat: '',
droplon: '',
deliverylat: '11.082948',
deliverylong: '77.005914',
...over,
} as unknown as OrderRow,
};
}
/* ── Reading one stop ────────────────────────────────────────────────────── */
test('a delivery gives up all three of its places', () => {
const stop = mapStop(delivery());
assert.deepEqual(stop.shop, { lat: 11.043236, lng: 77.0006758 });
assert.deepEqual(stop.drop, { lat: 11.082948, lng: 77.005914 });
assert.deepEqual(stop.rider, { lat: 11.082815, lng: 77.007213 });
});
// One letter apart, and reading the wrong one puts every shop on the prime
// meridian off the coast of Ghana.
test('pickuplon and pickuplong are both read, because the two endpoints differ', () => {
assert.deepEqual(mapStop(delivery()).shop, { lat: 11.043236, lng: 77.0006758 });
assert.deepEqual(mapStop(order()).shop, { lat: 11.0432363, lng: 77.0006758 });
});
// `droplat` is filled on deliveries and empty on orders, where `deliverylat`
// carries the same value. A waiting order still has an address to place.
test('an order with no droplat still gets its drop from deliverylat', () => {
const stop = mapStop(order());
assert.deepEqual(stop.drop, { lat: 11.082948, lng: 77.005914 });
});
test('a stop with no rider position has none, rather than a pin at zero', () => {
assert.equal(mapStop(delivery({ riderslat: '', riderslon: '' })).rider, null);
assert.equal(mapStop(order()).rider, null);
});
// A half-written pair reads as 0, and a pin on the equator is a confident lie
// where a missing pin is an honest gap.
test('half a coordinate is no coordinate', () => {
assert.equal(mapStop(delivery({ droplon: '', deliverylong: '' })).drop, null);
assert.equal(mapStop(delivery({ riderslat: '0' })).rider, null);
});
test('a stop is timed by the newest stamp on it', () => {
assert.equal(
mapStop(delivery()).at,
Date.parse('2026-08-29T13:16:57'),
'the delivery stamp, not the assign one',
);
});
test('a waiting order falls back to when it was placed', () => {
assert.equal(mapStop(order()).at, Date.parse('2026-08-29T14:48:42+05:30'));
});
test('orders and deliveries never collide on a key', () => {
assert.notEqual(mapStop(delivery()).key, mapStop(order()).key);
});
/* ── Names ───────────────────────────────────────────────────────────────── */
// `ridername` holds a delivery status on more rows than it holds a name, for
// two riders in five on tenant 916.
test('a status is never mistaken for a name', () => {
assert.equal(isRealName('Varun'), true);
assert.equal(isRealName('delivered'), false);
assert.equal(isRealName('cancelled'), false);
assert.equal(isRealName(' '), false);
assert.equal(isRealName(undefined), false);
});
/* ── Rounds ──────────────────────────────────────────────────────────────── */
test("a rider's stops are put in the order they were worked", () => {
const [round] = roundsOf([
delivery({ deliveryid: 2, deliverytime: '2026-08-29 14:00:00' }),
delivery({ deliveryid: 1, deliverytime: '2026-08-29 13:00:00' }),
delivery({ deliveryid: 3, deliverytime: '2026-08-29 15:00:00' }),
]);
assert.deepEqual(round!.stops.map((stop) => stop.orderid), ['916-1', '916-1', '916-1']);
assert.deepEqual(
round!.stops.map((stop) => stop.at),
[
Date.parse('2026-08-29T13:00:00'),
Date.parse('2026-08-29T14:00:00'),
Date.parse('2026-08-29T15:00:00'),
],
);
});
test('riders are grouped on their id, never on the name', () => {
const rounds = roundsOf([
delivery({ deliveryid: 1, userid: 883, ridername: 'Rajan' }),
delivery({ deliveryid: 2, userid: 883, ridername: 'delivered' }),
]);
assert.equal(rounds.length, 1);
assert.equal(rounds[0]!.rider, 'Rajan');
assert.equal(rounds[0]!.stops.length, 2);
});
// Rider 897 is "Varun" on 69 rows and "delivered" on 75. Most-common-wins
// would name them "delivered".
test('a status is not chosen as the name even when it is the common value', () => {
const rounds = roundsOf([
...Array.from({ length: 3 }, (_, i) =>
delivery({ deliveryid: i + 1, userid: 897, ridername: 'Varun' }),
),
...Array.from({ length: 7 }, (_, i) =>
delivery({ deliveryid: i + 10, userid: 897, ridername: 'delivered' }),
),
]);
assert.equal(rounds[0]!.rider, 'Varun');
});
test('a rider whose every row carried a status is named by their id', () => {
const rounds = roundsOf([delivery({ userid: 950, ridername: 'delivered' })]);
assert.equal(rounds[0]!.rider, 'Rider 950');
});
// Waiting work has an address and belongs on the map — seeing where it clusters
// is often the reason to look — but it is not a round and must not lead.
test('work nobody is carrying is its own group, and comes last', () => {
const rounds = roundsOf([
order(),
delivery({ deliveryid: 1, userid: 883 }),
delivery({ deliveryid: 2, userid: 883 }),
]);
assert.deepEqual(rounds.map((round) => round.rider), ['Rajan', 'Not assigned']);
});
test('the busiest round leads, so the legend starts with what matters', () => {
const rounds = roundsOf([
delivery({ deliveryid: 1, userid: 1, ridername: 'A' }),
delivery({ deliveryid: 2, userid: 2, ridername: 'B' }),
delivery({ deliveryid: 3, userid: 2, ridername: 'B' }),
]);
assert.deepEqual(rounds.map((round) => round.rider), ['B', 'A']);
});
test('a round carries only the rider fixes it actually has', () => {
const [round] = roundsOf([
delivery({ deliveryid: 1, deliverytime: '2026-08-29 13:00:00' }),
delivery({ deliveryid: 2, deliverytime: '2026-08-29 14:00:00', riderslat: '', riderslon: '' }),
]);
assert.equal(round!.stops.length, 2);
assert.equal(round!.fixes.length, 1);
});
/* ── Shops ───────────────────────────────────────────────────────────────── */
// The same branch arrives as both 11.016327 and 11.0163268 in production. Two
// pins on one shop looks like two shops.
test('one branch is one pin, however many decimals it arrived with', () => {
const shops = shopsOf([
mapStop(delivery({ pickuplat: '11.016327', pickuplon: '77.0147763' })),
mapStop(delivery({ pickuplat: '11.0163268', pickuplon: '77.0147763' })),
]);
assert.equal(shops.length, 1);
});
test('two genuinely different branches stay two pins', () => {
const shops = shopsOf([
mapStop(delivery({ pickuplat: '11.043236', pickuplon: '77.0006758' })),
mapStop(delivery({ pickuplat: '11.0388679', pickuplon: '76.9300797' })),
]);
assert.equal(shops.length, 2);
});
test('a stop with no shop coordinate contributes no pin', () => {
assert.deepEqual(shopsOf([mapStop(delivery({ pickuplat: '', pickuplon: '' }))]), []);
});
/* ── Coverage ────────────────────────────────────────────────────────────── */
// A map with six pins looks the same whether the day was quiet or the reporting
// failed. The count is what tells them apart.
test('coverage says how much of the group the map can place', () => {
const stops = [
mapStop(delivery({ deliveryid: 1 })),
mapStop(delivery({ deliveryid: 2, riderslat: '', riderslon: '' })),
mapStop(delivery({ deliveryid: 3, droplat: '', droplon: '', deliverylat: '', deliverylong: '' })),
];
assert.deepEqual(coverageOf(stops), { placed: 2, total: 3, withRider: 2 });
});
test('an empty group reports zeroes rather than throwing', () => {
assert.deepEqual(coverageOf([]), { placed: 0, total: 0, withRider: 0 });
assert.deepEqual(roundsOf([]), []);
});

View File

@@ -0,0 +1,242 @@
/**
* The three places a delivery happens, and where the rider was.
*
* ── What is actually on the row ─────────────────────────────────────────────
*
* Measured on 2026-09-09 over 500 rows each for tenants 916 and 908:
*
* pickup (the shop) 500/500 8 distinct branches on 916, 6 on 908
* drop (the customer) 500/500 145 distinct on 916, 341 on 908
* rider (last reported) 461/500 on 916, 324/500 on 908
*
* Shop and drop are on every row, so the map is never empty on a day with
* deliveries in it. The rider position is the sparse one, and it is real: on
* delivered rows it sits a median 134 m from the drop, which is a GPS fix taken
* at the customer's door.
*
* Not to be confused with `riderlogs`, which carries a coordinate on every ping
* and repeats the SAME one for a rider all day — see `riderShifts`.
*
* ── The two endpoints spell the columns differently ─────────────────────────
*
* getdeliveries pickuplat / pickuplon droplat / droplon deliverylat / deliverylong
* getorders pickuplat / pickuplong (both empty) deliverylat / deliverylong
*
* `pickuplon` versus `pickuplong` is one letter, and reading the wrong one puts
* every shop on the prime meridian off the coast of Ghana. `droplat`/`droplon`
* are filled on deliveries and empty on orders, so the delivery coordinates are
* the fallback rather than the primary. `coordOf` accepts every spelling, which
* is why a stop can be an order or a delivery without the caller caring.
*/
import type { DeliveryRow, OrderRow } from '@/api/types';
import type { Stop } from './dispatchModel';
import { isRealName } from './orderStatus';
/** A point that is actually a point. */
export interface Point {
lat: number;
lng: number;
}
export type PlaceKind = 'shop' | 'drop' | 'rider';
/** One stop, as far as the map is concerned. */
export interface MappedStop {
key: string;
orderid: string;
status: string;
/** The shop it came from. */
shop: Point | null;
/** Where it is going. The pin that matters. */
drop: Point | null;
/** Where the rider was when the job last moved. Absent on most waiting orders. */
rider: Point | null;
customer: string;
address: string;
value: number;
/** The rider's `app_users.userid`, or 0 when nobody is carrying it. */
userid: number;
ridername: string;
/** For ordering a round: the newest lifecycle stamp on the row. */
at: number | null;
}
/**
* One coordinate, from whichever of its spellings the endpoint used.
*
* Zero is rejected as well as blank: a half-written pair reads as 0, and a pin
* on the equator is a confident lie where a missing pin is an honest gap.
*/
function coordOf(row: Record<string, unknown>, ...names: string[]): number | null {
for (const name of names) {
const raw = row[name];
if (raw === undefined || raw === null || raw === '') continue;
const n = Number(raw);
if (Number.isFinite(n) && n !== 0) return n;
}
return null;
}
function pointOf(
row: Record<string, unknown>,
lats: string[],
lngs: string[],
): Point | null {
const lat = coordOf(row, ...lats);
const lng = coordOf(row, ...lngs);
// Both, or neither.
return lat !== null && lng !== null ? { lat, lng } : null;
}
/**
* When this stop last moved.
*
* The ladder's order — assign, arrive, pickup, start, deliver — with the latest
* present stamp winning, because `updatedelivery` writes the rider position
* alongside whichever stamp the new status sets. An order that nobody has
* touched falls back to when it was placed.
*/
function stampOf(row: Record<string, unknown>): number | null {
const candidates = [
'assigntime',
'arrivaltime',
'pickuptime',
'starttime',
'deliverytime',
'canceltime',
'orderdate',
];
let latest: number | null = null;
for (const name of candidates) {
const raw = row[name];
if (typeof raw !== 'string' || raw === '') continue;
const at = Date.parse(raw.replace(' ', 'T'));
if (Number.isFinite(at) && (latest === null || at > latest)) latest = at;
}
return latest;
}
/** One stop, read for the map. */
export function mapStop(stop: Stop): MappedStop {
const row = stop.row as unknown as Record<string, unknown>;
const delivery = stop.kind === 'delivery' ? (stop.row as DeliveryRow) : undefined;
const order = stop.kind === 'order' ? (stop.row as OrderRow) : undefined;
return {
key: delivery ? `d${delivery.deliveryid}` : `o${order?.orderheaderid ?? 0}`,
orderid: (row['orderid'] as string) || `#${delivery?.deliveryid ?? order?.orderheaderid ?? 0}`,
status: (row['orderstatus'] as string)?.trim().toLowerCase() || 'unknown',
shop: pointOf(row, ['pickuplat'], ['pickuplon', 'pickuplong']),
// `droplat` on deliveries, `deliverylat` on orders — and both carry the
// same value where both are filled.
drop: pointOf(row, ['droplat', 'deliverylat'], ['droplon', 'deliverylong']),
rider: pointOf(row, ['riderslat'], ['riderslon']),
customer: ((row['deliverycustomer'] as string) ?? '').trim(),
address:
((row['deliveryaddress'] as string) ?? '').trim() ||
((row['deliverysuburb'] as string) ?? '').trim(),
value: Number(row['deliveryamt'] ?? row['orderamount'] ?? 0) || 0,
userid: Number(row['userid'] ?? 0) || 0,
ridername: (row['ridername'] as string) ?? '',
at: stampOf(row),
};
}
/** One rider's round: their stops, in the order they were worked. */
export interface Round {
userid: number;
rider: string;
stops: MappedStop[];
/** The rider positions along the round, for the trail. */
fixes: Point[];
}
/**
* The day's stops, grouped into rounds.
*
* Grouped on `userid`, never on `ridername` — see `isRealName` for why that
* column cannot identify anybody. Work nobody is carrying lands under userid 0
* as "Not assigned", which is a legitimate group on this map: those drops still
* have an address, and seeing where the unassigned work is clustered is the
* point of looking.
*/
export function roundsOf(stops: readonly Stop[]): Round[] {
const mapped = stops.map(mapStop);
const byRider = new Map<number, MappedStop[]>();
for (const stop of mapped) {
const list = byRider.get(stop.userid);
if (list) list.push(stop);
else byRider.set(stop.userid, [stop]);
}
const nameOf = (userid: number) => {
if (userid === 0) return 'Not assigned';
const counts = new Map<string, number>();
for (const stop of mapped) {
if (stop.userid !== userid || !isRealName(stop.ridername)) continue;
const name = stop.ridername.trim();
counts.set(name, (counts.get(name) ?? 0) + 1);
}
let best = '';
let most = 0;
for (const [name, count] of counts) {
if (count > most) {
best = name;
most = count;
}
}
// Better a plain id than a confident wrong name.
return best || `Rider ${userid}`;
};
return [...byRider.entries()]
.map(([userid, list]) => {
const ordered = list.sort((a, b) => (a.at ?? 0) - (b.at ?? 0));
return {
userid,
rider: nameOf(userid),
stops: ordered,
fixes: ordered.flatMap((stop) => (stop.rider ? [stop.rider] : [])),
};
})
.sort((a, b) => {
// Unassigned work last: it is a real group, but it is not a round and it
// should not lead the legend.
if (a.userid === 0) return 1;
if (b.userid === 0) return -1;
return b.stops.length - a.stops.length;
});
}
/** The distinct shops a set of stops was collected from. */
export function shopsOf(stops: readonly MappedStop[]): Point[] {
const seen = new Map<string, Point>();
for (const stop of stops) {
if (!stop.shop) continue;
// Rounded to about a metre before de-duplicating: the same branch arrives
// as both 11.016327 and 11.0163268, and two pins on one shop look like two
// shops.
const key = `${stop.shop.lat.toFixed(5)},${stop.shop.lng.toFixed(5)}`;
if (!seen.has(key)) seen.set(key, stop.shop);
}
return [...seen.values()];
}
/**
* How much of the group the map can actually place.
*
* Stated on the page rather than implied by a sparse map: "6 of 29 stops can be
* placed" is the difference between a quiet day and a reporting gap, and a map
* with six pins looks the same either way.
*/
export function coverageOf(stops: readonly MappedStop[]): {
placed: number;
total: number;
withRider: number;
} {
return {
placed: stops.filter((stop) => stop.drop !== null).length,
total: stops.length,
withRider: stops.filter((stop) => stop.rider !== null).length,
};
}

View File

@@ -297,6 +297,33 @@ test('the waiting queue is oldest first — it is a work queue, not a log', () =
assert.deepEqual(groups[0]?.stops.map((s) => s.row.orderheaderid), [1, 2]);
});
// The rail is the list an operator picks a rider from, so a wrong label there
// is a phantom rider standing next to the real ones. `ridername` holds a
// delivery status on more rows than it holds a name for two riders in five on
// tenant 916, and naming the group from its first stop put "delivered" in the
// rail carrying 100 of the day's stops.
test('a rider is named from the whole round, so a status never labels the rail', () => {
const groups = groupByRider(
toStops(
[
delivery({ deliveryid: 1, userid: 897, ridername: 'delivered' }),
delivery({ deliveryid: 2, userid: 897, ridername: 'delivered' }),
delivery({ deliveryid: 3, userid: 897, ridername: 'Varun' }),
],
[],
),
);
assert.equal(groups.length, 1, 'grouping is on userid and was never the problem');
assert.equal(groups[0]?.name, 'Varun');
});
test('a rider whose every row carried a status keeps their id as the label', () => {
const groups = groupByRider(
toStops([delivery({ userid: 950, ridername: 'cancelled' })], []),
);
assert.equal(groups[0]?.name, 'Rider 950');
});
test('riders are keyed on userid, which survives a missing name', () => {
// `ridername` comes from a join and is blank whenever that join misses.
const groups = groupByRider(toStops([delivery({ userid: 1427, ridername: '' })], []));

View File

@@ -1,6 +1,7 @@
import type { CustomerInfo } from '@/api/customers';
import type { DeliveryRow, OrderRow, TenantLocation } from '@/api/types';
import { awaitingRider, assignedFrom } from './assignDelivery';
import { isRealName } from './orderStatus';
/**
* A day's dispatch, grouped three ways.
@@ -180,6 +181,31 @@ function fold(group: Group, stop: Stop): void {
if (rider) group.riders.add(String(rider));
}
/**
* The most common value in `ridername` that is not a delivery status.
*
* Returns undefined when the column held nothing but statuses, so the caller
* keeps whatever fallback it had rather than being handed a wrong name.
*/
function commonRiderName(stops: readonly Stop[]): string | undefined {
const counts = new Map<string, number>();
for (const stop of stops) {
if (stop.kind !== 'delivery') continue;
const name = stop.row.ridername?.trim();
if (!name || !isRealName(name)) continue;
counts.set(name, (counts.get(name) ?? 0) + 1);
}
let best: string | undefined;
let most = 0;
for (const [name, count] of counts) {
if (count > most) {
best = name;
most = count;
}
}
return best;
}
/** Busiest first, then alphabetical, so the board does not reshuffle randomly. */
function order(groups: Group[]): Group[] {
return groups.sort((a, b) => b.stops.length - a.stops.length || a.name.localeCompare(b.name));
@@ -298,19 +324,39 @@ export function groupByRider(stops: readonly Stop[]): Group[] {
const id = rider ? String(rider) : UNASSIGNED;
let group = map.get(id);
if (!group) {
const name =
id === UNASSIGNED
? 'Waiting for a rider'
: (stop.kind === 'delivery' ? stop.row.ridername : '') || `Rider ${id}`;
const detail = stop.kind === 'delivery' ? stop.row.ridercontact : undefined;
group = blank(id, name, detail);
// Named once the group is complete — see below.
group = blank(id, id === UNASSIGNED ? 'Waiting for a rider' : `Rider ${id}`, detail);
map.set(id, group);
}
fold(group, stop);
}
/*
Named from the whole group, not from whichever stop arrived first.
`ridername` is not reliably a name: every rider on tenant 916 has BOTH their
name and a delivery status in that column, and for two of the five the status
is the MORE common value —
883 { "Rajan": 21, "delivered": 42 }
897 { "Varun": 69, "delivered": 75 }
1111 { "Murali": 33, "delivered": 74, "cancelled": 1 }
1114 { "Tamilazhagan": 37, "delivered": 63 }
Taking the first stop's value put a rider called "delivered" in the rail, next
to the real riders, carrying 100 of the day's stops. Grouping was never
affected — that is on `userid` — but the label was, and the rail is the list
an operator picks from. So statuses are excluded by vocabulary and the most
common of what survives is the name; a rider whose every row carried a status
keeps their id, which is at least honest about being an id.
*/
for (const group of map.values()) {
group.stops.sort((a, b) => stopTime(a).localeCompare(stopTime(b)));
if (group.id !== UNASSIGNED) {
const name = commonRiderName(group.stops);
if (name) group.name = name;
}
}
const groups = order([...map.values()]);

View File

@@ -37,6 +37,39 @@ export const DELIVERY_STATUS: Record<string, string> = {
cancelled: '#ef4444',
};
/**
* `waiting` is not in either map — it is the dispatch board's own word for work
* nobody has touched — but it is just as wrong as a name.
*/
const WAITING_WORD = 'waiting';
/**
* True when a value is a person's name and not a status wearing one.
*
* `deliveries.ridername` is not reliably a name. Every rider on tenant 916 has
* BOTH their name and a delivery status in that column, and for two of the five
* the status is the MORE common value (measured 2026-09-09):
*
* 883 { "Rajan": 21, "delivered": 42 }
* 897 { "Varun": 69, "delivered": 75 }
* 1111 { "Murali": 33, "delivered": 74, "cancelled": 1 }
* 1114 { "Tamilazhagan": 37, "delivered": 63 }
*
* So neither "first non-empty" nor "most common" finds the name on its own. The
* statuses have to be excluded first, and they are excluded by VOCABULARY — the
* two status maps above — rather than by a hand-written list, so a status added
* to the ladder is excluded here the same day.
*
* It lives beside the vocabularies rather than with either caller: the dispatch
* rail and the map both need it, and one importing it from the other would put
* a cycle between them.
*/
export function isRealName(value: string | undefined): boolean {
const name = value?.trim().toLowerCase();
if (!name) return false;
return !(name in DELIVERY_STATUS) && !(name in ORDER_STATUS) && name !== WAITING_WORD;
}
/** Unknown statuses fall to meta grey rather than to a colour that means something. */
export const statusColor = (map: Record<string, string>, status: string | undefined): string =>
map[(status ?? '').trim().toLowerCase()] ?? 'var(--color-ink-3)';

View File

@@ -9,6 +9,7 @@ import {
MapPin,
Package,
Store,
Timer,
Truck,
UserX,
Users,
@@ -27,7 +28,9 @@ import { useBranchScope } from '../BranchScope';
import { count, money, moneyExact } from '../format';
import { DELIVERY_STATUS, statusColor } from '../orderStatus';
import { shortAge } from '../posStatus';
import { GroupMap } from '../GroupMap';
import { OrderDetailDrawer } from '../OrderDetailDrawer';
import { PlanVsActualPanel } from '../PlanVsActualPanel';
import {
dayTotals,
groupByCustomer,
@@ -70,13 +73,21 @@ import './dispatch.css';
* Deliveries tab could never answer, since it lists jobs flat with a rider
* column and no way to see a round.
*
* ── What this page deliberately is not ──────────────────────────────────────
* ── Why two of the three views draw a map instead of a table ────────────────
*
* There is no map yet. Every drop coordinate in production today sits within
* about four metres of every other — one test customer ordering repeatedly —
* so a map would render one pin with six underneath and imply a precision the
* data does not have. The grouping, the rounds and the rider positions all work
* without one, and the map is worth adding the day real drops spread out.
* A round and a shop's day are both SHAPES: where the work is, in what order,
* how far apart. A table of addresses cannot show either — you read twenty rows
* and still cannot see that a rider crossed the city twice. So picking a rider
* or a shop opens the map, and the table stays where a list is the right
* answer: the waiting queue, whose rows are ticked to assign them, and the
* customer view, where one customer's drops are all at one address.
*
* The coordinates support it. `pickuplat`/`pickuplon` and `droplat`/`droplon`
* are filled on 500 of 500 rows for both tenants measured, so the map is never
* empty on a day with work in it; the rider's own position is the sparse one,
* at 461 and 324 of 500. (An earlier note here said drops all sat within four
* metres of each other. That was one quiet test tenant: the live spread is 145
* distinct drops on tenant 916 and 341 on 908.)
*/
/**
* The delivery ladder plus the one status that is not on it.
@@ -89,10 +100,21 @@ import './dispatch.css';
*/
const DISPATCH_STATUS: Record<string, string> = { ...DELIVERY_STATUS, [WAITING]: '#ef4444' };
/**
* The fourth tab is not a fourth grouping.
*
* "By rider", "by store" and "by customer" are three orderings of the same
* rows; "Plan vs actual" asks a different question of them and replaces the
* rail-and-table body entirely. Kept as a separate piece of state rather than a
* fourth `ViewMode`, so the grouping functions never have to answer for a value
* that is not a grouping.
*/
type Board = ViewMode | 'timing';
export function DispatchPage() {
const { branches, selected, tenantid } = useBranchScope();
const dates = useDateScope();
const [mode, setMode] = useState<ViewMode>('riders');
const [mode, setMode] = useState<Board>('riders');
const [focused, setFocused] = useState<string | null>(null);
const [detail, setDetail] = useState<Stop | null>(null);
@@ -142,6 +164,7 @@ export function DispatchPage() {
);
const groups = useMemo(() => {
if (mode === 'timing') return [];
if (mode === 'stores') return groupByStore(stops, locations.data ?? branches);
if (mode === 'customers') return groupByCustomer(stops, customers.data ?? [], branchName);
return groupByRider(stops);
@@ -191,7 +214,7 @@ export function DispatchPage() {
[waiting, picked],
);
const changeMode = (next: ViewMode) => {
const changeMode = (next: Board) => {
setMode(next);
// A group id means nothing across groupings — a branch id is not a rider
// id — so the focus is dropped rather than carried into nonsense.
@@ -224,6 +247,12 @@ export function DispatchPage() {
isActive={mode === 'customers'}
onClick={() => changeMode('customers')}
/>
<ModeTab
label="Plan vs actual"
icon={<Timer size={14} />}
isActive={mode === 'timing'}
onClick={() => changeMode('timing')}
/>
</HStack>
}
/>
@@ -259,77 +288,84 @@ export function DispatchPage() {
/>
</div>
<div className="dispatch-body">
<div className="dispatch-rail">
<Text type="label" size="xsm" color="secondary">
{mode === 'riders'
? isOneDay
? 'Rounds'
: 'Riders'
: mode === 'stores'
? 'Shops'
: 'Customers'}
</Text>
<GroupList
groups={groups}
mode={mode}
isLoading={deliveries.isLoading || (mode === 'customers' && customers.isLoading)}
focused={focused}
onFocus={(id) => setFocused((prev) => (prev === id ? null : id))}
/>
</div>
<div className="dispatch-main">
{open ? (
<GroupDetail
group={open}
{mode === 'timing' ? (
/* A different question of the same rows, so it replaces the board
rather than sitting beside it: promised against delivered, and where
the hours between accepting an order and dropping it actually went. */
<PlanVsActualPanel rows={deliveries.data ?? []} isLoading={deliveries.isLoading} />
) : (
<div className="dispatch-body">
<div className="dispatch-rail">
<Text type="label" size="xsm" color="secondary">
{mode === 'riders'
? isOneDay
? 'Rounds'
: 'Riders'
: mode === 'stores'
? 'Shops'
: 'Customers'}
</Text>
<GroupList
groups={groups}
mode={mode}
onOpen={setDetail}
{...(open.id === UNASSIGNED
? {
selection: picked,
verdictOf: (row: OrderRow) => assignability(row, branchOf(row), assigned),
assignBar:
picked.count > 0 ? (
<AssignBar
orders={pickedOrders}
branchOf={branchOf}
assigned={assigned}
onClear={picked.clear}
onDone={picked.clear}
/>
) : null,
}
: {})}
isLoading={deliveries.isLoading || (mode === 'customers' && customers.isLoading)}
focused={focused}
onFocus={(id) => setFocused((prev) => (prev === id ? null : id))}
/>
) : (
<Card padding={0} elevation="low">
<VStack gap={1} padding={4} align="center">
<span style={{ color: 'var(--color-ink-4)' }}>
<MapPin size={22} />
</span>
<Text type="label" size="sm" weight="semibold">
{groups.length > 0
? 'Pick one to see its stops'
: isOneDay
? 'Nothing out on this day'
: 'Nothing out in this range'}
</Text>
<Text
type="body"
size="sm"
color="secondary"
style={{ textAlign: 'center', maxWidth: 380 }}
>
{groups.length > 0
? 'Every stop, in the order it was assigned, with where the rider last reported in.'
: 'Deliveries appear here once orders are assigned to a rider.'}
</Text>
</VStack>
</Card>
)}
</div>
<div className="dispatch-main">
{open ? (
<GroupDetail
group={open}
mode={mode}
onOpen={setDetail}
{...(open.id === UNASSIGNED
? {
selection: picked,
verdictOf: (row: OrderRow) => assignability(row, branchOf(row), assigned),
assignBar:
picked.count > 0 ? (
<AssignBar
orders={pickedOrders}
branchOf={branchOf}
assigned={assigned}
onClear={picked.clear}
onDone={picked.clear}
/>
) : null,
}
: {})}
/>
) : (
<Card padding={0} elevation="low">
<VStack gap={1} padding={4} align="center">
<span style={{ color: 'var(--color-ink-4)' }}>
<MapPin size={22} />
</span>
<Text type="label" size="sm" weight="semibold">
{groups.length > 0
? 'Pick one to see its stops'
: isOneDay
? 'Nothing out on this day'
: 'Nothing out in this range'}
</Text>
<Text
type="body"
size="sm"
color="secondary"
style={{ textAlign: 'center', maxWidth: 380 }}
>
{groups.length > 0
? 'Every stop, in the order it was assigned, with where the rider last reported in.'
: 'Deliveries appear here once orders are assigned to a rider.'}
</Text>
</VStack>
</Card>
)}
</div>
</div>
</div>
)}
{detail ? (
<OrderDetailDrawer row={detail.row} kind={detail.kind} onClose={() => setDetail(null)} />
@@ -451,6 +487,21 @@ function GroupDetail({
// rider's round means nothing on this one.
const paged = usePaged(group.stops, { resetKey: group.id });
/*
A round and a shop's day are both SHAPES — where the work is, in what order,
how far apart — and a table of addresses cannot show either. So those two
views draw the map instead of the list.
Never on the waiting queue. That is the only group whose rows are ticked to
assign them, a checkbox cannot live on a map pin, and nothing in it has been
worked yet so there is no shape to see. `selection` is passed only for that
group, so its presence is the test.
"By customer" keeps its table too: one customer's drops are all at one
address, so the map would be a single pin with the rest hidden underneath it.
*/
const showMap = (mode === 'riders' || mode === 'stores') && !selection;
return (
<VStack gap={1.5}>
<Card padding={0} elevation="low">
@@ -472,13 +523,10 @@ function GroupDetail({
</Text>
</HStack>
{/* Rider position, only in the rider view and only when reported.
Labelled with its age and the status that produced it, because it
is a last-known point and not a live feed — drawing it as a dot on
a map would claim otherwise. */}
{/* Never on the waiting queue: nobody is carrying that work, so
{/* The rider's last reported point, but only when the map is not
drawing it. On the waiting queue nobody is carrying the work, so
"No position reported" states the obvious and reads as a fault. */}
{mode === 'riders' && group.id !== UNASSIGNED ? (
{!showMap && mode === 'riders' && group.id !== UNASSIGNED ? (
seen ? (
<div className="last-seen">
<MapPin size={14} />
@@ -492,13 +540,6 @@ function GroupDetail({
{seen.orderid ? ` · ${seen.orderid}` : ''}
</span>
</div>
<a
href={`https://www.openstreetmap.org/?mlat=${seen.lat}&mlon=${seen.lon}#map=16/${seen.lat}/${seen.lon}`}
target="_blank"
rel="noreferrer"
>
Open map
</a>
</div>
) : (
<div className="last-seen" data-empty="true">
@@ -521,98 +562,106 @@ function GroupDetail({
is free, not on a screen you have to leave the list for. */}
{assignBar}
<Card padding={0} elevation="low">
<div className="table-scroll">
<table className="stops-table">
<thead>
<tr>
{selection ? (
<th className="tick-cell">
<input
type="checkbox"
aria-label="Select every order that can be assigned"
checked={selection.allChosen}
ref={(node) => {
// Some can go and some cannot: neither ticked nor
// empty, and the box should say so rather than pick one.
if (node) node.indeterminate = selection.someChosen;
}}
onChange={selection.toggleAll}
/>
</th>
) : null}
<th>#</th>
<th>Order</th>
<th>Going to</th>
{mode !== 'riders' ? <th>Rider</th> : null}
<th>KM</th>
<th>Value</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{paged.rows.map((stop, rowIndex) => {
const status = stopStatus(stop);
const tint = statusColor(DISPATCH_STATUS, status);
const order = stop.kind === 'order' ? stop.row : undefined;
const verdict = order && verdictOf ? verdictOf(order) : undefined;
const pickable = Boolean(verdict?.canAssign);
return (
<tr key={stopKey(stop)} onClick={() => onOpen(stop)}>
{selection ? (
/* The click is stopped here so ticking a row does not
also open its drawer — two different intentions on the
same row, and the drawer would bury the bar. */
<td className="tick-cell" onClick={(event) => event.stopPropagation()}>
<input
type="checkbox"
aria-label={`Select order ${stopOrderId(stop)}`}
checked={pickable && selection.has(order!.orderheaderid)}
disabled={!pickable}
title={pickable ? undefined : (verdict?.reason ?? 'Already with a rider')}
onChange={() => order && selection.toggle(order.orderheaderid)}
/>
</td>
) : null}
<td className="num">{paged.from + rowIndex}</td>
<td>
<strong>{stopOrderId(stop) || `#${stop.row.orderheaderid}`}</strong>
<span>{stopTime(stop) ? shortTime(stopTime(stop)) : ''}</span>
</td>
<td>
<strong>{stopCustomer(stop) || '—'}</strong>
<span>{stopAddress(stop) || stop.row.deliverysuburb}</span>
</td>
{mode !== 'riders' ? (
<td>
{stop.kind === 'delivery' && stop.row.ridername ? (
stop.row.ridername
) : (
<em className="muted">not assigned</em>
)}
</td>
) : null}
<td className="num">{stop.kind === 'delivery' ? km(stop.row) : '—'}</td>
<td className="num">{moneyExact(stopValue(stop))}</td>
<td>
<span
className="stop-status"
style={{
color: tint,
background: `color-mix(in oklab, ${tint} 12%, transparent)`,
{showMap ? (
<Card padding={0} elevation="low">
<VStack gap={1.5} padding={2}>
<GroupMap stops={group.stops} groupName={group.name} />
</VStack>
</Card>
) : (
<Card padding={0} elevation="low">
<div className="table-scroll">
<table className="stops-table">
<thead>
<tr>
{selection ? (
<th className="tick-cell">
<input
type="checkbox"
aria-label="Select every order that can be assigned"
checked={selection.allChosen}
ref={(node) => {
// Some can go and some cannot: neither ticked nor
// empty, and the box should say so rather than pick one.
if (node) node.indeterminate = selection.someChosen;
}}
>
{status}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<TablePager paged={paged} label="stops" />
</Card>
onChange={selection.toggleAll}
/>
</th>
) : null}
<th>#</th>
<th>Order</th>
<th>Going to</th>
{mode !== 'riders' ? <th>Rider</th> : null}
<th>KM</th>
<th>Value</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{paged.rows.map((stop, rowIndex) => {
const status = stopStatus(stop);
const tint = statusColor(DISPATCH_STATUS, status);
const order = stop.kind === 'order' ? stop.row : undefined;
const verdict = order && verdictOf ? verdictOf(order) : undefined;
const pickable = Boolean(verdict?.canAssign);
return (
<tr key={stopKey(stop)} onClick={() => onOpen(stop)}>
{selection ? (
/* The click is stopped here so ticking a row does not
also open its drawer — two different intentions on the
same row, and the drawer would bury the bar. */
<td className="tick-cell" onClick={(event) => event.stopPropagation()}>
<input
type="checkbox"
aria-label={`Select order ${stopOrderId(stop)}`}
checked={pickable && selection.has(order!.orderheaderid)}
disabled={!pickable}
title={pickable ? undefined : (verdict?.reason ?? 'Already with a rider')}
onChange={() => order && selection.toggle(order.orderheaderid)}
/>
</td>
) : null}
<td className="num">{paged.from + rowIndex}</td>
<td>
<strong>{stopOrderId(stop) || `#${stop.row.orderheaderid}`}</strong>
<span>{stopTime(stop) ? shortTime(stopTime(stop)) : ''}</span>
</td>
<td>
<strong>{stopCustomer(stop) || '—'}</strong>
<span>{stopAddress(stop) || stop.row.deliverysuburb}</span>
</td>
{mode !== 'riders' ? (
<td>
{stop.kind === 'delivery' && stop.row.ridername ? (
stop.row.ridername
) : (
<em className="muted">not assigned</em>
)}
</td>
) : null}
<td className="num">{stop.kind === 'delivery' ? km(stop.row) : '—'}</td>
<td className="num">{moneyExact(stopValue(stop))}</td>
<td>
<span
className="stop-status"
style={{
color: tint,
background: `color-mix(in oklab, ${tint} 12%, transparent)`,
}}
>
{status}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<TablePager paged={paged} label="stops" />
</Card>
)}
</VStack>
);
}

View File

@@ -253,3 +253,259 @@
text-transform: capitalize;
white-space: nowrap;
}
/* ── Plan vs actual ──────────────────────────────────────────────────────── */
/* One colour per step, reused by the stacked bar and the legend swatches so the
two read as the same object. Ordered as the journey runs — cool at the shop,
warm on the road — rather than by a palette's own sequence. */
.pva-bar {
display: flex;
height: 14px;
overflow: hidden;
border-radius: 7px;
background: var(--color-surface-sunken);
}
.pva-bar i,
.pva-swatch {
display: block;
}
.pva-bar i[data-step='toShop'],
.pva-swatch[data-step='toShop'] {
background: #662582;
}
.pva-bar i[data-step='counter'],
.pva-swatch[data-step='counter'] {
background: #8b6bab;
}
.pva-bar i[data-step='inRound'],
.pva-swatch[data-step='inRound'] {
background: #c2410c;
}
.pva-bar i[data-step='onRoad'],
.pva-swatch[data-step='onRoad'] {
background: #0f8a5f;
}
.pva-swatch {
width: 9px;
height: 9px;
border-radius: 2px;
}
.pva-steps {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 4px 16px;
}
.pva-step {
display: grid;
grid-template-columns: 9px minmax(0, 1fr) auto;
gap: 4px 8px;
align-items: center;
padding: 6px 8px;
border: 1px solid transparent;
border-radius: 7px;
}
/* The step costing the most is the finding. Outlined rather than recoloured, so
the swatch keeps naming its own segment of the bar above. */
.pva-step[data-bottleneck='true'] {
border-color: var(--color-border);
background: var(--color-surface-subtle);
}
.pva-step-label {
font-size: 12.5px;
color: var(--color-ink-1);
}
.pva-step-label em {
margin-left: 6px;
font-size: 10.5px;
font-style: normal;
font-weight: 600;
letter-spacing: 0.03em;
color: #b45309;
text-transform: uppercase;
}
.pva-step strong {
font-size: 13px;
font-variant-numeric: tabular-nums;
color: var(--color-ink-1);
}
.pva-step-note {
grid-column: 2 / -1;
font-size: 11px;
font-variant-numeric: tabular-nums;
color: var(--color-ink-4);
}
.pva-figure {
display: flex;
flex-direction: column;
gap: 1px;
padding-left: 10px;
border-left: 3px solid var(--color-border);
}
.pva-figure[data-tone='good'] {
border-left-color: #0f8a5f;
}
.pva-figure[data-tone='watch'] {
border-left-color: #b45309;
}
.pva-figure[data-tone='act'] {
border-left-color: #b91c1c;
}
.pva-figure-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.03em;
color: var(--color-ink-3);
text-transform: uppercase;
}
.pva-figure strong {
font-size: 19px;
font-variant-numeric: tabular-nums;
line-height: 1.2;
color: var(--color-ink-1);
}
.pva-figure-note {
font-size: 11.5px;
color: var(--color-ink-4);
}
.pva-note {
display: flex;
gap: 7px;
align-items: flex-start;
padding: 8px 10px;
border-radius: 7px;
background: var(--color-surface-subtle);
font-size: 11.5px;
line-height: 1.5;
color: var(--color-ink-3);
}
.pva-note svg {
flex: none;
margin-top: 2px;
}
.pva-toggle {
display: inline-flex;
gap: 6px;
align-items: center;
font-size: 12.5px;
color: var(--color-ink-2);
cursor: pointer;
}
/* Rows here are read, not opened — nothing lies behind one — so the pointer and
hover lift the stops table uses would promise a click that does nothing. */
.pva-table tbody tr {
cursor: default;
}
.pva-late[data-late='yes'] {
color: #b91c1c;
}
.pva-late[data-late='no'] {
color: #0f8a5f;
}
.pva-late[data-late='none'] {
color: var(--color-ink-4);
}
/* ── The map's rider filter ──────────────────────────────────────────────── */
.rider-chip {
display: inline-flex;
gap: 6px;
align-items: center;
padding: 4px 10px;
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-surface);
font: inherit;
font-size: 12px;
color: var(--color-ink-2);
cursor: pointer;
}
.rider-chip:hover {
background: var(--color-surface-subtle);
}
.rider-chip[data-active='true'] {
border-color: var(--color-brand);
background: var(--color-brand-tint);
color: var(--color-ink-1);
}
.rider-chip:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: 1px;
}
/* The swatch matches this rider's line on the map, which is the only thing
connecting a chip to the shape it filters to. */
.rider-chip i {
width: 8px;
height: 8px;
border-radius: 50%;
}
/* ── 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);
}

View File

@@ -0,0 +1,250 @@
/**
* Promised against happened.
*
* The rows below are copied from production (tenant 916, 29 August 2026) rather
* than invented, because every awkward thing this module exists to handle is
* something the live data does and a hand-written fixture would not: a
* `starttime` later than `arrivaltime`, an `actualkms` identical to `kms`, a
* `riderkms` of 0.0026, and a promise written in twelve-hour time.
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
import type { DeliveryRow } from '@/api/types';
import { compare, journeyOf, lateness, parsePromise, span } from './plannedVsActual';
/** One batch of four, assigned together — the rows that revealed the ladder. */
const BATCH: DeliveryRow[] = [
{
deliveryid: 1,
orderid: '916-1',
ridername: 'Varun',
orderstatus: 'delivered',
assigntime: '2026-08-29 11:22:36',
arrivaltime: '2026-08-29 12:37:26',
pickuptime: '2026-08-29 12:41:20',
starttime: '2026-08-29 13:16:23',
deliverytime: '2026-08-29 13:16:57',
expecteddeliverytime: '2026-08-29 01:00 PM',
kms: '6',
actualkms: '6.0',
riderkms: '4.2',
},
{
deliveryid: 2,
orderid: '916-2',
ridername: 'Varun',
orderstatus: 'delivered',
assigntime: '2026-08-29 11:22:36',
arrivaltime: '2026-08-29 12:37:29',
pickuptime: '2026-08-29 12:41:21',
starttime: '2026-08-29 13:17:19',
deliverytime: '2026-08-29 13:38:26',
expecteddeliverytime: '2026-08-29 01:30 PM',
kms: '8',
actualkms: '8.0',
riderkms: '6.1',
},
{
deliveryid: 3,
orderid: '916-3',
ridername: 'Varun',
orderstatus: 'delivered',
assigntime: '2026-08-29 11:22:36',
arrivaltime: '2026-08-29 12:37:30',
pickuptime: '2026-08-29 12:41:23',
starttime: '2026-08-29 13:38:37',
deliverytime: '2026-08-29 13:44:14',
expecteddeliverytime: '2026-08-29 01:20 PM',
kms: '2',
actualkms: '2.0',
// A failed GPS reading, exactly as production sends it.
riderkms: '0.0026',
},
{
deliveryid: 4,
orderid: '916-4',
ridername: 'Varun',
orderstatus: 'delivered',
assigntime: '2026-08-29 11:22:36',
arrivaltime: '2026-08-29 12:37:33',
pickuptime: '2026-08-29 12:41:26',
starttime: '2026-08-29 13:44:56',
deliverytime: '2026-08-29 13:59:19',
expecteddeliverytime: '2026-08-29 02:10 PM',
kms: '6',
actualkms: '6.0',
riderkms: '5.5',
},
];
/* ── Reading one delivery ────────────────────────────────────────────────── */
// The whole point. Read in column order the ladder gives negative steps; read
// as assign → arrive → pickup → start → deliver every step is positive.
test('every step of the ladder is a positive duration', () => {
for (const row of BATCH) {
for (const step of journeyOf(row).steps) {
assert.ok(step.ms !== null && step.ms >= 0, `${row.orderid} ${step.key} was ${step.ms}`);
}
}
});
test('the steps mean what the ladder says they mean', () => {
const journey = journeyOf(BATCH[1] as DeliveryRow);
const of = (key: string) => journey.steps.find((step) => step.key === key)!.ms!;
// 11:22:36 → 12:37:29
assert.equal(Math.round(of('toShop') / 60_000), 75);
// 12:37:29 → 12:41:21
assert.equal(Math.round(of('counter') / 60_000), 4);
// 12:41:21 → 13:17:19, waiting while the first drop was done
assert.equal(Math.round(of('inRound') / 60_000), 36);
// 13:17:19 → 13:38:26
assert.equal(Math.round(of('onRoad') / 60_000), 21);
});
// A round's first drop gets its `starttime` written moments before delivery.
test("a round's first drop is flagged rather than believed", () => {
assert.equal(journeyOf(BATCH[0] as DeliveryRow).isFirstLeg, true, '34 seconds is not a ride');
assert.equal(journeyOf(BATCH[1] as DeliveryRow).isFirstLeg, false);
});
// `actualkms` equals `kms` to the decimal on 498 of 498 production rows. Showing
// it as "actual" would draw two identical bars and call the round perfect.
test('the measured distance is riderkms, never actualkms', () => {
const journey = journeyOf(BATCH[1] as DeliveryRow);
assert.equal(journey.plannedKm, 8);
assert.equal(journey.riddenKm, 6.1, 'actualkms (8.0) was used as the measurement');
});
test('a GPS reading of three metres is treated as absent, not as a short trip', () => {
const journey = journeyOf(BATCH[2] as DeliveryRow);
assert.equal(journey.riddenKm, null);
assert.equal(journey.plannedKm, 2, 'the plan is still known');
});
test('a stamp written out of order leaves a blank, not a negative bar', () => {
const journey = journeyOf({
deliveryid: 9,
assigntime: '2026-08-29 13:00:00',
arrivaltime: '2026-08-29 12:00:00',
pickuptime: '2026-08-29 12:05:00',
} as DeliveryRow);
assert.equal(journey.steps.find((step) => step.key === 'toShop')!.ms, null);
assert.equal(journey.steps.find((step) => step.key === 'counter')!.ms, 5 * 60_000);
});
test('a delivery nobody has finished has a blank total, not a running one', () => {
const journey = journeyOf({
deliveryid: 9,
assigntime: '2026-08-29 13:00:00',
orderstatus: 'pending',
} as DeliveryRow);
assert.equal(journey.totalMs, null);
assert.equal(journey.lateMs, null);
assert.equal(journey.steps.every((step) => step.ms === null), true);
});
/* ── The promise ─────────────────────────────────────────────────────────── */
// The fourth timestamp format Fiesta sends, and the only twelve-hour one. Only
// the ISO format is specified; a meridiem string is implementation-defined, so
// this is pinned against an explicitly constructed instant rather than against
// whatever the running engine happens to do with the same string.
test('a twelve-hour promise is read as the instant it names', () => {
assert.equal(parsePromise('2026-08-29 07:27 PM'), Date.parse('2026-08-29T19:27:00'));
});
test('midnight and noon do not swap', () => {
assert.equal(parsePromise('2026-08-29 12:15 AM'), Date.parse('2026-08-29T00:15:00'));
assert.equal(parsePromise('2026-08-29 12:15 PM'), Date.parse('2026-08-29T12:15:00'));
});
test('a twenty-four-hour promise is read too, so a format change does not blank the panel', () => {
assert.equal(parsePromise('2026-08-29 19:27:30'), Date.parse('2026-08-29T19:27:30'));
});
test('no promise, or an unreadable one, is null rather than a guess', () => {
assert.equal(parsePromise(''), null);
assert.equal(parsePromise(undefined), null);
assert.equal(parsePromise('soon'), null);
});
test('lateness is measured against the promise, either way', () => {
// Promised 01:00 PM, delivered 13:16:57.
assert.ok(journeyOf(BATCH[0] as DeliveryRow).lateMs! > 16 * 60_000);
// Promised 02:10 PM, delivered 13:59:19 — early.
assert.ok(journeyOf(BATCH[3] as DeliveryRow).lateMs! < 0);
});
/* ── Many journeys ───────────────────────────────────────────────────────── */
test('the summary counts what was promised and what landed on time', () => {
const result = compare(BATCH);
assert.equal(result.journeys, 4);
assert.equal(result.promised, 4);
assert.equal(result.onTime, 1, 'only the last drop beat its promise');
});
// Getting to the shop was 75 minutes; everything else was minutes. Naming the
// bottleneck is the one thing this panel is for.
test('the bottleneck is the step that actually costs the time', () => {
assert.equal(compare(BATCH).bottleneck, 'toShop');
});
test("the first drop's artefact leg is kept out of the on-road figure", () => {
const result = compare(BATCH);
const onRoad = result.steps.find((step) => step.key === 'onRoad')!;
assert.equal(result.firstLegs, 1);
assert.equal(onRoad.measured, 3, "the 34-second leg was averaged in");
assert.ok(
onRoad.medianMs! > 5 * 60_000,
`a real ride, not ${Math.round(onRoad.medianMs! / 1000)}s`,
);
});
test('shares add up to the whole, so the bars fill the bar', () => {
const total = compare(BATCH).steps.reduce((sum, step) => sum + step.share, 0);
assert.ok(Math.abs(total - 1) < 0.0001);
});
test('a step nobody stamped is measured zero times rather than counted as instant', () => {
const result = compare([
{ deliveryid: 1, assigntime: '2026-08-29 11:00:00', arrivaltime: '2026-08-29 11:30:00' } as DeliveryRow,
]);
const counter = result.steps.find((step) => step.key === 'counter')!;
assert.equal(counter.measured, 0);
assert.equal(counter.medianMs, null);
assert.equal(counter.share, 0);
});
test('distance compares plan against measurement, over the rows that have both', () => {
const { distance } = compare(BATCH);
assert.equal(distance.measured, 3, 'the 0.0026 km row has no measurement');
assert.equal(distance.medianPlannedKm, 6);
assert.equal(distance.medianRiddenKm, 5.5);
});
test('an empty day summarises to blanks, not zeroes', () => {
const result = compare([]);
assert.equal(result.journeys, 0);
assert.equal(result.medianLateMs, null);
assert.equal(result.bottleneck, null);
assert.equal(result.distance.medianRiddenKm, null);
assert.equal(result.steps.every((step) => step.medianMs === null), true);
});
/* ── Wording ─────────────────────────────────────────────────────────────── */
test('spans read at the scale they are', () => {
assert.equal(span(null), '—');
assert.equal(span(34_000), '34s');
assert.equal(span(42 * 60_000), '42m');
assert.equal(span(69 * 60_000), '1h 09m');
});
test('lateness reads as a sentence, not a signed number', () => {
assert.equal(lateness(null), '—');
assert.equal(lateness(30_000), 'on time');
assert.equal(lateness(48 * 60_000), '48m late');
assert.equal(lateness(-12 * 60_000), '12m early');
});

View File

@@ -0,0 +1,298 @@
/**
* What was promised against what happened.
*
* ── The ladder is not the one the column names imply ────────────────────────
*
* `deliveries` carries five stamps — assign, start, arrival, pickup, delivery —
* and read in that order they are nonsense: `starttime` is later than
* `arrivaltime` on 316 of 316 rows measured on tenant 916. That looks like
* corrupt data and is not. Reading a batch out in sequence shows what the app
* is actually doing (tenant 916, 29 August, four drops assigned together):
*
* assign 11:22:36 all four handed over at once
* arrive 12:37:2x the rider reached the SHOP — once, for all four
* pickup 12:41:2x collected all four at the counter
* start 13:16:23 → deliver 13:16:57
* start 13:17:19 → deliver 13:38:26
* start 13:38:37 → deliver 13:44:14
* start 13:44:56 → deliver 13:59:19
*
* Each `starttime` lands twenty to forty seconds after the PREVIOUS drop was
* delivered. So `starttime` is per-DROP, not per-round: it is when the rider
* set off for this particular address, having finished the last one. The ladder
* is assign → arrive → pickup → start → deliver, and read that way every
* duration is positive and every step means something:
*
* assign → arrive getting to the shop median 69.5 min (tenant 916)
* arrive → pickup at the counter median 0.6 min
* pickup → start waiting its turn median 28.0 min
* start → deliver on the road median 0.5 min
*
* That last median is not a half-minute ride. A round's FIRST drop gets a
* `starttime` written moments before its delivery — the app stamps it late —
* so the first leg of every round reads as near-zero. It is flagged rather than
* averaged away; see `Journey.isFirstLeg`.
*
* ── `actualkms` is not the actual kilometres ────────────────────────────────
*
* It equals `kms` to the decimal on 498 of 498 delivered rows for tenant 916
* and 464 of 474 for tenant 908. It is a copy of the plan, and showing it
* beside `kms` as "planned vs actual" would draw two identical numbers and call
* the round perfectly executed. `riderkms` is the only measured distance — GPS
* derived, present on about two thirds of rows, and lossy: a quarter of the
* values it carries are under 3 metres. Below `MEASURED_KM_FLOOR` it is treated
* as absent, because "0.0026 km" is a failed reading, not a short trip.
*/
import type { DeliveryRow } from '@/api/types';
/** Under this, `riderkms` is a failed GPS reading rather than a short trip. */
const MEASURED_KM_FLOOR = 0.1;
/** A leg this short is the app stamping late, not a ride. See the note above. */
const IMPLAUSIBLE_LEG_MS = 60_000;
export type StepKey = 'toShop' | 'counter' | 'inRound' | 'onRoad';
export interface Step {
key: StepKey;
label: string;
/** Null when either stamp is missing — a blank, never a zero. */
ms: number | null;
}
export const STEP_LABEL: Record<StepKey, string> = {
toShop: 'Getting to the shop',
counter: 'At the counter',
inRound: 'Waiting its turn',
onRoad: 'On the road',
};
export interface Journey {
deliveryid: number;
orderid: string;
rider: string;
steps: Step[];
/** assign → deliver, the whole thing. Null unless both ends are stamped. */
totalMs: number | null;
/** `kms` — what the shop was quoted. */
plannedKm: number | null;
/** `riderkms` — the only measured distance, and only when it reads plausibly. */
riddenKm: number | null;
/** The promise made to the customer. */
promisedAt: number | null;
deliveredAt: number | null;
/** Positive is late. Null when nothing was promised or nothing was delivered. */
lateMs: number | null;
/**
* True when this drop's `onRoad` leg is too short to be a ride — the round's
* first drop, whose `starttime` the app writes moments before delivery.
* Excluded from the on-the-road median rather than dragging it to zero.
*/
isFirstLeg: boolean;
}
/** A naive `2026-08-29 16:22:59`, read in the viewer's zone. */
function stamp(value: string | undefined): number | null {
if (!value) return null;
const at = Date.parse(value.replace(' ', 'T'));
return Number.isFinite(at) ? at : null;
}
/**
* `expecteddeliverytime`, which arrives as `2026-08-29 07:27 PM`.
*
* The fourth timestamp format Fiesta sends and the only twelve-hour one, so it
* gets its own parser rather than being handed to `Date.parse`. ECMAScript
* mandates only the ISO format; everything else is implementation-defined, and
* a twelve-hour string with a meridiem is squarely in that territory. V8 does
* happen to read it correctly — so relying on `Date.parse` would work in Chrome
* and in the test runner, and could silently return Invalid Date in another
* engine, turning "45 minutes late" into "no promise recorded" on every row
* that has one. Parsed here so the answer does not depend on the browser.
*/
export function parsePromise(value: string | undefined): number | null {
if (!value) return null;
const match = value.match(/(\d{4})-(\d{2})-(\d{2})[T\s]+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM)?/i);
if (!match) return null;
const [, year, month, day, rawHour, minute, second, meridiem] = match;
let hour = Number(rawHour);
if (meridiem) {
hour %= 12;
if (/pm/i.test(meridiem)) hour += 12;
}
const at = Date.parse(
`${year}-${month}-${day}T${String(hour).padStart(2, '0')}:${minute}:${second ?? '00'}`,
);
return Number.isFinite(at) ? at : null;
}
function gap(from: number | null, to: number | null): number | null {
if (from === null || to === null) return null;
const ms = to - from;
// A negative step is a stamp written out of order. Blank, not a negative
// duration on the bar — the ladder above is what the stamps mean, and a row
// that contradicts it is a row this cannot describe.
return ms >= 0 ? ms : null;
}
/** One delivery, read as the journey it was. */
export function journeyOf(row: DeliveryRow): Journey {
const assigned = stamp(row.assigntime);
const arrived = stamp(row.arrivaltime);
const picked = stamp(row.pickuptime);
const started = stamp(row.starttime);
const delivered = stamp(row.deliverytime);
const onRoad = gap(started, delivered);
const promisedAt = parsePromise(row.expecteddeliverytime);
const ridden = Number(row.riderkms);
const planned = Number(row.kms);
return {
deliveryid: row.deliveryid,
orderid: row.orderid ?? `#${row.deliveryid}`,
rider: row.ridername?.trim() || '—',
steps: [
{ key: 'toShop', label: STEP_LABEL.toShop, ms: gap(assigned, arrived) },
{ key: 'counter', label: STEP_LABEL.counter, ms: gap(arrived, picked) },
{ key: 'inRound', label: STEP_LABEL.inRound, ms: gap(picked, started) },
{ key: 'onRoad', label: STEP_LABEL.onRoad, ms: onRoad },
],
totalMs: gap(assigned, delivered),
plannedKm: Number.isFinite(planned) && planned > 0 ? planned : null,
riddenKm: Number.isFinite(ridden) && ridden >= MEASURED_KM_FLOOR ? ridden : null,
promisedAt,
deliveredAt: delivered,
lateMs: promisedAt !== null && delivered !== null ? delivered - promisedAt : null,
isFirstLeg: onRoad !== null && onRoad < IMPLAUSIBLE_LEG_MS,
};
}
/** A step's shape across many journeys. */
export interface StepSummary {
key: StepKey;
label: string;
/** Journeys with both stamps. The rest contribute nothing rather than a zero. */
measured: number;
medianMs: number | null;
p90Ms: number | null;
/** This step's share of the median total, 0–1, for the bar widths. */
share: number;
}
export interface Comparison {
journeys: number;
/** Journeys carrying a promise AND a delivery — the on-time denominator. */
promised: number;
onTime: number;
/** Median lateness across `promised`. Positive is late. Null when none. */
medianLateMs: number | null;
steps: StepSummary[];
/** The step with the largest median. Where the time actually goes. */
bottleneck: StepKey | null;
/** Journeys with a usable `riderkms`, and the medians to compare. */
distance: {
measured: number;
medianPlannedKm: number | null;
medianRiddenKm: number | null;
};
/** Rounds' first drops, excluded from the on-the-road figure. */
firstLegs: number;
}
function median(values: number[]): number | null {
if (values.length === 0) return null;
const sorted = [...values].sort((a, b) => a - b);
return sorted[sorted.length >> 1] as number;
}
function percentile(values: number[], p: number): number | null {
if (values.length === 0) return null;
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] as number;
}
/**
* Many journeys, summarised.
*
* Medians rather than means throughout. One order assigned in the morning and
* delivered at closing drags a mean into uselessness, and the whole point of
* this panel is to say where the typical hour goes.
*/
export function compare(rows: readonly DeliveryRow[]): Comparison {
const journeys = rows.map(journeyOf);
const promised = journeys.filter(
(journey) => journey.lateMs !== null,
);
const lateness = promised.map((journey) => journey.lateMs as number);
const steps: StepSummary[] = (['toShop', 'counter', 'inRound', 'onRoad'] as StepKey[]).map(
(key) => {
const values = journeys
// The first drop of a round has a `starttime` written moments before
// its delivery, so its on-road leg is an artefact. Included, it pulls
// the median to half a minute and the panel reports that riders spend
// no time riding.
.filter((journey) => !(key === 'onRoad' && journey.isFirstLeg))
.map((journey) => journey.steps.find((step) => step.key === key)?.ms)
.filter((ms): ms is number => ms !== null && ms !== undefined);
return {
key,
label: STEP_LABEL[key],
measured: values.length,
medianMs: median(values),
p90Ms: percentile(values, 0.9),
share: 0,
};
},
);
const totalMedian = steps.reduce((total, step) => total + (step.medianMs ?? 0), 0);
for (const step of steps) {
step.share = totalMedian > 0 ? (step.medianMs ?? 0) / totalMedian : 0;
}
const withDistance = journeys.filter((journey) => journey.riddenKm !== null);
const bottleneck =
steps
.filter((step) => step.medianMs !== null)
.sort((a, b) => (b.medianMs as number) - (a.medianMs as number))[0]?.key ?? null;
return {
journeys: journeys.length,
promised: promised.length,
onTime: lateness.filter((ms) => ms <= 0).length,
medianLateMs: median(lateness),
steps,
bottleneck,
distance: {
measured: withDistance.length,
medianPlannedKm: median(
withDistance
.map((journey) => journey.plannedKm)
.filter((km): km is number => km !== null),
),
medianRiddenKm: median(withDistance.map((journey) => journey.riddenKm as number)),
},
firstLegs: journeys.filter((journey) => journey.isFirstLeg).length,
};
}
/** "1h 09m", "42m", "38s". Durations here span seconds to hours. */
export function span(ms: number | null): string {
if (ms === null) return '—';
if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
const minutes = Math.round(ms / 60_000);
if (minutes < 60) return `${minutes}m`;
return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m`;
}
/** "48m late", "12m early", "on time". Reads as a sentence, not a signed number. */
export function lateness(ms: number | null): string {
if (ms === null) return '—';
if (Math.abs(ms) < 60_000) return 'on time';
return ms > 0 ? `${span(ms)} late` : `${span(-ms)} early`;
}

View File

@@ -216,6 +216,38 @@ export function usePartnerRiderCounts(partnerids: readonly number[]) {
return counts;
}
/**
* Every GPS ping a partner's riders sent over a window.
*
* ── Why the window is small by default ────────────────────────────────────
*
* The response is one row per ping and the riders ping constantly: August 2026
* returned 320,132 rows for eight riders. A month is several megabytes of JSON
* to fetch, parse and smooth, so the fleet view asks for a day or two and says
* which day it is showing.
*
* Cached longer than `stable` allows because it is history: yesterday's pings
* do not change, and a refetch on every focus would re-download the day.
*/
export function usePartnerRiderLogs(
partnerid: number | undefined,
range: { fromdate: string; todate: string },
) {
return useQuery({
queryKey: queryKeys.partners.logs(partnerid ?? 0, range.fromdate, range.todate),
queryFn: () =>
partnersApi.riderLogs({
partnerid: partnerid as number,
fromdate: range.fromdate,
todate: range.todate,
}),
enabled:
typeof partnerid === 'number' && partnerid > 0 && Boolean(range.fromdate && range.todate),
...stable,
staleTime: 5 * 60_000,
});
}
/** The regions one partner covers. */
export function usePartnerLocations(partnerid: number | undefined) {
return useQuery({

View File

@@ -13,6 +13,8 @@ export const queryKeys = {
all: ['partners'] as const,
inRegion: (applocationid: number) => ['partners', 'region', applocationid] as const,
locations: (partnerid: number) => ['partners', 'locations', partnerid] as const,
logs: (partnerid: number, fromdate: string, todate: string) =>
['partners', 'logs', partnerid, fromdate, todate] as const,
},
/** The delivery regions a partner can cover. */
regions: { all: ['regions'] as const },