Compare commits
2 Commits
b760c1a078
...
35250717fc
| Author | SHA1 | Date | |
|---|---|---|---|
| 35250717fc | |||
| 34bf7989f7 |
71
package-lock.json
generated
71
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
205
src/components/TrailMap.tsx
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/** 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, '');
|
||||
}
|
||||
94
src/components/trailMap.css
Normal file
94
src/components/trailMap.css
Normal 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);
|
||||
}
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
|
||||
399
src/features/nearle-admin/pages/FleetPage.tsx
Normal file
399
src/features/nearle-admin/pages/FleetPage.tsx
Normal 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' });
|
||||
}
|
||||
263
src/features/nearle-admin/pages/fleet.css
Normal file
263
src/features/nearle-admin/pages/fleet.css
Normal 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;
|
||||
}
|
||||
251
src/features/nearle-admin/riderShifts.test.ts
Normal file
251
src/features/nearle-admin/riderShifts.test.ts
Normal 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');
|
||||
});
|
||||
324
src/features/nearle-admin/riderShifts.ts
Normal file
324
src/features/nearle-admin/riderShifts.ts
Normal 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`;
|
||||
}
|
||||
202
src/features/store-admin/GroupMap.tsx
Normal file
202
src/features/store-admin/GroupMap.tsx
Normal 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' });
|
||||
}
|
||||
333
src/features/store-admin/PlanVsActualPanel.tsx
Normal file
333
src/features/store-admin/PlanVsActualPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
243
src/features/store-admin/deliveryTrack.test.ts
Normal file
243
src/features/store-admin/deliveryTrack.test.ts
Normal 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([]), []);
|
||||
});
|
||||
242
src/features/store-admin/deliveryTrack.ts
Normal file
242
src/features/store-admin/deliveryTrack.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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: '' })], []));
|
||||
|
||||
@@ -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()]);
|
||||
|
||||
@@ -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)';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
250
src/features/store-admin/plannedVsActual.test.ts
Normal file
250
src/features/store-admin/plannedVsActual.test.ts
Normal 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');
|
||||
});
|
||||
298
src/features/store-admin/plannedVsActual.ts
Normal file
298
src/features/store-admin/plannedVsActual.ts
Normal 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`;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user