dispatch map, plan vs actual, and a fleet page

Three of the features the old console had and ours did not, built on what
the data can actually support rather than on what the column names imply.

Two findings changed the shape of the work:

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JYEsb8PNZ19G9R8gUjTU7n
This commit is contained in:
2026-09-09 17:02:21 +05:30
parent b760c1a078
commit 34bf7989f7
21 changed files with 3412 additions and 92 deletions

71
package-lock.json generated
View File

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

View File

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

View File

@@ -34,6 +34,9 @@ const OnboardTenantPage = named('OnboardTenantPage', () => import('@/features/ne
const GlobalCataloguePage = named('GlobalCataloguePage', () => import('@/features/nearle-admin/pages/GlobalCataloguePage')); const GlobalCataloguePage = named('GlobalCataloguePage', () => import('@/features/nearle-admin/pages/GlobalCataloguePage'));
const PartnersPage = named('PartnersPage', () => import('@/features/nearle-admin/pages/PartnersPage')); const PartnersPage = named('PartnersPage', () => import('@/features/nearle-admin/pages/PartnersPage'));
const NearleUploadsPage = named('UploadsPage', () => import('@/features/nearle-admin/pages/UploadsPage')); 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, /* 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 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 {/* Delivery partners — the companies that supply riders. Platform-side
only: a merchant is assigned one, never allowed to create one. */} only: a merchant is assigned one, never allowed to create one. */}
<Route path="partners" element={<PartnersPage />} /> <Route path="partners" element={<PartnersPage />} />
<Route path="fleet" element={<FleetPage />} />
<Route path="uploads" element={<NearleUploadsPage />} /> <Route path="uploads" element={<NearleUploadsPage />} />
{/* Absorbed here rather than by the global `*`, so a wrong sub-path can {/* 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 never bounce out to a HOME_ROUTE that points back into this

View File

@@ -388,4 +388,71 @@ export const partnersApi = {
/** The regions one partner covers. */ /** The regions one partner covers. */
locations: (partnerid: number) => locations: (partnerid: number) =>
api.list<PartnerLocation>(`${WEB}/partners/getpartnerlocations`, { partnerid }), 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;
}

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

