updats on the dispatch page
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -10,3 +10,6 @@ dist
|
||||
# stayed for weeks.
|
||||
*.zip
|
||||
*.tgz
|
||||
|
||||
# Build output from scripts/mapPreview.mjs — a local viewer, never committed.
|
||||
scripts/.preview/
|
||||
|
||||
578
package-lock.json
generated
578
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
167
scripts/mapPreview.mjs
Normal file
167
scripts/mapPreview.mjs
Normal file
@@ -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(
|
||||
<div style={{ padding: 16, maxWidth: 1100, margin: '0 auto' }}>
|
||||
<h1 style={{ font: '600 18px system-ui', margin: '0 0 4px' }}>Dispatch map — mock day</h1>
|
||||
<p style={{ font: '13px system-ui', color: '#5b6472', margin: '0 0 16px' }}>
|
||||
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.
|
||||
</p>
|
||||
<GroupMap stops={STOPS} groupName="the mock day" />
|
||||
</div>,
|
||||
);
|
||||
`,
|
||||
);
|
||||
|
||||
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'),
|
||||
`<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Dispatch map preview</title>
|
||||
<link rel="stylesheet" href="./map.css">
|
||||
<style>
|
||||
/* The console's own tokens, so the map is styled as it is in the app. */
|
||||
:root {
|
||||
--color-brand: #662582; --color-brand-strong: #531b6e; --color-brand-tint: #f4eef8;
|
||||
--color-ink-1: #1e2530; --color-ink-2: #414a58; --color-ink-3: #5b6472; --color-ink-4: #97a1b0;
|
||||
--color-surface: #fff; --color-surface-subtle: #fafbfc; --color-surface-sunken: #f2f4f7;
|
||||
--color-border: #e3e7ec;
|
||||
}
|
||||
body { margin: 0; background: #fff; font-family: system-ui, sans-serif; }
|
||||
.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); }
|
||||
.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) }
|
||||
.rider-chip { display:inline-flex; gap:6px; align-items:center; padding:4px 10px; border:1px solid var(--color-border);
|
||||
border-radius:999px; background:#fff; font:inherit; font-size:12px; color:var(--color-ink-2); cursor:pointer }
|
||||
.rider-chip[data-active='true'] { border-color:var(--color-brand); background:var(--color-brand-tint); color:var(--color-ink-1) }
|
||||
.rider-chip i { width:8px; height:8px; border-radius:50% }
|
||||
</style>
|
||||
<div id="root"></div>
|
||||
<script src="./map.js"></script>
|
||||
`,
|
||||
);
|
||||
|
||||
console.log('preview written to', join(out, 'map.html'));
|
||||
console.log('open it in a browser to see the real map with mock stops');
|
||||
@@ -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<T>(path: string, body: unknown): Promise<T> {
|
||||
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<unknown> {
|
||||
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,
|
||||
),
|
||||
};
|
||||
|
||||
135
src/api/routing.ts
Normal file
135
src/api/routing.ts
Normal file
@@ -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<string, RoadPath | null>();
|
||||
|
||||
/** 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<RoadPath | null> {
|
||||
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<Map<string, RoadPath>> => {
|
||||
const out = new Map<string, RoadPath>();
|
||||
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,
|
||||
};
|
||||
112
src/api/telemetry.ts
Normal file
112
src/api/telemetry.ts
Normal file
@@ -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<RiderSnapshot | null> => {
|
||||
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;
|
||||
},
|
||||
};
|
||||
@@ -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: `<span class="trail-pin" data-faded="${pin.isFaded ? 'true' : 'false'}" data-shape="${pin.shape ?? 'round'}" style="--pin:${escapeAttr(colour)}"></span>`,
|
||||
iconSize: [16, 16],
|
||||
iconAnchor: [8, 8],
|
||||
// A numbered pin has to be big enough to hold two digits legibly, so
|
||||
// the size follows the content rather than being fixed.
|
||||
html:
|
||||
`<span class="trail-pin" data-faded="${pin.isFaded ? 'true' : 'false'}"` +
|
||||
` data-shape="${pin.shape ?? 'round'}"` +
|
||||
` data-muted="${pin.isMuted ? 'true' : 'false'}"` +
|
||||
` data-numbered="${pin.step ? 'true' : 'false'}"` +
|
||||
` data-start="${pin.isStart ? 'true' : 'false'}"` +
|
||||
` style="--pin:${escapeAttr(colour)}">` +
|
||||
`${pin.step ? escapeHtml(String(pin.step)) : ''}</span>`,
|
||||
iconSize: pin.step ? [22, 22] : [16, 16],
|
||||
iconAnchor: pin.step ? [11, 11] : [8, 8],
|
||||
}),
|
||||
})
|
||||
.bindPopup(
|
||||
`<strong>${escapeHtml(pin.label)}</strong>` +
|
||||
(pin.lines ?? []).map((line) => `<br>${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 `
|
||||
<div class="map-popup-card">
|
||||
<div class="map-popup-header">
|
||||
<div class="map-popup-tag">
|
||||
<span class="map-popup-badge-icon">📦</span>
|
||||
<span class="map-popup-id">${p.items.length} Orders Here</span>
|
||||
</div>
|
||||
${p.step ? `<span class="map-popup-step">Stop #${escapeHtml(String(p.step))}</span>` : ''}
|
||||
</div>
|
||||
<div class="map-popup-body">
|
||||
<div class="map-popup-customer-row">
|
||||
<div class="map-popup-avatar">🏢</div>
|
||||
<div class="map-popup-cust-details">
|
||||
<span class="map-popup-customer">${escapeHtml(p.customer || 'Multiple Drops')}</span>
|
||||
<span class="map-popup-address">${escapeHtml(p.address || '')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="map-popup-multi-list">
|
||||
${p.items.slice(0, 4).map(item => `
|
||||
<div class="map-popup-item-row">
|
||||
<span class="map-popup-item-id">#${escapeHtml(item.orderid)}</span>
|
||||
<span class="map-popup-item-status">${escapeHtml(item.status)}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
${p.items.length > 4 ? `<div class="map-popup-item-more">+${p.items.length - 4} more orders</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="map-popup-footer">
|
||||
<div class="map-popup-rider">
|
||||
<span class="map-popup-rider-icon">🛵</span>
|
||||
<span class="map-popup-rider-name">${escapeHtml(p.rider || 'Assigned')}</span>
|
||||
</div>
|
||||
<div class="map-popup-amount">${escapeHtml(p.amount || '')}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (p.orderId) {
|
||||
return `
|
||||
<div class="map-popup-card">
|
||||
<div class="map-popup-header">
|
||||
<div class="map-popup-tag">
|
||||
<span class="map-popup-badge-icon">📦</span>
|
||||
<span class="map-popup-id">#${escapeHtml(p.orderId)}</span>
|
||||
</div>
|
||||
${p.step ? `<span class="map-popup-step">Stop #${escapeHtml(String(p.step))}</span>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="map-popup-body">
|
||||
<div class="map-popup-customer-row">
|
||||
<div class="map-popup-avatar">${escapeHtml((p.customer || 'C').charAt(0).toUpperCase())}</div>
|
||||
<div class="map-popup-cust-details">
|
||||
<span class="map-popup-customer">${escapeHtml(p.customer || 'Customer')}</span>
|
||||
<span class="map-popup-address">${escapeHtml(p.address || 'Delivery Address')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="map-popup-status-bar">
|
||||
<span class="map-popup-status-pill" style="--pill-color: ${escapeAttr(p.statusColor || '#662582')}">
|
||||
<span class="map-popup-status-dot"></span>
|
||||
<span class="map-popup-status-text">${escapeHtml(p.status || 'Pending')}</span>
|
||||
${p.time ? `<span class="map-popup-time">· ${escapeHtml(p.time)}</span>` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="map-popup-footer">
|
||||
<div class="map-popup-rider">
|
||||
<span class="map-popup-rider-icon">🛵</span>
|
||||
<span class="map-popup-rider-name">${escapeHtml(p.rider || 'Rider')}</span>
|
||||
</div>
|
||||
<div class="map-popup-amount">${escapeHtml(p.amount || '')}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const isStoreHub = p.subtitle?.includes('Hub') || p.title?.includes('Hub') || p.title?.includes('R mart') || pin.shape === 'square';
|
||||
|
||||
if (isStoreHub) {
|
||||
return `
|
||||
<div class="map-popup-card">
|
||||
<div class="map-popup-header">
|
||||
<div class="map-popup-tag">
|
||||
<span class="map-popup-badge-icon">🏪</span>
|
||||
<span class="map-popup-id">Store Hub</span>
|
||||
</div>
|
||||
<span class="map-popup-step" style="background: #662582;">Origin</span>
|
||||
</div>
|
||||
|
||||
<div class="map-popup-body">
|
||||
<div class="map-popup-customer-row">
|
||||
<div class="map-popup-avatar" style="background: rgba(102, 37, 130, 0.12); color: #662582; font-size: 13px;">🏪</div>
|
||||
<div class="map-popup-cust-details">
|
||||
<span class="map-popup-customer">${escapeHtml(p.title || 'R mart')}</span>
|
||||
<span class="map-popup-subtitle" style="font-size: 11px; color: #64748b; font-weight: 500;">${escapeHtml(p.subtitle || 'Fulfillment & Dispatch Hub')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="map-popup-address-box" style="display: flex; align-items: flex-start; gap: 5px; background: #f8fafc; padding: 6px 8px; border-radius: 6px; border: 1px solid #e2e8f0; font-size: 11px; color: #475569; line-height: 1.35;">
|
||||
<span style="font-size: 12px; flex-shrink: 0; line-height: 1.2;">📍</span>
|
||||
<span>${escapeHtml(p.address || 'RS Puram Main Rd, D.B. Road, Coimbatore - 641002')}</span>
|
||||
</div>
|
||||
|
||||
<div class="map-popup-status-bar">
|
||||
<span class="map-popup-status-pill" style="--pill-color: ${escapeAttr(p.statusColor || '#662582')}">
|
||||
<span class="map-popup-status-dot"></span>
|
||||
<span class="map-popup-status-text">${escapeHtml(p.status || 'Active Origin Depot')}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="map-popup-card">
|
||||
<div class="map-popup-header">
|
||||
<div class="map-popup-tag">
|
||||
<span class="map-popup-badge-icon">🛵</span>
|
||||
<span class="map-popup-id">${escapeHtml(p.title || pin.label)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="map-popup-body">
|
||||
${p.subtitle ? `<div class="map-popup-customer">${escapeHtml(p.subtitle)}</div>` : ''}
|
||||
${p.address ? `<div class="map-popup-address">${escapeHtml(p.address)}</div>` : ''}
|
||||
${p.status ? `
|
||||
<div class="map-popup-status-bar">
|
||||
<span class="map-popup-status-pill" style="--pill-color: ${escapeAttr(p.statusColor || '#662582')}">
|
||||
<span class="map-popup-status-dot"></span>
|
||||
<span class="map-popup-status-text">${escapeHtml(p.status)}</span>
|
||||
${p.time ? `<span class="map-popup-time">· ${escapeHtml(p.time)}</span>` : ''}
|
||||
</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Fallback for default pins
|
||||
return `
|
||||
<div class="map-popup-card">
|
||||
<div class="map-popup-header">
|
||||
<span class="map-popup-id">${escapeHtml(pin.label)}</span>
|
||||
</div>
|
||||
<div class="map-popup-body">
|
||||
${(pin.lines ?? []).map(line => `<div class="map-popup-line">${escapeHtml(line)}</div>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
|
||||
236
src/features/store-admin/ActivePanel.tsx
Normal file
236
src/features/store-admin/ActivePanel.tsx
Normal file
@@ -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<number, string>();
|
||||
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<string, (typeof mapped)[number]>();
|
||||
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 (
|
||||
<VStack gap={1.5}>
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1.5} padding={2}>
|
||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Out right now
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{board.length === 0
|
||||
? 'nobody is carrying anything'
|
||||
: `${board.length} carrying · closest to their drop first`}
|
||||
{live.isFetching ? ' · updating…' : ''}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
<TrailMap
|
||||
pins={pins}
|
||||
height={360}
|
||||
emptyNote={
|
||||
live.isLoading
|
||||
? 'Asking the riders where they are…'
|
||||
: userids.length === 0
|
||||
? 'Nothing is assigned, so nobody is out.'
|
||||
: 'No rider has reported a position.'
|
||||
}
|
||||
/>
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
{board.length > 0 ? (
|
||||
<div className="active-grid">
|
||||
{board.map((entry, index) => {
|
||||
const rider = entry.live;
|
||||
const worries = concerns(rider);
|
||||
return (
|
||||
<Card key={rider.userid} padding={0} elevation="low">
|
||||
<VStack gap={1} padding={2}>
|
||||
<HStack justify="between" align="center" gap={1}>
|
||||
<span className="active-name">
|
||||
<i style={{ background: trailColour(index) }} />
|
||||
{rider.name}
|
||||
</span>
|
||||
<span className="active-fresh" data-fresh={rider.freshness}>
|
||||
{agoOf(rider.ageMs)}
|
||||
</span>
|
||||
</HStack>
|
||||
|
||||
<div className="active-distance">
|
||||
<Navigation size={15} />
|
||||
<strong>{distanceLabel(entry.toDropM)}</strong>
|
||||
<span>
|
||||
{entry.toDropM === null
|
||||
? 'position or drop unknown'
|
||||
: 'from the drop, in a straight line'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="active-meta">
|
||||
<span title="The order they are on">{entry.orderid || '—'}</span>
|
||||
<span>{entry.customer || entry.address || 'no address'}</span>
|
||||
</div>
|
||||
|
||||
<div className="active-stats">
|
||||
<span>{rider.status}</span>
|
||||
<span>{rider.speed === null ? '— km/h' : `${rider.speed.toFixed(0)} km/h`}</span>
|
||||
<span data-low={rider.battery !== null && rider.battery <= 15}>
|
||||
{rider.battery === null ? '—' : `${rider.battery}%`}
|
||||
{rider.isCharging ? ' ⚡' : ''}
|
||||
</span>
|
||||
<span>{rider.connection}</span>
|
||||
</div>
|
||||
|
||||
{worries.length > 0 ? (
|
||||
<div className="active-worry">
|
||||
{rider.connection === 'none' ? (
|
||||
<WifiOff size={13} />
|
||||
) : rider.battery !== null && rider.battery <= 15 ? (
|
||||
<BatteryLow size={13} />
|
||||
) : (
|
||||
<MapPin size={13} />
|
||||
)}
|
||||
{worries.join(' · ')}
|
||||
</div>
|
||||
) : null}
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="pva-note">
|
||||
<Info size={13} />
|
||||
<span>
|
||||
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.`
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
359
src/features/store-admin/AutoAssignDrawer.tsx
Normal file
359
src/features/store-admin/AutoAssignDrawer.tsx
Normal file
@@ -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<number>;
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const client = useQueryClient();
|
||||
const [tuning, setTuning] = useState<Tuning>('balanced');
|
||||
const [absent, setAbsent] = useState<ReadonlySet<number>>(new Set());
|
||||
const [plan, setPlan] = useState<Plan | null>(null);
|
||||
const [outcome, setOutcome] = useState<string | null>(null);
|
||||
const abort = useRef<AbortController | null>(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 (
|
||||
<Drawer
|
||||
title="Auto-assign"
|
||||
subtitle={`${orders.length} order${orders.length === 1 ? '' : 's'} waiting`}
|
||||
width={620}
|
||||
onClose={onClose}
|
||||
isFooterSpread
|
||||
footer={
|
||||
<>
|
||||
<DrawerButton
|
||||
label={isRunning ? 'Cancel' : 'Close'}
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
if (isRunning) abort.current?.abort();
|
||||
else onClose();
|
||||
}}
|
||||
/>
|
||||
{plan ? (
|
||||
<DrawerButton
|
||||
label={commit.isPending ? 'Assigning…' : `Assign ${plan.meta.assigned}`}
|
||||
variant="primary"
|
||||
icon={<Bike size={15} />}
|
||||
isDisabled={!canCommit}
|
||||
onClick={() => commit.mutate()}
|
||||
/>
|
||||
) : (
|
||||
<DrawerButton
|
||||
label={isRunning ? 'Working…' : 'Run the optimiser'}
|
||||
variant="primary"
|
||||
icon={<Sparkles size={15} />}
|
||||
isDisabled={isRunning || orders.length === 0}
|
||||
onClick={() => solve.mutate()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<VStack gap={2}>
|
||||
<Note icon={<Sparkles size={15} />}>
|
||||
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.
|
||||
</Note>
|
||||
|
||||
{/* ── Before the run ──────────────────────────────────────────── */}
|
||||
{!plan ? (
|
||||
<>
|
||||
<Section title="How to solve it">
|
||||
<div className="aa-tunings">
|
||||
{TUNINGS.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
className="aa-tuning"
|
||||
data-active={tuning === option.id}
|
||||
disabled={isRunning}
|
||||
onClick={() => setTuning(option.id)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Anybody off today?">
|
||||
<DrawerCard>
|
||||
<Row
|
||||
label="Absent riders"
|
||||
value="Ticked riders are left out of the plan. Leaving this empty is normal — the solver then considers everyone."
|
||||
isStacked
|
||||
/>
|
||||
{fleet.length === 0 ? (
|
||||
<Row
|
||||
label="Fleet"
|
||||
value="No riders are on duty, so there is nobody to mark absent."
|
||||
isStacked
|
||||
/>
|
||||
) : (
|
||||
<div className="aa-absent">
|
||||
{fleet.map((rider) => {
|
||||
const name =
|
||||
`${rider.firstname ?? ''} ${rider.lastname ?? ''}`.trim() ||
|
||||
rider.fullname?.trim() ||
|
||||
`Rider ${rider.userid}`;
|
||||
return (
|
||||
<label key={rider.userid} className="aa-absent-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={absent.has(rider.userid)}
|
||||
disabled={isRunning}
|
||||
onChange={() =>
|
||||
setAbsent((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(rider.userid)) next.delete(rider.userid);
|
||||
else next.add(rider.userid);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
<UserMinus size={13} />
|
||||
{name}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</DrawerCard>
|
||||
</Section>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* ── The proposal ────────────────────────────────────────────── */}
|
||||
{plan?.blocked ? (
|
||||
<div className="aa-blocked">
|
||||
<AlertTriangle size={16} />
|
||||
<div>
|
||||
<strong>The optimiser could not consider anybody</strong>
|
||||
<span>{plan.blocked}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{plan && plan.proposals.length > 0 ? (
|
||||
<Section
|
||||
title={`${plan.meta.assigned} of ${plan.meta.totalOrders} placed with ${plan.proposals.length} rider${plan.proposals.length === 1 ? '' : 's'}`}
|
||||
>
|
||||
{plan.proposals.map((proposal) => (
|
||||
<DrawerCard key={proposal.userid}>
|
||||
<Row
|
||||
label={proposal.rider}
|
||||
value={
|
||||
<Badge
|
||||
label={`${proposal.stops.length} stop${proposal.stops.length === 1 ? '' : 's'}`}
|
||||
colour="var(--color-brand)"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{proposal.stops.map((stop) => (
|
||||
<Row
|
||||
key={stop.orderid || stop.orderheaderid}
|
||||
label={stop.orderid || `#${stop.orderheaderid}`}
|
||||
value={
|
||||
stop.row
|
||||
? moneyExact(
|
||||
stop.row.ordervalue || stop.row.orderamount || stop.row.deliveryamt || 0,
|
||||
)
|
||||
: 'not in this list'
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</DrawerCard>
|
||||
))}
|
||||
{plan.meta.profit > 0 ? (
|
||||
<Note>
|
||||
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.
|
||||
</Note>
|
||||
) : null}
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{missing.length > 0 ? (
|
||||
<Note icon={<AlertTriangle size={15} />}>
|
||||
{missing.length} proposed order{missing.length === 1 ? '' : 's'} could not be matched
|
||||
back to this list and will not be assigned: {missing.join(', ')}.
|
||||
</Note>
|
||||
) : null}
|
||||
|
||||
{plan && plan.unassigned.length > 0 ? (
|
||||
<Section title={`${plan.unassigned.length} left unassigned`}>
|
||||
<DrawerCard>
|
||||
{plan.unassigned.slice(0, 12).map((entry) => (
|
||||
<Row key={entry.orderid} label={entry.orderid} value={entry.reason} isStacked />
|
||||
))}
|
||||
{plan.unassigned.length > 12 ? (
|
||||
<Row
|
||||
label="…"
|
||||
value={`and ${plan.unassigned.length - 12} more, for the same reasons`}
|
||||
/>
|
||||
) : null}
|
||||
</DrawerCard>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{outcome ? (
|
||||
<HStack gap={1} align="center">
|
||||
<Text type="body" size="sm">
|
||||
{outcome}
|
||||
</Text>
|
||||
</HStack>
|
||||
) : null}
|
||||
</VStack>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
286
src/features/store-admin/GroupMap.dom.test.tsx
Normal file
286
src/features/store-admin/GroupMap.dom.test.tsx
Normal file
@@ -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('<!doctype html><html><body><div id="root"></div></body></html>', {
|
||||
url: 'http://localhost/',
|
||||
pretendToBeVisual: true,
|
||||
});
|
||||
const win = dom.window as unknown as Record<string, unknown>;
|
||||
const global = globalThis as Record<string, unknown>;
|
||||
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<Document>;
|
||||
|
||||
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<string, unknown>)['droplat'] = '';
|
||||
(blind.row as unknown as Record<string, unknown>)['droplon'] = '';
|
||||
(blind.row as unknown as Record<string, unknown>)['deliverylat'] = '';
|
||||
(blind.row as unknown as Record<string, unknown>)['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);
|
||||
});
|
||||
@@ -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<number | null>(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 (
|
||||
<VStack gap={1}>
|
||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{coverage.placed} of {coverage.total} stop{coverage.total === 1 ? '' : 's'} placed
|
||||
{coverage.withRider > 0 ? ` · ${coverage.withRider} with a rider position` : ''}
|
||||
</Text>
|
||||
<span className="map-key">
|
||||
<i data-key="shop" />
|
||||
shop
|
||||
<i data-key="drop" />
|
||||
drop
|
||||
<i data-key="rider" />
|
||||
rider
|
||||
</span>
|
||||
</HStack>
|
||||
|
||||
{/* In the store and customer views a group holds several riders' work, so
|
||||
the round filter earns its place. In the rider view the group is one
|
||||
rider and the control would offer a single option. */}
|
||||
{rounds.length > 1 ? (
|
||||
<HStack gap={0.5} wrap="wrap">
|
||||
<RoundChip
|
||||
label="Everyone"
|
||||
colour="var(--color-ink-4)"
|
||||
isActive={focused === null}
|
||||
onClick={() => setFocused(null)}
|
||||
/>
|
||||
{rounds.map((round, index) => (
|
||||
<RoundChip
|
||||
key={round.userid}
|
||||
label={`${round.rider} · ${round.stops.length}`}
|
||||
colour={trailColour(index)}
|
||||
isActive={focused === round.userid}
|
||||
onClick={() => setFocused((prev) => (prev === round.userid ? null : round.userid))}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
) : null}
|
||||
|
||||
<div className="group-map-clean-wrap" style={{ width: '100%', height: '100%' }}>
|
||||
<TrailMap
|
||||
trails={trails}
|
||||
pins={pins}
|
||||
height={440}
|
||||
height={460}
|
||||
emptyNote={
|
||||
coverage.total === 0
|
||||
? `Nothing out for ${groupName}.`
|
||||
: 'None of these stops carries an address the map can place.'
|
||||
stops.length === 0
|
||||
? `Nothing was delivered for ${groupName}.${emptyHint ? ` ${emptyHint}` : ''}`
|
||||
: 'These stops carry no address the map can place, so there is nothing to draw.'
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="pva-note">
|
||||
<Store size={13} />
|
||||
<span>
|
||||
Drops are joined in the order they were worked, not along the road the rider rode — the
|
||||
length of that line is not the distance covered. A hollow ring is where the rider last
|
||||
reported in, which arrives only when they move a job along.
|
||||
</span>
|
||||
</div>
|
||||
</VStack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RoundChip({
|
||||
label,
|
||||
colour,
|
||||
isActive,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
colour: string;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button type="button" className="rider-chip" data-active={isActive} onClick={onClick}>
|
||||
<i style={{ background: colour }} />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function clock(at: number): string {
|
||||
return new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
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}`;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
249
src/features/store-admin/ProfitPanel.tsx
Normal file
249
src/features/store-admin/ProfitPanel.tsx
Normal file
@@ -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 (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack padding={3}>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Reading the day…
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (result.deliveries === 0) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1} padding={4} align="center">
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Nothing finished in this range
|
||||
</Text>
|
||||
<Text type="body" size="sm" color="secondary" style={{ textAlign: 'center', maxWidth: 400 }}>
|
||||
Earnings are recorded when a delivery completes. Widen the date range in the top bar to
|
||||
see a day that has finished.
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack gap={1.5}>
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1.5} padding={2}>
|
||||
<HStack justify="between" align="end" gap={2} wrap="wrap">
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
The day's money
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{result.deliveries} finished deliver{result.deliveries === 1 ? 'y' : 'ies'}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
<HStack gap={3} wrap="wrap" align="center">
|
||||
<Figure label="Charged" value={moneyExact(result.charged)} note="to the shops" />
|
||||
<Figure label="Paid to riders" value={moneyExact(result.riderPaid)} note="rider charges" />
|
||||
<Figure
|
||||
label="Recorded profit"
|
||||
value={moneyExact(result.profit)}
|
||||
note={`over the ${result.costed} costed`}
|
||||
tone={result.profit > 0 ? 'good' : 'watch'}
|
||||
/>
|
||||
<Figure
|
||||
label="Per delivery"
|
||||
value={result.perDelivery === null ? '—' : moneyExact(result.perDelivery)}
|
||||
note="averaged over the costed ones"
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{/* The line that stops every figure above being over-read. */}
|
||||
<div className="profit-coverage">
|
||||
<span className="profit-bar">
|
||||
<i style={{ width: `${Math.round(covered * 100)}%` }} />
|
||||
</span>
|
||||
<span>
|
||||
{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.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{result.disagreeing > 0 ? (
|
||||
<div className="pva-note">
|
||||
<AlertTriangle size={13} />
|
||||
<span>
|
||||
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.
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1} padding={2}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
By rider
|
||||
</Text>
|
||||
</VStack>
|
||||
<div className="table-scroll">
|
||||
<table className="stops-table pva-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Rider</th>
|
||||
<th>Deliveries</th>
|
||||
<th>Charged</th>
|
||||
<th>Paid</th>
|
||||
<th>Profit</th>
|
||||
<th>Costed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.byRider.map((rider) => (
|
||||
<tr key={rider.rider}>
|
||||
<td>
|
||||
<strong>{rider.rider}</strong>
|
||||
</td>
|
||||
<td className="num">{rider.deliveries}</td>
|
||||
<td className="num">{moneyExact(rider.charged)}</td>
|
||||
<td className="num">{moneyExact(rider.riderPaid)}</td>
|
||||
<td className="num">{moneyExact(rider.profit)}</td>
|
||||
<td className="num">
|
||||
{rider.costed}/{rider.deliveries}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1} padding={2}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Delivery by delivery
|
||||
</Text>
|
||||
</VStack>
|
||||
<div className="table-scroll">
|
||||
<table className="stops-table pva-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Order</th>
|
||||
<th>Rider</th>
|
||||
<th>KM</th>
|
||||
<th>Charged</th>
|
||||
<th>Paid</th>
|
||||
<th>Recorded</th>
|
||||
<th>Charged − paid</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.rows.map((row) => (
|
||||
<tr key={row.deliveryid}>
|
||||
<td>
|
||||
<strong>{row.orderid}</strong>
|
||||
</td>
|
||||
<td>{row.rider}</td>
|
||||
<td className="num">{row.km ?? '—'}</td>
|
||||
<td className="num">{row.charged === null ? '—' : moneyExact(row.charged)}</td>
|
||||
<td className="num">{row.riderPaid === null ? '—' : moneyExact(row.riderPaid)}</td>
|
||||
<td className="num">
|
||||
{row.recorded === null ? (
|
||||
<em className="muted">not costed</em>
|
||||
) : (
|
||||
moneyExact(row.recorded)
|
||||
)}
|
||||
</td>
|
||||
<td className="num" data-disagrees={row.disagrees}>
|
||||
{row.derived === null ? '—' : moneyExact(row.derived)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<TablePager paged={paged} label="deliveries" />
|
||||
</Card>
|
||||
|
||||
<div className="pva-note">
|
||||
<Info size={13} />
|
||||
<span>
|
||||
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.
|
||||
</span>
|
||||
</div>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function Figure({
|
||||
label,
|
||||
value,
|
||||
note,
|
||||
tone = 'neutral',
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
note: string;
|
||||
tone?: 'good' | 'watch' | 'neutral';
|
||||
}) {
|
||||
return (
|
||||
<div className="pva-figure" data-tone={tone}>
|
||||
<span className="pva-figure-label">{label}</span>
|
||||
<strong>{value}</strong>
|
||||
<span className="pva-figure-note">{note}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
338
src/features/store-admin/autoAssign.test.ts
Normal file
338
src/features/store-admin/autoAssign.test.ts
Normal file
@@ -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> = {}): 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<OrderRow>));
|
||||
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, []);
|
||||
});
|
||||
350
src/features/store-admin/autoAssign.ts
Normal file
350
src/features/store-admin/autoAssign.ts
Normal file
@@ -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<string, unknown> {
|
||||
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<string, unknown>[];
|
||||
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<string, unknown>;
|
||||
|
||||
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<string, OrderRow>();
|
||||
const byHeader = new Map<number, OrderRow>();
|
||||
for (const row of orders) {
|
||||
if (row.orderid) byOrderid.set(row.orderid, row);
|
||||
byHeader.set(row.orderheaderid, row);
|
||||
}
|
||||
|
||||
const proposals = new Map<number, Proposal>();
|
||||
const seen = new Set<string>();
|
||||
|
||||
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<string>();
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -476,3 +476,5 @@ export function ymd(date: Date): string {
|
||||
export function isToday(day: string): boolean {
|
||||
return day === ymd(new Date());
|
||||
}
|
||||
|
||||
|
||||
|
||||
531
src/features/store-admin/dispatchPanels.css
Normal file
531
src/features/store-admin/dispatchPanels.css
Normal file
@@ -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);
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
.drawer-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 70;
|
||||
z-index: 3000;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -75,22 +75,55 @@ export const statusColor = (map: Record<string, string>, 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.
|
||||
*
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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() {
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<FilterRow
|
||||
tabs={statusTabs}
|
||||
status={status}
|
||||
counts={tabCounts}
|
||||
onStatus={setStatus}
|
||||
@@ -374,6 +386,7 @@ export function SalesPage() {
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<FilterRow
|
||||
tabs={statusTabs}
|
||||
status={status}
|
||||
counts={tabCounts}
|
||||
onStatus={setStatus}
|
||||
@@ -507,11 +520,14 @@ function freshRow(
|
||||
* downward now follows that order.
|
||||
*/
|
||||
function FilterRow({
|
||||
tabs,
|
||||
status,
|
||||
counts,
|
||||
onStatus,
|
||||
search,
|
||||
}: {
|
||||
/** Which ladder to show — orders and deliveries do not share one. */
|
||||
tabs: readonly { readonly key: StatusKey; readonly label: string }[];
|
||||
status: StatusKey;
|
||||
counts: Record<StatusKey, number>;
|
||||
onStatus: (key: StatusKey) => void;
|
||||
@@ -520,7 +536,7 @@ function FilterRow({
|
||||
return (
|
||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||
<HStack gap={0.5} wrap="wrap">
|
||||
{STATUS_TABS.map((entry) => (
|
||||
{tabs.map((entry) => (
|
||||
<StatusTab
|
||||
key={entry.key}
|
||||
label={entry.label}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
179
src/features/store-admin/profitability.test.ts
Normal file
179
src/features/store-admin/profitability.test.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Delivery economics, from the columns rather than from invented rates.
|
||||
*
|
||||
* The awkward rows are real: `profit` is filled on 210 of 500 tenant-916 rows
|
||||
* and blank on the rest, `ridercharges` on 249, and `calculationdistancekm`
|
||||
* reads 150087 on one of them. Every test below pins a decision about what to
|
||||
* do when a figure is simply absent — because the wrong answer, treating it as
|
||||
* zero, understates the day by exactly the rows nobody has costed.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import type { DeliveryRow } from '@/api/types';
|
||||
import { coverage, earningsOf, profitability } from './profitability';
|
||||
|
||||
function row(over: Record<string, unknown> = {}): 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, '—');
|
||||
});
|
||||
235
src/features/store-admin/profitability.ts
Normal file
235
src/features/store-admin/profitability.ts
Normal file
@@ -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<number, Map<string, number>>();
|
||||
for (const row of rows) {
|
||||
if (row.userid === 0 || !isRealName(row.rider)) continue;
|
||||
const forRider = counts.get(row.userid) ?? new Map<string, number>();
|
||||
forRider.set(row.rider, (forRider.get(row.rider) ?? 0) + 1);
|
||||
counts.set(row.userid, forRider);
|
||||
}
|
||||
|
||||
const resolved = new Map<number, string>();
|
||||
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<number, RiderEarnings>();
|
||||
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;
|
||||
}
|
||||
208
src/features/store-admin/riderLive.test.ts
Normal file
208
src/features/store-admin/riderLive.test.ts
Normal file
@@ -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<string, { lat: number; lng: number; customer: string; address: string }> = {
|
||||
'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);
|
||||
});
|
||||
233
src/features/store-admin/riderLive.ts
Normal file
233
src/features/store-admin/riderLive.ts
Normal file
@@ -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;
|
||||
});
|
||||
}
|
||||
52
src/features/store-admin/useRoads.ts
Normal file
52
src/features/store-admin/useRoads.ts
Normal file
@@ -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<string, RoadPath> {
|
||||
const [roads, setRoads] = useState<Map<string, RoadPath>>(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;
|
||||
}
|
||||
@@ -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<number, RiderSnapshot | null>();
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
19
tools/css-hooks.mjs
Normal file
19
tools/css-hooks.mjs
Normal file
@@ -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);
|
||||
}
|
||||
9
tools/stub-css.mjs
Normal file
9
tools/stub-css.mjs
Normal file
@@ -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);
|
||||
Reference in New Issue
Block a user