diff --git a/package-lock.json b/package-lock.json index dad3672..f92d0df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@astryxdesign/core": "^0.4.5", "@stylexjs/stylex": "^0.19.0", "@tanstack/react-query": "^5.101.4", + "leaflet": "^1.9.4", "lucide-react": "^1.33.0", "react": "^19.2.8", "react-dom": "^19.2.8", @@ -21,6 +22,7 @@ "devDependencies": { "@astryxdesign/cli": "^0.4.5", "@tailwindcss/vite": "^4.3.3", + "@types/leaflet": "^1.9.22", "@types/node": "^26.2.0", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", @@ -79,6 +81,7 @@ "integrity": "sha512-rbGaoAGZq1QImY2VWeWQNAh1ZqQa/KLWnmoOdy13lSjzMcAPDS6WrVv9fZfsNDx+DIG2oySLtMikwZk5WFl1Uw==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "intl-messageformat": "^11.2.9" }, @@ -119,6 +122,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -691,6 +695,17 @@ "node": ">=6.9.0" } }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -1519,6 +1534,7 @@ "resolved": "https://registry.npmjs.org/@stylexjs/stylex/-/stylex-0.19.0.tgz", "integrity": "sha512-CnUFp7YMaDLDeemsWOfJgoC/gKM5P/yBNMcpJaE6ChJmXr7s0DJwSeGTTlHJcqqwN9OW1qGtmARWLFhGZN1pTA==", "license": "MIT", + "peer": true, "dependencies": { "css-mediaquery": "^0.1.2", "invariant": "^2.2.4", @@ -1748,27 +1764,6 @@ "node": ">=14.0.0" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.11.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.11.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "dev": true, @@ -1997,12 +1992,30 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/leaflet": { + "version": "1.9.22", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz", + "integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/node": { "version": "26.2.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~8.3.0" } @@ -2013,6 +2026,7 @@ "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2449,6 +2463,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", @@ -3141,6 +3156,12 @@ "node": ">=0.10.0" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -3660,6 +3681,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -3669,6 +3691,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3688,6 +3711,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -3805,7 +3829,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -4027,6 +4052,7 @@ "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -4150,6 +4176,7 @@ "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", diff --git a/package.json b/package.json index 68fe634..c5c25cb 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@astryxdesign/core": "^0.4.5", "@stylexjs/stylex": "^0.19.0", "@tanstack/react-query": "^5.101.4", + "leaflet": "^1.9.4", "lucide-react": "^1.33.0", "react": "^19.2.8", "react-dom": "^19.2.8", @@ -32,6 +33,7 @@ "devDependencies": { "@astryxdesign/cli": "^0.4.5", "@tailwindcss/vite": "^4.3.3", + "@types/leaflet": "^1.9.22", "@types/node": "^26.2.0", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", diff --git a/src/App.tsx b/src/App.tsx index 49e4510..38bad89 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -34,6 +34,9 @@ const OnboardTenantPage = named('OnboardTenantPage', () => import('@/features/ne const GlobalCataloguePage = named('GlobalCataloguePage', () => import('@/features/nearle-admin/pages/GlobalCataloguePage')); const PartnersPage = named('PartnersPage', () => import('@/features/nearle-admin/pages/PartnersPage')); const NearleUploadsPage = named('UploadsPage', () => import('@/features/nearle-admin/pages/UploadsPage')); +/* Lazy like the rest, and it matters more here: this page pulls in leaflet and + its stylesheet, which nobody who never opens the fleet map should download. */ +const FleetPage = named('FleetPage', () => import('@/features/nearle-admin/pages/FleetPage')); /* One Console for both workspaces — it reads its own scope from BranchScope, which pins a store user to their outlet and lets an admin choose. Both routes @@ -108,6 +111,7 @@ export function App() { {/* Delivery partners — the companies that supply riders. Platform-side only: a merchant is assigned one, never allowed to create one. */} } /> + } /> } /> {/* 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 diff --git a/src/api/deliveries.ts b/src/api/deliveries.ts index 701fcba..9ecd466 100644 --- a/src/api/deliveries.ts +++ b/src/api/deliveries.ts @@ -388,4 +388,71 @@ export const partnersApi = { /** The regions one partner covers. */ locations: (partnerid: number) => api.list(`${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(`${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; +} diff --git a/src/components/TrailMap.tsx b/src/components/TrailMap.tsx new file mode 100644 index 0000000..8e6595e --- /dev/null +++ b/src/components/TrailMap.tsx @@ -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(null); + const map = useRef(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(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: ``, + iconSize: [16, 16], + iconAnchor: [8, 8], + }), + }) + .bindPopup( + `${escapeHtml(pin.label)}` + + (pin.lines ?? []).map((line) => `
${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 ( +
+
+ {isEmpty ?
{emptyNote}
: null} +
+ ); +} + +/** + * Distinct, legible line colours. + * + * Hand-picked rather than generated from a hue wheel: evenly spaced hues put + * two yellows next to each other on an OSM tile and both vanish. These are all + * dark enough to read over map detail and different enough to tell apart at the + * width of a polyline. + */ +export const TRAIL_COLOURS = [ + '#662582', + '#0f8a5f', + '#c2410c', + '#1d4ed8', + '#b91c1c', + '#0e7490', + '#7c2d12', + '#4d7c0f', +] as const; + +export function trailColour(index: number): string { + return TRAIL_COLOURS[index % TRAIL_COLOURS.length] as string; +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +/** Popup and icon HTML is a string, so a rider named `` must not become markup. */ +function escapeAttr(value: string): string { + return value.replace(/["'<>]/g, ''); +} diff --git a/src/components/trailMap.css b/src/components/trailMap.css new file mode 100644 index 0000000..222a600 --- /dev/null +++ b/src/components/trailMap.css @@ -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); +} diff --git a/src/features/nearle-admin/NearleAdminShell.tsx b/src/features/nearle-admin/NearleAdminShell.tsx index d82bc6c..a18bcec 100644 --- a/src/features/nearle-admin/NearleAdminShell.tsx +++ b/src/features/nearle-admin/NearleAdminShell.tsx @@ -13,6 +13,7 @@ const NAV: readonly NavEntry[] = [ { to: '/nearle/onboard/tenant', label: 'Onboard tenant' }, { to: '/nearle/catalogue', label: 'Global catalogue' }, { to: '/nearle/partners', label: 'Rider partners' }, + { to: '/nearle/fleet', label: 'Fleet' }, { to: '/nearle/uploads', label: 'Uploads' }, ]; diff --git a/src/features/nearle-admin/pages/FleetPage.tsx b/src/features/nearle-admin/pages/FleetPage.tsx new file mode 100644 index 0000000..16d5e12 --- /dev/null +++ b/src/features/nearle-admin/pages/FleetPage.tsx @@ -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(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(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 ( + + + + { + setDay(event.target.value); + setFocused(null); + }} + /> + + } + /> + + + +
+ } + /> + } + /> + 0 ? fleet.onlineMs / fleet.measured : null)} + note={`longest ${duration(fleet.longestMs)}`} + tone={toneOf(advice.severity)} + icon={} + /> + } + /> +
+ +
+ + + + Where the fleet is registered + + + + 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. + + + + + + + + The day, rider by rider + + {logs.isLoading ? ( + + Reading check-ins… + + ) : fleet.riders.length === 0 ? ( + + Nobody checked in on {day}. + + ) : ( +
+ {fleet.riders.map((rider, index) => ( + + setFocused((prev) => (prev === rider.userid ? null : rider.userid)) + } + /> + ))} + {fleet.window ? ( +
+ {clock(fleet.window.from)} + {clock(fleet.window.to)} +
+ ) : null} +
+ )} + {focused !== null ? r.userid === focused)} /> : null} +
+
+
+ + + + + + + + + What these numbers are, and are not + + + + Nothing here comes from a timesheet. The rider app sends a check-in every twenty + seconds or so and never closes a shift — workhours, logout 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. + + + +
+ ); +} + +/* ── 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 ( + + ); +} + +function ShiftDetail({ rider }: { rider: RiderDay | undefined }) { + if (!rider) return null; + return ( +
+ {rider.username} + {rider.shifts.length === 0 ? ( + + {rider.coverage.pings} check-in{rider.coverage.pings === 1 ? '' : 's'}, too few to make a + shift out of. + + ) : ( +
    + {rider.shifts.map((shift) => ( +
  • + + {clock(shift.from)} – {clock(shift.to)} + + {duration(shift.to - shift.from)} + {/* 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 ? ( + + quiet for {duration(shift.longestGapMs)} inside this run + + ) : null} +
  • + ))} +
+ )} +
+ ); +} + +/* ── Chrome ──────────────────────────────────────────────────────────────── */ + +function Banner({ advice }: { advice: ReturnType }) { + const Icon = + advice.severity === 'good' ? CheckCircle2 : advice.severity === 'watch' ? Info : AlertTriangle; + return ( +
+ +
+ {advice.headline} + {advice.detail} +
+
+ ); +} + +function Note({ children }: { children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} + +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' }); +} diff --git a/src/features/nearle-admin/pages/fleet.css b/src/features/nearle-admin/pages/fleet.css new file mode 100644 index 0000000..1182b97 --- /dev/null +++ b/src/features/nearle-admin/pages/fleet.css @@ -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; +} diff --git a/src/features/nearle-admin/riderShifts.test.ts b/src/features/nearle-admin/riderShifts.test.ts new file mode 100644 index 0000000..efa40da --- /dev/null +++ b/src/features/nearle-admin/riderShifts.test.ts @@ -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'); +}); diff --git a/src/features/nearle-admin/riderShifts.ts b/src/features/nearle-admin/riderShifts.ts new file mode 100644 index 0000000..fb5d343 --- /dev/null +++ b/src/features/nearle-admin/riderShifts.ts @@ -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 { + const byRider = new Map(); + 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(); + 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`; +} diff --git a/src/features/store-admin/DispatchMap.tsx b/src/features/store-admin/DispatchMap.tsx new file mode 100644 index 0000000..8fd5b5f --- /dev/null +++ b/src/features/store-admin/DispatchMap.tsx @@ -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(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 ( + + + + + Where the riders were + + + {coverage.positioned} of {coverage.total} deliver + {coverage.total === 1 ? 'y' : 'ies'} reported a position + + + + {paths.length > 1 ? ( + + setFocused(null)} + /> + {paths.map((path, index) => ( + setFocused((prev) => (prev === path.userid ? null : path.userid))} + /> + ))} + + ) : null} + + + +
+ + + 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. + +
+
+
+ ); +} + +function RiderChip({ + label, + colour, + isActive, + onClick, +}: { + label: string; + colour: string; + isActive: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function clock(at: number): string { + return new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} diff --git a/src/features/store-admin/PlanVsActualPanel.tsx b/src/features/store-admin/PlanVsActualPanel.tsx new file mode 100644 index 0000000..f0d0a3a --- /dev/null +++ b/src/features/store-admin/PlanVsActualPanel.tsx @@ -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 ( + + + + Reading the day… + + + + ); + } + + if (finished.length === 0) { + return ( + + + + Nothing finished in this range + + + 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. + + + + ); + } + + const onTimeShare = result.promised > 0 ? result.onTime / result.promised : null; + + return ( + + {/* ── The promise ────────────────────────────────────────────────── */} + + + + Against the promise + + {result.promised === 0 ? ( + + None of these {result.journeys} deliveries carried a promised time, so there is + nothing to measure them against. The app writes{' '} + expecteddeliverytime when it quotes the customer; on this shop it is + not being written. + + ) : ( + +
= 0.8 ? 'good' : (onTimeShare ?? 0) >= 0.5 ? 'watch' : 'act'} + /> +
15 * 60_000 ? 'act' : 'watch'} + /> + {result.journeys > result.promised ? ( +
+ ) : null} + + )} + + + + {/* ── Where the time goes ────────────────────────────────────────── */} + + + + + Where the time goes + + + median of {result.journeys} finished deliver{result.journeys === 1 ? 'y' : 'ies'} + + + + {/* 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. */} +
+ {result.steps.map((step) => + step.share > 0 ? ( + + ) : null, + )} +
+ +
+ {result.steps.map((step) => ( +
+ + + {step.label} + {step.key === result.bottleneck ? the bottleneck : null} + + {span(step.medianMs)} + + {step.measured === 0 + ? 'never stamped' + : `p90 ${span(step.p90Ms)} · ${step.measured} measured`} + +
+ ))} +
+ + {result.firstLegs > 0 ? ( + + {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. + + ) : null} +
+
+ + {/* ── Distance ───────────────────────────────────────────────────── */} + + + + Distance + + {result.distance.measured === 0 ? ( + + No delivery in this range carries a measured distance. The rider app reports one + (riderkms) on roughly two thirds of deliveries platform-wide, and the + readings under a hundred metres are treated as failed rather than as short trips. + + ) : ( + +
+
+ + )} + + + + {/* ── The deliveries themselves ──────────────────────────────────── */} + + + + + Delivery by delivery + + + + +
+ + + + + + + + + + + + + + + {paged.rows.map((journey) => { + const of = (key: StepKey) => + journey.steps.find((step) => step.key === key)?.ms ?? null; + const late = journey.lateMs; + return ( + + + + + + + + + + + ); + })} + +
OrderRiderTo the shopCounterWaitingOn the roadTotalAgainst promise
+ {journey.orderid} + + {journey.deliveredAt + ? new Date(journey.deliveredAt).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }) + : ''} + + {journey.rider}{span(of('toShop'))}{span(of('counter'))}{span(of('inRound'))} + {journey.isFirstLeg ? ( + + n/a + + ) : ( + span(of('onRoad')) + )} + {span(journey.totalMs)} + 60_000 ? 'yes' : 'no'} + > + {lateness(late)} + +
+
+ +
+ + ); +} + +function Figure({ + label, + value, + note, + tone, +}: { + label: string; + value: string; + note: string; + tone: 'good' | 'watch' | 'act' | 'neutral'; +}) { + return ( +
+ {label} + {value} + {note} +
+ ); +} + +function Note({ children }: { children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} diff --git a/src/features/store-admin/deliveryTrack.test.ts b/src/features/store-admin/deliveryTrack.test.ts new file mode 100644 index 0000000..9d0d305 --- /dev/null +++ b/src/features/store-admin/deliveryTrack.test.ts @@ -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 { + 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 }); +}); diff --git a/src/features/store-admin/deliveryTrack.ts b/src/features/store-admin/deliveryTrack.ts new file mode 100644 index 0000000..09e21a9 --- /dev/null +++ b/src/features/store-admin/deliveryTrack.ts @@ -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(); + 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(); + 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, + }; +} diff --git a/src/features/store-admin/pages/DispatchPage.tsx b/src/features/store-admin/pages/DispatchPage.tsx index f9be4fa..d4418df 100644 --- a/src/features/store-admin/pages/DispatchPage.tsx +++ b/src/features/store-admin/pages/DispatchPage.tsx @@ -6,9 +6,11 @@ import { VStack } from '@astryxdesign/core/VStack'; import { Bike, IndianRupee, + Map, MapPin, Package, Store, + Timer, Truck, UserX, Users, @@ -27,7 +29,9 @@ import { useBranchScope } from '../BranchScope'; import { count, money, moneyExact } from '../format'; import { DELIVERY_STATUS, statusColor } from '../orderStatus'; import { shortAge } from '../posStatus'; +import { DispatchMap } from '../DispatchMap'; import { OrderDetailDrawer } from '../OrderDetailDrawer'; +import { PlanVsActualPanel } from '../PlanVsActualPanel'; import { dayTotals, groupByCustomer, @@ -89,10 +93,21 @@ import './dispatch.css'; */ const DISPATCH_STATUS: Record = { ...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() { const { branches, selected, tenantid } = useBranchScope(); const dates = useDateScope(); - const [mode, setMode] = useState('riders'); + const [mode, setMode] = useState('riders'); const [focused, setFocused] = useState(null); const [detail, setDetail] = useState(null); @@ -142,6 +157,7 @@ export function DispatchPage() { ); const groups = useMemo(() => { + if (mode === 'timing' || mode === 'map') return []; if (mode === 'stores') return groupByStore(stops, locations.data ?? branches); if (mode === 'customers') return groupByCustomer(stops, customers.data ?? [], branchName); return groupByRider(stops); @@ -191,7 +207,7 @@ export function DispatchPage() { [waiting, picked], ); - const changeMode = (next: ViewMode) => { + const changeMode = (next: Board) => { setMode(next); // A group id means nothing across groupings — a branch id is not a rider // id — so the focus is dropped rather than carried into nonsense. @@ -224,6 +240,18 @@ export function DispatchPage() { isActive={mode === 'customers'} onClick={() => changeMode('customers')} /> + } + isActive={mode === 'timing'} + onClick={() => changeMode('timing')} + /> + } + isActive={mode === 'map'} + onClick={() => changeMode('map')} + /> } /> @@ -259,77 +287,89 @@ export function DispatchPage() { />
-
-
- - {mode === 'riders' - ? isOneDay - ? 'Rounds' - : 'Riders' - : mode === 'stores' - ? 'Shops' - : 'Customers'} - - setFocused((prev) => (prev === id ? null : id))} - /> -
- -
- {open ? ( - + ) : mode === 'timing' ? ( + /* A different question of the same rows, so it replaces the board + rather than sitting beside it: promised against delivered, and where + the hours between accepting an order and dropping it actually went. */ + + ) : ( +
+
+ + {mode === 'riders' + ? isOneDay + ? 'Rounds' + : 'Riders' + : mode === 'stores' + ? 'Shops' + : 'Customers'} + + assignability(row, branchOf(row), assigned), - assignBar: - picked.count > 0 ? ( - - ) : null, - } - : {})} + isLoading={deliveries.isLoading || (mode === 'customers' && customers.isLoading)} + focused={focused} + onFocus={(id) => setFocused((prev) => (prev === id ? null : id))} /> - ) : ( - - - - - - - {groups.length > 0 - ? 'Pick one to see its stops' - : isOneDay - ? 'Nothing out on this day' - : 'Nothing out in this range'} - - - {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.'} - - - - )} +
+ +
+ {open ? ( + assignability(row, branchOf(row), assigned), + assignBar: + picked.count > 0 ? ( + + ) : null, + } + : {})} + /> + ) : ( + + + + + + + {groups.length > 0 + ? 'Pick one to see its stops' + : isOneDay + ? 'Nothing out on this day' + : 'Nothing out in this range'} + + + {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.'} + + + + )} +
-
+ )} {detail ? ( setDetail(null)} /> diff --git a/src/features/store-admin/pages/dispatch.css b/src/features/store-admin/pages/dispatch.css index 8d1928d..f3509d2 100644 --- a/src/features/store-admin/pages/dispatch.css +++ b/src/features/store-admin/pages/dispatch.css @@ -253,3 +253,221 @@ text-transform: capitalize; white-space: nowrap; } + +/* ── Plan vs actual ──────────────────────────────────────────────────────── */ + +/* One colour per step, reused by the stacked bar and the legend swatches so the + two read as the same object. Ordered as the journey runs — cool at the shop, + warm on the road — rather than by a palette's own sequence. */ +.pva-bar { + display: flex; + height: 14px; + overflow: hidden; + border-radius: 7px; + background: var(--color-surface-sunken); +} + +.pva-bar i, +.pva-swatch { + display: block; +} + +.pva-bar i[data-step='toShop'], +.pva-swatch[data-step='toShop'] { + background: #662582; +} + +.pva-bar i[data-step='counter'], +.pva-swatch[data-step='counter'] { + background: #8b6bab; +} + +.pva-bar i[data-step='inRound'], +.pva-swatch[data-step='inRound'] { + background: #c2410c; +} + +.pva-bar i[data-step='onRoad'], +.pva-swatch[data-step='onRoad'] { + background: #0f8a5f; +} + +.pva-swatch { + width: 9px; + height: 9px; + border-radius: 2px; +} + +.pva-steps { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); + gap: 4px 16px; +} + +.pva-step { + display: grid; + grid-template-columns: 9px minmax(0, 1fr) auto; + gap: 4px 8px; + align-items: center; + padding: 6px 8px; + border: 1px solid transparent; + border-radius: 7px; +} + +/* The step costing the most is the finding. Outlined rather than recoloured, so + the swatch keeps naming its own segment of the bar above. */ +.pva-step[data-bottleneck='true'] { + border-color: var(--color-border); + background: var(--color-surface-subtle); +} + +.pva-step-label { + font-size: 12.5px; + color: var(--color-ink-1); +} + +.pva-step-label em { + margin-left: 6px; + font-size: 10.5px; + font-style: normal; + font-weight: 600; + letter-spacing: 0.03em; + color: #b45309; + text-transform: uppercase; +} + +.pva-step strong { + font-size: 13px; + font-variant-numeric: tabular-nums; + color: var(--color-ink-1); +} + +.pva-step-note { + grid-column: 2 / -1; + font-size: 11px; + font-variant-numeric: tabular-nums; + color: var(--color-ink-4); +} + +.pva-figure { + display: flex; + flex-direction: column; + gap: 1px; + padding-left: 10px; + border-left: 3px solid var(--color-border); +} + +.pva-figure[data-tone='good'] { + border-left-color: #0f8a5f; +} + +.pva-figure[data-tone='watch'] { + border-left-color: #b45309; +} + +.pva-figure[data-tone='act'] { + border-left-color: #b91c1c; +} + +.pva-figure-label { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.03em; + color: var(--color-ink-3); + text-transform: uppercase; +} + +.pva-figure strong { + font-size: 19px; + font-variant-numeric: tabular-nums; + line-height: 1.2; + color: var(--color-ink-1); +} + +.pva-figure-note { + font-size: 11.5px; + color: var(--color-ink-4); +} + +.pva-note { + display: flex; + gap: 7px; + align-items: flex-start; + padding: 8px 10px; + border-radius: 7px; + background: var(--color-surface-subtle); + font-size: 11.5px; + line-height: 1.5; + color: var(--color-ink-3); +} + +.pva-note svg { + flex: none; + margin-top: 2px; +} + +.pva-toggle { + display: inline-flex; + gap: 6px; + align-items: center; + font-size: 12.5px; + color: var(--color-ink-2); + cursor: pointer; +} + +/* Rows here are read, not opened — nothing lies behind one — so the pointer and + hover lift the stops table uses would promise a click that does nothing. */ +.pva-table tbody tr { + cursor: default; +} + +.pva-late[data-late='yes'] { + color: #b91c1c; +} + +.pva-late[data-late='no'] { + color: #0f8a5f; +} + +.pva-late[data-late='none'] { + color: var(--color-ink-4); +} + +/* ── The map's rider filter ──────────────────────────────────────────────── */ + +.rider-chip { + display: inline-flex; + gap: 6px; + align-items: center; + padding: 4px 10px; + border: 1px solid var(--color-border); + border-radius: 999px; + background: var(--color-surface); + font: inherit; + font-size: 12px; + color: var(--color-ink-2); + cursor: pointer; +} + +.rider-chip:hover { + background: var(--color-surface-subtle); +} + +.rider-chip[data-active='true'] { + border-color: var(--color-brand); + background: var(--color-brand-tint); + color: var(--color-ink-1); +} + +.rider-chip:focus-visible { + outline: 2px solid var(--color-brand); + outline-offset: 1px; +} + +/* The swatch matches this rider's line on the map, which is the only thing + connecting a chip to the shape it filters to. */ +.rider-chip i { + width: 8px; + height: 8px; + border-radius: 50%; +} diff --git a/src/features/store-admin/plannedVsActual.test.ts b/src/features/store-admin/plannedVsActual.test.ts new file mode 100644 index 0000000..136fbcb --- /dev/null +++ b/src/features/store-admin/plannedVsActual.test.ts @@ -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'); +}); diff --git a/src/features/store-admin/plannedVsActual.ts b/src/features/store-admin/plannedVsActual.ts new file mode 100644 index 0000000..4a609ff --- /dev/null +++ b/src/features/store-admin/plannedVsActual.ts @@ -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 = { + 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`; +} diff --git a/src/queries/hooks.ts b/src/queries/hooks.ts index df62458..c260616 100644 --- a/src/queries/hooks.ts +++ b/src/queries/hooks.ts @@ -216,6 +216,38 @@ export function usePartnerRiderCounts(partnerids: readonly number[]) { return counts; } +/** + * Every GPS ping a partner's riders sent over a window. + * + * ── Why the window is small by default ──────────────────────────────────── + * + * The response is one row per ping and the riders ping constantly: August 2026 + * returned 320,132 rows for eight riders. A month is several megabytes of JSON + * to fetch, parse and smooth, so the fleet view asks for a day or two and says + * which day it is showing. + * + * Cached longer than `stable` allows because it is history: yesterday's pings + * do not change, and a refetch on every focus would re-download the day. + */ +export function usePartnerRiderLogs( + partnerid: number | undefined, + range: { fromdate: string; todate: string }, +) { + return useQuery({ + queryKey: queryKeys.partners.logs(partnerid ?? 0, range.fromdate, range.todate), + queryFn: () => + partnersApi.riderLogs({ + partnerid: partnerid as number, + fromdate: range.fromdate, + todate: range.todate, + }), + enabled: + typeof partnerid === 'number' && partnerid > 0 && Boolean(range.fromdate && range.todate), + ...stable, + staleTime: 5 * 60_000, + }); +} + /** The regions one partner covers. */ export function usePartnerLocations(partnerid: number | undefined) { return useQuery({ diff --git a/src/queries/keys.ts b/src/queries/keys.ts index faf0b14..2177025 100644 --- a/src/queries/keys.ts +++ b/src/queries/keys.ts @@ -13,6 +13,8 @@ export const queryKeys = { all: ['partners'] as const, inRegion: (applocationid: number) => ['partners', 'region', applocationid] as const, locations: (partnerid: number) => ['partners', 'locations', partnerid] as const, + logs: (partnerid: number, fromdate: string, todate: string) => + ['partners', 'logs', partnerid, fromdate, todate] as const, }, /** The delivery regions a partner can cover. */ regions: { all: ['regions'] as const },