@@ -0,0 +1,199 @@
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;
}
/** 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'}" style="--pin:${escapeAttr(colour)}"></span>`,
iconSize: [16, 16],
iconAnchor: [8, 8],
}),
})
.bindPopup(
`<strong>${escapeHtml(pin.label)}</strong>` +
(pin.lines ?? []).map((line) => `<br>${escapeHtml(line)}`).join(''),
)
.addTo(layer);
bounds.extend([pin.lat, pin.lng]);
}
if (bounds.isValid()) {
// `maxZoom` matters: a single pin, or a rider who never left one street,
// otherwise zooms to building level and the map shows one grey rectangle
// with no landmarks to orient by.
instance.fitBounds(bounds, { padding: [28, 28], maxZoom: 16 });
}
}, [trails, pins]);
/* Leaflet measures its container once, at construction. Inside a drawer or a
tab the container is often zero-height at that moment, and the map renders
as a grey strip with one tile in the corner until something resizes the
window. Re-measuring whenever the box changes size fixes it for good,
including when the drawer animates open. */
useEffect(() => {
const node = host.current;
const instance = map.current;
if (!node || !instance || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => instance.invalidateSize());
observer.observe(node);
return () => observer.disconnect();
}, []);
return (
<div className="trail-map" style={{ height }}>
<div ref={host} className="trail-map-canvas" />
{isEmpty ? <div className="trail-map-empty">{emptyNote}</div> : null}
</div>
);
}
/**
* Distinct, legible line colours.
*
* Hand-picked rather than generated from a hue wheel: evenly spaced hues put
* two yellows next to each other on an OSM tile and both vanish. These are all
* dark enough to read over map detail and different enough to tell apart at the
* width of a polyline.
*/
export const TRAIL_COLOURS = [
'#662582',
'#0f8a5f',
'#c2410c',
'#1d4ed8',
'#b91c1c',
'#0e7490',
'#7c2d12',
'#4d7c0f',
] as const;
export function trailColour(index: number): string {
return TRAIL_COLOURS[index % TRAIL_COLOURS.length] as string;
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/** Popup and icon HTML is a string, so a rider named `<b>` must not become markup. */
function escapeAttr(value: string): string {
return value.replace(/["'<>]/g, '');
}

View File

@@ -0,0 +1,86 @@
/**
* 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 last-known position, not a live one. Hollow, so the difference between
"here now" and "here when they last reported" is visible on the map itself
and not only in the popup. */
.trail-pin[data-faded='true'] {
background: transparent;
border-color: var(--pin, var(--color-brand));
border-width: 3px;
box-shadow: none;
}
/* Leaflet's controls and popups default to its own font stack and a blue link
colour; both look foreign next to the rest of the console. */
.trail-map .leaflet-container {
font: inherit;
background: var(--color-surface-sunken);
}
.trail-map .leaflet-popup-content {
margin: 10px 12px;
font-size: 12.5px;
line-height: 1.5;
color: var(--color-ink-1);
}
.trail-map .leaflet-control-attribution {
font-size: 10px;
background: color-mix(in oklab, var(--color-surface) 85%, transparent);
}
.trail-map .leaflet-control-attribution a {
color: var(--color-ink-3);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,156 @@
import { useMemo, useState } from 'react';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { Info } from 'lucide-react';
import type { DeliveryRow } from '@/api/types';
import { TrailMap, trailColour, type MapPin, type MapTrail } from '@/components/TrailMap';
import { coverageOf, pathsOf } from './deliveryTrack';
import { statusColor } from './orderStatus';
import { DELIVERY_STATUS } from './orderStatus';
/**
* The day's rounds, on a map.
*
* ── Why this is fed by delivery rows and not the rider log ──────────────────
*
* `riderlogs` is the obvious source and is useless for position: every ping a
* rider sends carries the same coordinate, so a trail drawn from it is a single
* dot. `deliveries.riderslat` / `riderslon` are written when a rider moves a
* job along and do vary — 353 distinct positions across 461 positioned rows on
* one tenant. They are also tenant-scoped, so this belongs on the shop's board.
*
* ── The line is not a route ─────────────────────────────────────────────────
*
* Each point is where the rider stood when a delivery changed status, minutes
* apart. Joining them shows the ORDER a round was worked, which is worth
* seeing; it is not the road they took, and the note under the map says so
* rather than leaving the reader to assume a route they can act on.
*
* ── Coverage is stated ──────────────────────────────────────────────────────
*
* A map with four pins looks the same whether the day was quiet or the app
* stopped reporting. The count above it tells them apart.
*/
export function DispatchMap({ rows }: { rows: readonly DeliveryRow[] }) {
const paths = useMemo(() => pathsOf(rows), [rows]);
const coverage = useMemo(() => coverageOf(rows), [rows]);
const [focused, setFocused] = useState<number | null>(null);
const shown = focused === null ? paths : paths.filter((path) => path.userid === focused);
const trails: MapTrail[] = useMemo(
() =>
shown
.filter((path) => path.fixes.length > 1)
.map((path) => ({
id: path.userid,
label: `${path.rider} — ${path.fixes.length} stops, in order`,
points: path.fixes.map((fix) => ({ lat: fix.lat, lng: fix.lng })),
colour: trailColour(paths.findIndex((p) => p.userid === path.userid)),
})),
[shown, paths],
);
const pins: MapPin[] = useMemo(
() =>
shown.flatMap((path) =>
path.fixes.map((fix) => ({
id: `${path.userid}-${fix.deliveryid}`,
lat: fix.lat,
lng: fix.lng,
label: fix.orderid,
lines: [
path.rider,
fix.customer || fix.address || 'No address on the row',
`${fix.status}${fix.at ? ` · ${clock(fix.at)}` : ''}`,
],
// Coloured by the delivery's status, not the rider's: on a map the
// question is which stops are still open, and a round already reads
// as one shape from its line.
colour: statusColor(DELIVERY_STATUS, fix.status),
})),
),
[shown],
);
return (
<Card padding={0} elevation="low">
<VStack gap={1.5} padding={2}>
<HStack justify="between" align="center" gap={2} wrap="wrap">
<Text type="label" size="sm" weight="semibold">
Where the riders were
</Text>
<Text type="body" size="xsm" color="secondary">
{coverage.positioned} of {coverage.total} deliver
{coverage.total === 1 ? 'y' : 'ies'} reported a position
</Text>
</HStack>
{paths.length > 1 ? (
<HStack gap={0.5} wrap="wrap">
<RiderChip
label="Everyone"
colour="var(--color-ink-4)"
isActive={focused === null}
onClick={() => setFocused(null)}
/>
{paths.map((path, index) => (
<RiderChip
key={path.userid}
label={`${path.rider} · ${path.fixes.length}`}
colour={trailColour(index)}
isActive={focused === path.userid}
onClick={() => setFocused((prev) => (prev === path.userid ? null : path.userid))}
/>
))}
</HStack>
) : null}
<TrailMap
trails={trails}
pins={pins}
height={400}
emptyNote={
coverage.total === 0
? 'No deliveries in this range.'
: 'None of these deliveries carries a rider position. One is written each time a rider moves a job along, so a round nobody has touched yet has none.'
}
/>
<div className="pva-note">
<Info size={13} />
<span>
Each pin is where the rider stood when that delivery last changed status, coloured by
the status. The line joins one rider's stops in the order they were worked — it is not
the route they rode, and the distance along it is not the distance they covered.
</span>
</div>
</VStack>
</Card>
);
}
function RiderChip({
label,
colour,
isActive,
onClick,
}: {
label: string;
colour: string;
isActive: boolean;
onClick: () => void;
}) {
return (
<button type="button" className="rider-chip" data-active={isActive} onClick={onClick}>
<i style={{ background: colour }} />
{label}
</button>
);
}
function clock(at: number): string {
return new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}

View File

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

View File

@@ -0,0 +1,168 @@
/**
* Rider positions, from the only column that actually varies.
*
* The awkward rows here are copied from production: a `ridername` of
* "delivered", positions written as empty strings, and the single-fix rider who
* still deserves a pin.
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
import type { DeliveryRow } from '@/api/types';
import { coverageOf, fixesOf, pathsOf } from './deliveryTrack';
function row(over: Partial<DeliveryRow>): DeliveryRow {
return {
deliveryid: 1,
orderid: '916-1',
userid: 883,
ridername: 'Rajan',
orderstatus: 'delivered',
assigntime: '2026-08-29 11:22:36',
deliverytime: '2026-08-29 13:16:57',
riderslat: '11.039621',
riderslon: '76.929141',
...over,
} as DeliveryRow;
}
test('a positioned delivery becomes a fix', () => {
const [fix] = fixesOf([row({})]);
assert.equal(fix!.lat, 11.039621);
assert.equal(fix!.lng, 76.929141);
assert.equal(fix!.orderid, '916-1');
assert.equal(fix!.status, 'delivered');
});
// 39 of 500 rows on tenant 916 carry no position at all.
test('a delivery with no position is left off the map rather than placed at zero', () => {
assert.deepEqual(fixesOf([row({ riderslat: '', riderslon: '' })]), []);
assert.deepEqual(fixesOf([row({ riderslat: undefined, riderslon: undefined })]), []);
});
// A row with one half of the pair would land on the equator or the prime
// meridian, which is a confident lie rather than a missing value.
test('half a position is no position', () => {
assert.deepEqual(fixesOf([row({ riderslon: '' })]), []);
assert.deepEqual(fixesOf([row({ riderslat: '0' })]), []);
});
// `updatedelivery` writes the position alongside whichever stamp the new status
// sets, so the newest stamp is when the position was taken.
test('a fix is timed by the latest stamp on its row', () => {
const [fix] = fixesOf([
row({
assigntime: '2026-08-29 11:22:36',
arrivaltime: '2026-08-29 12:37:26',
deliverytime: '2026-08-29 13:16:57',
}),
]);
assert.equal(fix!.at, Date.parse('2026-08-29T13:16:57'));
});
test('a delivery only just assigned is timed by its assign stamp', () => {
const [fix] = fixesOf([
row({ orderstatus: 'pending', deliverytime: '', arrivaltime: '', assigntime: '2026-08-29 11:22:36' }),
]);
assert.equal(fix!.at, Date.parse('2026-08-29T11:22:36'));
});
test('a fix with no readable stamp is still a place, just an undated one', () => {
const [fix] = fixesOf([row({ assigntime: '', deliverytime: '', arrivaltime: '' })]);
assert.equal(fix!.at, null);
assert.equal(fix!.lat, 11.039621);
});
/* ── Paths ───────────────────────────────────────────────────────────────── */
test("a rider's fixes are joined in the order they happened", () => {
const [path] = pathsOf([
row({ deliveryid: 2, deliverytime: '2026-08-29 14:00:00', riderslat: '11.05', riderslon: '76.95' }),
row({ deliveryid: 1, deliverytime: '2026-08-29 13:00:00', riderslat: '11.04', riderslon: '76.94' }),
row({ deliveryid: 3, deliverytime: '2026-08-29 15:00:00', riderslat: '11.06', riderslon: '76.96' }),
]);
assert.deepEqual(path!.fixes.map((fix) => fix.deliveryid), [1, 2, 3]);
});
// `ridername` holds a delivery status on many rows. Grouping on it would split
// one rider's round in two and invent a rider called "delivered".
test('riders are grouped on their id, never on the name', () => {
const paths = pathsOf([
row({ deliveryid: 1, userid: 883, ridername: 'Rajan' }),
row({ deliveryid: 2, userid: 883, ridername: 'delivered', riderslat: '11.04' }),
]);
assert.equal(paths.length, 1);
assert.equal(paths[0]!.rider, 'Rajan');
assert.equal(paths[0]!.fixes.length, 2);
});
// The real shape on tenant 916: rider 897 is "Varun" on 69 rows and "delivered"
// on 75. Taking the most common value would name them "delivered".
test('a status is never mistaken for a name, even when it is the common value', () => {
const rows = [
...Array.from({ length: 3 }, (_, i) =>
row({ deliveryid: i + 1, userid: 897, ridername: 'Varun', riderslat: `11.0${i + 1}` }),
),
...Array.from({ length: 7 }, (_, i) =>
row({ deliveryid: i + 10, userid: 897, ridername: 'delivered', riderslat: `11.1${i}` }),
),
];
assert.equal(pathsOf(rows)[0]!.rider, 'Varun');
});
test('an order status in the name column is rejected too, not just a delivery one', () => {
assert.equal(
pathsOf([row({ userid: 1111, ridername: 'cancelled' })])[0]!.rider,
'Rider 1111',
);
});
// Better a plain id than a confident wrong name.
test('a rider whose every row carried a status is named by their id', () => {
const paths = pathsOf([
row({ deliveryid: 1, userid: 950, ridername: 'delivered' }),
row({ deliveryid: 2, userid: 950, ridername: 'delivered', riderslat: '11.04' }),
]);
assert.equal(paths[0]!.rider, 'Rider 950');
});
test('a rider with one known position still gets a path, and so a pin', () => {
const [path] = pathsOf([row({})]);
assert.equal(path!.fixes.length, 1);
});
test('the busiest rider is first, so the map legend leads with the round that matters', () => {
const paths = pathsOf([
row({ deliveryid: 1, userid: 1, ridername: 'A' }),
row({ deliveryid: 2, userid: 2, ridername: 'B', riderslat: '11.04' }),
row({ deliveryid: 3, userid: 2, ridername: 'B', riderslat: '11.05' }),
]);
assert.deepEqual(paths.map((path) => path.rider), ['B', 'A']);
});
test('a delivery nobody is carrying is grouped as unassigned rather than dropped', () => {
const [path] = pathsOf([row({ userid: 0, ridername: '' })]);
assert.equal(path!.rider, 'Unassigned');
});
// The line between two fixes is minutes apart and is not a road. The flag is on
// the type so the map cannot quietly start calling it a route.
test('a path always declares that its line is between events, not along a road', () => {
assert.equal(pathsOf([row({})])[0]!.isSampled, true);
});
/* ── Coverage ────────────────────────────────────────────────────────────── */
// A map with six pins looks the same whether the day was quiet or the reporting
// failed. The count is what tells them apart.
test('coverage says how much of the day the map can show', () => {
const coverage = coverageOf([
row({ deliveryid: 1 }),
row({ deliveryid: 2, riderslat: '11.04' }),
row({ deliveryid: 3, riderslat: '', riderslon: '' }),
]);
assert.deepEqual(coverage, { positioned: 2, total: 3, riders: 1 });
});
test('a day with no positions at all reports zero rather than throwing', () => {
assert.deepEqual(coverageOf([]), { positioned: 0, total: 0, riders: 0 });
});

View File

@@ -0,0 +1,200 @@
/**
* Where riders actually were, from the delivery rows.
*
* ── Why this and not the rider log ──────────────────────────────────────────
*
* `riderlogs` looks like the obvious source and is useless for position: every
* ping a rider sends carries the same coordinate. One rider's 2,404 pings on
* 14 August 2026 all read 11.052998, 76.929958, and the same holds on every day
* and every region checked. The app stamps a position once and repeats it.
*
* `deliveries.riderslat` / `riderslon` are written by `updatedelivery` when a
* rider moves a job along, and they DO vary — 353 distinct positions across the
* 461 positioned rows for tenant 916. Sparse (one point per delivery, not a
* trail) but real, and tenant-scoped, so a shop may see them.
*
* ── These are event locations, not samples ──────────────────────────────────
*
* Each point is where the rider stood when a delivery reached its current
* status. Nothing is smoothed or interpolated: a smoother would slide a
* "delivered" pin off the customer's door to make a line look better, and the
* door is the only thing on this map anybody needs to trust. The points are
* joined in time order so a round's shape is visible, and the line between two
* of them is explicitly not a route — see `RiderPath.isSampled`.
*/
import type { DeliveryRow } from '@/api/types';
import { DELIVERY_STATUS, ORDER_STATUS } from './orderStatus';
/** One place a rider was known to be, and why we know. */
export interface Fix {
deliveryid: number;
orderid: string;
lat: number;
lng: number;
/** The delivery's status when the position was written. */
status: string;
/** The most recent stamp on the row — when the position was most likely taken. */
at: number | null;
customer: string;
address: string;
}
export interface RiderPath {
userid: number;
rider: string;
fixes: Fix[];
/**
* Always true, and named so it cannot be forgotten: the line joining these
* points is drawn between events minutes apart, not sampled along a road. It
* shows the order a round was worked, never the route it took.
*/
isSampled: true;
}
/** A coordinate that is actually a coordinate. */
function coord(value: string | undefined): number | null {
if (!value) return null;
const n = Number(value);
return Number.isFinite(n) && n !== 0 ? n : null;
}
/**
* When a delivery's position was most likely written.
*
* `updatedelivery` writes the position alongside whichever stamp the new status
* sets, so the latest stamp on the row is the best available answer. Read in
* the ladder's order — assign, arrive, pickup, start, deliver — and the last
* one present wins.
*/
function fixedAt(row: DeliveryRow): number | null {
const stamps = [
row.assigntime,
row.arrivaltime,
row.pickuptime,
row.starttime,
row.deliverytime,
row.canceltime,
];
let latest: number | null = null;
for (const stamp of stamps) {
if (!stamp) continue;
const at = Date.parse(stamp.replace(' ', 'T'));
if (Number.isFinite(at) && (latest === null || at > latest)) latest = at;
}
return latest;
}
/** Every positioned delivery in the batch, as a fix. */
export function fixesOf(rows: readonly DeliveryRow[]): Fix[] {
const fixes: Fix[] = [];
for (const row of rows) {
const lat = coord(row.riderslat);
const lng = coord(row.riderslon);
// Both, or neither. A row with one of the pair is a half-written position
// and placing it on the equator would be worse than leaving it out.
if (lat === null || lng === null) continue;
fixes.push({
deliveryid: row.deliveryid,
orderid: row.orderid ?? `#${row.deliveryid}`,
lat,
lng,
status: row.orderstatus ?? 'unknown',
at: fixedAt(row),
customer: row.deliverycustomer?.trim() || '',
address: row.deliveryaddress?.trim() || row.deliverysuburb?.trim() || '',
});
}
return fixes;
}
/**
* A rider's name, from a column that sometimes holds a status instead.
*
* `ridername` is not reliably a name. Every rider on tenant 916 has BOTH their
* name and a delivery status in it, and for two of the five the status is the
* more common value:
*
* 883 { "Rajan": 21, "delivered": 42 }
* 897 { "Varun": 69, "delivered": 75 }
* 1111 { "Murali": 33, "delivered": 74, "cancelled": 1 }
* 1114 { "Tamilazhagan": 37, "delivered": 63 }
*
* So neither "first non-empty" nor "most common" finds the name — the first
* picks whichever row happens to sort first, the second picks "delivered" for
* two riders out of five. Statuses are excluded by vocabulary first, and the
* most common of what survives is the name.
*/
const NOT_A_NAME = new Set([
...Object.keys(DELIVERY_STATUS),
...Object.keys(ORDER_STATUS),
]);
function nameFor(rows: readonly DeliveryRow[], userid: number): string {
const counts = new Map<string, number>();
for (const row of rows) {
if (row.userid !== userid) continue;
const name = row.ridername?.trim();
if (!name || NOT_A_NAME.has(name.toLowerCase())) continue;
counts.set(name, (counts.get(name) ?? 0) + 1);
}
let best = '';
let most = 0;
for (const [name, count] of counts) {
if (count > most) {
best = name;
most = count;
}
}
// A rider whose every row carried a status is nameless rather than called
// "delivered" — the id is at least honest about being an id.
return best || (userid > 0 ? `Rider ${userid}` : 'Unassigned');
}
/**
* Fixes grouped into one path per rider, in time order.
*
* Grouped on `userid`, never on `ridername` — see `nameFor` for why that column
* cannot be trusted to identify anybody. Riders with a single fix are kept: one
* known position is still worth a pin, it just has no line.
*/
export function pathsOf(rows: readonly DeliveryRow[]): RiderPath[] {
const byRider = new Map<number, { rider: string; fixes: Fix[] }>();
const fixes = fixesOf(rows);
const rowById = new Map(rows.map((row) => [row.deliveryid, row]));
for (const fix of fixes) {
const userid = rowById.get(fix.deliveryid)?.userid ?? 0;
const entry = byRider.get(userid);
if (entry) entry.fixes.push(fix);
else byRider.set(userid, { rider: nameFor(rows, userid), fixes: [fix] });
}
return [...byRider.entries()]
.map(([userid, entry]) => ({
userid,
rider: entry.rider,
fixes: entry.fixes.sort((a, b) => (a.at ?? 0) - (b.at ?? 0)),
isSampled: true as const,
}))
.sort((a, b) => b.fixes.length - a.fixes.length);
}
/**
* How much of the day the map can actually show.
*
* Stated on the page rather than implied by a sparse map: "6 of 29 deliveries
* carry a position" is the difference between a quiet day and a reporting gap,
* and a map with six pins on it looks the same either way.
*/
export function coverageOf(rows: readonly DeliveryRow[]): {
positioned: number;
total: number;
riders: number;
} {
const paths = pathsOf(rows);
return {
positioned: paths.reduce((total, path) => total + path.fixes.length, 0),
total: rows.length,
riders: paths.length,
};
}

View File

@@ -6,9 +6,11 @@ import { VStack } from '@astryxdesign/core/VStack';
import { import {
Bike, Bike,
IndianRupee, IndianRupee,
Map,
MapPin, MapPin,
Package, Package,
Store, Store,
Timer,
Truck, Truck,
UserX, UserX,
Users, Users,
@@ -27,7 +29,9 @@ import { useBranchScope } from '../BranchScope';
import { count, money, moneyExact } from '../format'; import { count, money, moneyExact } from '../format';
import { DELIVERY_STATUS, statusColor } from '../orderStatus'; import { DELIVERY_STATUS, statusColor } from '../orderStatus';
import { shortAge } from '../posStatus'; import { shortAge } from '../posStatus';
import { DispatchMap } from '../DispatchMap';
import { OrderDetailDrawer } from '../OrderDetailDrawer'; import { OrderDetailDrawer } from '../OrderDetailDrawer';
import { PlanVsActualPanel } from '../PlanVsActualPanel';
import { import {
dayTotals, dayTotals,
groupByCustomer, groupByCustomer,
@@ -89,10 +93,21 @@ import './dispatch.css';
*/ */
const DISPATCH_STATUS: Record<string, string> = { ...DELIVERY_STATUS, [WAITING]: '#ef4444' }; 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' | 'map';
export function DispatchPage() { export function DispatchPage() {
const { branches, selected, tenantid } = useBranchScope(); const { branches, selected, tenantid } = useBranchScope();
const dates = useDateScope(); const dates = useDateScope();
const [mode, setMode] = useState<ViewMode>('riders'); const [mode, setMode] = useState<Board>('riders');
const [focused, setFocused] = useState<string | null>(null); const [focused, setFocused] = useState<string | null>(null);
const [detail, setDetail] = useState<Stop | null>(null); const [detail, setDetail] = useState<Stop | null>(null);
@@ -142,6 +157,7 @@ export function DispatchPage() {
); );
const groups = useMemo(() => { const groups = useMemo(() => {
if (mode === 'timing' || mode === 'map') return [];
if (mode === 'stores') return groupByStore(stops, locations.data ?? branches); if (mode === 'stores') return groupByStore(stops, locations.data ?? branches);
if (mode === 'customers') return groupByCustomer(stops, customers.data ?? [], branchName); if (mode === 'customers') return groupByCustomer(stops, customers.data ?? [], branchName);
return groupByRider(stops); return groupByRider(stops);
@@ -191,7 +207,7 @@ export function DispatchPage() {
[waiting, picked], [waiting, picked],
); );
const changeMode = (next: ViewMode) => { const changeMode = (next: Board) => {
setMode(next); setMode(next);
// A group id means nothing across groupings — a branch id is not a rider // 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. // id — so the focus is dropped rather than carried into nonsense.
@@ -224,6 +240,18 @@ export function DispatchPage() {
isActive={mode === 'customers'} isActive={mode === 'customers'}
onClick={() => changeMode('customers')} onClick={() => changeMode('customers')}
/> />
<ModeTab
label="Plan vs actual"
icon={<Timer size={14} />}
isActive={mode === 'timing'}
onClick={() => changeMode('timing')}
/>
<ModeTab
label="Map"
icon={<Map size={14} />}
isActive={mode === 'map'}
onClick={() => changeMode('map')}
/>
</HStack> </HStack>
} }
/> />
@@ -259,77 +287,89 @@ export function DispatchPage() {
/> />
</div> </div>
<div className="dispatch-body"> {mode === 'map' ? (
<div className="dispatch-rail"> /* The one place on the platform where rider positions actually move.
<Text type="label" size="xsm" color="secondary"> Fed by the delivery rows, which are tenant-scoped, so a shop sees its
{mode === 'riders' own rounds and nobody else's. */
? isOneDay <DispatchMap rows={deliveries.data ?? []} />
? 'Rounds' ) : mode === 'timing' ? (
: 'Riders' /* A different question of the same rows, so it replaces the board
: mode === 'stores' rather than sitting beside it: promised against delivered, and where
? 'Shops' the hours between accepting an order and dropping it actually went. */
: 'Customers'} <PlanVsActualPanel rows={deliveries.data ?? []} isLoading={deliveries.isLoading} />
</Text> ) : (
<GroupList <div className="dispatch-body">
groups={groups} <div className="dispatch-rail">
mode={mode} <Text type="label" size="xsm" color="secondary">
isLoading={deliveries.isLoading || (mode === 'customers' && customers.isLoading)} {mode === 'riders'
focused={focused} ? isOneDay
onFocus={(id) => setFocused((prev) => (prev === id ? null : id))} ? 'Rounds'
/> : 'Riders'
</div> : mode === 'stores'
? 'Shops'
<div className="dispatch-main"> : 'Customers'}
{open ? ( </Text>
<GroupDetail <GroupList
group={open} groups={groups}
mode={mode} mode={mode}
onOpen={setDetail} isLoading={deliveries.isLoading || (mode === 'customers' && customers.isLoading)}
{...(open.id === UNASSIGNED focused={focused}
? { onFocus={(id) => setFocused((prev) => (prev === id ? null : id))}
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,
}
: {})}
/> />
) : ( </div>
<Card padding={0} elevation="low">
<VStack gap={1} padding={4} align="center"> <div className="dispatch-main">
<span style={{ color: 'var(--color-ink-4)' }}> {open ? (
<MapPin size={22} /> <GroupDetail
</span> group={open}
<Text type="label" size="sm" weight="semibold"> mode={mode}
{groups.length > 0 onOpen={setDetail}
? 'Pick one to see its stops' {...(open.id === UNASSIGNED
: isOneDay ? {
? 'Nothing out on this day' selection: picked,
: 'Nothing out in this range'} verdictOf: (row: OrderRow) => assignability(row, branchOf(row), assigned),
</Text> assignBar:
<Text picked.count > 0 ? (
type="body" <AssignBar
size="sm" orders={pickedOrders}
color="secondary" branchOf={branchOf}
style={{ textAlign: 'center', maxWidth: 380 }} assigned={assigned}
> onClear={picked.clear}
{groups.length > 0 onDone={picked.clear}
? '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.'} ) : null,
</Text> }
</VStack> : {})}
</Card> />
)} ) : (
<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>
</div> )}
{detail ? ( {detail ? (
<OrderDetailDrawer row={detail.row} kind={detail.kind} onClose={() => setDetail(null)} /> <OrderDetailDrawer row={detail.row} kind={detail.kind} onClose={() => setDetail(null)} />

View File

@@ -253,3 +253,221 @@
text-transform: capitalize; text-transform: capitalize;
white-space: nowrap; 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%;
}

View File

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

View File

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

View File

@@ -216,6 +216,38 @@ export function usePartnerRiderCounts(partnerids: readonly number[]) {
return counts; 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. */ /** The regions one partner covers. */
export function usePartnerLocations(partnerid: number | undefined) { export function usePartnerLocations(partnerid: number | undefined) {
return useQuery({ return useQuery({

View File

@@ -13,6 +13,8 @@ export const queryKeys = {
all: ['partners'] as const, all: ['partners'] as const,
inRegion: (applocationid: number) => ['partners', 'region', applocationid] as const, inRegion: (applocationid: number) => ['partners', 'region', applocationid] as const,
locations: (partnerid: number) => ['partners', 'locations', partnerid] 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. */ /** The delivery regions a partner can cover. */
regions: { all: ['regions'] as const }, regions: { all: ['regions'] as const },