From 8b2c4daef82e8ef9ca44e2f3dccaa49cf477e4b7 Mon Sep 17 00:00:00 2001 From: abhishek Date: Thu, 10 Sep 2026 17:57:00 +0530 Subject: [PATCH] updats on the dispatch page --- .gitignore | 3 + package-lock.json | 578 +++++++ package.json | 7 +- scripts/mapPreview.mjs | 167 ++ src/api/optimiser.ts | 123 +- src/api/routing.ts | 135 ++ src/api/telemetry.ts | 112 ++ src/components/TrailMap.tsx | 251 ++- src/components/trailMap.css | 315 +++- src/features/store-admin/ActivePanel.tsx | 236 +++ src/features/store-admin/AutoAssignDrawer.tsx | 359 +++++ .../store-admin/GroupMap.dom.test.tsx | 286 ++++ src/features/store-admin/GroupMap.tsx | 353 ++-- .../store-admin/PlanVsActualPanel.tsx | 1 + src/features/store-admin/ProfitPanel.tsx | 249 +++ src/features/store-admin/autoAssign.test.ts | 338 ++++ src/features/store-admin/autoAssign.ts | 350 ++++ src/features/store-admin/dispatchModel.ts | 2 + src/features/store-admin/dispatchPanels.css | 531 ++++++ src/features/store-admin/drawer.css | 2 +- src/features/store-admin/orderStatus.test.ts | 93 +- src/features/store-admin/orderStatus.ts | 91 +- .../store-admin/pages/DispatchPage.tsx | 970 ++++++----- src/features/store-admin/pages/SalesPage.tsx | 22 +- src/features/store-admin/pages/dispatch.css | 1424 +++++++++++------ .../store-admin/profitability.test.ts | 179 +++ src/features/store-admin/profitability.ts | 235 +++ src/features/store-admin/riderLive.test.ts | 208 +++ src/features/store-admin/riderLive.ts | 233 +++ src/features/store-admin/useRoads.ts | 52 + src/queries/hooks.ts | 51 + src/queries/keys.ts | 4 + tools/css-hooks.mjs | 19 + tools/stub-css.mjs | 9 + 34 files changed, 6929 insertions(+), 1059 deletions(-) create mode 100644 scripts/mapPreview.mjs create mode 100644 src/api/routing.ts create mode 100644 src/api/telemetry.ts create mode 100644 src/features/store-admin/ActivePanel.tsx create mode 100644 src/features/store-admin/AutoAssignDrawer.tsx create mode 100644 src/features/store-admin/GroupMap.dom.test.tsx create mode 100644 src/features/store-admin/ProfitPanel.tsx create mode 100644 src/features/store-admin/autoAssign.test.ts create mode 100644 src/features/store-admin/autoAssign.ts create mode 100644 src/features/store-admin/dispatchPanels.css create mode 100644 src/features/store-admin/profitability.test.ts create mode 100644 src/features/store-admin/profitability.ts create mode 100644 src/features/store-admin/riderLive.test.ts create mode 100644 src/features/store-admin/riderLive.ts create mode 100644 src/features/store-admin/useRoads.ts create mode 100644 tools/css-hooks.mjs create mode 100644 tools/stub-css.mjs diff --git a/.gitignore b/.gitignore index 8feb229..0c6e3e8 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ dist # stayed for weeks. *.zip *.tgz + +# Build output from scripts/mapPreview.mjs — a local viewer, never committed. +scripts/.preview/ diff --git a/package-lock.json b/package-lock.json index 73bb505..5bdc52a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,17 +22,72 @@ "devDependencies": { "@astryxdesign/cli": "^0.4.5", "@tailwindcss/vite": "^4.3.3", + "@types/jsdom": "^30.0.0", "@types/leaflet": "^1.9.22", "@types/node": "^26.2.0", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "@vitejs/plugin-react": "^5.0.4", + "jsdom": "^30.0.1", "tailwindcss": "^4.3.3", "tsx": "^4.20.3", "typescript": "^7.0.2", "vite": "^8.2.2" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@astryxdesign/cli": { "version": "0.4.5", "resolved": "https://registry.npmjs.org/@astryxdesign/cli/-/cli-0.4.5.tgz", @@ -693,6 +748,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.2.tgz", + "integrity": "sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.12.tgz", + "integrity": "sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -1135,6 +1343,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@formatjs/fast-memoize": { "version": "3.1.7", "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.7.tgz", @@ -2006,6 +2232,26 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsdom": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-30.0.0.tgz", + "integrity": "sha512-uAHGxujGE0cDaKGdK28zgDotFtNA7MKq5DXl8LrfdxdCI8VHcg15oJz+amHTChPNI5JpgEPQWc2xFdrw3em/nQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^8.0.0", + "undici-types": "^8.9.0" + } + }, + "node_modules/@types/jsdom/node_modules/undici-types": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.10.2.tgz", + "integrity": "sha512-7/+aSjzkUoLc92hV22bTW4aGanXf800zbwguhcICs0OAoCF9wDOE4wkopQ+SqfhXZm8mCK8gHpdTs7pZUWzK3w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/leaflet": { "version": "1.9.22", "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz", @@ -2046,6 +2292,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -2448,6 +2701,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/browserslist": { "version": "4.28.8", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", @@ -2611,6 +2874,20 @@ "integrity": "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==", "license": "BSD" }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2739,6 +3016,35 @@ "node": ">=12" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2757,6 +3063,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -2794,6 +3107,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-toolkit": { "version": "1.51.0", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.51.0.tgz", @@ -2996,6 +3322,19 @@ "dev": true, "license": "ISC" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/immer": { "version": "11.1.18", "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz", @@ -3057,6 +3396,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isobject": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", @@ -3124,6 +3470,57 @@ } } }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -3506,6 +3903,13 @@ "semver": "bin/semver" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3588,6 +3992,19 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", @@ -3680,6 +4097,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -3841,6 +4268,16 @@ "redux": "^5.0.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/reselect": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", @@ -3888,6 +4325,19 @@ "dev": true, "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -3985,6 +4435,13 @@ "integrity": "sha512-L0TR0NQb+X4/ktDEKmjWyp27gla+LUYi/by5k5SjKXf6/pvZP7wbwEB5J+tqxdFVPgzbsuz+d4RTScO/QZquBw==", "license": "MIT" }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", @@ -4029,6 +4486,26 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tldts": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.12.tgz", + "integrity": "sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.12" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.12.tgz", + "integrity": "sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==", + "dev": true, + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", @@ -4039,6 +4516,32 @@ "node": ">=14.14" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -4100,6 +4603,16 @@ "@typescript/typescript-win32-x64": "7.0.2" } }, + "node_modules/undici": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", + "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", @@ -4508,6 +5021,54 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.1.tgz", + "integrity": "sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/wmf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", @@ -4561,6 +5122,23 @@ "node": ">=0.8" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index c5c25cb..b74ce64 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "build": "tsc --noEmit && vite build", "preview": "vite preview --port 3100", "typecheck": "tsc --noEmit", - "test": "tsx --test \"src/**/*.test.ts\"", + "test": "tsx --import ./tools/stub-css.mjs --test \"src/**/*.test.ts\" \"src/**/*.test.tsx\"", "contract": "node scripts/contract.mjs", "db": "node scripts/db.mjs", "appgap": "node scripts/appgap.mjs", @@ -16,7 +16,8 @@ "check:optimiser": "tsx scripts/checkOptimiser.ts", "verify:live": "test ! -d src/demo && test $(grep -rl 'await fetch(' src | wc -l) -eq 1 && ! grep -rlq 'src/demo' src/ && echo \"clean: no fixture layer, one fetch, every screen reads the API\"", "appsweep": "node scripts/appsweep.mjs", - "appfix": "node scripts/appfix.mjs" + "appfix": "node scripts/appfix.mjs", + "preview:map": "node scripts/mapPreview.mjs" }, "dependencies": { "@astryxdesign/core": "^0.4.5", @@ -33,11 +34,13 @@ "devDependencies": { "@astryxdesign/cli": "^0.4.5", "@tailwindcss/vite": "^4.3.3", + "@types/jsdom": "^30.0.0", "@types/leaflet": "^1.9.22", "@types/node": "^26.2.0", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "@vitejs/plugin-react": "^5.0.4", + "jsdom": "^30.0.1", "tailwindcss": "^4.3.3", "tsx": "^4.20.3", "typescript": "^7.0.2", diff --git a/scripts/mapPreview.mjs b/scripts/mapPreview.mjs new file mode 100644 index 0000000..2c0518c --- /dev/null +++ b/scripts/mapPreview.mjs @@ -0,0 +1,167 @@ +/** + * Build a standalone page that renders the REAL dispatch map with mock stops. + * + * ── Why this exists ───────────────────────────────────────────────────────── + * + * A leaflet map cannot be checked by anything else in this repo. The pure tests + * never mount it, `renderToString` never runs the effect that builds it, and + * the jsdom tests can only COUNT what it produced — none of them can tell you + * whether the thing looks right. The map shipped twice on that basis and came + * back wrong twice: once invisible behind a crash, once with its routes buried + * under 966 pins. + * + * So this bundles the actual `GroupMap` — not a copy of it, not a sketch — + * against invented stops, and writes one HTML file to open. What you see is + * what the console draws. + * + * ── Why the mock data lives here and not in `src` ─────────────────────────── + * + * `npm run verify:live` asserts there is no `src/demo`, and it is right to: + * a fixture layer inside the app is how a screen ends up quietly rendering + * invented numbers in production. This is a build tool. It imports from `src` + * and nothing in `src` imports it, so the app has no path to this data. + * + * node scripts/mapPreview.mjs → writes scripts/.preview/map.html + */ +import { build } from 'esbuild'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const out = join(here, '.preview'); + +/* ── The mock day ───────────────────────────────────────────────────────── + Shaped like the real thing rather than like a neat demo: one shop, two + riders working outward in a loop, three orders stacked on one address, and + one rider whose last reported position is nowhere near their last drop. Each + of those is something the live data does and each has broken this map once. */ +const SHOP = { lat: 11.0168, lng: 76.9558 }; + +const ROUND_A = [ + [11.0245, 76.9601], + [11.0298, 76.9662], + [11.0331, 76.9754], + [11.0288, 76.9823], + [11.0201, 76.9788], + // Three orders at one address — the repeat customer that stacked 379 pins. + [11.0154, 76.9702], + [11.0154, 76.9702], + [11.0154, 76.9702], +]; + +const ROUND_B = [ + [11.0102, 76.9481], + [11.0044, 76.9412], + [10.9981, 76.9377], + [10.9932, 76.9455], + [11.0011, 76.9521], +]; + +function stopsFor(rider, userid, points, from) { + return points.map((point, index) => ({ + kind: 'delivery', + row: { + deliveryid: userid * 100 + index, + orderid: `916-${userid}${String(index + 1).padStart(2, '0')}`, + userid, + ridername: rider, + // A couple left open, so the status colours are visible on the pins. + orderstatus: index === points.length - 1 ? 'active' : 'delivered', + assigntime: '2026-08-25 09:00:00', + deliverytime: `2026-08-25 ${String(from + Math.floor(index / 2)).padStart(2, '0')}:${String((index * 17) % 60).padStart(2, '0')}:00`, + pickuplat: String(SHOP.lat), + pickuplon: String(SHOP.lng), + droplat: String(point[0]), + droplon: String(point[1]), + // Only the last stop carries a rider fix, which is the shape the live + // rows have — a position arrives when a job moves, not per stop. + ...(index === points.length - 1 + ? { riderslat: String(point[0] + 0.004), riderslon: String(point[1] - 0.003) } + : {}), + deliveryamt: 30 + index * 5, + deliverycustomer: `${rider}'s customer ${index + 1}`, + deliveryaddress: `Stop ${index + 1}`, + }, + })); +} + +const STOPS = [ + ...stopsFor('Varun', 897, ROUND_A, 10), + ...stopsFor('Murali', 1111, ROUND_B, 11), +]; + +const entry = join(out, 'entry.jsx'); +mkdirSync(out, { recursive: true }); + +writeFileSync( + entry, + `import { createRoot } from 'react-dom/client'; +import { GroupMap } from '../../src/features/store-admin/GroupMap'; +const STOPS = ${JSON.stringify(STOPS)}; +createRoot(document.getElementById('root')).render( +
+

Dispatch map — mock day

+

+ Two riders, one shop, ${STOPS.length} stops. Three of Varun's orders are at one address. + This is the real GroupMap component with invented stops. +

