updats on the dispatch page

This commit is contained in:
2026-09-10 17:57:00 +05:30
parent 79a8f2b234
commit 8b2c4daef8
34 changed files with 6929 additions and 1059 deletions

3
.gitignore vendored
View File

@@ -10,3 +10,6 @@ dist
# stayed for weeks. # stayed for weeks.
*.zip *.zip
*.tgz *.tgz
# Build output from scripts/mapPreview.mjs — a local viewer, never committed.
scripts/.preview/

578
package-lock.json generated
View File

@@ -22,17 +22,72 @@
"devDependencies": { "devDependencies": {
"@astryxdesign/cli": "^0.4.5", "@astryxdesign/cli": "^0.4.5",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"@types/jsdom": "^30.0.0",
"@types/leaflet": "^1.9.22", "@types/leaflet": "^1.9.22",
"@types/node": "^26.2.0", "@types/node": "^26.2.0",
"@types/react": "^19.2.18", "@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4", "@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^5.0.4", "@vitejs/plugin-react": "^5.0.4",
"jsdom": "^30.0.1",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
"tsx": "^4.20.3", "tsx": "^4.20.3",
"typescript": "^7.0.2", "typescript": "^7.0.2",
"vite": "^8.2.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": { "node_modules/@astryxdesign/cli": {
"version": "0.4.5", "version": "0.4.5",
"resolved": "https://registry.npmjs.org/@astryxdesign/cli/-/cli-0.4.5.tgz", "resolved": "https://registry.npmjs.org/@astryxdesign/cli/-/cli-0.4.5.tgz",
@@ -693,6 +748,159 @@
"node": ">=6.9.0" "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": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
@@ -1135,6 +1343,24 @@
"node": ">=18" "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": { "node_modules/@formatjs/fast-memoize": {
"version": "3.1.7", "version": "3.1.7",
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.7.tgz", "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.7.tgz",
@@ -2006,6 +2232,26 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/leaflet": {
"version": "1.9.22", "version": "1.9.22",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz", "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz",
@@ -2046,6 +2292,13 @@
"@types/react": "^19.2.0" "@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": { "node_modules/@types/use-sync-external-store": {
"version": "0.0.6", "version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", "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": ">=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": { "node_modules/browserslist": {
"version": "4.28.8", "version": "4.28.8",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
@@ -2611,6 +2874,20 @@
"integrity": "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==", "integrity": "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==",
"license": "BSD" "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": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -2739,6 +3016,35 @@
"node": ">=12" "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": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "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": { "node_modules/decimal.js-light": {
"version": "2.5.1", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
@@ -2794,6 +3107,19 @@
"node": ">=10.13.0" "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": { "node_modules/es-toolkit": {
"version": "1.51.0", "version": "1.51.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.51.0.tgz", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.51.0.tgz",
@@ -2996,6 +3322,19 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/immer": {
"version": "11.1.18", "version": "11.1.18",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz", "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz",
@@ -3057,6 +3396,13 @@
"node": ">=0.10.0" "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": { "node_modules/isobject": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", "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": { "node_modules/jsesc": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -3506,6 +3903,13 @@
"semver": "bin/semver" "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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -3588,6 +3992,19 @@
"node": ">=6" "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": { "node_modules/path-exists": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
@@ -3680,6 +4097,16 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/react": {
"version": "19.2.8", "version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
@@ -3841,6 +4268,16 @@
"redux": "^5.0.0" "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": { "node_modules/reselect": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
@@ -3888,6 +4325,19 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/scheduler": {
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "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==", "integrity": "sha512-L0TR0NQb+X4/ktDEKmjWyp27gla+LUYi/by5k5SjKXf6/pvZP7wbwEB5J+tqxdFVPgzbsuz+d4RTScO/QZquBw==",
"license": "MIT" "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": { "node_modules/tailwindcss": {
"version": "4.3.3", "version": "4.3.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
@@ -4029,6 +4486,26 @@
"url": "https://github.com/sponsors/SuperchupuDev" "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": { "node_modules/tmp": {
"version": "0.2.7", "version": "0.2.7",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
@@ -4039,6 +4516,32 @@
"node": ">=14.14" "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": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -4100,6 +4603,16 @@
"@typescript/typescript-win32-x64": "7.0.2" "@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": { "node_modules/undici-types": {
"version": "8.3.0", "version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
@@ -4508,6 +5021,54 @@
"url": "https://opencollective.com/parcel" "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": { "node_modules/wmf": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
@@ -4561,6 +5122,23 @@
"node": ">=0.8" "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": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -8,7 +8,7 @@
"build": "tsc --noEmit && vite build", "build": "tsc --noEmit && vite build",
"preview": "vite preview --port 3100", "preview": "vite preview --port 3100",
"typecheck": "tsc --noEmit", "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", "contract": "node scripts/contract.mjs",
"db": "node scripts/db.mjs", "db": "node scripts/db.mjs",
"appgap": "node scripts/appgap.mjs", "appgap": "node scripts/appgap.mjs",
@@ -16,7 +16,8 @@
"check:optimiser": "tsx scripts/checkOptimiser.ts", "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\"", "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", "appsweep": "node scripts/appsweep.mjs",
"appfix": "node scripts/appfix.mjs" "appfix": "node scripts/appfix.mjs",
"preview:map": "node scripts/mapPreview.mjs"
}, },
"dependencies": { "dependencies": {
"@astryxdesign/core": "^0.4.5", "@astryxdesign/core": "^0.4.5",
@@ -33,11 +34,13 @@
"devDependencies": { "devDependencies": {
"@astryxdesign/cli": "^0.4.5", "@astryxdesign/cli": "^0.4.5",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"@types/jsdom": "^30.0.0",
"@types/leaflet": "^1.9.22", "@types/leaflet": "^1.9.22",
"@types/node": "^26.2.0", "@types/node": "^26.2.0",
"@types/react": "^19.2.18", "@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4", "@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^5.0.4", "@vitejs/plugin-react": "^5.0.4",
"jsdom": "^30.0.1",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
"tsx": "^4.20.3", "tsx": "^4.20.3",
"typescript": "^7.0.2", "typescript": "^7.0.2",

167
scripts/mapPreview.mjs Normal file
View 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');

View File

@@ -1,3 +1,4 @@
import type { SolverRequest, Tuning } from '@/features/store-admin/autoAssign';
import type { OrderRow } from './types'; 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 * does, which is the only reason its two identically-named endpoints do not
* collide. * 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 * This file used to say the opposite — that `riderassign` was useless because
* OWN fleet. Sending our orders returned them assigned to `rider_id 883, * it returned orders assigned to `rider_id 883, "Rajan A"`, "not one of ours".
* "Rajan A"` — not one of ours, and no parameter changes that. Auto-assignment * That was wrong, and it was wrong for the ordinary reason: an unfamiliar id
* needs either a riders-inline variant of that endpoint or a mapping onto * was taken for a stranger without checking the roster.
* `routemate`'s `doormile/assign`, which does accept `milers` inline. Neither *
* is wired here until somebody decides which. * 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'; 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. */ /** An order as the optimiser hands it back — ours, plus the routing it added. */
export interface SequencedStop extends OrderRow { export interface SequencedStop extends OrderRow {
/** 1..N. The order to visit in. */ /** 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; 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 = { export const optimiserApi = {
/** /**
* Put a set of orders in a sensible order. * Put a set of orders in a sensible order.
@@ -132,4 +207,38 @@ export const optimiserApi = {
*/ */
reconcile: (riders: PlannedRider[]) => reconcile: (riders: PlannedRider[]) =>
post<{ riders: PlannedRider[] }>('/optimization/reconcile-steps', { riders }), 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
View 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
View 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;
},
};

View File

@@ -28,6 +28,29 @@ export interface MapTrail {
label: string; label: string;
points: readonly { lat: number; lng: number }[]; points: readonly { lat: number; lng: number }[];
colour: string; 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 { export interface MapPin {
@@ -37,6 +60,8 @@ export interface MapPin {
label: string; label: string;
/** Shown under the label in the popup. Plain text, one line per entry. */ /** Shown under the label in the popup. Plain text, one line per entry. */
lines?: string[]; lines?: string[];
popup?: MapPinPopup;
popupHtml?: string;
colour?: string; colour?: string;
/** A hollow ring rather than a filled pin — for a last-known, not-live point. */ /** A hollow ring rather than a filled pin — for a last-known, not-live point. */
isFaded?: boolean; isFaded?: boolean;
@@ -46,6 +71,23 @@ export interface MapPin {
* customer among its own drops. * customer among its own drops.
*/ */
shape?: 'round' | 'square'; 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. */ /** 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) { for (const trail of trails) {
if (trail.points.length < 2) continue; if (trail.points.length < 2) continue;
const line = trail.points.map((point) => [point.lat, point.lng] as [number, number]); 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, { L.polyline(line, {
color: trail.colour, color: trail.colour,
weight: 3, weight: trail.isMuted ? 2.5 : 3.5,
opacity: 0.85, opacity: trail.isMuted ? 0.35 : 0.95,
// Rounded joins, or a dense GPS trail draws spikes at every turn. // Rounded joins, or a dense trail draws spikes at every turn.
lineJoin: 'round', lineJoin: 'round',
lineCap: 'round', lineCap: 'round',
}) })
.bindTooltip(trail.label, { sticky: true }) .bindTooltip(trail.label, { sticky: true })
.addTo(layer); .addTo(layer);
line.forEach((point) => bounds.extend(point)); line.forEach((point) => bounds.extend(point));
} }
@@ -125,15 +186,27 @@ export function TrailMap({
title: pin.label, title: pin.label,
icon: L.divIcon({ icon: L.divIcon({
className: 'trail-pin-wrap', 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>`, // A numbered pin has to be big enough to hold two digits legibly, so
iconSize: [16, 16], // the size follows the content rather than being fixed.
iconAnchor: [8, 8], 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( .bindPopup(pin.popupHtml ?? formatPinPopup(pin), {
`<strong>${escapeHtml(pin.label)}</strong>` + className: 'trail-leaflet-popup-card',
(pin.lines ?? []).map((line) => `<br>${escapeHtml(line)}`).join(''), closeButton: true,
) maxWidth: 290,
minWidth: 230,
autoPanPadding: [20, 20],
})
.addTo(layer); .addTo(layer);
bounds.extend([pin.lat, pin.lng]); 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; 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 { function escapeHtml(value: string): string {
return value return value
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')

View File

@@ -77,11 +77,289 @@
background: var(--color-surface-sunken); 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 { .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; font-size: 12.5px;
line-height: 1.5; font-weight: 800;
color: var(--color-ink-1); 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 { .trail-map .leaflet-control-attribution {
@@ -92,3 +370,34 @@
.trail-map .leaflet-control-attribution a { .trail-map .leaflet-control-attribution a {
color: var(--color-ink-3); 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%);
}

View 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>
);
}

View 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>
);
}

View 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);
});

View File

@@ -1,202 +1,259 @@
import { useMemo, useState } from 'react'; import { useMemo } from 'react';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { Store } from 'lucide-react';
import { TrailMap, trailColour, type MapPin, type MapTrail } from '@/components/TrailMap'; import { TrailMap, trailColour, type MapPin, type MapTrail } from '@/components/TrailMap';
import type { Stop } from './dispatchModel'; 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 { DELIVERY_STATUS, statusColor } from './orderStatus';
import { moneyExact } from './format'; import { moneyExact } from './format';
import './dispatchPanels.css';
/** /**
* A selected group's stops, on the map instead of in a table. * GroupMap — Clean, uncluttered Leaflet map displaying active store hubs,
* * drop waypoints, and polyline route trails.
* ── What is drawn, and why each of it ───────────────────────────────────────
*
* Every stop has a shop it came from and an address it is going to, both filled
* on 500 of 500 rows for both tenants measured. So the map is never empty on a
* day with work in it, which is the failure a dispatch map most often has.
*
* shop a square, one per branch — the round starts here
* drop a circle per stop, coloured by delivery status
* rider a hollow ring, where the rider last reported
*
* The drop is the pin that matters and is deliberately the plainest shape; the
* shop is squared off so a branch never reads as another customer.
*
* ── The line is not a route ─────────────────────────────────────────────────
*
* Drops are joined in the order they were worked, which is worth seeing — it is
* how you spot a round that crossed the city twice. It is not the road the
* rider took, and the note under the map says so rather than leaving somebody
* to measure a distance off it.
*
* ── Scope ───────────────────────────────────────────────────────────────────
*
* Fed entirely from the group already on screen, so a shop sees its own work
* and nobody else's, and choosing a different rider or branch in the rail
* redraws this without another request.
*/ */
export function GroupMap({ stops, groupName }: { stops: readonly Stop[]; groupName: string }) { 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 mapped = useMemo(() => stops.map(mapStop), [stops]);
const rounds = useMemo(() => roundsOf(stops), [stops]); const rounds = useMemo(() => roundsOf(stops), [stops]);
const coverage = useMemo(() => coverageOf(mapped), [mapped]);
const shops = useMemo(() => shopsOf(mapped), [mapped]); const shops = useMemo(() => shopsOf(mapped), [mapped]);
/* Only offered when there is more than one round to separate — in the rider const legs = useMemo(
view the group IS one rider, so the filter would be a control with a single () =>
option in it. */ rounds.flatMap((round) => {
const [focused, setFocused] = useState<number | null>(null); const drops = round.stops.flatMap((stop) => (stop.drop ? [stop.drop] : []));
const shown = focused === null ? rounds : rounds.filter((round) => round.userid === focused); 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( const trails: MapTrail[] = useMemo(
() => () =>
shown.flatMap((round) => { rounds.flatMap((round) => {
const drops = round.stops.flatMap((stop) => (stop.drop ? [stop.drop] : [])); 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 [ return [
{ {
id: round.userid, id: round.userid,
label: `${round.rider} — ${drops.length} drops, in the order they were worked`, label: `${round.rider} — ${drops.length} drop${drops.length > 1 ? 's' : ''}`,
points: drops, points,
colour: trailColour(rounds.findIndex((r) => r.userid === round.userid)), colour: trailColour(rounds.findIndex((r) => r.userid === round.userid)),
}, },
]; ];
}), }),
[shown, rounds], [rounds, roads],
); );
const pins: MapPin[] = useMemo(() => { const pins: MapPin[] = useMemo(() => {
const out: MapPin[] = []; 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()) { 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({ out.push({
id: `shop-${index}`, id: `shop-${index}`,
lat: shop.lat, lat: shop.lat,
lng: shop.lng, lng: shop.lng,
label: 'Shop', label: shopName,
lines: ['Where these orders were collected'], 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)', colour: 'var(--color-brand)',
shape: 'square', shape: 'square',
}); });
} }
for (const round of shown) { const key = (point: Point) => `${point.lat.toFixed(5)},${point.lng.toFixed(5)}`;
for (const [index, stop] of round.stops.entries()) { const places = new Map<
if (stop.drop) { string,
out.push({ {
id: `${stop.key}-drop`, point: Point;
lat: stop.drop.lat, stops: typeof rounds[number]['stops'];
lng: stop.drop.lng, rider: string;
label: `${index + 1}. ${stop.orderid}`, userid: number;
lines: [ step: number;
stop.customer || stop.address || 'No address on the row', }
`${stop.status}${stop.at ? ` · ${clock(stop.at)}` : ''}`, >();
`${round.rider} · ${moneyExact(stop.value)}`,
], for (const round of rounds) {
colour: statusColor(DELIVERY_STATUS, stop.status), let step = 0;
}); for (const stop of round.stops) {
} if (!stop.drop) continue;
if (stop.rider) { step += 1;
out.push({ const placeKey = key(stop.drop);
id: `${stop.key}-rider`, const existing = places.get(placeKey);
lat: stop.rider.lat, if (existing) {
lng: stop.rider.lng, existing.stops.push(stop);
label: round.rider, } else {
lines: [`Last reported on ${stop.orderid}`, stop.at ? clock(stop.at) : ''], places.set(placeKey, {
colour: trailColour(rounds.findIndex((r) => r.userid === round.userid)), point: stop.drop,
isFaded: true, 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; return out;
}, [shown, shops, rounds]); }, [rounds, shops, locations]);
return ( return (
<VStack gap={1}> <div className="group-map-clean-wrap" style={{ width: '100%', height: '100%' }}>
<HStack justify="between" align="center" gap={2} wrap="wrap">
<Text type="body" size="xsm" color="secondary">
{coverage.placed} of {coverage.total} stop{coverage.total === 1 ? '' : 's'} placed
{coverage.withRider > 0 ? ` · ${coverage.withRider} with a rider position` : ''}
</Text>
<span className="map-key">
<i data-key="shop" />
shop
<i data-key="drop" />
drop
<i data-key="rider" />
rider
</span>
</HStack>
{/* In the store and customer views a group holds several riders' work, so
the round filter earns its place. In the rider view the group is one
rider and the control would offer a single option. */}
{rounds.length > 1 ? (
<HStack gap={0.5} wrap="wrap">
<RoundChip
label="Everyone"
colour="var(--color-ink-4)"
isActive={focused === null}
onClick={() => setFocused(null)}
/>
{rounds.map((round, index) => (
<RoundChip
key={round.userid}
label={`${round.rider} · ${round.stops.length}`}
colour={trailColour(index)}
isActive={focused === round.userid}
onClick={() => setFocused((prev) => (prev === round.userid ? null : round.userid))}
/>
))}
</HStack>
) : null}
<TrailMap <TrailMap
trails={trails} trails={trails}
pins={pins} pins={pins}
height={440} height={460}
emptyNote={ emptyNote={
coverage.total === 0 stops.length === 0
? `Nothing out for ${groupName}.` ? `Nothing was delivered for ${groupName}.${emptyHint ? ` ${emptyHint}` : ''}`
: 'None of these stops carries an address the map can place.' : 'These stops carry no address the map can place, so there is nothing to draw.'
} }
/> />
</div>
<div className="pva-note">
<Store size={13} />
<span>
Drops are joined in the order they were worked, not along the road the rider rode — the
length of that line is not the distance covered. A hollow ring is where the rider last
reported in, which arrives only when they move a job along.
</span>
</div>
</VStack>
); );
} }
function RoundChip({ function clock(stampMs: number): string {
label, const d = new Date(stampMs);
colour, const h = d.getHours();
isActive, const m = String(d.getMinutes()).padStart(2, '0');
onClick, const ampm = h >= 12 ? 'pm' : 'am';
}: { const hour12 = ((h + 11) % 12) + 1;
label: string; return `${hour12}:${m} ${ampm}`;
colour: string;
isActive: boolean;
onClick: () => void;
}) {
return (
<button type="button" className="rider-chip" data-active={isActive} onClick={onClick}>
<i style={{ background: colour }} />
{label}
</button>
);
}
function clock(at: number): string {
return new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} }

View File

@@ -8,6 +8,7 @@ import type { DeliveryRow } from '@/api/types';
import { TablePager } from '@/components/TablePager'; import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged'; import { usePaged } from '@/components/usePaged';
import { compare, journeyOf, lateness, span, type StepKey } from './plannedVsActual'; import { compare, journeyOf, lateness, span, type StepKey } from './plannedVsActual';
import './dispatchPanels.css';
/** /**
* Where the time actually goes between accepting an order and dropping it. * Where the time actually goes between accepting an order and dropping it.

View 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>
);
}

View 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, []);
});

View 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);
}

View File

@@ -476,3 +476,5 @@ export function ymd(date: Date): string {
export function isToday(day: string): boolean { export function isToday(day: string): boolean {
return day === ymd(new Date()); return day === ymd(new Date());
} }

View 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);
}

View File

@@ -12,7 +12,7 @@
.drawer-root { .drawer-root {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 70; z-index: 3000;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
} }

View File

@@ -11,8 +11,14 @@
*/ */
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { test } from 'node:test'; import { test } from 'node:test';
import { billedAtMs, syncLagMs , import {
stampAfterMs,} from './orderStatus'; 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 /* 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 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'; const raw = '2026-09-05T14:46:00+05:30';
assert.equal(stampAfterMs(raw, '2026-09-05T14:48:42+05:30'), new Date(raw).getTime()); 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'],
);
});

View File

@@ -75,22 +75,55 @@ export const statusColor = (map: Record<string, string>, status: string | undefi
map[(status ?? '').trim().toLowerCase()] ?? 'var(--color-ink-3)'; 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 * ── Why not one shared set ──────────────────────────────────────────────────
* constantly during a shift, and a dropdown hides the counts. Each carries how *
* many rows it holds, which is half the reason to look. * 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: 'all', label: 'All' },
{ key: 'created', label: 'Created' }, { key: 'created', label: 'Created' },
{ key: 'pending', label: 'Pending' }, { key: 'pending', label: 'Pending' },
{ key: 'processing', label: 'Processing' },
{ key: 'delivered', label: 'Delivered' }, { key: 'delivered', label: 'Delivered' },
{ key: 'cancelled', label: 'Cancelled' }, { key: 'cancelled', label: 'Cancelled' },
] as const; ] 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? * 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 * Substring matching, lowercased. Fiesta stores status as free text and the
* casing is inconsistent between writers, so an exact match is how a * casing is inconsistent between writers, so an exact match is how a
* "Delivered" row silently stops counting the day someone writes "delivered". * "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 { export function matchesStatus(key: StatusKey, status: string | undefined): boolean {
if (key === 'all') return true; 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'); return s.includes('created') || s.includes('new');
case 'pending': case 'pending':
return s.includes('pending'); return s.includes('pending');
case 'processing': case 'accepted':
return ( // Not "accept" alone — that would also catch "unaccepted" if it appears.
s.includes('process') || return s.includes('accepted');
s.includes('confirm') || case 'arrived':
s.includes('prepar') || return s.includes('arrived');
s.includes('ready') || case 'picked':
s.includes('accept') || return s.includes('picked') || s.includes('pickup');
s.includes('picked') || case 'active':
s.includes('active') || // Everything between collecting and dropping: the platform writes
s.includes('arrived') || // `active`, and `out for delivery` means the same thing.
s.includes('out for') 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': case 'delivered':
return s.includes('deliver') && !s.includes('undeliver') && !s.includes('out for'); return s.includes('deliver') && !s.includes('undeliver') && !s.includes('out for');
case 'cancelled': case 'cancelled':
// `skipped` lives here too. It is its own delivery status — the rider return s.includes('cancel');
// 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');
} }
} }
/** /**
* The money on an order row, in the order the old console reads it. * The money on an order row, in the order the old console reads it.
* *

File diff suppressed because it is too large Load Diff

View File

@@ -21,7 +21,8 @@ import { branchLabel, count, money, moneyExact } from '../format';
import { import {
DELIVERY_STATUS, DELIVERY_STATUS,
ORDER_STATUS, ORDER_STATUS,
STATUS_TABS, DELIVERY_STATUS_TABS,
ORDER_STATUS_TABS,
matchesStatus, matchesStatus,
orderQuantity, orderQuantity,
orderValue, orderValue,
@@ -130,11 +131,21 @@ export function SalesPage() {
.sort((a, b) => (billedAtMs(b.bill) ?? 0) - (billedAtMs(a.bill) ?? 0)); .sort((a, b) => (billedAtMs(b.bill) ?? 0) - (billedAtMs(a.bill) ?? 0));
}, [billPages, branchIds, branches, keyword]); }, [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. */ /** Counts for the tab strip, from the unfiltered set. */
const tabCounts = useMemo(() => { const tabCounts = useMemo(() => {
const source = tab === 'deliveries' ? allDeliveries : allOrders; const source = tab === 'deliveries' ? allDeliveries : allOrders;
const tabs = tab === 'deliveries' ? DELIVERY_STATUS_TABS : ORDER_STATUS_TABS;
return Object.fromEntries( return Object.fromEntries(
STATUS_TABS.map((entry) => [ tabs.map((entry) => [
entry.key, entry.key,
source.filter((row) => matchesStatus(entry.key, row.orderstatus)).length, source.filter((row) => matchesStatus(entry.key, row.orderstatus)).length,
]), ]),
@@ -291,6 +302,7 @@ export function SalesPage() {
<VStack gap={1.5}> <VStack gap={1.5}>
<FilterRow <FilterRow
tabs={statusTabs}
status={status} status={status}
counts={tabCounts} counts={tabCounts}
onStatus={setStatus} onStatus={setStatus}
@@ -374,6 +386,7 @@ export function SalesPage() {
<VStack gap={1.5}> <VStack gap={1.5}>
<FilterRow <FilterRow
tabs={statusTabs}
status={status} status={status}
counts={tabCounts} counts={tabCounts}
onStatus={setStatus} onStatus={setStatus}
@@ -507,11 +520,14 @@ function freshRow(
* downward now follows that order. * downward now follows that order.
*/ */
function FilterRow({ function FilterRow({
tabs,
status, status,
counts, counts,
onStatus, onStatus,
search, search,
}: { }: {
/** Which ladder to show — orders and deliveries do not share one. */
tabs: readonly { readonly key: StatusKey; readonly label: string }[];
status: StatusKey; status: StatusKey;
counts: Record<StatusKey, number>; counts: Record<StatusKey, number>;
onStatus: (key: StatusKey) => void; onStatus: (key: StatusKey) => void;
@@ -520,7 +536,7 @@ function FilterRow({
return ( return (
<HStack justify="between" align="center" gap={2} wrap="wrap"> <HStack justify="between" align="center" gap={2} wrap="wrap">
<HStack gap={0.5} wrap="wrap"> <HStack gap={0.5} wrap="wrap">
{STATUS_TABS.map((entry) => ( {tabs.map((entry) => (
<StatusTab <StatusTab
key={entry.key} key={entry.key}
label={entry.label} label={entry.label}

File diff suppressed because it is too large Load Diff

View 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, '—');
});

View 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;
}

View 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);
});

View 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;
});
}

View 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;
}

View File

@@ -18,6 +18,7 @@ import { stockApi, type StockRequestQuery } from '@/api/stock';
import { tenantsApi, utilsApi, type TenantListQuery } from '@/api/tenants'; import { tenantsApi, utilsApi, type TenantListQuery } from '@/api/tenants';
import { partnersApi } from '@/api/deliveries'; import { partnersApi } from '@/api/deliveries';
import { customersApi, type CustomerQuery } from '@/api/customers'; import { customersApi, type CustomerQuery } from '@/api/customers';
import { telemetryApi, type RiderSnapshot } from '@/api/telemetry';
import { uploadsApi } from '@/api/uploads'; import { uploadsApi } from '@/api/uploads';
import { APP_BROWSE_CATEGORY } from '@/features/catalogue/tenantCategories'; import { APP_BROWSE_CATEGORY } from '@/features/catalogue/tenantCategories';
import { queryKeys } from './keys'; import { queryKeys } from './keys';
@@ -618,3 +619,53 @@ export function usePartners(applocationid: number | undefined) {
...stable, ...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),
};
}

View File

@@ -16,6 +16,10 @@ export const queryKeys = {
logs: (partnerid: number, fromdate: string, todate: string) => logs: (partnerid: number, fromdate: string, todate: string) =>
['partners', 'logs', partnerid, fromdate, todate] as const, ['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. */ /** The delivery regions a partner can cover. */
regions: { all: ['regions'] as const }, regions: { all: ['regions'] as const },
uploads: { uploads: {

19
tools/css-hooks.mjs Normal file
View 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
View 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);