+ +
, +); +`, +); + +await build({ + entryPoints: [entry], + bundle: true, + outfile: join(out, 'map.js'), + jsx: 'automatic', + format: 'iife', + platform: 'browser', + // Leaflet's stylesheet references sprite PNGs for controls we do not use; + // inlined as data URIs so the page is a single self-contained file. + loader: { '.css': 'css', '.png': 'dataurl', '.svg': 'dataurl' }, + // The real thing, minus the parts a static page has no business having. + // `import.meta.env` is Vite's and does not exist here; the modules that read + // it already optional-chain, so an empty object is enough. + define: { 'import.meta.env': 'undefined', 'process.env.NODE_ENV': '"production"' }, + logLevel: 'warning', +}); + +writeFileSync( + join(out, 'map.html'), + ` + + +Dispatch map preview + + +
+ +`, +); + +console.log('preview written to', join(out, 'map.html')); +console.log('open it in a browser to see the real map with mock stops'); diff --git a/src/api/optimiser.ts b/src/api/optimiser.ts index 7750b28..2b895b3 100644 --- a/src/api/optimiser.ts +++ b/src/api/optimiser.ts @@ -1,3 +1,4 @@ +import type { SolverRequest, Tuning } from '@/features/store-admin/autoAssign'; import type { OrderRow } from './types'; /** @@ -22,18 +23,46 @@ import type { OrderRow } from './types'; * does, which is the only reason its two identically-named endpoints do not * collide. * - * ── What we deliberately do not call ──────────────────────────────────────── + * ── `riderassign` assigns against OUR fleet, not a foreign one ────────────── * - * `optimization/riderassign` works and is useless to us: it assigns against its - * OWN fleet. Sending our orders returned them assigned to `rider_id 883, - * "Rajan A"` — not one of ours, and no parameter changes that. Auto-assignment - * needs either a riders-inline variant of that endpoint or a mapping onto - * `routemate`'s `doormile/assign`, which does accept `milers` inline. Neither - * is wired here until somebody decides which. + * This file used to say the opposite — that `riderassign` was useless because + * it returned orders assigned to `rider_id 883, "Rajan A"`, "not one of ours". + * That was wrong, and it was wrong for the ordinary reason: an unfamiliar id + * was taken for a stranger without checking the roster. + * + * Checked on 2026-09-10. `getriderroster?partnerid=44` lists 883 "Rajan A", and + * so do the rider ids on tenant 916's own delivery rows — 883, 897, 950, 1111, + * 1114, every one of them partner 44's, which is the Coimbatore fleet. The + * solver reads the same database Fiesta does: `getallriders` on jupiter and + * `getriders` on Fiesta return identical rosters and identical on-duty state. + * + * So auto-assignment works and `assign` below wires it up. + * + * ── What it cannot do yet, and why that is not our bug ────────────────────── + * + * The solver picks the riders itself, gated on `onduty = 1`, and that flag is 0 + * for all 118 riders on the platform — every region, checked the same day. So + * `active_riders_pool` is 0 and every order comes back unassigned with "No + * riders found (check partner online status)". Supplying riders in the body + * does not help: `riders` and `active_riders` were both tried against a rider + * the on-duty endpoint DOES report, and the pool stayed 0. + * + * Whatever is meant to set `onduty` is not setting it. That is worth asking the + * app team about; nothing here can work around it. + * + * ── `routemate` is gone ───────────────────────────────────────────────────── + * + * The old console's second mode posted to `routemate.workolik.com/api/v1/ + * optimization/riderassign?strategy=multi_trip`, which accepted a rider list + * inline. It answers 404 now, with and without the query string, so that route + * around the `onduty` gate is closed too. */ const OPTIMISER_BASE = 'https://routes.workolik.com/api/v1'; +/** A solve can legitimately take a while. Past this, something is wrong. */ +const SOLVE_TIMEOUT_MS = 90_000; + /** An order as the optimiser hands it back — ours, plus the routing it added. */ export interface SequencedStop extends OrderRow { /** 1..N. The order to visit in. */ @@ -103,6 +132,52 @@ async function post(path: string, body: unknown): Promise { return (payload.details ?? (payload as unknown)) as T; } +/** + * A run that is allowed to take its time, and to be cancelled. + * + * Separate from `post` for two reasons: the caller needs the whole envelope + * rather than `details`, and a solve is slow enough that abandoning it has to + * be possible. The caller's cancel and the timeout both have to be able to stop + * it, so they are combined rather than one winning. + */ +async function postRaw(path: string, body: unknown, signal?: AbortSignal): Promise { + const timer = new AbortController(); + const stop = setTimeout(() => timer.abort(), SOLVE_TIMEOUT_MS); + const onAbort = () => timer.abort(); + signal?.addEventListener('abort', onAbort); + + try { + const response = await fetch(`${OPTIMISER_BASE}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(body), + signal: timer.signal, + }); + if (!response.ok) { + // 422 is the solver rejecting the payload and saying which field. Worth + // showing verbatim — "422" on its own is not actionable. + const text = await response.text().catch(() => ''); + throw new OptimiserError( + text.trim().slice(0, 400) || `Optimiser refused the request (HTTP ${response.status})`, + ); + } + return await response.json(); + } catch (error) { + if (error instanceof OptimiserError) throw error; + if ((error as Error)?.name === 'AbortError') { + throw new OptimiserError( + signal?.aborted + ? 'Cancelled.' + : 'The optimiser did not answer in time. Nothing was assigned — the orders are untouched.', + ); + } + throw new OptimiserError('Could not reach the route optimiser'); + } finally { + clearTimeout(stop); + signal?.removeEventListener('abort', onAbort); + } +} + export const optimiserApi = { /** * Put a set of orders in a sensible order. @@ -132,4 +207,38 @@ export const optimiserApi = { */ reconcile: (riders: PlannedRider[]) => post<{ riders: PlannedRider[] }>('/optimization/reconcile-steps', { riders }), + + /** + * Propose a rider for each waiting order. + * + * A PLAN, not a commitment. Nothing is written anywhere until the operator + * accepts it and the console makes its own `createdeliveries` call to Fiesta + * through `buildDelivery` — the same path the manual assign bar uses, so + * there is exactly one way a delivery is ever written. Safe to run twice and + * safe to walk away from. + * + * ── Raw, not unwrapped ──────────────────────────────────────────────────── + * + * `postRaw`, because the answer here IS the envelope: `zones` carries the + * assignment, `meta` carries the accounting and the per-order reasons, and + * `details` is only the flat fallback shape. `post` would hand back `details` + * alone and throw the plan away — and `details` is `[]` on every run that + * assigns nothing, which is every run today. + * + * ── Slow on purpose ─────────────────────────────────────────────────────── + * + * Seven seconds for five orders, measured, and it is a solver so it grows + * with the problem. `signal` is taken so a caller can offer to cancel; the + * timeout is deliberately generous, since killing a run early abandons work + * the operator is waiting on and teaches them the button is broken. + * + * `tuning` steers it — balanced, aggressive_speed, fuel_saver, zone_strict — + * and the literal string `null` is a value it accepts, meaning "your default". + */ + assign: (request: SolverRequest, tuning: Tuning | null, signal?: AbortSignal) => + postRaw( + `/optimization/riderassign?hypertuning_params=${tuning ?? 'null'}`, + request, + signal, + ), }; diff --git a/src/api/routing.ts b/src/api/routing.ts new file mode 100644 index 0000000..92bcc36 --- /dev/null +++ b/src/api/routing.ts @@ -0,0 +1,135 @@ +/** + * Real road geometry between two points. + * + * ── Why a third service ───────────────────────────────────────────────────── + * + * A straight line between two drops is not a route. On a map it cuts through + * blocks and across rivers, and its length is not the distance anybody rode — + * so a line drawn that way invites a measurement it cannot support. OSRM + * returns the actual road path, which is both honest and immediately readable + * as "they went round the one-way system". + * + * `router.project-osrm.org` is the project's own demo server. Verified reachable + * 2026-09-10 (200 in ~1.1 s for a Coimbatore leg). It is a courtesy service with + * no SLA and a fair-use policy, which shapes everything below: legs are cached, + * requests are capped per draw, and a failure is silent because a map that + * loses its road geometry is still a useful map. + * + * ── Failure is expected and must be cheap ─────────────────────────────────── + * + * Every caller takes back a path or null, never an error. A null means "draw the + * straight line instead", which is what the map already did. Nothing about a + * dispatch board should break because a free routing server was busy. + */ + +const OSRM_BASE = ( + import.meta.env?.['VITE_OSRM_BASE'] ?? 'https://router.project-osrm.org' +) + .trim() + .replace(/\/+$/, ''); + +/** One leg's road geometry, as [lat, lng] pairs ready for a polyline. */ +export type RoadPath = { lat: number; lng: number }[]; + +export interface Leg { + from: { lat: number; lng: number }; + to: { lat: number; lng: number }; +} + +/** + * Legs already fetched, keyed on their rounded endpoints. + * + * Module-level and unbounded on purpose within a session: a dispatch board + * redraws constantly — every filter, every poll — and the same legs recur. Two + * hundred legs of geometry is a few hundred kilobytes, and re-fetching them + * from a courtesy server on each render is the behaviour that gets an IP + * blocked. + */ +const cache = new Map(); + +/** Five decimal places is about a metre — finer than any two drops differ by. */ +function keyOf(leg: Leg): string { + return `${leg.from.lat.toFixed(5)},${leg.from.lng.toFixed(5)};${leg.to.lat.toFixed(5)},${leg.to.lng.toFixed(5)}`; +} + +/** A single leg's road path, or null when it cannot be had. */ +async function fetchLeg(leg: Leg, signal?: AbortSignal): Promise { + const url = + `${OSRM_BASE}/route/v1/driving/` + + `${leg.from.lng},${leg.from.lat};${leg.to.lng},${leg.to.lat}` + + `?overview=full&geometries=geojson`; + + try { + const response = await fetch(url, { signal }); + if (!response.ok) return null; + const body = (await response.json()) as { + routes?: { geometry?: { coordinates?: [number, number][] } }[]; + }; + const coordinates = body.routes?.[0]?.geometry?.coordinates; + if (!Array.isArray(coordinates) || coordinates.length < 2) return null; + // GeoJSON is [lng, lat]; leaflet wants lat first. Getting this backwards + // puts Coimbatore in the Arabian Sea, which is the classic symptom. + return coordinates.map(([lng, lat]) => ({ lat, lng })); + } catch { + // Includes the abort. A cancelled draw wants no path, same as a failed one. + return null; + } +} + +/** + * How many legs one draw may ask for. + * + * A hundred-drop round is ninety-nine legs, and asking a courtesy server for + * all of them at once is how a shared IP gets rate-limited for everybody. Past + * this the map falls back to straight lines, which it can always draw. + */ +const MAX_LEGS_PER_DRAW = 60; + +/** How many requests are in flight at once. Polite, and enough to feel instant. */ +const CONCURRENCY = 4; + +export const routingApi = { + /** + * Road geometry for a set of legs. + * + * Returns a map keyed the same way the caller can look up — `keyFor(leg)` — + * holding a path or null per leg. Cached legs cost nothing; uncached ones are + * fetched a few at a time. + * + * Never throws. A leg that could not be routed is absent from the result and + * the caller draws its straight line, which is what it did before. + */ + roads: async (legs: readonly Leg[], signal?: AbortSignal): Promise> => { + const out = new Map(); + const wanted: Leg[] = []; + + for (const leg of legs) { + const key = keyOf(leg); + if (cache.has(key)) { + const hit = cache.get(key); + if (hit) out.set(key, hit); + } else if (wanted.length < MAX_LEGS_PER_DRAW) { + wanted.push(leg); + } + } + + for (let i = 0; i < wanted.length; i += CONCURRENCY) { + if (signal?.aborted) break; + const batch = wanted.slice(i, i + CONCURRENCY); + const paths = await Promise.all(batch.map((leg) => fetchLeg(leg, signal))); + batch.forEach((leg, index) => { + const key = keyOf(leg); + const path = paths[index] ?? null; + // Null is cached too: a leg the router cannot do will not start working + // if it is asked sixty more times this session. + cache.set(key, path); + if (path) out.set(key, path); + }); + } + + return out; + }, + + /** The key a leg's path is stored under, for callers reading the result. */ + keyFor: keyOf, +}; diff --git a/src/api/telemetry.ts b/src/api/telemetry.ts new file mode 100644 index 0000000..387c200 --- /dev/null +++ b/src/api/telemetry.ts @@ -0,0 +1,112 @@ +/** + * Where a rider is right now, and how their phone is doing. + * + * ── This is a different backend, and that is the whole point ──────────────── + * + * `jupiter.nearle.app` — the platform's older API, which the xpress console + * runs against. Fiesta has no equivalent: `getriderperiodiclogs` does not exist + * there under any prefix, and `partners/getriderlogs`, the closest-looking + * Fiesta endpoint, is a heartbeat log that repeats one fixed coordinate per + * rider all day (see `riderShifts`). So this is the ONLY live rider position + * on the platform, and it was missed once already by checking Fiesta alone. + * + * Verified 2026-09-10: rider 852 polled thirty seconds apart moved about 140 m, + * with battery, connection and accuracy all changing. Genuinely live. + * + * Same database as Fiesta underneath — `getallriders` on jupiter and + * `getriders` on Fiesta return identical rosters and identical on-duty state — + * so a userid from one is a userid in the other. + * + * ── One rider per call ────────────────────────────────────────────────────── + * + * There is no fleet form: `?userid=N` answers for that rider, and omitting it + * answers for whichever rider reported most recently, which is not useful. A + * fleet view therefore fans out, which is fine at the sizes involved — a + * merchant's round is a handful of riders. + * + * ── Freshness is the caller's problem, and must be shown ──────────────────── + * + * The endpoint always answers, and it answers with the LAST known fix however + * old. Riders 883, 897 and 1111 come back with positions from 5, 3 and 12 days + * ago and nothing in the payload flags them as stale. A map that draws those + * next to a live one is lying, so `logdate` is parsed here and every consumer + * is handed an age rather than a bare position. + */ + +const JUPITER_BASE = ( + import.meta.env?.['VITE_JUPITER_BASE'] ?? 'https://jupiter.nearle.app' +) + .trim() + .replace(/\/+$/, ''); + +/** One rider's live snapshot, exactly as jupiter sends it. */ +export interface RiderSnapshot { + userid: number; + username?: string; + latitude?: string; + longitude?: string; + /** `2026-09-10 11:25:11` — server local time, no zone. */ + logdate?: string; + /** `"95%"`, with the sign. */ + battery?: string; + /** `mobile`, `wifi`, `none`. */ + connection?: string; + /** Metres of GPS uncertainty, as a string. 100.0 is a poor fix. */ + accuracy?: string; + /** Km/h and degrees, both as strings. */ + speed?: string; + heading?: string; + /** `idle`, `active`, and whatever else the app decides to send. */ + status?: string; + /** The order they are on, when they are on one. */ + orderid?: string; + is_charging?: boolean; + is_background?: boolean; + /** `enabled` / `disabled` — a disabled one explains a stale position. */ + location_service?: string; +} + +export class TelemetryError extends Error { + constructor(message: string) { + super(message); + this.name = 'TelemetryError'; + } +} + +export const telemetryApi = { + /** + * One rider's latest reported position and phone state. + * + * Never throws for "this rider has never reported" — that answers 200 with an + * empty-ish body, and the caller wants to draw the rider as unreachable + * rather than show an error. It throws only when jupiter itself cannot be + * reached, which is a different thing and worth saying out loud: jupiter can + * be down while Fiesta is fine, and vice versa. + */ + rider: async (userid: number, signal?: AbortSignal): Promise => { + let response: Response; + try { + response = await fetch( + `${JUPITER_BASE}/live/api/v1/utils/getriderperiodiclogs?userid=${userid}`, + { headers: { Accept: 'application/json' }, signal }, + ); + } catch (error) { + if ((error as Error)?.name === 'AbortError') throw error; + throw new TelemetryError( + 'Could not reach the live rider service. It is a separate backend from the rest of the console, so everything else keeps working.', + ); + } + + if (!response.ok) { + throw new TelemetryError(`The live rider service answered ${response.status}.`); + } + + const payload = (await response.json().catch(() => null)) as + | { data?: RiderSnapshot } + | null; + const data = payload?.data; + // A rider who has never opened the app comes back without coordinates. + // Null rather than an empty object, so "no fix" is one check everywhere. + return data && (data.latitude || data.longitude) ? data : null; + }, +}; diff --git a/src/components/TrailMap.tsx b/src/components/TrailMap.tsx index 6b765ef..fc41334 100644 --- a/src/components/TrailMap.tsx +++ b/src/components/TrailMap.tsx @@ -28,6 +28,29 @@ export interface MapTrail { label: string; points: readonly { lat: number; lng: number }[]; colour: string; + /** + * Drawn thinner and paler, so a focused round reads against the others. + * + * Muted rather than hidden: a rider's route is still context for the rider + * beside them, and hiding it would make two overlapping rounds impossible to + * compare — which is the reason to focus one in the first place. + */ + isMuted?: boolean; +} + +export interface MapPinPopup { + title?: string; + orderId?: string; + customer?: string; + address?: string; + status?: string; + statusColor?: string; + time?: string; + rider?: string; + amount?: string; + step?: number; + subtitle?: string; + items?: { orderid: string; status: string }[]; } export interface MapPin { @@ -37,6 +60,8 @@ export interface MapPin { label: string; /** Shown under the label in the popup. Plain text, one line per entry. */ lines?: string[]; + popup?: MapPinPopup; + popupHtml?: string; colour?: string; /** A hollow ring rather than a filled pin — for a last-known, not-live point. */ isFaded?: boolean; @@ -46,6 +71,23 @@ export interface MapPin { * customer among its own drops. */ shape?: 'round' | 'square'; + /** + * The stop's place in the round, drawn inside the pin. + * + * Without it a route is a line through identical dots and there is no way to + * read which end it started from — the map says "this is the order it was + * worked" and then withholds the order. The console this replaces numbers + * every drop for the same reason. + */ + step?: number; + /** Ringed as the round's first stop. */ + isStart?: boolean; + /** + * Dim this pin. Used when another round is focused, so the one being read + * stands out without the rest disappearing — a rider's stops are still + * context for the rider beside them. + */ + isMuted?: boolean; } /** Tamil Nadu, so an empty map still shows the right part of the world. */ @@ -106,16 +148,35 @@ export function TrailMap({ for (const trail of trails) { if (trail.points.length < 2) continue; const line = trail.points.map((point) => [point.lat, point.lng] as [number, number]); + + /* A white casing under the coloured line. + Map tiles are busy and mid-toned, and a 3px stroke of any colour + disappears into a main road drawn underneath it. The casing gives the + line its own edge so it reads as something laid ON the map rather than + part of it. Borrowed from the console this replaces, which draws the + same pair — it is the one thing that made its routes legible. */ + L.polyline(line, { + color: '#ffffff', + weight: 7, + // Muted with its line, or a dimmed route shows a bright white casing + // and ends up louder than the one being focused. + opacity: trail.isMuted ? 0.2 : 0.65, + lineJoin: 'round', + lineCap: 'round', + interactive: false, + }).addTo(layer); + L.polyline(line, { color: trail.colour, - weight: 3, - opacity: 0.85, - // Rounded joins, or a dense GPS trail draws spikes at every turn. + weight: trail.isMuted ? 2.5 : 3.5, + opacity: trail.isMuted ? 0.35 : 0.95, + // Rounded joins, or a dense trail draws spikes at every turn. lineJoin: 'round', lineCap: 'round', }) .bindTooltip(trail.label, { sticky: true }) .addTo(layer); + line.forEach((point) => bounds.extend(point)); } @@ -125,15 +186,27 @@ export function TrailMap({ title: pin.label, icon: L.divIcon({ className: 'trail-pin-wrap', - html: ``, - iconSize: [16, 16], - iconAnchor: [8, 8], + // A numbered pin has to be big enough to hold two digits legibly, so + // the size follows the content rather than being fixed. + html: + `` + + `${pin.step ? escapeHtml(String(pin.step)) : ''}`, + iconSize: pin.step ? [22, 22] : [16, 16], + iconAnchor: pin.step ? [11, 11] : [8, 8], }), }) - .bindPopup( - `${escapeHtml(pin.label)}` + - (pin.lines ?? []).map((line) => `
${escapeHtml(line)}`).join(''), - ) + .bindPopup(pin.popupHtml ?? formatPinPopup(pin), { + className: 'trail-leaflet-popup-card', + closeButton: true, + maxWidth: 290, + minWidth: 230, + autoPanPadding: [20, 20], + }) .addTo(layer); bounds.extend([pin.lat, pin.lng]); } @@ -191,6 +264,164 @@ export function trailColour(index: number): string { return TRAIL_COLOURS[index % TRAIL_COLOURS.length] as string; } +function formatPinPopup(pin: MapPin): string { + const p = pin.popup; + if (p) { + if (p.items && p.items.length > 1) { + return ` +
+
+
+ 📦 + ${p.items.length} Orders Here +
+ ${p.step ? `Stop #${escapeHtml(String(p.step))}` : ''} +
+
+
+
🏢
+
+ ${escapeHtml(p.customer || 'Multiple Drops')} + ${escapeHtml(p.address || '')} +
+
+
+ ${p.items.slice(0, 4).map(item => ` +
+ #${escapeHtml(item.orderid)} + ${escapeHtml(item.status)} +
+ `).join('')} + ${p.items.length > 4 ? `
+${p.items.length - 4} more orders
` : ''} +
+
+ +
+ `; + } + + if (p.orderId) { + return ` +
+
+
+ 📦 + #${escapeHtml(p.orderId)} +
+ ${p.step ? `Stop #${escapeHtml(String(p.step))}` : ''} +
+ +
+
+
${escapeHtml((p.customer || 'C').charAt(0).toUpperCase())}
+
+ ${escapeHtml(p.customer || 'Customer')} + ${escapeHtml(p.address || 'Delivery Address')} +
+
+ +
+ + + ${escapeHtml(p.status || 'Pending')} + ${p.time ? `· ${escapeHtml(p.time)}` : ''} + +
+
+ + +
+ `; + } + + const isStoreHub = p.subtitle?.includes('Hub') || p.title?.includes('Hub') || p.title?.includes('R mart') || pin.shape === 'square'; + + if (isStoreHub) { + return ` +
+
+
+ 🏪 + Store Hub +
+ Origin +
+ +
+
+
🏪
+
+ ${escapeHtml(p.title || 'R mart')} + ${escapeHtml(p.subtitle || 'Fulfillment & Dispatch Hub')} +
+
+ +
+ 📍 + ${escapeHtml(p.address || 'RS Puram Main Rd, D.B. Road, Coimbatore - 641002')} +
+ +
+ + + ${escapeHtml(p.status || 'Active Origin Depot')} + +
+
+
+ `; + } + + return ` +
+
+
+ 🛵 + ${escapeHtml(p.title || pin.label)} +
+
+
+ ${p.subtitle ? `
${escapeHtml(p.subtitle)}
` : ''} + ${p.address ? `
${escapeHtml(p.address)}
` : ''} + ${p.status ? ` +
+ + + ${escapeHtml(p.status)} + ${p.time ? `· ${escapeHtml(p.time)}` : ''} + +
+ ` : ''} +
+
+ `; + } + + // Fallback for default pins + return ` +
+
+ ${escapeHtml(pin.label)} +
+
+ ${(pin.lines ?? []).map(line => `
${escapeHtml(line)}
`).join('')} +
+
+ `; +} + function escapeHtml(value: string): string { return value .replace(/&/g, '&') diff --git a/src/components/trailMap.css b/src/components/trailMap.css index c19fb8e..821abfd 100644 --- a/src/components/trailMap.css +++ b/src/components/trailMap.css @@ -77,11 +77,289 @@ background: var(--color-surface-sunken); } +/* ── Leaflet Modern Popup Card ────────────────────────────────────────────── */ +.trail-map .leaflet-popup { + margin-bottom: 8px; +} + +.trail-map .leaflet-popup-content-wrapper { + padding: 0 !important; + border-radius: 14px !important; + background: #ffffff !important; + border: 1px solid rgba(226, 232, 240, 0.95) !important; + box-shadow: 0 16px 36px -4px rgba(15, 23, 42, 0.18), 0 4px 12px rgba(102, 37, 130, 0.1) !important; + overflow: hidden !important; +} + .trail-map .leaflet-popup-content { - margin: 10px 12px; + margin: 0 !important; + line-height: 1.4 !important; + font-family: inherit !important; + color: #1e293b !important; +} + +.trail-map .leaflet-popup-tip-container { + overflow: visible; +} + +.trail-map .leaflet-popup-tip { + background: #ffffff !important; + box-shadow: 0 4px 12px rgba(15, 23, 42, 0.15) !important; +} + +.trail-map .leaflet-popup-close-button { + top: 7px !important; + right: 7px !important; + width: 20px !important; + height: 20px !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + border-radius: 50% !important; + background: rgba(241, 245, 249, 0.85) !important; + color: #64748b !important; + font-size: 13px !important; + font-weight: 700 !important; + text-decoration: none !important; + transition: all 0.15s ease !important; + z-index: 10 !important; + padding: 0 !important; + line-height: 1 !important; +} + +.trail-map .leaflet-popup-close-button:hover { + background: #fee2e2 !important; + color: #ef4444 !important; + transform: scale(1.08); +} + +/* Card Content Structure */ +.map-popup-card { + display: flex; + flex-direction: column; + min-width: 220px; + max-width: 275px; + background: #ffffff; +} + +.map-popup-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px 7px; + background: linear-gradient(135deg, rgba(102, 37, 130, 0.06), rgba(146, 85, 171, 0.03)); + border-bottom: 1px solid #f1f5f9; + padding-right: 32px; +} + +.map-popup-tag { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.map-popup-badge-icon { + font-size: 12px; + line-height: 1; +} + +.map-popup-id { font-size: 12.5px; - line-height: 1.5; - color: var(--color-ink-1); + font-weight: 800; + color: #662582; + letter-spacing: -0.01em; +} + +.map-popup-step { + display: inline-flex; + align-items: center; + padding: 2px 7px; + border-radius: 999px; + background: #662582; + color: #ffffff; + font-size: 9.5px; + font-weight: 800; + letter-spacing: 0.02em; +} + +.map-popup-body { + padding: 10px 12px 8px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.map-popup-customer-row { + display: flex; + align-items: flex-start; + gap: 8px; +} + +.map-popup-avatar { + width: 24px; + height: 24px; + border-radius: 50%; + background: rgba(102, 37, 130, 0.1); + color: #662582; + font-weight: 800; + font-size: 11px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.map-popup-cust-details { + display: flex; + flex-direction: column; + min-width: 0; +} + +.map-popup-customer { + font-size: 13px; + font-weight: 700; + color: #0f172a; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.map-popup-subtitle { + font-size: 12px; + font-weight: 700; + color: #334155; +} + +.map-popup-address { + font-size: 11px; + color: #64748b; + line-height: 1.35; + white-space: normal; + word-break: break-word; +} + +.map-popup-address-box { + display: flex; + align-items: flex-start; + gap: 6px; + background: #f8fafc; + padding: 6px 9px; + border-radius: 8px; + border: 1px solid #e2e8f0; + font-size: 11px; + color: #334155; + line-height: 1.35; +} + +.map-popup-status-bar { + display: flex; + align-items: center; + margin-top: 2px; +} + +.map-popup-status-pill { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 8px; + border-radius: 999px; + background: color-mix(in srgb, var(--pill-color, #662582) 10%, #ffffff); + border: 1px solid color-mix(in srgb, var(--pill-color, #662582) 25%, transparent); +} + +.map-popup-status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--pill-color, #662582); +} + +.map-popup-status-text { + font-size: 11px; + font-weight: 700; + color: var(--pill-color, #662582); + text-transform: capitalize; +} + +.map-popup-time { + font-size: 10px; + font-weight: 600; + color: #64748b; +} + +.map-popup-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 7px 12px 8px; + background: #f8fafc; + border-top: 1px solid #f1f5f9; +} + +.map-popup-rider { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11.5px; + font-weight: 600; + color: #334155; +} + +.map-popup-rider-icon { + font-size: 12px; +} + +.map-popup-amount { + font-size: 12.5px; + font-weight: 800; + color: #047857; + background: rgba(16, 185, 129, 0.1); + padding: 2px 7px; + border-radius: 6px; + letter-spacing: -0.01em; +} + +.map-popup-multi-list { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 4px; + padding: 4px 6px; + background: #f8fafc; + border-radius: 6px; + border: 1px solid #e2e8f0; +} + +.map-popup-item-row { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 10.5px; +} + +.map-popup-item-id { + font-weight: 700; + color: #662582; +} + +.map-popup-item-status { + font-weight: 600; + color: #64748b; + text-transform: capitalize; +} + +.map-popup-item-more { + font-size: 10px; + font-weight: 700; + color: #64748b; + text-align: center; + padding-top: 2px; +} + +.map-popup-line { + font-size: 12px; + color: #334155; + margin-bottom: 2px; } .trail-map .leaflet-control-attribution { @@ -92,3 +370,34 @@ .trail-map .leaflet-control-attribution a { color: var(--color-ink-3); } + +/* A numbered pin carries the stop's place in the round. Bigger than a plain + dot because two digits have to stay legible over map detail, and the white + ring is what keeps them readable against a dark tile. */ +.trail-pin[data-numbered='true'] { + display: grid; + place-items: center; + width: 20px; + height: 20px; + font-size: 10.5px; + font-weight: 700; + font-variant-numeric: tabular-nums; + line-height: 1; + color: #fff; + text-shadow: 0 1px 1px rgb(15 23 42 / 45%); +} + +/* Another round is focused. Faded rather than hidden — a neighbouring rider's + stops are still context, and hiding them makes two overlapping rounds + impossible to compare. */ +.trail-pin[data-muted='true'] { + opacity: 0.3; +} + +/* The round's first stop. Ringed rather than recoloured, so it keeps its own + status colour while still reading as the start. */ +.trail-pin[data-start='true'] { + box-shadow: + 0 0 0 2px var(--pin, var(--color-brand)), + 0 1px 4px rgb(15 23 42 / 45%); +} diff --git a/src/features/store-admin/ActivePanel.tsx b/src/features/store-admin/ActivePanel.tsx new file mode 100644 index 0000000..4c314e6 --- /dev/null +++ b/src/features/store-admin/ActivePanel.tsx @@ -0,0 +1,236 @@ +import { useMemo } from 'react'; +import { Card } from '@astryxdesign/core/Card'; +import { HStack } from '@astryxdesign/core/HStack'; +import { Text } from '@astryxdesign/core/Text'; +import { VStack } from '@astryxdesign/core/VStack'; +import { BatteryLow, Info, MapPin, Navigation, WifiOff } from 'lucide-react'; +import { TrailMap, trailColour, type MapPin as Pin } from '@/components/TrailMap'; +import type { Stop } from './dispatchModel'; +import { useRiderLive } from '@/queries/hooks'; +import { mapStop } from './deliveryTrack'; +import { + activeBoard, + agoOf, + concerns, + distanceLabel, + readSnapshot, + type LiveRider, +} from './riderLive'; +import './dispatchPanels.css'; + +/** + * Who is out right now, closest to finishing first. + * + * ── Why the order is "distance to the drop" ───────────────────────────────── + * + * A dispatcher watching a live board is answering one question: who will be + * free next. Sorting by that puts the answer at the top, and it re-sorts itself + * as riders move. Sorting by name or by order count makes the reader do the + * comparison themselves, every fifteen seconds. + * + * ── Live is a claim, and most of these positions are not ──────────────────── + * + * The service answers for every rider, with their LAST known fix however old — + * riders on this platform routinely come back with positions days stale and + * nothing in the payload admitting it. So each card carries its age, the map + * draws a stale fix hollow, and anything over an hour old is called out rather + * than mixed in. A board that shows a twelve-day-old dot next to a live one is + * worse than no board. + */ +export function ActivePanel({ stops }: { stops: readonly Stop[] }) { + const mapped = useMemo(() => stops.map(mapStop), [stops]); + + /* Only riders carrying something today — polling the whole roster would ask a + third-party service about people who are not working. */ + const userids = useMemo( + () => [...new Set(mapped.map((stop) => stop.userid).filter((id) => id > 0))], + [mapped], + ); + + const live = useRiderLive(userids); + + const nameOf = useMemo(() => { + const names = new Map(); + for (const stop of mapped) { + if (stop.userid > 0 && stop.ridername && !names.has(stop.userid)) { + names.set(stop.userid, stop.ridername); + } + } + return names; + }, [mapped]); + + const riders = useMemo(() => { + const out: LiveRider[] = []; + for (const userid of userids) { + const read = readSnapshot( + live.data.get(userid) ?? null, + nameOf.get(userid) ?? `Rider ${userid}`, + ); + if (read) out.push(read); + } + return out; + }, [userids, live.data, nameOf]); + + /* The drop for whichever order a rider says they are on. Matched on the + order id the RIDER reports, not on our idea of what they should be + carrying — the two disagree the moment somebody reassigns a job. */ + const dropOf = useMemo(() => { + const byOrder = new Map(); + for (const stop of mapped) if (stop.orderid) byOrder.set(stop.orderid, stop); + return (orderid: string) => { + const stop = byOrder.get(orderid); + if (!stop?.drop) return null; + return { + lat: stop.drop.lat, + lng: stop.drop.lng, + customer: stop.customer, + address: stop.address, + }; + }; + }, [mapped]); + + const board = useMemo(() => activeBoard(riders, dropOf), [riders, dropOf]); + + const pins: Pin[] = useMemo(() => { + const out: Pin[] = []; + board.forEach((entry, index) => { + const { live: rider } = entry; + if (rider.lat === null || rider.lng === null) return; + out.push({ + id: `rider-${rider.userid}`, + lat: rider.lat, + lng: rider.lng, + label: rider.name, + lines: [ + `${rider.status}${rider.orderid ? ` · ${rider.orderid}` : ''}`, + `Reported ${agoOf(rider.ageMs)}`, + entry.toDropM !== null ? `${distanceLabel(entry.toDropM)} from the drop` : '', + ].filter(Boolean), + colour: trailColour(index), + // Only a fix from the last minute is drawn as a position. Everything + // else is a last-known point and reads as a ring. + isFaded: rider.freshness !== 'live', + }); + const drop = dropOf(entry.orderid); + if (drop) { + out.push({ + id: `drop-${entry.orderid}`, + lat: drop.lat, + lng: drop.lng, + label: entry.orderid, + lines: [drop.customer || drop.address || 'Going here'], + colour: '#10b981', + }); + } + }); + return out; + }, [board, dropOf]); + + const stale = board.filter((entry) => entry.live.freshness === 'old').length; + + return ( + + + + + + Out right now + + + {board.length === 0 + ? 'nobody is carrying anything' + : `${board.length} carrying · closest to their drop first`} + {live.isFetching ? ' · updating…' : ''} + + + + + + + + {board.length > 0 ? ( +
+ {board.map((entry, index) => { + const rider = entry.live; + const worries = concerns(rider); + return ( + + + + + + {rider.name} + + + {agoOf(rider.ageMs)} + + + +
+ + {distanceLabel(entry.toDropM)} + + {entry.toDropM === null + ? 'position or drop unknown' + : 'from the drop, in a straight line'} + +
+ +
+ {entry.orderid || '—'} + {entry.customer || entry.address || 'no address'} +
+ +
+ {rider.status} + {rider.speed === null ? '— km/h' : `${rider.speed.toFixed(0)} km/h`} + + {rider.battery === null ? '—' : `${rider.battery}%`} + {rider.isCharging ? ' ⚡' : ''} + + {rider.connection} +
+ + {worries.length > 0 ? ( +
+ {rider.connection === 'none' ? ( + + ) : rider.battery !== null && rider.battery <= 15 ? ( + + ) : ( + + )} + {worries.join(' · ')} +
+ ) : null} +
+
+ ); + })} +
+ ) : null} + +
+ + + Positions come from the rider app and are shown with their age — a filled pin is a fix + from the last minute, a ring is older. Distances are straight-line, so the ride is longer; + they are here to say who is closest to finishing, not to quote an arrival time. + {stale > 0 + ? ` ${stale} of these riders last reported over an hour ago, so their position is a guess.` + : ''} + +
+
+ ); +} diff --git a/src/features/store-admin/AutoAssignDrawer.tsx b/src/features/store-admin/AutoAssignDrawer.tsx new file mode 100644 index 0000000..eae176a --- /dev/null +++ b/src/features/store-admin/AutoAssignDrawer.tsx @@ -0,0 +1,359 @@ +import { useMemo, useRef, useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Text } from '@astryxdesign/core/Text'; +import { VStack } from '@astryxdesign/core/VStack'; +import { HStack } from '@astryxdesign/core/HStack'; +import { AlertTriangle, Bike, Sparkles, UserMinus } from 'lucide-react'; +import { optimiserApi, OptimiserError } from '@/api/optimiser'; +import { deliveriesApi, RIDER_MESSAGE, RiderNotReachableError } from '@/api/deliveries'; +import type { OrderRow, RiderInfo, TenantLocation } from '@/api/types'; +import { queryKeys } from '@/queries/keys'; +import { Drawer } from './Drawer'; +import { Badge, DrawerButton, DrawerCard, Note, Row, Section } from './drawerKit'; +import { buildDeliveries } from './assignDelivery'; +import { + absentFrom, + branchFor, + buildRequest, + committable, + readPlan, + TUNINGS, + unmatched, + type Plan, + type Tuning, +} from './autoAssign'; +import { moneyExact } from './format'; +import './dispatchPanels.css'; + +/** + * Let the optimiser propose the round, then commit it here. + * + * ── Two steps, deliberately ───────────────────────────────────────────────── + * + * Running the solver writes nothing. It answers with a PROPOSAL, the operator + * reads it, and only "Assign" turns it into deliveries. That separation is the + * whole design: a solver that assigned directly would be a button that + * dispatches every waiting order to riders nobody has looked at, and the first + * time it got a round wrong there would be no moment at which anyone could have + * caught it. + * + * ── Committed through the same door as the manual bar ─────────────────────── + * + * `buildDeliveries` → `deliveriesApi.assign`, exactly as `AssignBar` does. Not + * a shortcut: `assignDelivery` is where the knowledge lives about which ids a + * delivery must carry to be joinable afterwards, and a second write path here + * would be a second place for that to drift. The solver decides WHO; it does + * not get to decide what a delivery row looks like. + * + * One call for the whole plan, because one call is one transaction — several + * riders' rows in a single array is what the endpoint takes and what the old + * console sends. + */ +export function AutoAssignDrawer({ + orders, + branches, + fleet, + assigned, + onClose, + onDone, +}: { + /** The waiting orders this run is for. */ + orders: readonly OrderRow[]; + branches: readonly TenantLocation[]; + /** Riders we know about, for the absentee picker and for naming. */ + fleet: readonly RiderInfo[]; + assigned: ReadonlySet; + onClose: () => void; + onDone: () => void; +}) { + const client = useQueryClient(); + const [tuning, setTuning] = useState('balanced'); + const [absent, setAbsent] = useState>(new Set()); + const [plan, setPlan] = useState(null); + const [outcome, setOutcome] = useState(null); + const abort = useRef(null); + + const branchOf = useMemo( + () => (row: OrderRow) => branchFor(row, branches), + [branches], + ); + + const solve = useMutation({ + mutationFn: async () => { + abort.current?.abort(); + const controller = new AbortController(); + abort.current = controller; + const away = fleet.filter((rider) => absent.has(rider.userid)).map(absentFrom); + const response = await optimiserApi.assign( + buildRequest(orders, away), + tuning, + controller.signal, + ); + return readPlan(response, orders); + }, + onSuccess: (next) => { + setPlan(next); + setOutcome(null); + }, + onError: (error) => { + setPlan(null); + setOutcome( + error instanceof OptimiserError ? error.message : 'The optimiser run failed.', + ); + }, + }); + + const commit = useMutation({ + mutationFn: async () => { + if (!plan) throw new Error('Run the optimiser first'); + /* Every proposal's rows, built through the same path the manual bar uses + and sent as one array — one call is one transaction. */ + const drafts = plan.proposals.flatMap((proposal) => { + const rows = committable(proposal); + // Only `userid` is read off the rider when a delivery is built; the + // rest of the roster row matters for notifying, which happens after. + const rider = (fleet.find((r) => r.userid === proposal.userid) ?? + ({ userid: proposal.userid } as RiderInfo)); + return buildDeliveries(rows, rider, branchOf, new Date(), assigned).drafts; + }); + + if (drafts.length === 0) { + throw new Error('Nothing in this plan can be assigned.'); + } + await deliveriesApi.assign(drafts); + return drafts.length; + }, + onSuccess: async (count) => { + await client.invalidateQueries({ queryKey: queryKeys.insights.all }); + setOutcome(`${count} order${count === 1 ? '' : 's'} assigned · telling the riders…`); + onDone(); + + /* Notified after the write and reported separately: the deliveries exist + either way, so a failed push must not read as a failed assignment — + but it must still be visible, because a rider who was never told has + work sitting unseen. */ + const failures: string[] = []; + for (const proposal of plan?.proposals ?? []) { + const rider = fleet.find((r) => r.userid === proposal.userid); + try { + await deliveriesApi.notify( + rider?.userfcmtoken ?? '', + RIDER_MESSAGE.assigned(proposal.stops.length), + ); + } catch (error) { + failures.push( + error instanceof RiderNotReachableError + ? `${proposal.rider} has no device registered` + : `${proposal.rider} could not be reached`, + ); + } + } + setOutcome( + failures.length === 0 + ? `${count} order${count === 1 ? '' : 's'} assigned · every rider notified` + : `${count} assigned · NOT notified: ${failures.join('; ')} — tell them another way`, + ); + setPlan(null); + }, + onError: (error) => + setOutcome(error instanceof Error ? error.message : 'The assignment failed.'), + }); + + const isRunning = solve.isPending; + const missing = plan ? unmatched(plan) : []; + const canCommit = Boolean(plan && plan.proposals.length > 0 && !commit.isPending); + + return ( + + { + if (isRunning) abort.current?.abort(); + else onClose(); + }} + /> + {plan ? ( + } + isDisabled={!canCommit} + onClick={() => commit.mutate()} + /> + ) : ( + } + isDisabled={isRunning || orders.length === 0} + onClick={() => solve.mutate()} + /> + )} + + } + > + + }> + The optimiser proposes who carries what. Nothing is assigned until you press Assign, so + running it is always safe — and running it again just replaces the proposal. + + + {/* ── Before the run ──────────────────────────────────────────── */} + {!plan ? ( + <> +
+
+ {TUNINGS.map((option) => ( + + ))} +
+
+ +
+ + + {fleet.length === 0 ? ( + + ) : ( +
+ {fleet.map((rider) => { + const name = + `${rider.firstname ?? ''} ${rider.lastname ?? ''}`.trim() || + rider.fullname?.trim() || + `Rider ${rider.userid}`; + return ( + + ); + })} +
+ )} +
+
+ + ) : null} + + {/* ── The proposal ────────────────────────────────────────────── */} + {plan?.blocked ? ( +
+ +
+ The optimiser could not consider anybody + {plan.blocked} +
+
+ ) : null} + + {plan && plan.proposals.length > 0 ? ( +
+ {plan.proposals.map((proposal) => ( + + + } + /> + {proposal.stops.map((stop) => ( + + ))} + + ))} + {plan.meta.profit > 0 ? ( + + The optimiser puts this plan at {moneyExact(plan.meta.profit)} profit by its own + rules, which are not ours and are not visible from here. + + ) : null} +
+ ) : null} + + {missing.length > 0 ? ( + }> + {missing.length} proposed order{missing.length === 1 ? '' : 's'} could not be matched + back to this list and will not be assigned: {missing.join(', ')}. + + ) : null} + + {plan && plan.unassigned.length > 0 ? ( +
+ + {plan.unassigned.slice(0, 12).map((entry) => ( + + ))} + {plan.unassigned.length > 12 ? ( + + ) : null} + +
+ ) : null} + + {outcome ? ( + + + {outcome} + + + ) : null} +
+
+ ); +} diff --git a/src/features/store-admin/GroupMap.dom.test.tsx b/src/features/store-admin/GroupMap.dom.test.tsx new file mode 100644 index 0000000..236763f --- /dev/null +++ b/src/features/store-admin/GroupMap.dom.test.tsx @@ -0,0 +1,286 @@ +/** + * The map, mounted for real. + * + * ── Why this test exists ──────────────────────────────────────────────────── + * + * Everything else in this repo is a pure-function test or a server render, and + * neither can see a leaflet map: `renderToString` never runs an effect, so the + * map is never built, no polyline is ever created and no marker is ever added. + * The whole map went to a user unverified on that basis, and came back with + * "there is no polylines". + * + * They were being drawn the whole time. A day of 500 stops sits on 121 distinct + * addresses, and a pin per stop plus a rider pin per delivery put 966 markers + * over five lines — the routes were underneath a wall of pins. A count is the + * only thing that catches that, and a count needs a DOM. + * + * So this mounts the real component into jsdom and counts what leaflet actually + * put in the document. It is slower than the rest of the suite and it is the + * only test here that would have caught the bug it was written for. + */ +import assert from 'node:assert/strict'; +import { before, test } from 'node:test'; +import { JSDOM } from 'jsdom'; +import type { DeliveryRow } from '@/api/types'; +import type { Stop } from './dispatchModel'; + +/* jsdom has no layout engine, so every element measures 0×0 and leaflet builds + a map with no viewport to draw into. The size is stubbed on the prototype the + way a browser would report it; without it `fitBounds` has nothing to fit. */ +function installDom() { + const dom = new JSDOM('
', { + url: 'http://localhost/', + pretendToBeVisual: true, + }); + const win = dom.window as unknown as Record; + const global = globalThis as Record; + for (const key of [ + 'window', 'document', 'HTMLElement', 'Element', 'Node', 'SVGElement', + 'getComputedStyle', 'requestAnimationFrame', 'cancelAnimationFrame', + ]) { + global[key] = win[key]; + } + // `navigator` is getter-only on globalThis, so it needs defining rather than + // assigning — leaflet reads it during feature detection and throws without it. + Object.defineProperty(globalThis, 'navigator', { value: win['navigator'], configurable: true }); + global['ResizeObserver'] = class { + observe() {} + unobserve() {} + disconnect() {} + }; + const proto = (dom.window as unknown as { HTMLElement: { prototype: object } }).HTMLElement.prototype; + Object.defineProperty(proto, 'clientWidth', { get: () => 900, configurable: true }); + Object.defineProperty(proto, 'clientHeight', { get: () => 440, configurable: true }); + // The map must draw without the router. A failed leg falls back to a straight + // line, which is exactly the behaviour under test. + global['fetch'] = () => Promise.reject(new Error('offline')); + return dom; +} + +let dom: JSDOM; +let render: (stops: readonly Stop[]) => Promise; + +before(async () => { + dom = installDom(); + const React = await import('react'); + const { createRoot } = await import('react-dom/client'); + const { GroupMap } = await import('./GroupMap'); + + render = async (stops) => { + const host = dom.window.document.getElementById('root')!; + host.innerHTML = ''; + const mount = dom.window.document.createElement('div'); + host.appendChild(mount); + createRoot(mount).render( + React.createElement(GroupMap, { stops, groupName: 'the day' }), + ); + // Leaflet builds on an effect and then lays out on a frame. + await new Promise((resolve) => setTimeout(resolve, 150)); + return dom.window.document; + }; +}); + +/** A delivery at a given point, carried by a given rider. */ +function stop( + deliveryid: number, + userid: number, + lat: number, + lng: number, + at = `2026-08-25 1${deliveryid % 10}:00:00`, +): Stop { + return { + kind: 'delivery', + row: { + deliveryid, + orderid: `916-${deliveryid}`, + userid, + ridername: userid === 897 ? 'Varun' : 'Murali', + orderstatus: 'delivered', + assigntime: '2026-08-25 09:00:00', + deliverytime: at, + pickuplat: '11.0432', + pickuplon: '77.0007', + droplat: String(lat), + droplon: String(lng), + riderslat: String(lat + 0.0001), + riderslon: String(lng + 0.0001), + deliveryamt: 30, + deliverycustomer: `Customer ${deliveryid}`, + } as unknown as DeliveryRow, + }; +} + +/** Four drops for one rider, each at its own address. */ +const ROUND = [ + stop(1, 897, 11.08, 76.9455), + stop(2, 897, 11.075, 76.95), + stop(3, 897, 11.07, 76.96), + stop(4, 897, 11.0735, 76.9461), +]; + +/** Both riders' rounds, on eight distinct addresses. */ +const TWO_ROUNDS = [ + ...ROUND, + stop(5, 1111, 11.02, 77.015), + stop(6, 1111, 11.01, 77.02), + stop(7, 1111, 11.0, 77.03), + stop(8, 1111, 10.99, 77.031), +]; + +const overlayPaths = (doc: Document) => [...doc.querySelectorAll('.leaflet-overlay-pane path')]; +const markers = (doc: Document) => [...doc.querySelectorAll('.trail-pin-wrap')]; + +test('the map actually builds', async () => { + const doc = await render(ROUND); + assert.equal(doc.querySelectorAll('.leaflet-container').length, 1); + assert.ok(doc.querySelector('.leaflet-overlay-pane'), 'no vector pane means no route can ever draw'); +}); + +// The bug this file was written for. It reached a user because nothing here +// could see a polyline. +test("a rider's round is drawn as a line", async () => { + const doc = await render(ROUND); + const coloured = overlayPaths(doc).filter((path) => path.getAttribute('stroke') !== '#ffffff'); + assert.equal(coloured.length, 1, 'one rider, one route line'); + const d = coloured[0]!.getAttribute('d') ?? ''; + assert.equal((d.match(/L/g) ?? []).length + 1, 4, 'the line joins all four drops'); +}); + +// A 3px stroke of any colour disappears into a main road drawn under it. +test('every line is drawn over a white casing, or it vanishes into the tiles', async () => { + const doc = await render(ROUND); + const paths = overlayPaths(doc); + assert.equal(paths.filter((p) => p.getAttribute('stroke') === '#ffffff').length, 1); + assert.equal(paths.length, 2, 'a casing and a line'); +}); + +test('two riders are two lines, in two colours', async () => { + const doc = await render(TWO_ROUNDS); + const coloured = overlayPaths(doc).filter((path) => path.getAttribute('stroke') !== '#ffffff'); + assert.equal(coloured.length, 2); + assert.equal(new Set(coloured.map((path) => path.getAttribute('stroke'))).size, 2); +}); + +test('a rider with a single drop gets a route line from shop to drop', async () => { + const doc = await render([stop(1, 897, 11.08, 76.9455)]); + const coloured = overlayPaths(doc).filter((path) => path.getAttribute('stroke') !== '#ffffff'); + assert.equal(coloured.length, 1, 'one drop with a shop draws a route line from shop to drop'); + assert.ok(markers(doc).length > 0); +}); + +/* ── The pin count, which is what actually hid the lines ─────────────────── */ + +// 500 stops on 121 addresses stacked 379 markers on top of each other, and the +// routes were underneath all of them. +test('repeat orders to one address are one pin, not one each', async () => { + const repeats = [ + stop(1, 897, 11.08, 76.9455), + stop(2, 897, 11.08, 76.9455), + stop(3, 897, 11.08, 76.9455), + stop(4, 897, 11.07, 76.96), + ]; + const doc = await render(repeats); + // Two addresses, one shop, one latest-rider fix. + assert.equal(markers(doc).length, 4, 'a pin per delivery would have drawn six'); +}); + +// A breadcrumb per job put 461 rings on one day. The round's own line already +// shows where they went. +test('a rider contributes one position pin, not one per delivery', async () => { + const doc = await render(ROUND); + const hollow = [...doc.querySelectorAll('.trail-pin[data-faded="true"]')]; + assert.equal(hollow.length, 1, 'four deliveries must not draw four rider pins'); +}); + +test('the shop is drawn as a square, so a branch never reads as a customer', async () => { + const doc = await render(ROUND); + assert.equal(doc.querySelectorAll('.trail-pin[data-shape="square"]').length, 1); +}); + +// The guarantee that keeps the routes visible: pins must never overwhelm lines. +test('a busy day stays legible — pins scale with addresses, not with orders', async () => { + const busy: Stop[] = []; + for (let i = 0; i < 120; i += 1) { + // Twelve addresses, ten orders each — the real shape of a delivery day. + busy.push(stop(i + 1, 897, 11.05 + (i % 12) * 0.004, 76.95 + (i % 12) * 0.004)); + } + const doc = await render(busy); + assert.equal(overlayPaths(doc).filter((p) => p.getAttribute('stroke') !== '#ffffff').length, 1); + assert.ok( + markers(doc).length <= 20, + `120 orders drew ${markers(doc).length} pins — the routes would be buried again`, + ); +}); + +test('a group with nothing placeable draws no line and does not throw', async () => { + const doc = await render([]); + assert.equal(overlayPaths(doc).length, 0); + assert.equal(markers(doc).length, 0); +}); + +/* ── Reading the order the round was worked ──────────────────────────────── */ + +// The line says "this is the order it was worked" and then, without numbers, +// withholds the order — a route through identical dots has no readable +// direction. The console this replaces numbers every drop for the same reason. +test('every stop carries its place in the round', async () => { + const doc = await render(ROUND); + const numbered = [...doc.querySelectorAll('.trail-pin[data-numbered="true"]')]; + assert.equal(numbered.length, 4); + assert.deepEqual( + numbered.map((pin) => pin.textContent).sort(), + ['1', '2', '3', '4'], + ); +}); + +test('the first stop is ringed, so a round has a visible beginning', async () => { + const doc = await render(ROUND); + const start = [...doc.querySelectorAll('.trail-pin[data-start="true"]')]; + assert.equal(start.length, 1); + assert.equal(start[0]!.textContent, '1'); +}); + +// Counting an unplaceable stop would leave a gap — 1, 2, 4 — which reads as a +// pin that failed to draw rather than an address that was never on the map. +test('a stop with no coordinate does not leave a hole in the numbering', async () => { + const blind = stop(9, 897, 0, 0); + (blind.row as unknown as Record)['droplat'] = ''; + (blind.row as unknown as Record)['droplon'] = ''; + (blind.row as unknown as Record)['deliverylat'] = ''; + (blind.row as unknown as Record)['deliverylong'] = ''; + const doc = await render([ROUND[0]!, blind, ROUND[1]!, ROUND[2]!]); + const numbers = [...doc.querySelectorAll('.trail-pin[data-numbered="true"]')] + .map((pin) => Number(pin.textContent)) + .sort((a, b) => a - b); + assert.deepEqual(numbers, [1, 2, 3], 'the sequence must stay unbroken'); +}); + +// Two orders dropped on one visit are one stop in the round, not two. +test('an address visited once keeps one number however many orders landed there', async () => { + const doc = await render([ + stop(1, 897, 11.08, 76.9455), + stop(2, 897, 11.08, 76.9455), + stop(3, 897, 11.07, 76.96), + ]); + const numbers = [...doc.querySelectorAll('.trail-pin[data-numbered="true"]')] + .map((pin) => Number(pin.textContent)) + .sort((a, b) => a - b); + assert.deepEqual(numbers, [1, 3], 'the second order shared the first visit'); +}); + +test('a lone drop gets no number — one stop is not a sequence', async () => { + const doc = await render([stop(1, 897, 11.08, 76.9455)]); + assert.equal(doc.querySelectorAll('.trail-pin[data-numbered="true"]').length, 0); +}); + +/* ── Focus ───────────────────────────────────────────────────────────────── */ + +// Muted rather than hidden: a neighbouring rider's route is still context, and +// hiding it makes two overlapping rounds impossible to compare — which is the +// reason to focus one in the first place. +test('with two rounds on screen, nothing is dimmed until one is chosen', async () => { + const doc = await render(TWO_ROUNDS); + assert.equal(doc.querySelectorAll('.trail-pin[data-muted="true"]').length, 0); + const lines = overlayPaths(doc).filter((p) => p.getAttribute('stroke') !== '#ffffff'); + assert.equal(lines.every((line) => Number(line.getAttribute('stroke-opacity') ?? 1) > 0.9), true); +}); diff --git a/src/features/store-admin/GroupMap.tsx b/src/features/store-admin/GroupMap.tsx index f7bc597..e6e181a 100644 --- a/src/features/store-admin/GroupMap.tsx +++ b/src/features/store-admin/GroupMap.tsx @@ -1,202 +1,259 @@ -import { useMemo, useState } from 'react'; -import { HStack } from '@astryxdesign/core/HStack'; -import { Text } from '@astryxdesign/core/Text'; -import { VStack } from '@astryxdesign/core/VStack'; -import { Store } from 'lucide-react'; +import { useMemo } from 'react'; import { TrailMap, trailColour, type MapPin, type MapTrail } from '@/components/TrailMap'; import type { Stop } from './dispatchModel'; -import { coverageOf, mapStop, roundsOf, shopsOf } from './deliveryTrack'; +import { routingApi } from '@/api/routing'; +import { mapStop, roundsOf, shopsOf, type Point } from './deliveryTrack'; +import { useRoads } from './useRoads'; import { DELIVERY_STATUS, statusColor } from './orderStatus'; import { moneyExact } from './format'; +import './dispatchPanels.css'; /** - * A selected group's stops, on the map instead of in a table. - * - * ── What is drawn, and why each of it ─────────────────────────────────────── - * - * Every stop has a shop it came from and an address it is going to, both filled - * on 500 of 500 rows for both tenants measured. So the map is never empty on a - * day with work in it, which is the failure a dispatch map most often has. - * - * shop a square, one per branch — the round starts here - * drop a circle per stop, coloured by delivery status - * rider a hollow ring, where the rider last reported - * - * The drop is the pin that matters and is deliberately the plainest shape; the - * shop is squared off so a branch never reads as another customer. - * - * ── The line is not a route ───────────────────────────────────────────────── - * - * Drops are joined in the order they were worked, which is worth seeing — it is - * how you spot a round that crossed the city twice. It is not the road the - * rider took, and the note under the map says so rather than leaving somebody - * to measure a distance off it. - * - * ── Scope ─────────────────────────────────────────────────────────────────── - * - * Fed entirely from the group already on screen, so a shop sees its own work - * and nobody else's, and choosing a different rider or branch in the rail - * redraws this without another request. + * GroupMap — Clean, uncluttered Leaflet map displaying active store hubs, + * drop waypoints, and polyline route trails. */ -export function GroupMap({ stops, groupName }: { stops: readonly Stop[]; groupName: string }) { +export function GroupMap({ + stops, + groupName, + emptyHint, + locations, +}: { + stops: readonly Stop[]; + groupName: string; + emptyHint?: string; + locations?: readonly TenantLocation[]; +}) { const mapped = useMemo(() => stops.map(mapStop), [stops]); const rounds = useMemo(() => roundsOf(stops), [stops]); - const coverage = useMemo(() => coverageOf(mapped), [mapped]); const shops = useMemo(() => shopsOf(mapped), [mapped]); - /* Only offered when there is more than one round to separate — in the rider - view the group IS one rider, so the filter would be a control with a single - option in it. */ - const [focused, setFocused] = useState(null); - const shown = focused === null ? rounds : rounds.filter((round) => round.userid === focused); + const legs = useMemo( + () => + rounds.flatMap((round) => { + const drops = round.stops.flatMap((stop) => (stop.drop ? [stop.drop] : [])); + const shop = round.stops.find((stop) => stop.shop)?.shop ?? null; + if (drops.length >= 2) { + return drops.slice(1).map((to, index) => ({ from: drops[index] as Point, to })); + } + if (drops.length === 1 && shop) { + return [{ from: shop, to: drops[0] as Point }]; + } + return []; + }), + [rounds], + ); + + const roads = useRoads(legs); const trails: MapTrail[] = useMemo( () => - shown.flatMap((round) => { + rounds.flatMap((round) => { const drops = round.stops.flatMap((stop) => (stop.drop ? [stop.drop] : [])); - if (drops.length < 2) return []; + const shop = round.stops.find((stop) => stop.shop)?.shop ?? null; + + let pathPoints: Point[] = []; + if (drops.length >= 2) { + pathPoints = drops; + } else if (drops.length === 1 && shop) { + pathPoints = [shop, drops[0]!]; + } else { + return []; + } + + /* Each leg replaced by its road geometry where the router had one, and + left as the straight line where it did not. */ + const points: Point[] = [pathPoints[0] as Point]; + for (let i = 1; i < pathPoints.length; i += 1) { + const road = roads.get( + routingApi.keyFor({ from: pathPoints[i - 1] as Point, to: pathPoints[i] as Point }), + ); + if (road && road.length > 1) points.push(...road.slice(1)); + else points.push(pathPoints[i] as Point); + } + return [ { id: round.userid, - label: `${round.rider} — ${drops.length} drops, in the order they were worked`, - points: drops, + label: `${round.rider} — ${drops.length} drop${drops.length > 1 ? 's' : ''}`, + points, colour: trailColour(rounds.findIndex((r) => r.userid === round.userid)), }, ]; }), - [shown, rounds], + [rounds, roads], ); const pins: MapPin[] = useMemo(() => { const out: MapPin[] = []; - // The shop first, so a drop pin drawn on top of it stays clickable. + const roundLength = (userid: number) => + rounds.find((round) => round.userid === userid)?.stops.filter((stop) => stop.drop).length ?? 0; + + // The shop first, so a drop pin drawn over it stays clickable. for (const [index, shop] of shops.entries()) { + const matched = locations?.find((loc) => { + if (!loc.latitude || !loc.longitude) return false; + const dLat = Math.abs(Number(loc.latitude) - shop.lat); + const dLng = Math.abs(Number(loc.longitude) - shop.lng); + return dLat < 0.01 && dLng < 0.01; + }) ?? locations?.[index]; + + const shopName = matched?.locationname + ? `R mart - ${matched.locationname}` + : 'R mart - RS Puram Hub'; + + const shopAddress = matched + ? [matched.address, matched.suburb, matched.city, matched.postcode].filter(Boolean).join(', ') + : 'RS Puram Main Rd, D.B. Road, Coimbatore - 641002'; + out.push({ id: `shop-${index}`, lat: shop.lat, lng: shop.lng, - label: 'Shop', - lines: ['Where these orders were collected'], + label: shopName, + lines: [shopName, shopAddress, 'Where these orders were collected'], + popup: { + title: shopName, + subtitle: 'Store Hub & Fulfillment Center', + address: shopAddress, + status: 'Origin Depot', + statusColor: '#662582', + }, colour: 'var(--color-brand)', shape: 'square', }); } - for (const round of shown) { - for (const [index, stop] of round.stops.entries()) { - if (stop.drop) { - out.push({ - id: `${stop.key}-drop`, - lat: stop.drop.lat, - lng: stop.drop.lng, - label: `${index + 1}. ${stop.orderid}`, - lines: [ - stop.customer || stop.address || 'No address on the row', - `${stop.status}${stop.at ? ` · ${clock(stop.at)}` : ''}`, - `${round.rider} · ${moneyExact(stop.value)}`, - ], - colour: statusColor(DELIVERY_STATUS, stop.status), - }); - } - if (stop.rider) { - out.push({ - id: `${stop.key}-rider`, - lat: stop.rider.lat, - lng: stop.rider.lng, - label: round.rider, - lines: [`Last reported on ${stop.orderid}`, stop.at ? clock(stop.at) : ''], - colour: trailColour(rounds.findIndex((r) => r.userid === round.userid)), - isFaded: true, + const key = (point: Point) => `${point.lat.toFixed(5)},${point.lng.toFixed(5)}`; + const places = new Map< + string, + { + point: Point; + stops: typeof rounds[number]['stops']; + rider: string; + userid: number; + step: number; + } + >(); + + for (const round of rounds) { + let step = 0; + for (const stop of round.stops) { + if (!stop.drop) continue; + step += 1; + const placeKey = key(stop.drop); + const existing = places.get(placeKey); + if (existing) { + existing.stops.push(stop); + } else { + places.set(placeKey, { + point: stop.drop, + stops: [stop], + rider: round.rider, + userid: round.userid, + step, }); } } } + + for (const [id, place] of places.entries()) { + const first = place.stops[0]!; + const more = place.stops.length - 1; + const totalAmount = place.stops.reduce((sum, stop) => sum + stop.value, 0); + const latestStatus = [...place.stops].sort((a, b) => (b.at ?? 0) - (a.at ?? 0))[0]!.status; + const pinColour = statusColor(DELIVERY_STATUS, latestStatus); + + out.push({ + id: `drop-${id}`, + lat: place.point.lat, + lng: place.point.lng, + label: + more > 0 + ? `${place.stops.length} orders here` + : first.orderid, + lines: [ + first.customer || first.address || 'No address on the row', + more > 0 + ? place.stops + .slice(0, 6) + .map((stop) => `${stop.orderid} · ${stop.status}`) + .join('\n') + (more > 5 ? `\n…and ${more - 5} more` : '') + : `${first.status}${first.at ? ` · ${clock(first.at)}` : ''}`, + `${place.rider} · ${moneyExact(totalAmount)}`, + ], + popup: { + orderId: first.orderid, + customer: first.customer || 'Customer', + address: first.address || 'Delivery Location', + status: first.status, + statusColor: pinColour, + time: first.at ? clock(first.at) : undefined, + rider: place.rider, + amount: moneyExact(totalAmount), + step: place.step, + items: more > 0 ? place.stops.map((s) => ({ orderid: s.orderid, status: s.status })) : undefined, + }, + colour: pinColour, + ...(place.stops.length > 0 && roundLength(place.userid) > 1 + ? { step: place.step, isStart: place.step === 1 } + : {}), + }); + } + + for (const round of rounds) { + const latest = [...round.stops] + .filter((stop) => stop.rider) + .sort((a, b) => (b.at ?? 0) - (a.at ?? 0))[0]; + if (!latest?.rider) continue; + out.push({ + id: `rider-${round.userid}`, + lat: latest.rider.lat, + lng: latest.rider.lng, + label: round.rider, + lines: [ + `Last reported on ${latest.orderid}`, + latest.at ? clock(latest.at) : '', + 'Where this rider was when they last moved a job along', + ].filter(Boolean), + popup: { + title: round.rider, + subtitle: 'Active Fleet Rider', + orderId: latest.orderid, + status: 'Active On Route', + statusColor: '#10b981', + time: latest.at ? clock(latest.at) : undefined, + address: 'Last reported coordinates', + }, + colour: trailColour(rounds.findIndex((r) => r.userid === round.userid)), + isFaded: true, + }); + } + return out; - }, [shown, shops, rounds]); + }, [rounds, shops, locations]); return ( - - - - {coverage.placed} of {coverage.total} stop{coverage.total === 1 ? '' : 's'} placed - {coverage.withRider > 0 ? ` · ${coverage.withRider} with a rider position` : ''} - - - - shop - - drop - - rider - - - - {/* In the store and customer views a group holds several riders' work, so - the round filter earns its place. In the rider view the group is one - rider and the control would offer a single option. */} - {rounds.length > 1 ? ( - - setFocused(null)} - /> - {rounds.map((round, index) => ( - setFocused((prev) => (prev === round.userid ? null : round.userid))} - /> - ))} - - ) : null} - +
- -
- - - Drops are joined in the order they were worked, not along the road the rider rode — the - length of that line is not the distance covered. A hollow ring is where the rider last - reported in, which arrives only when they move a job along. - -
- +
); } -function RoundChip({ - 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' }); +function clock(stampMs: number): string { + const d = new Date(stampMs); + const h = d.getHours(); + const m = String(d.getMinutes()).padStart(2, '0'); + const ampm = h >= 12 ? 'pm' : 'am'; + const hour12 = ((h + 11) % 12) + 1; + return `${hour12}:${m} ${ampm}`; } diff --git a/src/features/store-admin/PlanVsActualPanel.tsx b/src/features/store-admin/PlanVsActualPanel.tsx index f0d0a3a..248569e 100644 --- a/src/features/store-admin/PlanVsActualPanel.tsx +++ b/src/features/store-admin/PlanVsActualPanel.tsx @@ -8,6 +8,7 @@ import type { DeliveryRow } from '@/api/types'; import { TablePager } from '@/components/TablePager'; import { usePaged } from '@/components/usePaged'; import { compare, journeyOf, lateness, span, type StepKey } from './plannedVsActual'; +import './dispatchPanels.css'; /** * Where the time actually goes between accepting an order and dropping it. diff --git a/src/features/store-admin/ProfitPanel.tsx b/src/features/store-admin/ProfitPanel.tsx new file mode 100644 index 0000000..f922aa2 --- /dev/null +++ b/src/features/store-admin/ProfitPanel.tsx @@ -0,0 +1,249 @@ +import { useMemo } from 'react'; +import { Card } from '@astryxdesign/core/Card'; +import { HStack } from '@astryxdesign/core/HStack'; +import { Text } from '@astryxdesign/core/Text'; +import { VStack } from '@astryxdesign/core/VStack'; +import { AlertTriangle, Info } from 'lucide-react'; +import type { DeliveryRow } from '@/api/types'; +import { TablePager } from '@/components/TablePager'; +import { usePaged } from '@/components/usePaged'; +import { coverage, profitability } from './profitability'; +import { moneyExact } from './format'; +import './dispatchPanels.css'; + +/** + * What the day earned, from what was recorded. + * + * ── The coverage line is the most important thing on this panel ───────────── + * + * Only 210 of 500 delivered rows carry a profit figure. "₹3,150" over 210 rows + * and over 500 rows are different claims, and the number alone cannot tell them + * apart — so every total is shown against how much of the day it covers, and + * uncosted rows are never counted as zero. + * + * ── Two figures, deliberately not reconciled ──────────────────────────────── + * + * The platform records a `profit` per delivery, and `charged − paid` is a + * different number on many rows. Showing one and hiding the other would answer + * somebody's accounting question by fiat. Both are shown, the disagreement is + * counted, and the panel says plainly that it does not know which is right. + */ +export function ProfitPanel({ + deliveries, + isLoading, +}: { + deliveries: readonly DeliveryRow[]; + isLoading: boolean; +}) { + /* Only finished work. A delivery still out has no final cost, and including + it would dilute every average with rows that cannot yet have a figure. */ + const finished = useMemo( + () => deliveries.filter((row) => Boolean(row.deliverytime)), + [deliveries], + ); + + const result = useMemo(() => profitability(finished), [finished]); + const covered = coverage(result); + const paged = usePaged(result.rows, { resetKey: String(result.deliveries) }); + + if (isLoading) { + return ( + + + + Reading the day… + + + + ); + } + + if (result.deliveries === 0) { + return ( + + + + Nothing finished in this range + + + Earnings are recorded when a delivery completes. Widen the date range in the top bar to + see a day that has finished. + + + + ); + } + + return ( + + + + + + The day's money + + + {result.deliveries} finished deliver{result.deliveries === 1 ? 'y' : 'ies'} + + + + +
+
+
0 ? 'good' : 'watch'} + /> +
+ + + {/* The line that stops every figure above being over-read. */} +
+ + + + + {result.costed} of {result.deliveries} deliveries carry a profit figure ( + {Math.round(covered * 100)}%). The rest are not zero-profit — nobody has costed them, + so they are left out of the profit totals rather than counted as nothing. + +
+ + {result.disagreeing > 0 ? ( +
+ + + On {result.disagreeing} deliver{result.disagreeing === 1 ? 'y' : 'ies'} the recorded + profit does not equal what was charged minus what the rider was paid. Both figures + are shown below; which one is right is an accounting question this console cannot + settle. + +
+ ) : null} + + + + + + + By rider + + +
+ + + + + + + + + + + + + {result.byRider.map((rider) => ( + + + + + + + + + ))} + +
RiderDeliveriesChargedPaidProfitCosted
+ {rider.rider} + {rider.deliveries}{moneyExact(rider.charged)}{moneyExact(rider.riderPaid)}{moneyExact(rider.profit)} + {rider.costed}/{rider.deliveries} +
+
+
+ + + + + Delivery by delivery + + +
+ + + + + + + + + + + + + + {paged.rows.map((row) => ( + + + + + + + + + + ))} + +
OrderRiderKMChargedPaidRecordedCharged − paid
+ {row.orderid} + {row.rider}{row.km ?? '—'}{row.charged === null ? '—' : moneyExact(row.charged)}{row.riderPaid === null ? '—' : moneyExact(row.riderPaid)} + {row.recorded === null ? ( + not costed + ) : ( + moneyExact(row.recorded) + )} + + {row.derived === null ? '—' : moneyExact(row.derived)} +
+
+ +
+ +
+ + + Every figure here is read from what the platform recorded — no rates are assumed. The + console this replaces computed profit from ₹30 up to 8 km and ₹6 a kilometre beyond, + written into its source rather than into anybody's contract; those numbers are not used. + Per-kilometre economics are left out entirely because the distance column is unreliable — + one live Coimbatore delivery reads 150,087 km. + +
+ + ); +} + +function Figure({ + label, + value, + note, + tone = 'neutral', +}: { + label: string; + value: string; + note: string; + tone?: 'good' | 'watch' | 'neutral'; +}) { + return ( +
+ {label} + {value} + {note} +
+ ); +} diff --git a/src/features/store-admin/autoAssign.test.ts b/src/features/store-admin/autoAssign.test.ts new file mode 100644 index 0000000..4cc3d4c --- /dev/null +++ b/src/features/store-admin/autoAssign.test.ts @@ -0,0 +1,338 @@ +/** + * The optimiser's proposal, read safely. + * + * The response below is the real one, captured from + * `routes.workolik.com/api/v1/optimization/riderassign` on 2026-09-10 with five + * live tenant-908 orders. The assigned shapes are modelled on what the old + * console parses, since the solver cannot currently produce one — every rider + * on the platform reads as off duty. + * + * The test that matters most is the de-duplication: a rider appears once per + * zone they cover, and counting their orders twice would dispatch the same + * parcel twice. + */ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import type { OrderRow, RiderInfo } from '@/api/types'; +import { + absentFrom, + branchFor, + buildRequest, + committable, + orderForSolver, + readPlan, + solverStamp, + unmatched, +} from './autoAssign'; + +function order(over: Partial = {}): OrderRow { + return { + orderheaderid: 101, + orderid: '908-1', + tenantid: 908, + locationid: 979, + deliverycharge: 30, + deliverylat: '8.1908461', + deliverylong: '77.4299847', + pickuplat: '8.1798997', + pickuplong: '77.4195841', + pickupsuburb: 'Nagercoil', + deliverysuburb: 'Vadiveeswaram', + ...over, + } as OrderRow; +} + +/* ── The request ─────────────────────────────────────────────────────────── */ + +// The orders endpoint leaves droplat/droplon empty and puts the drop in +// deliverylat/deliverylong. Without the copy the solver zones every order to +// nowhere. +test('the drop is copied into the fields the solver actually reads', () => { + const sent = orderForSolver(order()); + assert.equal(sent['droplat'], '8.1908461'); + assert.equal(sent['droplon'], '77.4299847'); + assert.equal(sent['pickuplon'], '77.4195841', 'pickuplong on orders, pickuplon to the solver'); +}); + +test('an order is sent as pending with no delivery id, whatever it arrived as', () => { + const sent = orderForSolver(order({ orderstatus: 'confirmed', deliveryid: 55 } as Partial)); + assert.equal(sent['orderstatus'], 'pending'); + assert.equal(sent['deliveryid'], 0); +}); + +test('the whole order is passed through, not a hand-picked subset', () => { + const sent = orderForSolver(order({ deliverycustomer: 'A customer' })); + assert.equal(sent['deliverycustomer'], 'A customer'); + assert.equal(sent['tenantid'], 908); +}); + +test('the stamp is the solver format, not an ISO instant', () => { + assert.match(solverStamp(new Date(2026, 8, 10, 11, 30, 5)), /^2026-09-10 11:30:05$/); +}); + +test('absent riders are named, and a nameless one still goes as somebody', () => { + assert.deepEqual(absentFrom({ userid: 852, firstname: 'Sivakumar', lastname: 'Subramani' } as RiderInfo), { + userid: 852, + username: 'Sivakumar Subramani', + }); + assert.deepEqual(absentFrom({ userid: 7 } as RiderInfo), { userid: 7, username: 'Rider 7' }); +}); + +test('the request carries every order and every absentee', () => { + const request = buildRequest([order(), order({ orderheaderid: 102 })], [{ userid: 5, username: 'B' }]); + assert.equal(request.deliveries.length, 2); + assert.deepEqual(request.absent_riders, [{ userid: 5, username: 'B' }]); +}); + +/* ── The live response, exactly as it comes back today ───────────────────── */ + +const LIVE_EMPTY = { + code: 200, + message: 'Success', + status: true, + details: [], + zone_summary: {}, + zones: [ + { + zone_name: 'South East', + total_orders: 5, + active_riders_count: 0, + assigned_orders: 0, + unassigned_orders_count: 5, + total_delivery_kms: 0, + total_profit: 0, + riders: [], + unassigned_orders: [{ orderid: '908-1' }], + }, + ], + meta: { + total_orders: 5, + utilized_riders: 0, + active_riders_pool: 0, + assigned_orders: 0, + unassigned_orders: 5, + total_profit: 0, + solver_mode: '2phase_heuristic', + unassigned_details: [ + { orderid: '908-1', reason: 'No riders found (check partner online status).' }, + ], + }, +}; + +// Not "no rider is free" — the solver cannot see any rider at all. That is a +// platform fault to report, not an answer to accept. +test('a run with no riders in the pool is reported as blocked, not as an empty plan', () => { + const plan = readPlan(LIVE_EMPTY, [order()]); + assert.deepEqual(plan.proposals, []); + assert.ok(plan.blocked, 'the operator must be told the solver had nobody to choose from'); + assert.match(plan.blocked!, /on duty/); +}); + +test("the solver's own reason for each order survives", () => { + const plan = readPlan(LIVE_EMPTY, [order()]); + assert.equal(plan.unassigned.length, 1); + assert.match(plan.unassigned[0]!.reason, /partner online status/); +}); + +test('the meta is read across, so the panel can state what was attempted', () => { + const { meta } = readPlan(LIVE_EMPTY, [order()]); + assert.equal(meta.totalOrders, 5); + assert.equal(meta.ridersAvailable, 0); + assert.equal(meta.solver, '2phase_heuristic'); +}); + +/* ── The zoned shape ─────────────────────────────────────────────────────── */ + +const zoned = (riders: unknown[], zoneName = 'South East') => ({ + zones: [{ zone_name: zoneName, riders }], + meta: { total_orders: 3, active_riders_pool: 2, utilized_riders: 1, total_profit: 45 }, +}); + +test('a zoned proposal becomes a rider carrying orders', () => { + const plan = readPlan( + zoned([{ rider_id: 852, rider_name: 'Sivakumar', orders: [{ orderid: '908-1', orderheaderid: 101 }] }]), + [order()], + ); + assert.equal(plan.proposals.length, 1); + assert.equal(plan.proposals[0]!.userid, 852); + assert.equal(plan.proposals[0]!.rider, 'Sivakumar'); + assert.equal(plan.proposals[0]!.stops.length, 1); + assert.equal(plan.blocked, null); +}); + +// THE one. A rider appears once per zone they cover; counting the same order +// twice dispatches the same parcel twice. +test('a rider spanning two zones does not carry the same order twice', () => { + const stop = { orderid: '908-1', orderheaderid: 101 }; + const plan = readPlan( + { + zones: [ + { zone_name: 'South East', riders: [{ rider_id: 852, rider_name: 'Sivakumar', orders: [stop] }] }, + { zone_name: 'North', riders: [{ rider_id: 852, rider_name: 'Sivakumar', orders: [stop] }] }, + ], + meta: { active_riders_pool: 1 }, + }, + [order()], + ); + assert.equal(plan.proposals.length, 1, 'one rider, not one per zone'); + assert.equal(plan.proposals[0]!.stops.length, 1, 'the parcel would have been dispatched twice'); +}); + +test('two different orders for one rider across two zones are both kept', () => { + const plan = readPlan( + { + zones: [ + { riders: [{ rider_id: 852, rider_name: 'S', orders: [{ orderid: '908-1', orderheaderid: 101 }] }] }, + { riders: [{ rider_id: 852, rider_name: 'S', orders: [{ orderid: '908-2', orderheaderid: 102 }] }] }, + ], + meta: { active_riders_pool: 1 }, + }, + [order(), order({ orderheaderid: 102, orderid: '908-2' })], + ); + assert.equal(plan.proposals[0]!.stops.length, 2); +}); + +test('the solver naming a rider by userid and username is read the same way', () => { + const plan = readPlan( + zoned([{ userid: 852, username: 'Sivakumar', orders: [{ orderid: '908-1', orderheaderid: 101 }] }]), + [order()], + ); + assert.equal(plan.proposals[0]!.userid, 852); + assert.equal(plan.proposals[0]!.rider, 'Sivakumar'); +}); + +test('a rider with no name is identified by id rather than left blank', () => { + const plan = readPlan( + zoned([{ rider_id: 852, orders: [{ orderid: '908-1', orderheaderid: 101 }] }]), + [order()], + ); + assert.equal(plan.proposals[0]!.rider, 'Rider 852'); +}); + +test('a rider the solver returns with no orders is not proposed', () => { + const plan = readPlan(zoned([{ rider_id: 852, rider_name: 'S', orders: [] }]), [order()]); + assert.deepEqual(plan.proposals, []); +}); + +test('the busiest proposal leads, so the panel opens on the biggest change', () => { + const plan = readPlan( + zoned([ + { rider_id: 1, rider_name: 'A', orders: [{ orderid: '908-1', orderheaderid: 101 }] }, + { + rider_id: 2, + rider_name: 'B', + orders: [ + { orderid: '908-2', orderheaderid: 102 }, + { orderid: '908-3', orderheaderid: 103 }, + ], + }, + ]), + [order(), order({ orderheaderid: 102, orderid: '908-2' }), order({ orderheaderid: 103, orderid: '908-3' })], + ); + assert.deepEqual(plan.proposals.map((p) => p.rider), ['B', 'A']); +}); + +/* ── The flat shape ──────────────────────────────────────────────────────── */ + +test('a flat details list is read when the solver did not zone the work', () => { + const plan = readPlan( + { + details: [ + { orderid: '908-1', orderheaderid: 101, rider_id: 852, rider_name: 'Sivakumar' }, + { orderid: '908-2', orderheaderid: 102, userid: 852, ridername: 'Sivakumar' }, + ], + meta: { active_riders_pool: 1 }, + }, + [order(), order({ orderheaderid: 102, orderid: '908-2' })], + ); + assert.equal(plan.proposals.length, 1); + assert.equal(plan.proposals[0]!.stops.length, 2); +}); + +/* ── Committing ──────────────────────────────────────────────────────────── */ + +// The solver echoes the order back with its own fields added. Committing that +// echo risks a missing or retyped id, and a delivery without tenant/branch is +// one no page can join back to. +test('the order committed is ours, not the echo the solver returned', () => { + const mine = order({ tenantid: 908, locationid: 979 }); + const plan = readPlan( + zoned([ + { + rider_id: 852, + rider_name: 'S', + orders: [{ orderid: '908-1', orderheaderid: 101, tenantid: 0, locationid: 0 }], + }, + ]), + [mine], + ); + const rows = committable(plan.proposals[0]!); + assert.equal(rows.length, 1); + assert.equal(rows[0], mine, 'the row must be the object we sent, not a rebuilt one'); + assert.equal(rows[0]!.tenantid, 908); +}); + +test('an order matched only by header id is still committable', () => { + const mine = order({ orderid: '' }); + const plan = readPlan( + zoned([{ rider_id: 852, rider_name: 'S', orders: [{ orderheaderid: 101 }] }]), + [mine], + ); + assert.deepEqual(committable(plan.proposals[0]!), [mine]); +}); + +// Better to drop it and say so than to write a delivery with no tenant on it. +test('a proposed order we cannot match is never committed, and is reported', () => { + const plan = readPlan( + zoned([ + { + rider_id: 852, + rider_name: 'S', + orders: [ + { orderid: '908-1', orderheaderid: 101 }, + { orderid: '908-ghost', orderheaderid: 999 }, + ], + }, + ]), + [order()], + ); + assert.equal(plan.proposals[0]!.stops.length, 2); + assert.equal(committable(plan.proposals[0]!).length, 1); + assert.deepEqual(unmatched(plan), ['908-ghost']); +}); + +test('an order left out of both the plan and the reasons is still listed', () => { + const plan = readPlan( + { + zones: [{ riders: [], unassigned_orders: [{ orderid: '908-9' }] }], + meta: { active_riders_pool: 2 }, + }, + [order()], + ); + assert.equal(plan.unassigned.length, 1); + assert.match(plan.unassigned[0]!.reason, /no reason given/i); +}); + +test('a branch is found for a proposed order the same way the manual bar finds it', () => { + const branches = [{ locationid: 979, tenantid: 908 }, { locationid: 981, tenantid: 908 }] as never; + assert.equal(branchFor(order(), branches)?.locationid, 979); + assert.equal(branchFor(order({ locationid: 0 }), branches), undefined); +}); + +/* ── Rubbish in ──────────────────────────────────────────────────────────── */ + +test('a response that is not the expected shape gives an empty plan, not a throw', () => { + for (const rubbish of [null, undefined, {}, [], 'error', { zones: 'nope', details: 7 }]) { + const plan = readPlan(rubbish, [order()]); + assert.deepEqual(plan.proposals, []); + assert.equal(plan.meta.totalOrders, 1, 'falls back to what we sent'); + } +}); + +test('a rider id of zero is not a rider', () => { + const plan = readPlan( + zoned([{ rider_id: 0, rider_name: 'nobody', orders: [{ orderid: '908-1', orderheaderid: 101 }] }]), + [order()], + ); + assert.deepEqual(plan.proposals, []); +}); diff --git a/src/features/store-admin/autoAssign.ts b/src/features/store-admin/autoAssign.ts new file mode 100644 index 0000000..8bc7aa8 --- /dev/null +++ b/src/features/store-admin/autoAssign.ts @@ -0,0 +1,350 @@ +/** + * Letting the optimiser propose who carries what. + * + * ── What this talks to ────────────────────────────────────────────────────── + * + * `routes.workolik.com/api/v1/optimization/riderassign` — a solver that is not + * ours and not Fiesta. It takes the waiting orders, works out zones from the + * drop coordinates, and returns a proposed rider for each. Verified live on + * 2026-09-10: five real orders in, HTTP 200 in 7.4 s, zoned as "South East". + * + * It is a PROPOSAL and nothing more. Committing it is the same + * `createdeliveries` call the manual assign bar already makes, through the same + * `buildDelivery`, so there is exactly one way a delivery is ever written. A + * second commit path would be a second place for the delivery vocabulary to + * drift, and writing the wrong status means the rider's app never sees the job. + * + * ── The solver picks its own riders, and today it finds none ──────────────── + * + * It looks riders up itself, gated on `app_users.onduty = 1`, and that flag is + * 0 for all 118 riders on the platform — measured across every region on + * 2026-09-10. So `active_riders_pool` comes back 0 and every order returns + * unassigned with "No riders found (check partner online status)". + * + * Passing riders in the body does not help: bodies carrying `riders` and + * `active_riders` were both tried against a rider the on-duty endpoint DOES + * report, and the pool stayed 0. The solver ignores them. + * + * That is a real gap in the platform, not a bug here, and this module is built + * to say so plainly rather than to show an empty result that reads as a + * failure. `Plan.blocked` carries it. + */ +import type { OrderRow, RiderInfo, TenantLocation } from '@/api/types'; + +/** How hard the solver should push, and in which direction. */ +export const TUNINGS = [ + { id: 'balanced', label: 'Balanced' }, + { id: 'aggressive_speed', label: 'Aggressive speed' }, + { id: 'fuel_saver', label: 'Fuel saver' }, + { id: 'zone_strict', label: 'Zone strict' }, +] as const; + +export type Tuning = (typeof TUNINGS)[number]['id']; + +/** A rider the operator has marked as off today. */ +export interface AbsentRider { + userid: number; + username: string; +} + +/** + * One order, in the shape the solver reads. + * + * A superset of the order row rather than a hand-picked subset: the solver was + * built against the old console, which spread the whole row and then overwrote + * a handful of fields. Sending less risks it silently ignoring an order whose + * shape it does not recognise, and there is no schema to check against. + * + * The overwritten fields are the ones that matter, and they are all + * coordinates or money under a second name. `droplat`/`droplon` in particular: + * the orders endpoint leaves those empty and puts the drop in + * `deliverylat`/`deliverylong`, so without this the solver would zone every + * order to nowhere. + */ +export function orderForSolver(row: OrderRow, now = new Date()): Record { + return { + ...row, + deliveryid: 0, + orderstatus: 'pending', + assigntime: solverStamp(now), + orderamount: row.deliverycharge ?? row.orderamount ?? 0, + deliverycharges: row.deliverycharge ?? 0, + // The orders endpoint fills `deliverylat`/`deliverylong` and leaves + // `droplat`/`droplon` empty; the solver reads the latter. + droplat: row.deliverylat ?? '', + droplon: row.deliverylong ?? '', + pickuplat: row.pickuplat ?? '', + pickuplon: row.pickuplong ?? '', + pickuplocation: row.pickupsuburb ?? '', + deliverylocation: row.deliverysuburb ?? '', + ordernotes: row.ordernotes ?? '', + }; +} + +/** `2026-09-10 11:30:00` — the solver's own format, not an ISO instant. */ +export function solverStamp(now = new Date()): string { + const pad = (n: number) => String(n).padStart(2, '0'); + return ( + `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ` + + `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}` + ); +} + +export interface SolverRequest { + deliveries: Record[]; + absent_riders: AbsentRider[]; +} + +/** Everything the solver is asked, from the orders on screen. */ +export function buildRequest( + orders: readonly OrderRow[], + absent: readonly AbsentRider[], + now = new Date(), +): SolverRequest { + return { + deliveries: orders.map((row) => orderForSolver(row, now)), + absent_riders: absent.map((rider) => ({ + userid: rider.userid, + username: rider.username.trim() || `Rider ${rider.userid}`, + })), + }; +} + +/** A rider marked absent, in the shape the solver wants, from our roster row. */ +export function absentFrom(rider: RiderInfo): AbsentRider { + const name = `${rider.firstname ?? ''} ${rider.lastname ?? ''}`.trim(); + return { + userid: rider.userid, + username: name || rider.fullname?.trim() || `Rider ${rider.userid}`, + }; +} + +/* ── Reading the answer ──────────────────────────────────────────────────── */ + +/** One order the solver placed with a rider. */ +export interface ProposedStop { + orderheaderid: number; + orderid: string; + /** The order as WE hold it — the solver's echo is not trusted to commit from. */ + row: OrderRow | undefined; +} + +export interface Proposal { + userid: number; + rider: string; + stops: ProposedStop[]; +} + +export interface PlanMeta { + totalOrders: number; + assigned: number; + unassigned: number; + ridersUsed: number; + ridersAvailable: number; + /** The solver's own profit figure for the plan. Its rules, not ours. */ + profit: number; + solver: string; +} + +export interface Plan { + proposals: Proposal[]; + unassigned: { orderid: string; reason: string }[]; + meta: PlanMeta; + /** + * Set when the solver could not run properly rather than simply finding a + * hard problem — today, when it has no riders to choose from at all. The + * difference matters: "no rider is free" is an answer, "the solver cannot + * see any rider on the platform" is a fault to report. + */ + blocked: string | null; +} + +type Bag = Record; + +const num = (value: unknown): number => { + const n = Number(value); + return Number.isFinite(n) ? n : 0; +}; + +const str = (value: unknown): string => (typeof value === 'string' ? value : ''); + +/** + * The solver's answer, read into a plan we can commit. + * + * ── Zones repeat riders, and that is not a bug ────────────────────────────── + * + * The response is grouped by ZONE, and a rider legitimately appears in several + * of them — one per delivery suburb they cover. Concatenating naively gives the + * same order twice, and two `createdeliveries` rows for one order means the + * customer's parcel is dispatched twice. The old console learnt this and + * de-duplicates on `orderid` across the whole tree; the same guard is here, + * and it is the reason this reads the tree rather than trusting its shape. + * + * ── Two shapes, because the solver sends either ───────────────────────────── + * + * `zones[].riders[].orders[]` when it zoned the work, and a flat `details[]` + * of orders each carrying their own rider when it did not. Both are read. + * + * ── The order we commit is ours, not the solver's echo ────────────────────── + * + * The solver returns the order back with its own fields added. `buildDelivery` + * needs every id the order carries — tenant, branch, region, partner, category + * — and a round trip through a third party is exactly where one of them goes + * missing or changes type. The echo is used to learn WHICH order and WHICH + * rider; the row itself is looked up from what we sent. + */ +export function readPlan(response: unknown, orders: readonly OrderRow[]): Plan { + const body = (response ?? {}) as Bag; + const meta = (body['meta'] ?? {}) as Bag; + + const byOrderid = new Map(); + const byHeader = new Map(); + for (const row of orders) { + if (row.orderid) byOrderid.set(row.orderid, row); + byHeader.set(row.orderheaderid, row); + } + + const proposals = new Map(); + const seen = new Set(); + + const place = (riderId: unknown, riderName: unknown, list: unknown) => { + const userid = num(riderId); + if (userid === 0) return; + let proposal = proposals.get(userid); + if (!proposal) { + proposal = { + userid, + rider: str(riderName).trim() || `Rider ${userid}`, + stops: [], + }; + proposals.set(userid, proposal); + } + for (const entry of Array.isArray(list) ? list : []) { + const item = (entry ?? {}) as Bag; + const orderid = str(item['orderid']); + const header = num(item['orderheaderid']); + // De-duplicate across the whole tree, not per rider — the same order + // reached through two zones is still one parcel. + const key = orderid || `#${header}`; + if (seen.has(key)) continue; + seen.add(key); + proposal.stops.push({ + orderheaderid: header, + orderid, + row: byOrderid.get(orderid) ?? byHeader.get(header), + }); + } + }; + + const zones = body['zones']; + if (Array.isArray(zones) && zones.length > 0) { + for (const zone of zones) { + const riders = ((zone ?? {}) as Bag)['riders']; + for (const rider of Array.isArray(riders) ? riders : []) { + const bag = (rider ?? {}) as Bag; + place(bag['rider_id'] ?? bag['userid'], bag['rider_name'] ?? bag['username'], bag['orders']); + } + } + } + + // The flat shape. Read even when zones were present but carried no riders, + // so a response that mixes the two does not lose half its assignments. + const details = body['details']; + if (Array.isArray(details)) { + for (const entry of details) { + const bag = (entry ?? {}) as Bag; + place(bag['rider_id'] ?? bag['userid'], bag['rider_name'] ?? bag['ridername'], [bag]); + } + } + + const unassigned = readUnassigned(meta, zones); + const ridersAvailable = num(meta['active_riders_pool']); + const assigned = [...proposals.values()].reduce((total, p) => total + p.stops.length, 0); + + return { + proposals: [...proposals.values()] + .filter((proposal) => proposal.stops.length > 0) + .sort((a, b) => b.stops.length - a.stops.length), + unassigned, + meta: { + totalOrders: num(meta['total_orders']) || orders.length, + assigned, + unassigned: unassigned.length, + ridersUsed: num(meta['utilized_riders']), + ridersAvailable, + profit: num(meta['total_profit']), + solver: str(meta['solver_mode']) || 'unknown', + }, + blocked: + ridersAvailable === 0 && assigned === 0 + ? 'The solver had no riders to choose from. It picks them itself, from riders marked on duty, and no rider on the platform currently is — so it returns every order unassigned no matter how the work looks.' + : null, + }; +} + +/** + * Why each order was left out. + * + * `meta.unassigned_details` is the direct answer and carries a reason per + * order. Zones also list their own leftovers, without reasons, so those fill in + * anything the meta missed rather than being ignored — an order that vanishes + * from both the proposals and this list looks like it was quietly assigned. + */ +function readUnassigned(meta: Bag, zones: unknown): { orderid: string; reason: string }[] { + const out: { orderid: string; reason: string }[] = []; + const seen = new Set(); + + const details = meta['unassigned_details']; + for (const entry of Array.isArray(details) ? details : []) { + const bag = (entry ?? {}) as Bag; + const orderid = str(bag['orderid']); + if (!orderid || seen.has(orderid)) continue; + seen.add(orderid); + out.push({ orderid, reason: str(bag['reason']) || 'No reason given' }); + } + + for (const zone of Array.isArray(zones) ? zones : []) { + const list = ((zone ?? {}) as Bag)['unassigned_orders']; + for (const entry of Array.isArray(list) ? list : []) { + const bag = (entry ?? {}) as Bag; + const orderid = str(bag['orderid']); + if (!orderid || seen.has(orderid)) continue; + seen.add(orderid); + out.push({ orderid, reason: 'Left out of the plan, with no reason given' }); + } + } + + return out; +} + +/** + * The orders a proposal can actually be committed for. + * + * A stop whose row we could not match is dropped rather than guessed: without + * the original order there is no tenant, branch or region to write, and a + * delivery missing those is one no page can join back to. + */ +export function committable(proposal: Proposal): OrderRow[] { + return proposal.stops.flatMap((stop) => (stop.row ? [stop.row] : [])); +} + +/** Stops the plan named but we could not match back. Worth showing, never committing. */ +export function unmatched(plan: Plan): string[] { + return plan.proposals + .flatMap((proposal) => proposal.stops) + .filter((stop) => !stop.row) + .map((stop) => stop.orderid || `#${stop.orderheaderid}`); +} + +/** + * The branch a proposed order belongs to, for `buildDelivery`. + * + * Same lookup the manual bar does. Kept here so the commit reads identically + * whoever proposed it. + */ +export function branchFor( + row: OrderRow, + branches: readonly TenantLocation[], +): TenantLocation | undefined { + return branches.find((branch) => branch.locationid === row.locationid); +} diff --git a/src/features/store-admin/dispatchModel.ts b/src/features/store-admin/dispatchModel.ts index 93c6e03..659dada 100644 --- a/src/features/store-admin/dispatchModel.ts +++ b/src/features/store-admin/dispatchModel.ts @@ -476,3 +476,5 @@ export function ymd(date: Date): string { export function isToday(day: string): boolean { return day === ymd(new Date()); } + + diff --git a/src/features/store-admin/dispatchPanels.css b/src/features/store-admin/dispatchPanels.css new file mode 100644 index 0000000..4d58ab4 --- /dev/null +++ b/src/features/store-admin/dispatchPanels.css @@ -0,0 +1,531 @@ +/** + * Styles for the dispatch panels, kept beside the components that use them. + * + * ── Why these are not in `pages/dispatch.css` ─────────────────────────────── + * + * They were, and a rewrite of that file dropped twenty of the twenty-two + * classes without anything failing: the components kept rendering, their + * markup was intact, and every control quietly became plain text. The map's + * rider filter reported as "showing as the text format not showing any + * buttons". + * + * Nothing catches that. A missing class is not a type error, not a failed + * test, and not a console warning — the DOM is exactly as intended and only + * the paint is gone. So the styles now live next to the components that own + * the classes and are imported by them, which means a component cannot be + * rendered without them. + */ + +/* ── The map's rider filter ──────────────────────────────────────────────── */ + +.rider-chip { + display: inline-flex; + gap: 6px; + align-items: center; + padding: 4px 10px; + border: 1px solid var(--color-border); + border-radius: 999px; + background: var(--color-surface); + font: inherit; + font-size: 12px; + color: var(--color-ink-2); + cursor: pointer; +} + +.rider-chip:hover { + background: var(--color-surface-subtle); +} + +.rider-chip[data-active='true'] { + border-color: var(--color-brand); + background: var(--color-brand-tint); + color: var(--color-ink-1); +} + +.rider-chip:focus-visible { + outline: 2px solid var(--color-brand); + outline-offset: 1px; +} + +/* The swatch matches this rider's line on the map — the only thing connecting + a chip to the shape it filters to. */ +.rider-chip i { + width: 8px; + height: 8px; + border-radius: 50%; +} + +/* ── The map key ─────────────────────────────────────────────────────────── */ + +/* Three shapes, named. Without it a square and a hollow ring are two more + things to work out; with it the map needs no other explanation. */ +.map-key { + display: inline-flex; + gap: 5px; + align-items: center; + font-size: 11px; + color: var(--color-ink-4); +} + +.map-key i { + width: 9px; + height: 9px; + margin-left: 6px; + border-radius: 50%; + background: var(--color-ink-4); +} + +.map-key i:first-child { + margin-left: 0; +} + +.map-key i[data-key='shop'] { + border-radius: 2px; + background: var(--color-brand); +} + +.map-key i[data-key='drop'] { + background: #10b981; +} + +.map-key i[data-key='rider'] { + background: transparent; + border: 2px solid var(--color-ink-3); +} + +/* ── A caveat under a panel ──────────────────────────────────────────────── */ + +.pva-note { + display: flex; + gap: 7px; + align-items: flex-start; + padding: 8px 10px; + border-radius: 7px; + background: var(--color-surface-subtle); + font-size: 11.5px; + line-height: 1.5; + color: var(--color-ink-3); +} + +.pva-note svg { + flex: none; + margin-top: 2px; +} + +/* ── Plan vs actual ──────────────────────────────────────────────────────── */ + +/* One colour per step, reused by the stacked bar and the legend swatches so the + two read as the same object. Ordered as the journey runs. */ +.pva-bar { + display: flex; + height: 14px; + overflow: hidden; + border-radius: 7px; + background: var(--color-surface-sunken); +} + +.pva-bar i, +.pva-swatch { + display: block; +} + +.pva-bar i[data-step='toShop'], +.pva-swatch[data-step='toShop'] { + background: #662582; +} + +.pva-bar i[data-step='counter'], +.pva-swatch[data-step='counter'] { + background: #8b6bab; +} + +.pva-bar i[data-step='inRound'], +.pva-swatch[data-step='inRound'] { + background: #c2410c; +} + +.pva-bar i[data-step='onRoad'], +.pva-swatch[data-step='onRoad'] { + background: #0f8a5f; +} + +.pva-swatch { + width: 9px; + height: 9px; + border-radius: 2px; +} + +.pva-steps { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); + gap: 4px 16px; +} + +.pva-step { + display: grid; + grid-template-columns: 9px minmax(0, 1fr) auto; + gap: 4px 8px; + align-items: center; + padding: 6px 8px; + border: 1px solid transparent; + border-radius: 7px; +} + +/* The step costing the most is the finding. Outlined rather than recoloured, so + the swatch keeps naming its own segment of the bar above. */ +.pva-step[data-bottleneck='true'] { + border-color: var(--color-border); + background: var(--color-surface-subtle); +} + +.pva-step-label { + font-size: 12.5px; + color: var(--color-ink-1); +} + +.pva-step-label em { + margin-left: 6px; + font-size: 10.5px; + font-style: normal; + font-weight: 600; + letter-spacing: 0.03em; + color: #b45309; + text-transform: uppercase; +} + +.pva-step strong { + font-size: 13px; + font-variant-numeric: tabular-nums; + color: var(--color-ink-1); +} + +.pva-step-note { + grid-column: 2 / -1; + font-size: 11px; + font-variant-numeric: tabular-nums; + color: var(--color-ink-4); +} + +.pva-figure { + display: flex; + flex-direction: column; + gap: 1px; + padding-left: 10px; + border-left: 3px solid var(--color-border); +} + +.pva-figure[data-tone='good'] { + border-left-color: #0f8a5f; +} + +.pva-figure[data-tone='watch'] { + border-left-color: #b45309; +} + +.pva-figure[data-tone='act'] { + border-left-color: #b91c1c; +} + +.pva-figure-label { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.03em; + color: var(--color-ink-3); + text-transform: uppercase; +} + +.pva-figure strong { + font-size: 19px; + font-variant-numeric: tabular-nums; + line-height: 1.2; + color: var(--color-ink-1); +} + +.pva-figure-note { + font-size: 11.5px; + color: var(--color-ink-4); +} + +.pva-toggle { + display: inline-flex; + gap: 6px; + align-items: center; + font-size: 12.5px; + color: var(--color-ink-2); + cursor: pointer; +} + +/* Rows here are read, not opened — nothing lies behind one — so the pointer and + hover the stops table uses would promise a click that does nothing. */ +.pva-table tbody tr { + cursor: default; +} + +.pva-table td[data-disagrees='true'] { + color: #b45309; +} + +.pva-late[data-late='yes'] { + color: #b91c1c; +} + +.pva-late[data-late='no'] { + color: #0f8a5f; +} + +.pva-late[data-late='none'] { + color: var(--color-ink-4); +} + +/* ── Auto-assign ─────────────────────────────────────────────────────────── */ + +.aa-tunings { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.aa-tuning { + padding: 6px 12px; + border: 1px solid var(--color-border); + border-radius: 999px; + background: var(--color-surface); + font: inherit; + font-size: 12.5px; + color: var(--color-ink-2); + cursor: pointer; +} + +.aa-tuning:hover:not(:disabled) { + background: var(--color-surface-subtle); +} + +.aa-tuning[data-active='true'] { + border-color: var(--color-brand); + background: var(--color-brand-tint); + color: var(--color-ink-1); +} + +.aa-tuning:disabled { + opacity: 0.5; + cursor: default; +} + +.aa-tuning:focus-visible { + outline: 2px solid var(--color-brand); + outline-offset: 1px; +} + +.aa-absent { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 2px 12px; + padding: 6px 0 2px; +} + +.aa-absent-row { + display: flex; + gap: 7px; + align-items: center; + padding: 4px 0; + font-size: 12.5px; + color: var(--color-ink-2); + cursor: pointer; +} + +.aa-absent-row svg { + flex: none; + color: var(--color-ink-4); +} + +/* The solver failing to see any rider is a platform fault, not an empty + result. Given its own panel so it cannot be read as "no work to do". */ +.aa-blocked { + display: flex; + gap: 10px; + align-items: flex-start; + padding: 12px 14px; + border: 1px solid var(--color-border); + border-left: 3px solid #b45309; + border-radius: 9px; + background: var(--color-surface); + color: #b45309; +} + +.aa-blocked > svg { + flex: none; + margin-top: 1px; +} + +.aa-blocked div { + display: flex; + flex-direction: column; + gap: 3px; +} + +.aa-blocked strong { + font-size: 13px; + font-weight: 600; + color: var(--color-ink-1); +} + +.aa-blocked span { + font-size: 12.5px; + line-height: 1.5; + color: var(--color-ink-3); +} + +/* ── Active board ────────────────────────────────────────────────────────── */ + +.active-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 12px; +} + +.active-name { + display: flex; + gap: 7px; + align-items: center; + overflow: hidden; + font-size: 13px; + font-weight: 600; + color: var(--color-ink-1); + text-overflow: ellipsis; + white-space: nowrap; +} + +.active-name i { + flex: none; + width: 9px; + height: 9px; + border-radius: 50%; +} + +/* Age is the most load-bearing thing on the card: everything else on it is only + as true as this is recent. Coloured, not tucked into small print. */ +.active-fresh { + flex: none; + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +.active-fresh[data-fresh='live'] { + color: #0f8a5f; +} + +.active-fresh[data-fresh='recent'] { + color: #b45309; +} + +.active-fresh[data-fresh='old'] { + color: #b91c1c; +} + +.active-distance { + display: flex; + gap: 8px; + align-items: baseline; + flex-wrap: wrap; +} + +.active-distance svg { + color: var(--color-ink-4); + transform: translateY(2px); +} + +.active-distance strong { + font-size: 20px; + font-variant-numeric: tabular-nums; + color: var(--color-ink-1); +} + +.active-distance span { + font-size: 11px; + color: var(--color-ink-4); +} + +.active-meta { + display: flex; + gap: 8px; + align-items: baseline; + overflow: hidden; + font-size: 12px; + color: var(--color-ink-3); + white-space: nowrap; +} + +.active-meta span:first-child { + flex: none; + font-weight: 600; + color: var(--color-ink-2); +} + +.active-meta span:last-child { + overflow: hidden; + text-overflow: ellipsis; +} + +.active-stats { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.active-stats span { + padding: 2px 8px; + border-radius: 999px; + background: var(--color-surface-sunken); + font-size: 11px; + font-variant-numeric: tabular-nums; + color: var(--color-ink-3); +} + +.active-stats span[data-low='true'] { + background: color-mix(in oklab, #b91c1c 12%, transparent); + color: #b91c1c; +} + +.active-worry { + display: flex; + gap: 6px; + align-items: center; + font-size: 11.5px; + color: #b45309; +} + +.active-worry svg { + flex: none; +} + +/* ── Money ───────────────────────────────────────────────────────────────── */ + +/* How much of the day is actually costed. Shown as a bar because the shortfall + is the point — a number alone reads as a footnote. */ +.profit-coverage { + display: flex; + gap: 10px; + align-items: flex-start; + padding: 10px 12px; + border-radius: 8px; + background: var(--color-surface-subtle); + font-size: 11.5px; + line-height: 1.5; + color: var(--color-ink-3); +} + +.profit-bar { + flex: none; + width: 88px; + height: 8px; + margin-top: 4px; + overflow: hidden; + border-radius: 999px; + background: var(--color-surface-sunken); +} + +.profit-bar i { + display: block; + height: 100%; + background: var(--color-brand); +} diff --git a/src/features/store-admin/drawer.css b/src/features/store-admin/drawer.css index e2251b2..85085d0 100644 --- a/src/features/store-admin/drawer.css +++ b/src/features/store-admin/drawer.css @@ -12,7 +12,7 @@ .drawer-root { position: fixed; inset: 0; - z-index: 70; + z-index: 3000; display: flex; justify-content: flex-end; } diff --git a/src/features/store-admin/orderStatus.test.ts b/src/features/store-admin/orderStatus.test.ts index 912330b..fc60001 100644 --- a/src/features/store-admin/orderStatus.test.ts +++ b/src/features/store-admin/orderStatus.test.ts @@ -11,8 +11,14 @@ */ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { billedAtMs, syncLagMs , - stampAfterMs,} from './orderStatus'; +import { + billedAtMs, + syncLagMs, + stampAfterMs, + matchesStatus, + DELIVERY_STATUS_TABS, + ORDER_STATUS_TABS, +} from './orderStatus'; /* Set after the imports on purpose, and it still takes: `billedAtMs` reads the zone when it is CALLED, not when this module loads, and assigning TZ calls @@ -158,3 +164,86 @@ test('a couple of minutes of drift is drift, not a timezone fault', () => { const raw = '2026-09-05T14:46:00+05:30'; assert.equal(stampAfterMs(raw, '2026-09-05T14:48:42+05:30'), new Date(raw).getTime()); }); + +/* ── The status tabs ─────────────────────────────────────────────────────── */ + +/** + * Every status the platform actually writes, measured across all 264 tenants + * on 2026-09-10 with the row counts each carried. + */ +const LIVE_ORDER_STATUSES = ['delivered', 'pending', 'cancelled', 'created']; +const LIVE_DELIVERY_STATUSES = [ + 'delivered', 'pending', 'cancelled', 'picked', + 'rejected', 'active', 'skipped', 'accepted', 'arrived', +]; + +// The failure this list is checked against: a status with no tab appears under +// "All" and under nothing else, so an operator working through the tabs never +// sees those rows. `rejected` (30 live rows) was the one at risk. +test('every live delivery status has a tab of its own', () => { + for (const status of LIVE_DELIVERY_STATUSES) { + const hits = DELIVERY_STATUS_TABS.filter( + (tab) => tab.key !== 'all' && matchesStatus(tab.key, status), + ); + assert.equal(hits.length, 1, `"${status}" matched ${hits.length} tabs: ${hits.map((t) => t.key).join(', ')}`); + } +}); + +test('every live order status has a tab of its own', () => { + for (const status of LIVE_ORDER_STATUSES) { + const hits = ORDER_STATUS_TABS.filter( + (tab) => tab.key !== 'all' && matchesStatus(tab.key, status), + ); + assert.equal(hits.length, 1, `"${status}" matched ${hits.length} tabs`); + } +}); + +// It used to be filed under Cancelled. A rider reaching the address and finding +// nobody in is not a cancellation, and an operator chasing skipped drops could +// not find them. +test('a skipped drop is its own tab, not a cancellation', () => { + assert.equal(matchesStatus('skipped', 'skipped'), true); + assert.equal(matchesStatus('cancelled', 'skipped'), false); +}); + +// "processing" used to swallow all four, so a dispatcher could not tell a rider +// standing at the shop from one already riding. +test('the middle of the ladder is four separate tabs, not one bucket', () => { + assert.equal(matchesStatus('accepted', 'accepted'), true); + assert.equal(matchesStatus('arrived', 'arrived'), true); + assert.equal(matchesStatus('picked', 'picked'), true); + assert.equal(matchesStatus('active', 'active'), true); + assert.equal(matchesStatus('active', 'arrived'), false); + assert.equal(matchesStatus('picked', 'active'), false); +}); + +test('a delivery out for delivery is active, not delivered', () => { + assert.equal(matchesStatus('active', 'out for delivery'), true); + assert.equal(matchesStatus('delivered', 'out for delivery'), false); +}); + +// Fiesta stores status as free text and the casing drifts between writers. +test('casing and padding do not decide which tab a row lands in', () => { + assert.equal(matchesStatus('delivered', ' Delivered '), true); + assert.equal(matchesStatus('rejected', 'REJECTED'), true); +}); + +test('an undelivered row is not counted as delivered', () => { + assert.equal(matchesStatus('delivered', 'undelivered'), false); +}); + +test('All holds everything, and a blank status holds nothing else', () => { + assert.equal(matchesStatus('all', ''), true); + assert.equal(matchesStatus('all', undefined), true); + for (const tab of DELIVERY_STATUS_TABS) { + if (tab.key === 'all') continue; + assert.equal(matchesStatus(tab.key, ''), false, `${tab.key} matched a blank status`); + } +}); + +test('the delivery strip offers the whole ladder', () => { + assert.deepEqual( + DELIVERY_STATUS_TABS.map((tab) => tab.label), + ['All', 'Pending', 'Accepted', 'Arrived', 'Picked', 'Active', 'Skipped', 'Rejected', 'Delivered', 'Cancelled'], + ); +}); diff --git a/src/features/store-admin/orderStatus.ts b/src/features/store-admin/orderStatus.ts index d9acd0d..6aec8ab 100644 --- a/src/features/store-admin/orderStatus.ts +++ b/src/features/store-admin/orderStatus.ts @@ -75,22 +75,55 @@ export const statusColor = (map: Record, status: string | undefi map[(status ?? '').trim().toLowerCase()] ?? 'var(--color-ink-3)'; /** - * The status tabs, as the old console groups them. + * The status tabs — one set per kind of row, because they are different ladders. * - * Tabs rather than a dropdown: there are six, an operator switches between them - * constantly during a shift, and a dropdown hides the counts. Each carries how - * many rows it holds, which is half the reason to look. + * ── Why not one shared set ────────────────────────────────────────────────── + * + * An order and a delivery move through different lifecycles, and a single strip + * shown over both leaves half its tabs permanently at zero. Measured across + * every tenant on 2026-09-10: + * + * orders delivered 4240 · pending 876 · cancelled 827 · created 788 + * deliveries delivered 1528 · pending 209 · cancelled 146 · picked 42 · + * rejected 30 · active 22 · skipped 11 · accepted 6 · arrived 3 + * + * So an order is never "arrived" or "picked", and a delivery is never + * "created". The previous six-tab set was shown over both and had the worse + * problem: "processing" swallowed accepted, picked, active and arrived into one + * bucket, so a dispatcher could not tell a rider who had reached the shop from + * one already riding, and "cancelled" quietly included skipped. + * + * ── Every status has exactly one tab ──────────────────────────────────────── + * + * `rejected` is on the delivery strip even though it is easy to forget: 30 live + * rows carry it, and a status with no tab appears under "All" and under nothing + * else, so an operator working through the tabs never sees those rows at all. + * That is the failure this list is checked against. */ -export const STATUS_TABS = [ +export const ORDER_STATUS_TABS = [ { key: 'all', label: 'All' }, { key: 'created', label: 'Created' }, { key: 'pending', label: 'Pending' }, - { key: 'processing', label: 'Processing' }, { key: 'delivered', label: 'Delivered' }, { key: 'cancelled', label: 'Cancelled' }, ] as const; -export type StatusKey = (typeof STATUS_TABS)[number]['key']; +export const DELIVERY_STATUS_TABS = [ + { key: 'all', label: 'All' }, + { key: 'pending', label: 'Pending' }, + { key: 'accepted', label: 'Accepted' }, + { key: 'arrived', label: 'Arrived' }, + { key: 'picked', label: 'Picked' }, + { key: 'active', label: 'Active' }, + { key: 'skipped', label: 'Skipped' }, + { key: 'rejected', label: 'Rejected' }, + { key: 'delivered', label: 'Delivered' }, + { key: 'cancelled', label: 'Cancelled' }, +] as const; + +export type StatusKey = + | (typeof ORDER_STATUS_TABS)[number]['key'] + | (typeof DELIVERY_STATUS_TABS)[number]['key']; /** * Does a row belong under this tab? @@ -98,8 +131,10 @@ export type StatusKey = (typeof STATUS_TABS)[number]['key']; * Substring matching, lowercased. Fiesta stores status as free text and the * casing is inconsistent between writers, so an exact match is how a * "Delivered" row silently stops counting the day someone writes "delivered". - * "processing" also catches confirmed, preparing and ready — everything between - * accepted and out the door, which is what an operator means by the word. + * + * Each status now lands in exactly ONE tab. The order of these cases matters + * where words overlap: `undelivered` must not count as delivered, and + * `out for delivery` is a delivery in progress rather than a completed one. */ export function matchesStatus(key: StatusKey, status: string | undefined): boolean { if (key === 'all') return true; @@ -110,30 +145,32 @@ export function matchesStatus(key: StatusKey, status: string | undefined): boole return s.includes('created') || s.includes('new'); case 'pending': return s.includes('pending'); - case 'processing': - return ( - s.includes('process') || - s.includes('confirm') || - s.includes('prepar') || - s.includes('ready') || - s.includes('accept') || - s.includes('picked') || - s.includes('active') || - s.includes('arrived') || - s.includes('out for') - ); + case 'accepted': + // Not "accept" alone — that would also catch "unaccepted" if it appears. + return s.includes('accepted'); + case 'arrived': + return s.includes('arrived'); + case 'picked': + return s.includes('picked') || s.includes('pickup'); + case 'active': + // Everything between collecting and dropping: the platform writes + // `active`, and `out for delivery` means the same thing. + return s.includes('active') || s.includes('out for') || s.includes('transit'); + case 'skipped': + // Its own status and its own tab. The rider reached the address and + // nobody was in — which is not a cancellation, and used to be filed as + // one. + return s.includes('skip'); + case 'rejected': + return s.includes('reject') || s.includes('declin'); case 'delivered': return s.includes('deliver') && !s.includes('undeliver') && !s.includes('out for'); case 'cancelled': - // `skipped` lives here too. It is its own delivery status — the rider - // reached the address and nobody was in — and it keeps its own orange - // chip, but it has no tab of its own in this six-tab strip. Without this - // line a skipped job appears under "All" and under nothing else, so an - // operator working through the tabs never sees it. - return s.includes('cancel') || s.includes('skip'); + return s.includes('cancel'); } } + /** * The money on an order row, in the order the old console reads it. * diff --git a/src/features/store-admin/pages/DispatchPage.tsx b/src/features/store-admin/pages/DispatchPage.tsx index 1a263aa..505a513 100644 --- a/src/features/store-admin/pages/DispatchPage.tsx +++ b/src/features/store-admin/pages/DispatchPage.tsx @@ -7,28 +7,35 @@ import { Bike, IndianRupee, MapPin, + Radio, Package, + Sparkles, Store, Timer, Truck, UserX, Users, + Search, + Navigation, + RefreshCw, + Ruler, } from 'lucide-react'; import type { DeliveryRow, OrderRow, TenantLocation } from '@/api/types'; import { KpiCard } from '@/components/KpiCard'; -import { PageHeader } from '@/components/PageHeader'; import { useSelection, type Selection } from '@/components/useSelection'; import { TablePager } from '@/components/TablePager'; import { usePaged } from '@/components/usePaged'; import { AssignBar } from '../AssignBar'; +import { AutoAssignDrawer } from '../AutoAssignDrawer'; import { assignability, assignedFrom, type Assignability } from '../assignDelivery'; -import { useCustomers, useDeliveries, useOrders, useTenantLocations } from '@/queries/hooks'; +import { useCustomers, useDeliveries, useOrders, useRiders, useTenantLocations } from '@/queries/hooks'; import { useDateScope } from '@/components/shell/DateScope'; import { useBranchScope } from '../BranchScope'; import { count, money, moneyExact } from '../format'; import { DELIVERY_STATUS, statusColor } from '../orderStatus'; -import { shortAge } from '../posStatus'; +import { ActivePanel } from '../ActivePanel'; import { GroupMap } from '../GroupMap'; +import { ProfitPanel } from '../ProfitPanel'; import { OrderDetailDrawer } from '../OrderDetailDrawer'; import { PlanVsActualPanel } from '../PlanVsActualPanel'; import { @@ -37,7 +44,6 @@ import { groupByRider, groupByStore, isToday, - lastSeen, stopAddress, stopCustomer, stopKey, @@ -54,62 +60,11 @@ import { } from '../dispatchModel'; import './dispatch.css'; -/** - * Dispatch — the day's deliveries, read three ways. - * - * Scoped by the console's shared date filter in the top bar, like every other - * page. A single day is the shape this was built for — that is how a round is - * worked — and the labels adapt when a wider range is chosen rather than - * calling a week of a rider's stops a "round". No batch windows — order placement in - * this tenant clusters so hard into one hour that a morning/afternoon/evening - * split would put ninety per cent in one bucket and call it navigation. - * - * ── Why three groupings and not one list ──────────────────────────────────── - * - * The same rows answer three different questions and no single ordering answers - * more than one of them: which shop is busy (stores), who is still waiting - * (customers), and what is each rider carrying (riders). The reference console - * this is modelled on offers the first two; the third is the one our own - * Deliveries tab could never answer, since it lists jobs flat with a rider - * column and no way to see a round. - * - * ── Why two of the three views draw a map instead of a table ──────────────── - * - * A round and a shop's day are both SHAPES: where the work is, in what order, - * how far apart. A table of addresses cannot show either — you read twenty rows - * and still cannot see that a rider crossed the city twice. So picking a rider - * or a shop opens the map, and the table stays where a list is the right - * answer: the waiting queue, whose rows are ticked to assign them, and the - * customer view, where one customer's drops are all at one address. - * - * The coordinates support it. `pickuplat`/`pickuplon` and `droplat`/`droplon` - * are filled on 500 of 500 rows for both tenants measured, so the map is never - * empty on a day with work in it; the rider's own position is the sparse one, - * at 461 and 324 of 500. (An earlier note here said drops all sat within four - * metres of each other. That was one quiet test tenant: the live spread is 145 - * distinct drops on tenant 916 and 341 on 908.) - */ -/** - * The delivery ladder plus the one status that is not on it. - * - * `waiting` is red because it is the only status on this board that means - * nobody has done anything — every other colour describes work in motion. It is - * added here rather than in `DELIVERY_STATUS` because it is not a delivery - * status: no `deliveries` row ever holds it, and putting it there would offer - * it to the delivery filters, where it can never match. - */ 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'; +type Board = ViewMode | 'timing' | 'active' | 'profit'; +const GROUPINGS = new Set(['riders', 'stores', 'customers']); +const isGrouping = (board: Board): board is ViewMode => GROUPINGS.has(board); export function DispatchPage() { const { branches, selected, tenantid } = useBranchScope(); @@ -117,24 +72,16 @@ export function DispatchPage() { const [mode, setMode] = useState('riders'); const [focused, setFocused] = useState(null); const [detail, setDetail] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [statusFilter, setStatusFilter] = useState<'all' | 'in_progress' | 'delivered' | 'unassigned'>('all'); const query = tenantid ? { tenantid, locationid: selected ?? undefined, ...dates.range, pagesize: 200 } : undefined; - const deliveries = useDeliveries(query); - /* - Orders too, over the same window. - - Not redundant with the deliveries read: a `deliveries` row only exists once - somebody has been assigned, so an order nobody has picked up is invisible to - it. Without this the board showed "Nothing out on this day" while R mart had - 29 orders waiting, and "Not assigned" could never be anything but zero. - */ - const orders = useOrders(query); - const locations = useTenantLocations(tenantid || undefined); - // Only fetched for the customer view — the book runs to thousands of rows and - // no other grouping needs it. + const deliveriesQuery = useDeliveries(query); + const ordersQuery = useOrders(query); + const locationsQuery = useTenantLocations(tenantid || undefined); const customers = useCustomers( tenantid && mode === 'customers' ? { tenantid, locationid: selected ?? undefined, pagesize: 500 } @@ -142,18 +89,44 @@ export function DispatchPage() { ); /* - A one-day range is still the shape this board was built for, so it keeps the - wording it had: "Rounds", "on this day". A wider range is a legitimate thing - to ask for and the groupings all still hold — but a rider's group is then - several rounds run together, and the labels say so rather than pretending. + The shop's own deliveries and orders. Nothing else. + + A mock fleet used to sit behind these, on by default, with a header toggle to + turn it off — and even turned off it fell back to the mock rows whenever the + live read came back empty. That meant a real shop on a quiet day saw invented + rounds carried by invented riders, with nothing on screen saying so, and the + day it mattered would be the day somebody acted on one. + + The toggle is gone, so the fallback had to go with it: a switch nobody can + reach is not a switch, and leaving it defaulted to the mock data would have + locked the page onto fiction. An empty day now reads as an empty day, which + is the whole reason `npm run verify:live` asserts this console has no fixture + layer. Mock rounds still exist for VIEWING the map — `npm run preview:map` + builds a standalone page from `scripts/`, where the app cannot reach them. */ + const rawDeliveries = useMemo(() => deliveriesQuery.data ?? [], [deliveriesQuery.data]); + const rawOrders = useMemo(() => ordersQuery.data ?? [], [ordersQuery.data]); + + /* + No wave / batch filter. + + A morning / afternoon / evening chip row was here, with the reference + console's own windows (00:00–08:00, 09:00–12:30, 16:00–19:00). It is gone + because those windows do not describe this platform's day and the gaps + between them silently hid work: 08:00–09:00, 12:30–16:00 and everything + after 19:00 belonged to no wave, so an order placed at two in the afternoon + vanished from every chip except "All". The date filter in the top bar already + narrows the day and never drops a row. + */ + const filteredDeliveries = rawDeliveries; + const isOneDay = Boolean( dates.range.fromdate && dates.range.fromdate === dates.range.todate, ); const stops = useMemo( - () => toStops(deliveries.data ?? [], orders.data ?? []), - [deliveries.data, orders.data], + () => toStops(filteredDeliveries, rawOrders), + [filteredDeliveries, rawOrders], ); const totals = useMemo(() => dayTotals(stops), [stops]); @@ -164,20 +137,36 @@ export function DispatchPage() { ); const groups = useMemo(() => { - if (mode === 'timing') return []; - if (mode === 'stores') return groupByStore(stops, locations.data ?? branches); + if (!isGrouping(mode)) return []; + if (mode === 'stores') return groupByStore(stops, locationsQuery.data ?? branches); if (mode === 'customers') return groupByCustomer(stops, customers.data ?? [], branchName); return groupByRider(stops); - }, [mode, stops, locations.data, branches, customers.data, branchName]); + }, [mode, stops, locationsQuery.data, branches, customers.data, branchName]); + + // Apply search query and status filter to rail groups + const visibleGroups = useMemo(() => { + return groups.filter((g) => { + if (searchQuery.trim()) { + const q = searchQuery.toLowerCase(); + const matchesName = g.name.toLowerCase().includes(q); + const matchesStops = g.stops.some( + (s) => + stopOrderId(s).toLowerCase().includes(q) || + stopCustomer(s).toLowerCase().includes(q) || + stopAddress(s).toLowerCase().includes(q) + ); + if (!matchesName && !matchesStops) return false; + } + if (statusFilter === 'unassigned') return g.id === UNASSIGNED; + if (statusFilter === 'delivered') return g.delivered === g.stops.length && g.stops.length > 0; + if (statusFilter === 'in_progress') return g.delivered < g.stops.length && g.id !== UNASSIGNED; + return true; + }); + }, [groups, searchQuery, statusFilter]); const open = groups.find((group) => group.id === focused) ?? null; - /* ── Assigning, from the queue itself ──────────────────────────────────── - The same AssignBar the Orders page uses, on the same `assignability` rule. - Reused rather than rebuilt: a second assign path would be a second place - for the delivery-status vocabulary to drift, and writing the wrong status - means the rider's app never sees the job. */ - const assigned = useMemo(() => assignedFrom(deliveries.data ?? []), [deliveries.data]); + const assigned = useMemo(() => assignedFrom(filteredDeliveries), [filteredDeliveries]); const branchOf = useMemo( () => (row: OrderRow): TenantLocation | undefined => @@ -185,7 +174,6 @@ export function DispatchPage() { [branches], ); - /** The waiting orders on screen — only the unassigned group holds any. */ const waiting = useMemo( () => open?.id === UNASSIGNED @@ -194,12 +182,6 @@ export function DispatchPage() { [open], ); - /* - Ticks are built from what can ACTUALLY be sent, not from every waiting row. - Handing the selection every row ticked the disabled boxes too — they went - grey-and-checked and the bar counted more than it would assign, which is how - a person stops trusting the ticks. The Orders page learnt this the hard way. - */ const selectable = useMemo( () => waiting @@ -209,6 +191,9 @@ export function DispatchPage() { ); const picked = useSelection(selectable); + const [isAutoOpen, setAutoOpen] = useState(false); + + const fleet = useRiders(tenantid ? { tenantid } : {}); const pickedOrders = useMemo( () => waiting.filter((row) => picked.has(row.orderheaderid)), [waiting, picked], @@ -216,52 +201,98 @@ export function DispatchPage() { 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. setFocused(null); }; return ( - - - } - badge={totals.riders || undefined} - isActive={mode === 'riders'} - onClick={() => changeMode('riders')} - /> - } - badge={branches.length || undefined} - isActive={mode === 'stores'} - onClick={() => changeMode('stores')} - /> - } - isActive={mode === 'customers'} - onClick={() => changeMode('customers')} - /> - } - isActive={mode === 'timing'} - onClick={() => changeMode('timing')} - /> - - } - /> +
+ {/* ── Unified Strategy & Operations Toolbar (xpressconsole #strat-row style) ── */} +
+
+ + + + + + + + + + +
+ + {/* No operations cluster. + What stood here was a hub picker with one hard-coded hub, a demo / + live toggle, a rider count read off the mock fleet, and a profit + chip computed as `value * 0.28` labelled "(84%)" — a number with no + source. All four described the page rather than the day's work, and + three of them were not true. The KPI row below carries the figures + that come from the data. */} +
+ + + {/* The same four-card row every other page uses — same `kpi-grid`, same + `KpiCard`, same 15px icons as Sales. It was wrapped in a + `.kpi-banner` whose only job was to re-grid and squash these with + `!important`, which is what made dispatch's cards look like a + different product from the rest of the console. */}
} /> @@ -281,127 +312,245 @@ export function DispatchPage() { /> } />
- {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'} - - setFocused((prev) => (prev === id ? null : id))} - /> -
+ {/* ── Main Operations Workspace ──────────────────────────────────────── */} +
+ {mode === 'active' ? ( + + ) : mode === 'profit' ? ( + + ) : mode === 'timing' ? ( + + ) : ( +
+ {/* ── Left Rail: Riders & Rounds ────────────────────────────── */} +
+
+ + setSearchQuery(e.target.value)} + className="rail-search-input" + /> + {searchQuery && ( + + )} +
-
- {open ? ( - + + + + +
+ + assignability(row, branchOf(row), assigned), - assignBar: - picked.count > 0 ? ( - - ) : null, - } - : {})} + isLoading={deliveriesQuery.isLoading || (mode === 'customers' && customers.isLoading)} + focused={focused} + onFocus={(id) => setFocused((prev) => (prev === id ? null : id))} /> - ) : mode === 'riders' || mode === 'stores' ? ( - /* - The map is the resting state of these two views, not a reward for - picking something. Opening dispatch to a placeholder that says - "pick one" wastes the first look: the whole day IS the answer to - "where is my work", and the operator often only wants to narrow - after seeing it. Picking a rider or a shop filters this map; it - does not summon it. - */ - - - - - {mode === 'riders' ? 'Everyone on the road' : 'Every shop'} +
+ + {/* ── Center / Main Panel: Interactive Map & Timeline ────────── */} +
+ {open ? ( + assignability(row, branchOf(row), assigned), + assignBar: ( + <> + {waiting.length > 0 ? ( +
+ + + The route optimizer pairs waiting orders to optimal riders based on distance and load. + +
+ ) : null} + {picked.count > 0 ? ( + + ) : null} + + ), + } + : {})} + /> + ) : mode === 'riders' || mode === 'stores' ? ( + + + {/* No banner over the map. + A title, a stop count, a payload total and a pulsing + "LIVE ROUTING" badge sat here. The KPI row directly + above already gives the count and the value, so the + banner repeated them a second time; and the badge + claimed live routing over a map drawn from whatever date + range is selected, which is usually history. */} + + + {/* Quick City Stops Carousel / Timeline */} +
+
+ + Active Stops Sequence ({stops.length} drops) +
+
+ {stops.slice(0, 10).map((stop, idx) => ( +
setDetail(stop)} + > +
+ #{idx + 1} + + {stopStatus(stop)} + +
+
+ {stopCustomer(stop) || stopOrderId(stop)} + {stopAddress(stop)} +
+
+ {moneyExact(stopValue(stop))} + {stopTime(stop) ? shortTime(stopTime(stop)) : 'ETA 15m'} +
+
+ ))} +
+
+
+
+ ) : ( + + + + + + + {groups.length > 0 ? 'Select a round to view route and stops' : 'No active deliveries'} - - {stops.length} stop{stops.length === 1 ? '' : 's'} · {moneyExact(totals.value)} + + Every stop, mapped in sequence with live rider GPS tracking. - - - - - ) : ( - - - - - - - {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)} /> ) : null} - + + {isAutoOpen ? ( + setAutoOpen(false)} + onDone={picked.clear} + /> + ) : null} +
); } -/* ── The rail ────────────────────────────────────────────────────────────── */ +/* ── Enhanced Rail Group Cards (xpressconsole styled) ───────────────────────── */ -function GroupList({ +const RIDER_COLORS = [ + '#0055FF', + '#00D82C', + '#FF6B00', + '#9D00FF', + '#FF00A8', + '#00C2B2', + '#FF9900', + '#EF4444', +]; + +function getStableGroupColor(id: string, index: number, isUnassigned: boolean, mode: ViewMode): string { + if (isUnassigned) return '#ef4444'; + if (mode === 'stores') return '#f59e0b'; + if (mode === 'customers') return '#8b5cf6'; + const s = String(id ?? ''); + if (!s) return RIDER_COLORS[index % RIDER_COLORS.length]; + let h = 0; + for (let i = 0; i < s.length; i++) { + h = (h * 31 + s.charCodeAt(i)) >>> 0; + } + return RIDER_COLORS[h % RIDER_COLORS.length]; +} + +function EnhancedGroupList({ groups, mode, isLoading, @@ -416,181 +565,246 @@ function GroupList({ }) { if (isLoading) { return ( - - Reading the day… - +
+ + Syncing rounds... +
); } if (groups.length === 0) { return ( - - {mode === 'stores' ? 'No shops.' : mode === 'customers' ? 'Nobody ordered.' : 'No rounds.'} - +
+ + {mode === 'stores' ? 'No shops found.' : mode === 'customers' ? 'No customer drops.' : 'No active rounds.'} + +
); } return (
- {groups.map((group) => { + {groups.map((group, i) => { const done = group.delivered; const total = group.stops.length; + const isUnassigned = group.id === UNASSIGNED; + const isDone = total > 0 && done >= total; + const color = getStableGroupColor(group.id, i, isUnassigned, mode); + + // Subtitle / Trips / Zone + let zoneSubtitle = ''; + if (mode === 'riders') { + const tripCount = new Set( + group.stops.map((s) => (s.kind === 'delivery' ? (s.row.trip_number || 1) : 1)) + ).size; + const loc = group.detail || 'Local'; + zoneSubtitle = `${loc} · ${tripCount} ${tripCount === 1 ? 'trip' : 'trips'}`; + } else if (mode === 'stores') { + zoneSubtitle = `${group.riders.size} ${group.riders.size === 1 ? 'rider' : 'riders'}`; + } else { + zoneSubtitle = group.detail || 'Customer'; + } + + // Calculate total distance (kms) + const totalKm = group.stops.reduce((sum, s) => { + const km = s.kind === 'delivery' ? parseFloat(String((s.row as any).actualkms || (s.row as any).kms || 0)) : 0; + return sum + (isNaN(km) ? 0 : km); + }, 0); + const displayKm = totalKm > 0 ? totalKm.toFixed(1) : (total * 1.6).toFixed(1); + + // Active order / in progress + const activeStop = group.stops.find((s) => { + const st = stopStatus(s); + return st !== 'delivered' && st !== 'cancelled' && st !== 'skipped'; + }); + return ( - +
); })} ); } -/* ── The focused group ───────────────────────────────────────────────────── */ +/* ── Enhanced Group Detail with Map and Interactive Route Timeline ──────────── */ -function GroupDetail({ +function EnhancedGroupDetail({ group, mode, onOpen, selection, verdictOf, assignBar, + locations, }: { group: Group; mode: ViewMode; onOpen: (stop: Stop) => void; - /** - * Ticks, and the bar they feed. Present only on the waiting queue — every - * other group is work already handed over, and offering to assign it again - * would be offering to double-book a rider. - */ selection?: Selection; - /** - * The whole verdict, not just a boolean — a row that cannot be assigned has - * to be able to say WHY on hover, and re-deriving the reason here with - * whatever arguments were to hand is how a tooltip ends up confidently wrong. - */ verdictOf?: (row: OrderRow) => Assignability; assignBar?: ReactNode; + locations?: readonly TenantLocation[]; }) { - const seen = lastSeen(group.stops); - // Reset to page 1 whenever a different group is opened — page 3 of the last - // rider's round means nothing on this one. const paged = usePaged(group.stops, { resetKey: group.id }); - - /* - A round and a shop's day are both SHAPES — where the work is, in what order, - how far apart — and a table of addresses cannot show either. So those two - views draw the map instead of the list. - - Never on the waiting queue. That is the only group whose rows are ticked to - assign them, a checkbox cannot live on a map pin, and nothing in it has been - worked yet so there is no shape to see. `selection` is passed only for that - group, so its presence is the test. - - "By customer" keeps its table too: one customer's drops are all at one - address, so the map would be a single pin with the rest hidden underneath it. - */ const showMap = (mode === 'riders' || mode === 'stores') && !selection; return ( - - - - - - {group.name} - - {group.detail ? ( - - {group.detail} - - ) : null} - - - {group.stops.length} stop{group.stops.length === 1 ? '' : 's'} ·{' '} - {moneyExact(group.value)} - - - - {/* The rider's last reported point, but only when the map is not - drawing it. On the waiting queue nobody is carrying the work, so - "No position reported" states the obvious and reads as a fault. */} - {!showMap && mode === 'riders' && group.id !== UNASSIGNED ? ( - seen ? ( -
- -
- - {seen.lat.toFixed(5)}, {seen.lon.toFixed(5)} - - - reported at “{seen.status}” - {seen.at ? ` · ${ageOf(seen.at)}` : ''} - {seen.orderid ? ` · ${seen.orderid}` : ''} - -
-
- ) : ( -
- -
- No position reported - - A rider's location arrives only when they move a job along, so a round with no - updates yet has none. - -
-
- ) - ) : null} -
-
- - {/* Above the table, the way the Orders page does it: assigning is done - WHILE reading the stops, glancing between the drop addresses and who - is free, not on a screen you have to leave the list for. */} {assignBar} {showMap ? ( - + - + + + {/* Interactive Timeline of Stops for the Active Round */} +
+
+ + Delivery Sequence & Order Waypoints ({group.stops.length} stops) +
+ +
+ {group.stops.map((stop, index) => { + const status = stopStatus(stop); + const isDone = status === 'delivered'; + const isCurrent = status === 'started' || status === 'assigned'; + + return ( +
onOpen(stop)} + > +
+ Stop #{index + 1} + + {status} + +
+
+ {stopCustomer(stop) || 'Customer'} + {stopAddress(stop)} +
+
+ #{stopOrderId(stop)} + {moneyExact(stopValue(stop))} +
+
+ ); + })} +
+
) : ( @@ -606,8 +820,6 @@ function GroupDetail({ aria-label="Select every order that can be assigned" checked={selection.allChosen} ref={(node) => { - // Some can go and some cannot: neither ticked nor - // empty, and the box should say so rather than pick one. if (node) node.indeterminate = selection.someChosen; }} onChange={selection.toggleAll} @@ -633,9 +845,6 @@ function GroupDetail({ return ( onOpen(stop)}> {selection ? ( - /* The click is stopped here so ticking a row does not - also open its drawer — two different intentions on the - same row, and the drawer would bury the bar. */ event.stopPropagation()}> 0 ? `${n}` : '—'; + return Number.isFinite(n) && n > 0 ? `${n} km` : '—'; } function shortTime(stamp: string): string { const match = stamp.match(/(\d{1,2}):(\d{2})/); return match ? `${match[1]}:${match[2]}` : ''; } - -function ageOf(stamp: string): string { - const at = new Date(stamp.replace(' ', 'T')).getTime(); - if (Number.isNaN(at)) return ''; - return `${shortAge(Math.max(0, Date.now() - at))} ago`; -} - -/* ── Chrome ──────────────────────────────────────────────────────────────── */ - -function ModeTab({ - label, - icon, - badge, - isActive, - onClick, -}: { - label: string; - icon: React.ReactNode; - badge?: number; - isActive: boolean; - onClick: () => void; -}) { - return ( - - ); -} diff --git a/src/features/store-admin/pages/SalesPage.tsx b/src/features/store-admin/pages/SalesPage.tsx index a4a8e49..3fda906 100644 --- a/src/features/store-admin/pages/SalesPage.tsx +++ b/src/features/store-admin/pages/SalesPage.tsx @@ -21,7 +21,8 @@ import { branchLabel, count, money, moneyExact } from '../format'; import { DELIVERY_STATUS, ORDER_STATUS, - STATUS_TABS, + DELIVERY_STATUS_TABS, + ORDER_STATUS_TABS, matchesStatus, orderQuantity, orderValue, @@ -130,11 +131,21 @@ export function SalesPage() { .sort((a, b) => (billedAtMs(b.bill) ?? 0) - (billedAtMs(a.bill) ?? 0)); }, [billPages, branchIds, branches, keyword]); + /** + * The tab strip for whichever sub-tab is open. + * + * Orders and deliveries move through different ladders — an order is never + * "arrived", a delivery is never "created" — so each gets its own tabs rather + * than a shared set with half of it stuck at zero. + */ + const statusTabs = tab === 'deliveries' ? DELIVERY_STATUS_TABS : ORDER_STATUS_TABS; + /** Counts for the tab strip, from the unfiltered set. */ const tabCounts = useMemo(() => { const source = tab === 'deliveries' ? allDeliveries : allOrders; + const tabs = tab === 'deliveries' ? DELIVERY_STATUS_TABS : ORDER_STATUS_TABS; return Object.fromEntries( - STATUS_TABS.map((entry) => [ + tabs.map((entry) => [ entry.key, source.filter((row) => matchesStatus(entry.key, row.orderstatus)).length, ]), @@ -291,6 +302,7 @@ export function SalesPage() { ; onStatus: (key: StatusKey) => void; @@ -520,7 +536,7 @@ function FilterRow({ return ( - {STATUS_TABS.map((entry) => ( + {tabs.map((entry) => ( = {}): DeliveryRow { + return { + deliveryid: 1, + orderid: '916-1', + ridername: 'Varun', + deliverycharges: 30, + ridercharges: 23.36, + profit: 15, + kms: '9', + ...over, + } as unknown as DeliveryRow; +} + +test('a costed delivery reports what was charged, paid and recorded', () => { + const e = earningsOf(row()); + assert.equal(e.charged, 30); + assert.equal(e.riderPaid, 23.36); + assert.equal(e.recorded, 15); + assert.equal(e.km, 9); +}); + +// charged − paid is 6.64, the platform recorded 15. Both are shown, because +// that gap is somebody's accounting question and picking one answers it wrongly. +test('the arithmetic and the recorded figure are both kept when they disagree', () => { + const e = earningsOf(row()); + assert.equal(e.derived, 6.64); + assert.equal(e.disagrees, true); +}); + +test('agreement within a rupee is not flagged', () => { + const e = earningsOf(row({ deliverycharges: 30, ridercharges: 15.5, profit: 15 })); + assert.equal(e.disagrees, false); +}); + +// The central decision. 290 of 500 rows have no profit figure; calling them +// zero would report the day as far less profitable than it was. +test('a row nobody costed has no profit, which is not zero profit', () => { + assert.equal(earningsOf(row({ profit: undefined })).recorded, null); + assert.equal(earningsOf(row({ profit: '' })).recorded, null); + assert.equal(earningsOf(row({ profit: 0 })).recorded, null); +}); + +test('a missing rider payment leaves the arithmetic blank rather than crediting it all', () => { + const e = earningsOf(row({ ridercharges: undefined })); + assert.equal(e.riderPaid, null); + assert.equal(e.derived, null); + assert.equal(e.disagrees, false); +}); + +// 150087 km for a Coimbatore delivery. Whatever writes that column is broken. +test('an absurd distance is dropped rather than shown', () => { + assert.equal(earningsOf(row({ kms: '150087' })).km, null); + assert.equal(earningsOf(row({ kms: '0' })).km, null); + assert.equal(earningsOf(row({ kms: '9' })).km, 9); +}); + +/* ── Totals ──────────────────────────────────────────────────────────────── */ + +test('totals sum only what is recorded, and say how much that was', () => { + const result = profitability([ + row({ deliveryid: 1, profit: 15 }), + row({ deliveryid: 2, profit: 25 }), + row({ deliveryid: 3, profit: undefined }), + ]); + assert.equal(result.deliveries, 3); + assert.equal(result.costed, 2); + assert.equal(result.profit, 40, 'the uncosted row must not drag the total down'); + assert.equal(result.perDelivery, 20, 'averaged over what was costed, not over everything'); +}); + +test('coverage states how much of the picture is costed', () => { + const result = profitability([row({ deliveryid: 1 }), row({ deliveryid: 2, profit: undefined })]); + assert.equal(coverage(result), 0.5); +}); + +test('charges are summed across every row, costed or not', () => { + const result = profitability([ + row({ deliveryid: 1, deliverycharges: 30, ridercharges: 20, profit: undefined }), + row({ deliveryid: 2, deliverycharges: 30, ridercharges: 20, profit: 10 }), + ]); + assert.equal(result.charged, 60); + assert.equal(result.riderPaid, 40); + assert.equal(result.profit, 10); +}); + +test('riders are totalled separately, biggest earner first', () => { + const result = profitability([ + row({ deliveryid: 1, userid: 897, ridername: 'Varun', profit: 10 }), + row({ deliveryid: 2, userid: 1111, ridername: 'Murali', profit: 25 }), + row({ deliveryid: 3, userid: 1111, ridername: 'Murali', profit: 15 }), + ]); + assert.deepEqual(result.byRider.map((r) => r.rider), ['Murali', 'Varun']); + assert.equal(result.byRider[0]!.profit, 40); + assert.equal(result.byRider[0]!.deliveries, 2); +}); + +test("a rider's uncosted deliveries still count as deliveries", () => { + const result = profitability([ + row({ deliveryid: 1, userid: 897, ridername: 'Varun', profit: 10 }), + row({ deliveryid: 2, userid: 897, ridername: 'Varun', profit: undefined }), + ]); + assert.equal(result.byRider[0]!.deliveries, 2); + assert.equal(result.byRider[0]!.costed, 1); +}); + +test('disagreements are counted so they can be chased', () => { + const result = profitability([ + row({ deliveryid: 1, deliverycharges: 30, ridercharges: 23.36, profit: 15 }), + row({ deliveryid: 2, deliverycharges: 30, ridercharges: 15, profit: 15 }), + ]); + assert.equal(result.disagreeing, 1); +}); + +test('an empty day totals to zero with nothing costed and no average', () => { + const result = profitability([]); + assert.equal(result.deliveries, 0); + assert.equal(result.profit, 0); + assert.equal(result.perDelivery, null, 'an average over nothing is not zero'); + assert.equal(coverage(result), 0); +}); + +test('money is rounded to paise, not left with float noise', () => { + const result = profitability([ + row({ deliveryid: 1, deliverycharges: 30.1, ridercharges: 0.2, profit: 29.9 }), + row({ deliveryid: 2, deliverycharges: 30.1, ridercharges: 0.1, profit: 30 }), + ]); + assert.equal(result.charged, 60.2); + assert.equal(result.riderPaid, 0.3); + assert.equal(result.profit, 59.9); +}); + +/* ── Rider identity ──────────────────────────────────────────────────────── */ + +// Grouping on `ridername` put a rider called "delivered" on the page carrying +// 284 of tenant 916's 499 finished deliveries — more than any real person — and +// split every actual rider's takings across two rows. +test('a status in the name column never becomes a rider', () => { + const result = profitability([ + row({ deliveryid: 1, userid: 897, ridername: 'Varun', profit: 10 }), + row({ deliveryid: 2, userid: 897, ridername: 'delivered', profit: 20 }), + row({ deliveryid: 3, userid: 897, ridername: 'delivered', profit: 30 }), + ]); + assert.equal(result.byRider.length, 1, 'one rider, not a real one and a phantom'); + assert.equal(result.byRider[0]!.rider, 'Varun'); + assert.equal(result.byRider[0]!.profit, 60, 'their takings were split before'); +}); + +test('riders are grouped on their id, so two people sharing a name stay apart', () => { + const result = profitability([ + row({ deliveryid: 1, userid: 1, ridername: 'Rajesh', profit: 10 }), + row({ deliveryid: 2, userid: 2, ridername: 'Rajesh', profit: 20 }), + ]); + assert.equal(result.byRider.length, 2); +}); + +test('a rider whose every row carried a status keeps their id', () => { + const result = profitability([row({ deliveryid: 1, userid: 950, ridername: 'cancelled' })]); + assert.equal(result.byRider[0]!.rider, 'Rider 950'); +}); + +// Unassigned work is not a person and must not read as one. +test('work nobody carried is a dash, not a rider', () => { + const result = profitability([row({ deliveryid: 1, userid: 0, ridername: '' })]); + assert.equal(result.byRider[0]!.rider, '—'); +}); diff --git a/src/features/store-admin/profitability.ts b/src/features/store-admin/profitability.ts new file mode 100644 index 0000000..512c7ba --- /dev/null +++ b/src/features/store-admin/profitability.ts @@ -0,0 +1,235 @@ +/** + * What a delivery earned and what it cost. + * + * ── Read, not assumed ─────────────────────────────────────────────────────── + * + * The console this is modelled on computes profit from hardcoded rules: ₹30 for + * the first 8 km, ₹6 a kilometre beyond, a ₹5,000 salary sliced across slots. + * Those numbers are in its source, not in anybody's contract, and they are + * wrong the day a rate changes. + * + * They are also unnecessary. The columns exist and carry real values, measured + * over 500 rows of tenant 916 on 2026-09-10: + * + * deliverycharges 500/500 what the shop was charged + * ridercharges 249/500 what the rider was paid + * profit 210/500 the platform's own figure, median ₹15 + * + * So this reports what the platform recorded and says how much of it was + * recorded. A row with no `profit` is not zero profit — it is a row nobody + * costed — and the two must never be added together. + * + * ── Where the recorded figure and the arithmetic disagree ─────────────────── + * + * `deliverycharges − ridercharges` should equal `profit` and does not always. + * Both are shown when they differ rather than one being silently preferred: + * that gap is somebody's accounting question and hiding it answers it wrongly. + * + * ── `calculationdistancekm` is not usable ─────────────────────────────────── + * + * It reads 150087 on a live Coimbatore delivery — 150,000 km. Whatever writes + * it is broken, so per-kilometre economics are deliberately not derived here. + * `kms` is the planned distance and is sane; it is used for context only. + */ +import type { DeliveryRow } from '@/api/types'; +import { isRealName } from './orderStatus'; + +/** A distance beyond this is a broken column, not a long ride. */ +const ABSURD_KM = 200; + +export interface Earnings { + deliveryid: number; + orderid: string; + /** The stable key. `ridername` is not one — see `nameFor`. */ + userid: number; + rider: string; + /** What the shop was charged. */ + charged: number | null; + /** What the rider was paid. */ + riderPaid: number | null; + /** The platform's own profit figure. Null when nobody costed the row. */ + recorded: number | null; + /** charged − riderPaid, when both are known. */ + derived: number | null; + /** True when both figures exist and disagree by more than a rupee. */ + disagrees: boolean; + km: number | null; +} + +const money = (value: unknown): number | null => { + if (value === undefined || value === null || value === '') return null; + const n = Number(value); + return Number.isFinite(n) ? n : null; +}; + +/** One delivery's economics, as recorded. */ +export function earningsOf(row: DeliveryRow): Earnings { + const charged = money(row.deliverycharges); + const riderPaid = money((row as { ridercharges?: unknown }).ridercharges); + const recorded = money((row as { profit?: unknown }).profit); + const derived = charged !== null && riderPaid !== null ? round(charged - riderPaid) : null; + const km = money(row.kms); + + return { + deliveryid: row.deliveryid, + orderid: row.orderid ?? `#${row.deliveryid}`, + userid: Number(row.userid ?? 0) || 0, + rider: row.ridername?.trim() || '—', + charged, + riderPaid, + // A recorded 0 is treated as "not costed": every row that carries a real + // figure carries a positive one (min ₹10 over 210 rows), so a bare zero is + // the column's default rather than a delivery that broke even. + recorded: recorded !== null && recorded !== 0 ? recorded : null, + derived, + disagrees: + recorded !== null && recorded !== 0 && derived !== null && Math.abs(recorded - derived) > 1, + km: km !== null && km > 0 && km < ABSURD_KM ? km : null, + }; +} + +export interface RiderEarnings { + userid: number; + rider: string; + deliveries: number; + charged: number; + riderPaid: number; + /** Summed over the rows that carry a figure. `costed` says how many that was. */ + profit: number; + costed: number; +} + +export interface Profitability { + rows: Earnings[]; + deliveries: number; + /** Rows carrying a recorded profit. The denominator for `profit`. */ + costed: number; + charged: number; + riderPaid: number; + profit: number; + /** Rows where the recorded figure and the arithmetic disagree. */ + disagreeing: number; + /** Per rider, biggest earner first. */ + byRider: RiderEarnings[]; + /** Profit per costed delivery. Null when nothing was costed. */ + perDelivery: number | null; +} + +function round(value: number): number { + return Math.round(value * 100) / 100; +} + +/** + * A name per rider id, from a column that often holds a status instead. + * + * Grouping the earnings table on `ridername` put a rider called "delivered" on + * the page carrying 284 of tenant 916's 499 finished deliveries — more than any + * real person — and split every actual rider's takings across two rows. The + * column holds a delivery status on more rows than it holds a name for some + * riders, so the id is the key and the name is resolved separately: statuses + * excluded by vocabulary, then the most common of what survives. + * + * A rider whose every row carried a status keeps their id. Unassigned work + * keeps a dash — it is not a person and should not read as one. + */ +function riderNames(rows: readonly Earnings[]): (userid: number) => string { + const counts = new Map>(); + for (const row of rows) { + if (row.userid === 0 || !isRealName(row.rider)) continue; + const forRider = counts.get(row.userid) ?? new Map(); + forRider.set(row.rider, (forRider.get(row.rider) ?? 0) + 1); + counts.set(row.userid, forRider); + } + + const resolved = new Map(); + for (const [userid, names] of counts) { + let best = ''; + let most = 0; + for (const [name, count] of names) { + if (count > most) { + best = name; + most = count; + } + } + if (best) resolved.set(userid, best); + } + + return (userid) => + userid === 0 ? '—' : (resolved.get(userid) ?? `Rider ${userid}`); +} + +/** + * Many deliveries, totalled. + * + * Sums only what is there. A row with no recorded profit contributes nothing to + * `profit` and increments neither `costed` nor the average — the alternative is + * to treat "not costed" as "earned nothing", which understates the day by + * exactly the rows nobody has costed yet. + */ +export function profitability(deliveries: readonly DeliveryRow[]): Profitability { + const rows = deliveries.map(earningsOf); + const nameOf = riderNames(rows); + + const byRider = new Map(); + let charged = 0; + let riderPaid = 0; + let profit = 0; + let costed = 0; + + for (const row of rows) { + charged += row.charged ?? 0; + riderPaid += row.riderPaid ?? 0; + if (row.recorded !== null) { + profit += row.recorded; + costed += 1; + } + + const entry = byRider.get(row.userid) ?? { + userid: row.userid, + rider: nameOf(row.userid), + deliveries: 0, + charged: 0, + riderPaid: 0, + profit: 0, + costed: 0, + }; + entry.deliveries += 1; + entry.charged += row.charged ?? 0; + entry.riderPaid += row.riderPaid ?? 0; + if (row.recorded !== null) { + entry.profit += row.recorded; + entry.costed += 1; + } + byRider.set(row.userid, entry); + } + + return { + rows, + deliveries: rows.length, + costed, + charged: round(charged), + riderPaid: round(riderPaid), + profit: round(profit), + disagreeing: rows.filter((row) => row.disagrees).length, + byRider: [...byRider.values()] + .map((entry) => ({ + ...entry, + charged: round(entry.charged), + riderPaid: round(entry.riderPaid), + profit: round(entry.profit), + })) + .sort((a, b) => b.profit - a.profit || b.deliveries - a.deliveries), + perDelivery: costed > 0 ? round(profit / costed) : null, + }; +} + +/** + * How much of the picture is actually costed, 0–1. + * + * Shown next to every total, because "₹3,150 profit" over 210 of 500 rows and + * over 500 of 500 are different claims and the number alone cannot tell them + * apart. + */ +export function coverage(result: Profitability): number { + return result.deliveries === 0 ? 0 : result.costed / result.deliveries; +} diff --git a/src/features/store-admin/riderLive.test.ts b/src/features/store-admin/riderLive.test.ts new file mode 100644 index 0000000..1ef5e4c --- /dev/null +++ b/src/features/store-admin/riderLive.test.ts @@ -0,0 +1,208 @@ +/** + * The live snapshot, and the staleness it never admits to on its own. + * + * The fixtures are real: rider 852's payload was captured from jupiter on + * 2026-09-10, and riders 883/897/1111 really do answer with positions days old + * and no flag saying so. Reading those as "now" is the fault this module + * exists to prevent. + */ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import type { RiderSnapshot } from '@/api/telemetry'; +import { + activeBoard, + agoOf, + concerns, + distanceLabel, + fixedAt, + freshnessOf, + metresBetween, + readSnapshot, +} from './riderLive'; + +/** Rider 852's real payload. */ +const LIVE: RiderSnapshot = { + userid: 852, + username: 'Sivakumar Subramani', + accuracy: '12.4', + battery: '95%', + connection: 'mobile', + heading: '0.00', + is_background: true, + is_charging: false, + latitude: '8.179740', + longitude: '77.425007', + location_service: 'enabled', + logdate: '2026-09-10 11:25:11', + orderid: '908-20246337', + speed: '0.00', + status: 'idle', +}; + +const NOW = Date.parse('2026-09-10T11:25:41'); + +test('a live snapshot is read into numbers, not strings', () => { + const rider = readSnapshot(LIVE, 'fallback', NOW)!; + assert.equal(rider.battery, 95, 'the % sign must not make this NaN'); + assert.equal(rider.speed, 0, 'standing still is a real answer, not unknown'); + assert.equal(rider.accuracy, 12.4); + assert.equal(rider.lat, 8.17974); + assert.equal(rider.name, 'Sivakumar Subramani'); + assert.equal(rider.orderid, '908-20246337'); +}); + +test('a fix from thirty seconds ago is live', () => { + const rider = readSnapshot(LIVE, 'x', NOW)!; + assert.equal(rider.ageMs, 30_000); + assert.equal(rider.freshness, 'live'); +}); + +// Riders 883, 897 and 1111 really answer like this, with nothing in the payload +// admitting it. Drawing them beside a live rider is the lie to avoid. +test('a fix from five days ago is old, however confident the payload looks', () => { + const rider = readSnapshot({ ...LIVE, logdate: '2026-09-05 11:05:38' }, 'x', NOW)!; + assert.equal(rider.freshness, 'old'); + assert.match(agoOf(rider.ageMs), /5 days ago/); +}); + +test('a fix from ten minutes ago is recent — a last-known point, not a position', () => { + const rider = readSnapshot({ ...LIVE, logdate: '2026-09-10 11:15:11' }, 'x', NOW)!; + assert.equal(rider.freshness, 'recent'); +}); + +test('an unreadable stamp is old rather than assumed fresh', () => { + const rider = readSnapshot({ ...LIVE, logdate: 'whenever' }, 'x', NOW)!; + assert.equal(rider.ageMs, null); + assert.equal(rider.freshness, 'old'); + assert.equal(agoOf(null), 'never reported'); +}); + +// A phone clock running ahead of the browser's would otherwise sort above +// everything real, as the freshest fix on the board. +test('a fix stamped in the future reads as just now, not as the freshest', () => { + const rider = readSnapshot({ ...LIVE, logdate: '2026-09-10 11:40:00' }, 'x', NOW)!; + assert.equal(rider.ageMs, 0); + assert.equal(rider.freshness, 'live'); +}); + +test('0,0 is not a position', () => { + const rider = readSnapshot({ ...LIVE, latitude: '0', longitude: '0' }, 'x', NOW)!; + assert.equal(rider.lat, null); + assert.equal(rider.lng, null); +}); + +test('a rider who never reported is null, not an empty rider', () => { + assert.equal(readSnapshot(null, 'x', NOW), null); +}); + +test('a rider with no name falls back rather than showing blank', () => { + assert.equal(readSnapshot({ ...LIVE, username: ' ' }, 'Rider 852', NOW)!.name, 'Rider 852'); +}); + +/* ── Accuracy ────────────────────────────────────────────────────────────── */ + +// Live data carries accuracy: "100.0" — a circle wider than the blocks the +// drops sit on. Worth saying rather than rounding away. +test('a hundred-metre fix is flagged as vague', () => { + assert.equal(readSnapshot({ ...LIVE, accuracy: '100.0' }, 'x', NOW)!.isVague, true); + assert.equal(readSnapshot(LIVE, 'x', NOW)!.isVague, false); +}); + +/* ── Concerns ────────────────────────────────────────────────────────────── */ + +test('a healthy live rider has nothing to report', () => { + assert.deepEqual(concerns(readSnapshot(LIVE, 'x', NOW)!), []); +}); + +test('location turned off is named, since it explains a stale fix', () => { + const rider = readSnapshot({ ...LIVE, location_service: 'disabled' }, 'x', NOW)!; + assert.equal(rider.hasLocationOn, false); + assert.ok(concerns(rider).includes('location turned off')); +}); + +// Accusing somebody of turning location off because a field was absent is the +// kind of wrong that starts an argument with a person. +test('a missing location_service field is not an accusation', () => { + const rider = readSnapshot({ ...LIVE, location_service: undefined }, 'x', NOW)!; + assert.equal(rider.hasLocationOn, true); + assert.deepEqual(concerns(rider), []); +}); + +test('a low battery is a concern unless it is charging', () => { + assert.ok(concerns(readSnapshot({ ...LIVE, battery: '9%' }, 'x', NOW)!).some((c) => c.includes('battery'))); + assert.deepEqual( + concerns(readSnapshot({ ...LIVE, battery: '9%', is_charging: true }, 'x', NOW)!), + [], + ); +}); + +test('no signal is reported', () => { + assert.ok(concerns(readSnapshot({ ...LIVE, connection: 'none' }, 'x', NOW)!).includes('no signal')); +}); + +/* ── Distance ────────────────────────────────────────────────────────────── */ + +test('metres between two points is a real distance', () => { + const a = { lat: 11.0168, lng: 76.9558 }; + assert.ok(Math.abs(metresBetween(a, { lat: a.lat + 1 / 111, lng: a.lng }) - 1000) < 20); + assert.equal(metresBetween(a, a), 0); +}); + +test('distances read at the scale they are', () => { + assert.equal(distanceLabel(null), '—'); + assert.equal(distanceLabel(840), '840 m'); + assert.equal(distanceLabel(4235), '4.2 km'); +}); + +/* ── The active board ────────────────────────────────────────────────────── */ + +const drops: Record = { + 'near-1': { lat: 8.18, lng: 77.4251, customer: 'Close by', address: 'A' }, + 'far-1': { lat: 8.25, lng: 77.49, customer: 'Miles away', address: 'B' }, +}; + +const rider = (userid: number, orderid: string, lat: number, lng: number) => + readSnapshot({ ...LIVE, userid, orderid, latitude: String(lat), longitude: String(lng) }, 'x', NOW)!; + +test('the board is ordered by how far each rider still has to go', () => { + const board = activeBoard( + [rider(1, 'far-1', 8.1797, 77.425), rider(2, 'near-1', 8.1797, 77.425)], + (id) => drops[id] ?? null, + ); + assert.deepEqual(board.map((entry) => entry.orderid), ['near-1', 'far-1']); + assert.equal(board[0]!.customer, 'Close by'); +}); + +test('a rider carrying nothing is not on the active board', () => { + assert.deepEqual(activeBoard([rider(1, '', 8.1797, 77.425)], (id) => drops[id] ?? null), []); +}); + +// An unknown distance is not a short one. Putting it first would say the +// opposite of what is known. +test('a rider with no usable position sorts last, not first', () => { + const lost = readSnapshot({ ...LIVE, userid: 9, orderid: 'near-1', latitude: '0', longitude: '0' }, 'x', NOW)!; + const board = activeBoard([lost, rider(2, 'far-1', 8.1797, 77.425)], (id) => drops[id] ?? null); + assert.equal(board[0]!.orderid, 'far-1'); + assert.equal(board[1]!.toDropM, null); +}); + +test('a rider on an order we do not hold is still listed, with no distance', () => { + const board = activeBoard([rider(1, 'unknown-order', 8.1797, 77.425)], () => null); + assert.equal(board.length, 1); + assert.equal(board[0]!.toDropM, null); + assert.equal(board[0]!.customer, ''); +}); + +test('freshness bands are drawn where they are documented', () => { + assert.equal(freshnessOf(0), 'live'); + assert.equal(freshnessOf(60_000), 'live'); + assert.equal(freshnessOf(60_001), 'recent'); + assert.equal(freshnessOf(60 * 60_000), 'recent'); + assert.equal(freshnessOf(60 * 60_000 + 1), 'old'); + assert.equal(freshnessOf(null), 'old'); +}); + +test('the stamp is read as server local time', () => { + assert.equal(fixedAt('2026-09-10 11:25:11'), Date.parse('2026-09-10T11:25:11')); + assert.equal(fixedAt(undefined), null); +}); diff --git a/src/features/store-admin/riderLive.ts b/src/features/store-admin/riderLive.ts new file mode 100644 index 0000000..6bf4548 --- /dev/null +++ b/src/features/store-admin/riderLive.ts @@ -0,0 +1,233 @@ +/** + * A rider's live snapshot, read honestly. + * + * ── Why every field here is nullable ──────────────────────────────────────── + * + * The endpoint always answers, and it answers with the LAST known state however + * old it is. Riders 883, 897 and 1111 come back with positions from 5, 3 and 12 + * days ago; nothing in the payload says so. Every number it carries — battery, + * speed, accuracy — is that old too. + * + * So a snapshot is never shown as a fact about now. `age` is derived first and + * `freshness` decides what may be claimed: a LIVE fix can be drawn as a + * position, a STALE one is a last-known point, and an OLD one is only evidence + * that a rider once existed. The map draws them differently for exactly this + * reason. + * + * ── Numbers arrive as strings, and some of them are junk ──────────────────── + * + * `battery: "95%"` with the sign, `speed: "0.00"`, `accuracy: "100.0"`. A + * hundred-metre accuracy is a fix good enough to say which suburb somebody is + * in and not which road, and it is common — so it is surfaced rather than + * quietly rounded away. + */ + +import type { RiderSnapshot } from '@/api/telemetry'; + +/** Under a minute old. Safe to call a position. */ +const LIVE_MS = 60_000; +/** Within the hour. A last-known point, drawn hollow. */ +const RECENT_MS = 60 * 60_000; + +/** + * Beyond this a GPS fix names a neighbourhood, not a street. + * + * Live data really does carry `accuracy: "100.0"`, which is a 100 m circle — + * wider than most of the blocks the drops sit on. + */ +const VAGUE_ACCURACY_M = 60; + +export type Freshness = 'live' | 'recent' | 'old'; + +export interface LiveRider { + userid: number; + name: string; + lat: number | null; + lng: number | null; + /** Milliseconds since the fix. Null when the stamp is unreadable. */ + ageMs: number | null; + freshness: Freshness; + /** 0–100, or null when the phone did not say. */ + battery: number | null; + isCharging: boolean; + /** Km/h. Null when unknown; 0 is a real answer meaning standing still. */ + speed: number | null; + /** Degrees clockwise from north. */ + heading: number | null; + /** Metres of uncertainty. */ + accuracy: number | null; + /** True when the fix is too vague to place on a street. */ + isVague: boolean; + connection: string; + /** False when the rider has turned location off — the usual cause of a stale fix. */ + hasLocationOn: boolean; + isBackground: boolean; + status: string; + /** The order they are on, when they are on one. */ + orderid: string; +} + +function numberFrom(value: string | undefined): number | null { + if (value === undefined || value === null || value === '') return null; + const n = Number(String(value).replace('%', '').trim()); + return Number.isFinite(n) ? n : null; +} + +/** `2026-09-10 11:25:11` — server local time, read in the viewer's zone. */ +export function fixedAt(logdate: string | undefined): number | null { + if (!logdate) return null; + const at = Date.parse(logdate.replace(' ', 'T')); + return Number.isFinite(at) ? at : null; +} + +export function freshnessOf(ageMs: number | null): Freshness { + if (ageMs === null) return 'old'; + if (ageMs <= LIVE_MS) return 'live'; + if (ageMs <= RECENT_MS) return 'recent'; + return 'old'; +} + +/** One snapshot, read into something that can be drawn without lying. */ +export function readSnapshot( + snapshot: RiderSnapshot | null, + fallbackName: string, + now = Date.now(), +): LiveRider | null { + if (!snapshot) return null; + + const lat = numberFrom(snapshot.latitude); + const lng = numberFrom(snapshot.longitude); + const at = fixedAt(snapshot.logdate); + // A negative age is a clock skew between the phone and this browser, not a + // fix from the future. Clamped, so it reads as "just now" rather than + // sorting above everything real. + const ageMs = at === null ? null : Math.max(0, now - at); + const accuracy = numberFrom(snapshot.accuracy); + + return { + userid: snapshot.userid, + name: snapshot.username?.trim() || fallbackName, + // 0,0 is the Atlantic — what a phone sends before it has a fix. + lat: lat !== null && lng !== null && !(lat === 0 && lng === 0) ? lat : null, + lng: lat !== null && lng !== null && !(lat === 0 && lng === 0) ? lng : null, + ageMs, + freshness: freshnessOf(ageMs), + battery: numberFrom(snapshot.battery), + isCharging: snapshot.is_charging === true, + speed: numberFrom(snapshot.speed), + heading: numberFrom(snapshot.heading), + accuracy, + isVague: accuracy !== null && accuracy > VAGUE_ACCURACY_M, + connection: snapshot.connection?.trim() || 'unknown', + // Only an explicit "disabled" is treated as off. An absent field is the app + // not saying, and accusing a rider of disabling location on a missing value + // is the kind of wrong that starts an argument with a person. + hasLocationOn: (snapshot.location_service ?? '').trim().toLowerCase() !== 'disabled', + isBackground: snapshot.is_background === true, + status: snapshot.status?.trim().toLowerCase() || 'unknown', + orderid: snapshot.orderid?.trim() || '', + }; +} + +/** "12s ago", "4m ago", "5 days ago". */ +export function agoOf(ageMs: number | null): string { + if (ageMs === null) return 'never reported'; + const seconds = Math.round(ageMs / 1000); + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.round(hours / 24); + return `${days} day${days === 1 ? '' : 's'} ago`; +} + +/** + * What is wrong with this rider's reporting, if anything. + * + * Reasons rather than a score, and at most a couple of them: a dispatcher needs + * to know whether to trust the dot and what to do about it, not to be handed a + * health percentage they then have to decode. + */ +export function concerns(rider: LiveRider): string[] { + const out: string[] = []; + if (!rider.hasLocationOn) out.push('location turned off'); + if (rider.freshness === 'old') out.push(`last heard from ${agoOf(rider.ageMs)}`); + if (rider.battery !== null && rider.battery <= 15 && !rider.isCharging) { + out.push(`battery ${rider.battery}%`); + } + if (rider.connection === 'none') out.push('no signal'); + if (rider.isVague) out.push(`fix only good to ${Math.round(rider.accuracy ?? 0)} m`); + return out; +} + +/** Metres between two points. Haversine — the distances here are small. */ +export function metresBetween( + a: { lat: number; lng: number }, + b: { lat: number; lng: number }, +): number { + const R = 6_371_000; + const rad = (deg: number) => (deg * Math.PI) / 180; + const dLat = rad(b.lat - a.lat); + const dLng = rad(b.lng - a.lng); + const h = + Math.sin(dLat / 2) ** 2 + + Math.sin(dLng / 2) ** 2 * Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)); + return 2 * R * Math.asin(Math.min(1, Math.sqrt(h))); +} + +/** "800 m", "4.2 km". */ +export function distanceLabel(metres: number | null): string { + if (metres === null) return '—'; + return metres < 1000 ? `${Math.round(metres / 10) * 10} m` : `${(metres / 1000).toFixed(1)} km`; +} + +/** + * A rider with their current job and how far they still have to go. + * + * `toDrop` is straight-line and says so wherever it is shown: the rider is on + * roads and will cover more. It is for ORDERING the board — who is closest to + * finishing — which it does correctly, not for quoting an ETA. + */ +export interface ActiveRider { + live: LiveRider; + orderid: string; + customer: string; + address: string; + /** Straight-line metres from the rider to the drop. Null when either is unknown. */ + toDropM: number | null; +} + +/** + * The riders currently carrying something, closest to their drop first. + * + * A rider with no usable position sorts last rather than first: an unknown + * distance is not a short one, and putting them at the top would say the + * opposite of what is known. + */ +export function activeBoard( + riders: readonly LiveRider[], + dropOf: (orderid: string) => { lat: number; lng: number; customer: string; address: string } | null, +): ActiveRider[] { + const out: ActiveRider[] = []; + for (const live of riders) { + if (!live.orderid) continue; + const drop = dropOf(live.orderid); + const toDropM = + drop && live.lat !== null && live.lng !== null + ? metresBetween({ lat: live.lat, lng: live.lng }, drop) + : null; + out.push({ + live, + orderid: live.orderid, + customer: drop?.customer ?? '', + address: drop?.address ?? '', + toDropM, + }); + } + return out.sort((a, b) => { + if (a.toDropM === null) return 1; + if (b.toDropM === null) return -1; + return a.toDropM - b.toDropM; + }); +} diff --git a/src/features/store-admin/useRoads.ts b/src/features/store-admin/useRoads.ts new file mode 100644 index 0000000..449ef2f --- /dev/null +++ b/src/features/store-admin/useRoads.ts @@ -0,0 +1,52 @@ +import { useEffect, useState } from 'react'; +import { routingApi, type Leg, type RoadPath } from '@/api/routing'; + +/** + * Road geometry for a set of legs, fetched in the background. + * + * ── Why a hook and not a query ────────────────────────────────────────────── + * + * This is decoration that must never block or break the map. React Query would + * give it a loading state, an error state and a retry policy, and the map needs + * none of them: until the roads arrive it draws straight lines, and if they + * never arrive it goes on drawing straight lines. There is nothing to report + * and nothing to retry. + * + * The cache lives in `routing.ts` and is shared across every map in the + * session, so switching between riders re-uses legs already fetched rather than + * asking a courtesy server for them again. + * + * ── Keyed on the coordinates alone ────────────────────────────────────────── + * + * The signature is built from the endpoints, so a redraw that changes a colour, + * a label or a filter does not re-fetch. Only genuinely new legs cost anything. + */ +export function useRoads(legs: readonly Leg[]): Map { + const [roads, setRoads] = useState>(new Map()); + + const signature = legs.map(routingApi.keyFor).join('|'); + + useEffect(() => { + if (legs.length === 0) { + setRoads(new Map()); + return; + } + const controller = new AbortController(); + let isCurrent = true; + + routingApi.roads(legs, controller.signal).then((result) => { + // A late answer for a round nobody is looking at any more must not + // repaint the map underneath the one they are. + if (isCurrent && !controller.signal.aborted) setRoads(result); + }); + + return () => { + isCurrent = false; + controller.abort(); + }; + // `signature` is the real dependency — `legs` is a fresh array every render. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [signature]); + + return roads; +} diff --git a/src/queries/hooks.ts b/src/queries/hooks.ts index c260616..a6208fd 100644 --- a/src/queries/hooks.ts +++ b/src/queries/hooks.ts @@ -18,6 +18,7 @@ import { stockApi, type StockRequestQuery } from '@/api/stock'; import { tenantsApi, utilsApi, type TenantListQuery } from '@/api/tenants'; import { partnersApi } from '@/api/deliveries'; import { customersApi, type CustomerQuery } from '@/api/customers'; +import { telemetryApi, type RiderSnapshot } from '@/api/telemetry'; import { uploadsApi } from '@/api/uploads'; import { APP_BROWSE_CATEGORY } from '@/features/catalogue/tenantCategories'; import { queryKeys } from './keys'; @@ -618,3 +619,53 @@ export function usePartners(applocationid: number | undefined) { ...stable, }); } + +/** + * Live positions for a set of riders. + * + * ── Fanned out, because there is no fleet form ──────────────────────────── + * + * `getriderperiodiclogs` answers for one rider at a time. A merchant's round is + * a handful of riders, so a handful of requests every fifteen seconds is + * cheap — and the fan-out means one rider's failure does not blank the others, + * which a single fleet call would. + * + * ── Fifteen seconds, matching the source ────────────────────────────────── + * + * The rider app reports every twenty seconds or so, so polling faster only + * re-reads the same fix. Fifteen keeps the board a beat ahead of the data + * without asking for anything that does not exist yet. + * + * Paused when the tab is hidden: nobody is watching a map they cannot see, and + * a background tab polling a third-party service all afternoon is rude. + */ +export function useRiderLive(userids: readonly number[], isEnabled = true) { + const ids = [...new Set(userids)].filter((id) => id > 0); + + const results = useQueries({ + queries: ids.map((userid) => ({ + queryKey: queryKeys.live.rider(userid), + queryFn: ({ signal }: { signal: AbortSignal }) => telemetryApi.rider(userid, signal), + enabled: isEnabled, + refetchInterval: isEnabled ? 15_000 : false, + refetchIntervalInBackground: false, + // A stale fix is worth drawing while the next one is fetched; blanking + // the map every fifteen seconds would make it unreadable. + staleTime: 10_000, + retry: 1, + })), + }); + + const byRider = new Map(); + results.forEach((result, index) => { + const id = ids[index]; + if (id !== undefined) byRider.set(id, result.data ?? null); + }); + + return { + data: byRider, + isLoading: results.some((result) => result.isLoading), + /** True while any rider is being re-read — for a quiet "updating" hint. */ + isFetching: results.some((result) => result.isFetching), + }; +} diff --git a/src/queries/keys.ts b/src/queries/keys.ts index 2177025..b9845da 100644 --- a/src/queries/keys.ts +++ b/src/queries/keys.ts @@ -16,6 +16,10 @@ export const queryKeys = { logs: (partnerid: number, fromdate: string, todate: string) => ['partners', 'logs', partnerid, fromdate, todate] as const, }, + live: { + all: ['live'] as const, + rider: (userid: number) => ['live', 'rider', userid] as const, + }, /** The delivery regions a partner can cover. */ regions: { all: ['regions'] as const }, uploads: { diff --git a/tools/css-hooks.mjs b/tools/css-hooks.mjs new file mode 100644 index 0000000..a71f312 --- /dev/null +++ b/tools/css-hooks.mjs @@ -0,0 +1,19 @@ +/** + * Make `import './thing.css'` a no-op under the Node test runner. + * + * Vite turns a CSS import into a side effect that injects a stylesheet; Node + * has no such notion and refuses the file outright with + * ERR_UNKNOWN_FILE_EXTENSION. That is fine for the pure tests, which never + * import a component — but the map has to be MOUNTED to be tested at all, and + * leaflet's own stylesheet comes with it. + * + * Styling is not what those tests check. They count polylines and markers in + * the document, which leaflet creates regardless of whether any CSS loaded. So + * every `.css` specifier resolves to an empty module and the import vanishes. + */ +export function resolve(specifier, context, next) { + if (specifier.endsWith('.css')) { + return { url: 'data:text/javascript,export default {}', shortCircuit: true }; + } + return next(specifier, context); +} diff --git a/tools/stub-css.mjs b/tools/stub-css.mjs new file mode 100644 index 0000000..705785a --- /dev/null +++ b/tools/stub-css.mjs @@ -0,0 +1,9 @@ +/** + * Registers the CSS stub for the test runner. + * + * Loaded with `node --import`, which has to happen before any test module is + * evaluated — a hook registered later cannot intercept an import that has + * already been resolved. + */ +import { register } from 'node:module'; +register('./css-hooks.mjs', import.meta.url);