diff --git a/src/components/TrailMap.tsx b/src/components/TrailMap.tsx index 8e6595e..6b765ef 100644 --- a/src/components/TrailMap.tsx +++ b/src/components/TrailMap.tsx @@ -40,6 +40,12 @@ export interface MapPin { colour?: string; /** A hollow ring rather than a filled pin — for a last-known, not-live point. */ isFaded?: boolean; + /** + * A square marks a place rather than an event — a shop, a depot. Round pins + * are the things that happened there, so a branch never reads as one more + * customer among its own drops. + */ + shape?: 'round' | 'square'; } /** Tamil Nadu, so an empty map still shows the right part of the world. */ @@ -119,7 +125,7 @@ export function TrailMap({ title: pin.label, icon: L.divIcon({ className: 'trail-pin-wrap', - html: ``, + html: ``, iconSize: [16, 16], iconAnchor: [8, 8], }), diff --git a/src/components/trailMap.css b/src/components/trailMap.css index 222a600..c19fb8e 100644 --- a/src/components/trailMap.css +++ b/src/components/trailMap.css @@ -52,6 +52,14 @@ box-shadow: 0 1px 4px rgb(15 23 42 / 45%); } +/* A place, not an event. Squared off and a touch larger so a shop reads as the + thing the round starts from rather than as one more drop on it. */ +.trail-pin[data-shape='square'] { + width: 15px; + height: 15px; + border-radius: 3px; +} + /* A last-known position, not a live one. Hollow, so the difference between "here now" and "here when they last reported" is visible on the map itself and not only in the popup. */ diff --git a/src/features/store-admin/DispatchMap.tsx b/src/features/store-admin/DispatchMap.tsx deleted file mode 100644 index 8fd5b5f..0000000 --- a/src/features/store-admin/DispatchMap.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { useMemo, useState } from 'react'; -import { Card } from '@astryxdesign/core/Card'; -import { HStack } from '@astryxdesign/core/HStack'; -import { Text } from '@astryxdesign/core/Text'; -import { VStack } from '@astryxdesign/core/VStack'; -import { Info } from 'lucide-react'; -import type { DeliveryRow } from '@/api/types'; -import { TrailMap, trailColour, type MapPin, type MapTrail } from '@/components/TrailMap'; -import { coverageOf, pathsOf } from './deliveryTrack'; -import { statusColor } from './orderStatus'; -import { DELIVERY_STATUS } from './orderStatus'; - -/** - * The day's rounds, on a map. - * - * ── Why this is fed by delivery rows and not the rider log ────────────────── - * - * `riderlogs` is the obvious source and is useless for position: every ping a - * rider sends carries the same coordinate, so a trail drawn from it is a single - * dot. `deliveries.riderslat` / `riderslon` are written when a rider moves a - * job along and do vary — 353 distinct positions across 461 positioned rows on - * one tenant. They are also tenant-scoped, so this belongs on the shop's board. - * - * ── The line is not a route ───────────────────────────────────────────────── - * - * Each point is where the rider stood when a delivery changed status, minutes - * apart. Joining them shows the ORDER a round was worked, which is worth - * seeing; it is not the road they took, and the note under the map says so - * rather than leaving the reader to assume a route they can act on. - * - * ── Coverage is stated ────────────────────────────────────────────────────── - * - * A map with four pins looks the same whether the day was quiet or the app - * stopped reporting. The count above it tells them apart. - */ -export function DispatchMap({ rows }: { rows: readonly DeliveryRow[] }) { - const paths = useMemo(() => pathsOf(rows), [rows]); - const coverage = useMemo(() => coverageOf(rows), [rows]); - const [focused, setFocused] = useState(null); - - const shown = focused === null ? paths : paths.filter((path) => path.userid === focused); - - const trails: MapTrail[] = useMemo( - () => - shown - .filter((path) => path.fixes.length > 1) - .map((path) => ({ - id: path.userid, - label: `${path.rider} — ${path.fixes.length} stops, in order`, - points: path.fixes.map((fix) => ({ lat: fix.lat, lng: fix.lng })), - colour: trailColour(paths.findIndex((p) => p.userid === path.userid)), - })), - [shown, paths], - ); - - const pins: MapPin[] = useMemo( - () => - shown.flatMap((path) => - path.fixes.map((fix) => ({ - id: `${path.userid}-${fix.deliveryid}`, - lat: fix.lat, - lng: fix.lng, - label: fix.orderid, - lines: [ - path.rider, - fix.customer || fix.address || 'No address on the row', - `${fix.status}${fix.at ? ` · ${clock(fix.at)}` : ''}`, - ], - // Coloured by the delivery's status, not the rider's: on a map the - // question is which stops are still open, and a round already reads - // as one shape from its line. - colour: statusColor(DELIVERY_STATUS, fix.status), - })), - ), - [shown], - ); - - return ( - - - - - Where the riders were - - - {coverage.positioned} of {coverage.total} deliver - {coverage.total === 1 ? 'y' : 'ies'} reported a position - - - - {paths.length > 1 ? ( - - setFocused(null)} - /> - {paths.map((path, index) => ( - setFocused((prev) => (prev === path.userid ? null : path.userid))} - /> - ))} - - ) : null} - - - -
- - - Each pin is where the rider stood when that delivery last changed status, coloured by - the status. The line joins one rider's stops in the order they were worked — it is not - the route they rode, and the distance along it is not the distance they covered. - -
-
-
- ); -} - -function RiderChip({ - label, - colour, - isActive, - onClick, -}: { - label: string; - colour: string; - isActive: boolean; - onClick: () => void; -}) { - return ( - - ); -} - -function clock(at: number): string { - return new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); -} diff --git a/src/features/store-admin/GroupMap.tsx b/src/features/store-admin/GroupMap.tsx new file mode 100644 index 0000000..f7bc597 --- /dev/null +++ b/src/features/store-admin/GroupMap.tsx @@ -0,0 +1,202 @@ +import { useMemo, useState } from 'react'; +import { HStack } from '@astryxdesign/core/HStack'; +import { Text } from '@astryxdesign/core/Text'; +import { VStack } from '@astryxdesign/core/VStack'; +import { Store } from 'lucide-react'; +import { TrailMap, trailColour, type MapPin, type MapTrail } from '@/components/TrailMap'; +import type { Stop } from './dispatchModel'; +import { coverageOf, mapStop, roundsOf, shopsOf } from './deliveryTrack'; +import { DELIVERY_STATUS, statusColor } from './orderStatus'; +import { moneyExact } from './format'; + +/** + * A selected group's stops, on the map instead of in a table. + * + * ── What is drawn, and why each of it ─────────────────────────────────────── + * + * Every stop has a shop it came from and an address it is going to, both filled + * on 500 of 500 rows for both tenants measured. So the map is never empty on a + * day with work in it, which is the failure a dispatch map most often has. + * + * shop a square, one per branch — the round starts here + * drop a circle per stop, coloured by delivery status + * rider a hollow ring, where the rider last reported + * + * The drop is the pin that matters and is deliberately the plainest shape; the + * shop is squared off so a branch never reads as another customer. + * + * ── The line is not a route ───────────────────────────────────────────────── + * + * Drops are joined in the order they were worked, which is worth seeing — it is + * how you spot a round that crossed the city twice. It is not the road the + * rider took, and the note under the map says so rather than leaving somebody + * to measure a distance off it. + * + * ── Scope ─────────────────────────────────────────────────────────────────── + * + * Fed entirely from the group already on screen, so a shop sees its own work + * and nobody else's, and choosing a different rider or branch in the rail + * redraws this without another request. + */ +export function GroupMap({ stops, groupName }: { stops: readonly Stop[]; groupName: string }) { + const mapped = useMemo(() => stops.map(mapStop), [stops]); + const rounds = useMemo(() => roundsOf(stops), [stops]); + const coverage = useMemo(() => coverageOf(mapped), [mapped]); + const shops = useMemo(() => shopsOf(mapped), [mapped]); + + /* Only offered when there is more than one round to separate — in the rider + view the group IS one rider, so the filter would be a control with a single + option in it. */ + const [focused, setFocused] = useState(null); + const shown = focused === null ? rounds : rounds.filter((round) => round.userid === focused); + + const trails: MapTrail[] = useMemo( + () => + shown.flatMap((round) => { + const drops = round.stops.flatMap((stop) => (stop.drop ? [stop.drop] : [])); + if (drops.length < 2) return []; + return [ + { + id: round.userid, + label: `${round.rider} — ${drops.length} drops, in the order they were worked`, + points: drops, + colour: trailColour(rounds.findIndex((r) => r.userid === round.userid)), + }, + ]; + }), + [shown, rounds], + ); + + const pins: MapPin[] = useMemo(() => { + const out: MapPin[] = []; + + // The shop first, so a drop pin drawn on top of it stays clickable. + for (const [index, shop] of shops.entries()) { + out.push({ + id: `shop-${index}`, + lat: shop.lat, + lng: shop.lng, + label: 'Shop', + lines: ['Where these orders were collected'], + colour: 'var(--color-brand)', + shape: 'square', + }); + } + + for (const round of shown) { + for (const [index, stop] of round.stops.entries()) { + if (stop.drop) { + out.push({ + id: `${stop.key}-drop`, + lat: stop.drop.lat, + lng: stop.drop.lng, + label: `${index + 1}. ${stop.orderid}`, + lines: [ + stop.customer || stop.address || 'No address on the row', + `${stop.status}${stop.at ? ` · ${clock(stop.at)}` : ''}`, + `${round.rider} · ${moneyExact(stop.value)}`, + ], + colour: statusColor(DELIVERY_STATUS, stop.status), + }); + } + if (stop.rider) { + out.push({ + id: `${stop.key}-rider`, + lat: stop.rider.lat, + lng: stop.rider.lng, + label: round.rider, + lines: [`Last reported on ${stop.orderid}`, stop.at ? clock(stop.at) : ''], + colour: trailColour(rounds.findIndex((r) => r.userid === round.userid)), + isFaded: true, + }); + } + } + } + return out; + }, [shown, shops, rounds]); + + return ( + + + + {coverage.placed} of {coverage.total} stop{coverage.total === 1 ? '' : 's'} placed + {coverage.withRider > 0 ? ` · ${coverage.withRider} with a rider position` : ''} + + + + shop + + drop + + rider + + + + {/* In the store and customer views a group holds several riders' work, so + the round filter earns its place. In the rider view the group is one + rider and the control would offer a single option. */} + {rounds.length > 1 ? ( + + setFocused(null)} + /> + {rounds.map((round, index) => ( + setFocused((prev) => (prev === round.userid ? null : round.userid))} + /> + ))} + + ) : null} + + + +
+ + + Drops are joined in the order they were worked, not along the road the rider rode — the + length of that line is not the distance covered. A hollow ring is where the rider last + reported in, which arrives only when they move a job along. + +
+
+ ); +} + +function RoundChip({ + label, + colour, + isActive, + onClick, +}: { + label: string; + colour: string; + isActive: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function clock(at: number): string { + return new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} diff --git a/src/features/store-admin/deliveryTrack.test.ts b/src/features/store-admin/deliveryTrack.test.ts index 9d0d305..c23b6cb 100644 --- a/src/features/store-admin/deliveryTrack.test.ts +++ b/src/features/store-admin/deliveryTrack.test.ts @@ -1,168 +1,243 @@ /** - * Rider positions, from the only column that actually varies. + * The three places a delivery happens. * - * The awkward rows here are copied from production: a `ridername` of - * "delivered", positions written as empty strings, and the single-fix rider who - * still deserves a pin. + * The awkward rows here are copied from production: `pickuplon` on the delivery + * endpoint against `pickuplong` on the orders one, `droplat` filled on + * deliveries and empty on orders, and a `ridername` of "delivered". */ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import type { DeliveryRow } from '@/api/types'; -import { coverageOf, fixesOf, pathsOf } from './deliveryTrack'; +import type { DeliveryRow, OrderRow } from '@/api/types'; +import type { Stop } from './dispatchModel'; +import { coverageOf, mapStop, roundsOf, shopsOf } from './deliveryTrack'; +import { isRealName } from './orderStatus'; -function row(over: Partial): DeliveryRow { +/** A delivery as `getdeliveries` sends one. */ +function delivery(over: Record = {}): Stop { return { - deliveryid: 1, - orderid: '916-1', - userid: 883, - ridername: 'Rajan', - orderstatus: 'delivered', - assigntime: '2026-08-29 11:22:36', - deliverytime: '2026-08-29 13:16:57', - riderslat: '11.039621', - riderslon: '76.929141', - ...over, - } as DeliveryRow; + kind: 'delivery', + row: { + deliveryid: 1, + orderid: '916-1', + userid: 883, + ridername: 'Rajan', + orderstatus: 'delivered', + assigntime: '2026-08-29 11:22:36', + deliverytime: '2026-08-29 13:16:57', + pickuplat: '11.043236', + pickuplon: '77.0006758', + droplat: '11.082948', + droplon: '77.005914', + deliverylat: '11.082948', + deliverylong: '77.005914', + riderslat: '11.082815', + riderslon: '77.007213', + deliverycustomer: 'A customer', + deliveryaddress: 'Peelamedu', + deliveryamt: 36, + ...over, + } as unknown as DeliveryRow, + }; } -test('a positioned delivery becomes a fix', () => { - const [fix] = fixesOf([row({})]); - assert.equal(fix!.lat, 11.039621); - assert.equal(fix!.lng, 76.929141); - assert.equal(fix!.orderid, '916-1'); - assert.equal(fix!.status, 'delivered'); +/** An order as `getorders` sends one — different spellings, no drop columns. */ +function order(over: Record = {}): Stop { + return { + kind: 'order', + row: { + orderheaderid: 55, + orderid: '916-55', + orderstatus: 'pending', + orderdate: '2026-08-29T14:48:42+05:30', + pickuplat: '11.0432363', + pickuplong: '77.0006758', + droplat: '', + droplon: '', + deliverylat: '11.082948', + deliverylong: '77.005914', + ...over, + } as unknown as OrderRow, + }; +} + +/* ── Reading one stop ────────────────────────────────────────────────────── */ + +test('a delivery gives up all three of its places', () => { + const stop = mapStop(delivery()); + assert.deepEqual(stop.shop, { lat: 11.043236, lng: 77.0006758 }); + assert.deepEqual(stop.drop, { lat: 11.082948, lng: 77.005914 }); + assert.deepEqual(stop.rider, { lat: 11.082815, lng: 77.007213 }); }); -// 39 of 500 rows on tenant 916 carry no position at all. -test('a delivery with no position is left off the map rather than placed at zero', () => { - assert.deepEqual(fixesOf([row({ riderslat: '', riderslon: '' })]), []); - assert.deepEqual(fixesOf([row({ riderslat: undefined, riderslon: undefined })]), []); +// One letter apart, and reading the wrong one puts every shop on the prime +// meridian off the coast of Ghana. +test('pickuplon and pickuplong are both read, because the two endpoints differ', () => { + assert.deepEqual(mapStop(delivery()).shop, { lat: 11.043236, lng: 77.0006758 }); + assert.deepEqual(mapStop(order()).shop, { lat: 11.0432363, lng: 77.0006758 }); }); -// A row with one half of the pair would land on the equator or the prime -// meridian, which is a confident lie rather than a missing value. -test('half a position is no position', () => { - assert.deepEqual(fixesOf([row({ riderslon: '' })]), []); - assert.deepEqual(fixesOf([row({ riderslat: '0' })]), []); +// `droplat` is filled on deliveries and empty on orders, where `deliverylat` +// carries the same value. A waiting order still has an address to place. +test('an order with no droplat still gets its drop from deliverylat', () => { + const stop = mapStop(order()); + assert.deepEqual(stop.drop, { lat: 11.082948, lng: 77.005914 }); }); -// `updatedelivery` writes the position alongside whichever stamp the new status -// sets, so the newest stamp is when the position was taken. -test('a fix is timed by the latest stamp on its row', () => { - const [fix] = fixesOf([ - row({ - assigntime: '2026-08-29 11:22:36', - arrivaltime: '2026-08-29 12:37:26', - deliverytime: '2026-08-29 13:16:57', - }), - ]); - assert.equal(fix!.at, Date.parse('2026-08-29T13:16:57')); +test('a stop with no rider position has none, rather than a pin at zero', () => { + assert.equal(mapStop(delivery({ riderslat: '', riderslon: '' })).rider, null); + assert.equal(mapStop(order()).rider, null); }); -test('a delivery only just assigned is timed by its assign stamp', () => { - const [fix] = fixesOf([ - row({ orderstatus: 'pending', deliverytime: '', arrivaltime: '', assigntime: '2026-08-29 11:22:36' }), - ]); - assert.equal(fix!.at, Date.parse('2026-08-29T11:22:36')); +// A half-written pair reads as 0, and a pin on the equator is a confident lie +// where a missing pin is an honest gap. +test('half a coordinate is no coordinate', () => { + assert.equal(mapStop(delivery({ droplon: '', deliverylong: '' })).drop, null); + assert.equal(mapStop(delivery({ riderslat: '0' })).rider, null); }); -test('a fix with no readable stamp is still a place, just an undated one', () => { - const [fix] = fixesOf([row({ assigntime: '', deliverytime: '', arrivaltime: '' })]); - assert.equal(fix!.at, null); - assert.equal(fix!.lat, 11.039621); -}); - -/* ── Paths ───────────────────────────────────────────────────────────────── */ - -test("a rider's fixes are joined in the order they happened", () => { - const [path] = pathsOf([ - row({ deliveryid: 2, deliverytime: '2026-08-29 14:00:00', riderslat: '11.05', riderslon: '76.95' }), - row({ deliveryid: 1, deliverytime: '2026-08-29 13:00:00', riderslat: '11.04', riderslon: '76.94' }), - row({ deliveryid: 3, deliverytime: '2026-08-29 15:00:00', riderslat: '11.06', riderslon: '76.96' }), - ]); - assert.deepEqual(path!.fixes.map((fix) => fix.deliveryid), [1, 2, 3]); -}); - -// `ridername` holds a delivery status on many rows. Grouping on it would split -// one rider's round in two and invent a rider called "delivered". -test('riders are grouped on their id, never on the name', () => { - const paths = pathsOf([ - row({ deliveryid: 1, userid: 883, ridername: 'Rajan' }), - row({ deliveryid: 2, userid: 883, ridername: 'delivered', riderslat: '11.04' }), - ]); - assert.equal(paths.length, 1); - assert.equal(paths[0]!.rider, 'Rajan'); - assert.equal(paths[0]!.fixes.length, 2); -}); - -// The real shape on tenant 916: rider 897 is "Varun" on 69 rows and "delivered" -// on 75. Taking the most common value would name them "delivered". -test('a status is never mistaken for a name, even when it is the common value', () => { - const rows = [ - ...Array.from({ length: 3 }, (_, i) => - row({ deliveryid: i + 1, userid: 897, ridername: 'Varun', riderslat: `11.0${i + 1}` }), - ), - ...Array.from({ length: 7 }, (_, i) => - row({ deliveryid: i + 10, userid: 897, ridername: 'delivered', riderslat: `11.1${i}` }), - ), - ]; - assert.equal(pathsOf(rows)[0]!.rider, 'Varun'); -}); - -test('an order status in the name column is rejected too, not just a delivery one', () => { +test('a stop is timed by the newest stamp on it', () => { assert.equal( - pathsOf([row({ userid: 1111, ridername: 'cancelled' })])[0]!.rider, - 'Rider 1111', + mapStop(delivery()).at, + Date.parse('2026-08-29T13:16:57'), + 'the delivery stamp, not the assign one', ); }); -// Better a plain id than a confident wrong name. +test('a waiting order falls back to when it was placed', () => { + assert.equal(mapStop(order()).at, Date.parse('2026-08-29T14:48:42+05:30')); +}); + +test('orders and deliveries never collide on a key', () => { + assert.notEqual(mapStop(delivery()).key, mapStop(order()).key); +}); + +/* ── Names ───────────────────────────────────────────────────────────────── */ + +// `ridername` holds a delivery status on more rows than it holds a name, for +// two riders in five on tenant 916. +test('a status is never mistaken for a name', () => { + assert.equal(isRealName('Varun'), true); + assert.equal(isRealName('delivered'), false); + assert.equal(isRealName('cancelled'), false); + assert.equal(isRealName(' '), false); + assert.equal(isRealName(undefined), false); +}); + +/* ── Rounds ──────────────────────────────────────────────────────────────── */ + +test("a rider's stops are put in the order they were worked", () => { + const [round] = roundsOf([ + delivery({ deliveryid: 2, deliverytime: '2026-08-29 14:00:00' }), + delivery({ deliveryid: 1, deliverytime: '2026-08-29 13:00:00' }), + delivery({ deliveryid: 3, deliverytime: '2026-08-29 15:00:00' }), + ]); + assert.deepEqual(round!.stops.map((stop) => stop.orderid), ['916-1', '916-1', '916-1']); + assert.deepEqual( + round!.stops.map((stop) => stop.at), + [ + Date.parse('2026-08-29T13:00:00'), + Date.parse('2026-08-29T14:00:00'), + Date.parse('2026-08-29T15:00:00'), + ], + ); +}); + +test('riders are grouped on their id, never on the name', () => { + const rounds = roundsOf([ + delivery({ deliveryid: 1, userid: 883, ridername: 'Rajan' }), + delivery({ deliveryid: 2, userid: 883, ridername: 'delivered' }), + ]); + assert.equal(rounds.length, 1); + assert.equal(rounds[0]!.rider, 'Rajan'); + assert.equal(rounds[0]!.stops.length, 2); +}); + +// Rider 897 is "Varun" on 69 rows and "delivered" on 75. Most-common-wins +// would name them "delivered". +test('a status is not chosen as the name even when it is the common value', () => { + const rounds = roundsOf([ + ...Array.from({ length: 3 }, (_, i) => + delivery({ deliveryid: i + 1, userid: 897, ridername: 'Varun' }), + ), + ...Array.from({ length: 7 }, (_, i) => + delivery({ deliveryid: i + 10, userid: 897, ridername: 'delivered' }), + ), + ]); + assert.equal(rounds[0]!.rider, 'Varun'); +}); + test('a rider whose every row carried a status is named by their id', () => { - const paths = pathsOf([ - row({ deliveryid: 1, userid: 950, ridername: 'delivered' }), - row({ deliveryid: 2, userid: 950, ridername: 'delivered', riderslat: '11.04' }), + const rounds = roundsOf([delivery({ userid: 950, ridername: 'delivered' })]); + assert.equal(rounds[0]!.rider, 'Rider 950'); +}); + +// Waiting work has an address and belongs on the map — seeing where it clusters +// is often the reason to look — but it is not a round and must not lead. +test('work nobody is carrying is its own group, and comes last', () => { + const rounds = roundsOf([ + order(), + delivery({ deliveryid: 1, userid: 883 }), + delivery({ deliveryid: 2, userid: 883 }), ]); - assert.equal(paths[0]!.rider, 'Rider 950'); + assert.deepEqual(rounds.map((round) => round.rider), ['Rajan', 'Not assigned']); }); -test('a rider with one known position still gets a path, and so a pin', () => { - const [path] = pathsOf([row({})]); - assert.equal(path!.fixes.length, 1); -}); - -test('the busiest rider is first, so the map legend leads with the round that matters', () => { - const paths = pathsOf([ - row({ deliveryid: 1, userid: 1, ridername: 'A' }), - row({ deliveryid: 2, userid: 2, ridername: 'B', riderslat: '11.04' }), - row({ deliveryid: 3, userid: 2, ridername: 'B', riderslat: '11.05' }), +test('the busiest round leads, so the legend starts with what matters', () => { + const rounds = roundsOf([ + delivery({ deliveryid: 1, userid: 1, ridername: 'A' }), + delivery({ deliveryid: 2, userid: 2, ridername: 'B' }), + delivery({ deliveryid: 3, userid: 2, ridername: 'B' }), ]); - assert.deepEqual(paths.map((path) => path.rider), ['B', 'A']); + assert.deepEqual(rounds.map((round) => round.rider), ['B', 'A']); }); -test('a delivery nobody is carrying is grouped as unassigned rather than dropped', () => { - const [path] = pathsOf([row({ userid: 0, ridername: '' })]); - assert.equal(path!.rider, 'Unassigned'); +test('a round carries only the rider fixes it actually has', () => { + const [round] = roundsOf([ + delivery({ deliveryid: 1, deliverytime: '2026-08-29 13:00:00' }), + delivery({ deliveryid: 2, deliverytime: '2026-08-29 14:00:00', riderslat: '', riderslon: '' }), + ]); + assert.equal(round!.stops.length, 2); + assert.equal(round!.fixes.length, 1); }); -// The line between two fixes is minutes apart and is not a road. The flag is on -// the type so the map cannot quietly start calling it a route. -test('a path always declares that its line is between events, not along a road', () => { - assert.equal(pathsOf([row({})])[0]!.isSampled, true); +/* ── Shops ───────────────────────────────────────────────────────────────── */ + +// The same branch arrives as both 11.016327 and 11.0163268 in production. Two +// pins on one shop looks like two shops. +test('one branch is one pin, however many decimals it arrived with', () => { + const shops = shopsOf([ + mapStop(delivery({ pickuplat: '11.016327', pickuplon: '77.0147763' })), + mapStop(delivery({ pickuplat: '11.0163268', pickuplon: '77.0147763' })), + ]); + assert.equal(shops.length, 1); +}); + +test('two genuinely different branches stay two pins', () => { + const shops = shopsOf([ + mapStop(delivery({ pickuplat: '11.043236', pickuplon: '77.0006758' })), + mapStop(delivery({ pickuplat: '11.0388679', pickuplon: '76.9300797' })), + ]); + assert.equal(shops.length, 2); +}); + +test('a stop with no shop coordinate contributes no pin', () => { + assert.deepEqual(shopsOf([mapStop(delivery({ pickuplat: '', pickuplon: '' }))]), []); }); /* ── Coverage ────────────────────────────────────────────────────────────── */ // A map with six pins looks the same whether the day was quiet or the reporting // failed. The count is what tells them apart. -test('coverage says how much of the day the map can show', () => { - const coverage = coverageOf([ - row({ deliveryid: 1 }), - row({ deliveryid: 2, riderslat: '11.04' }), - row({ deliveryid: 3, riderslat: '', riderslon: '' }), - ]); - assert.deepEqual(coverage, { positioned: 2, total: 3, riders: 1 }); +test('coverage says how much of the group the map can place', () => { + const stops = [ + mapStop(delivery({ deliveryid: 1 })), + mapStop(delivery({ deliveryid: 2, riderslat: '', riderslon: '' })), + mapStop(delivery({ deliveryid: 3, droplat: '', droplon: '', deliverylat: '', deliverylong: '' })), + ]; + assert.deepEqual(coverageOf(stops), { placed: 2, total: 3, withRider: 2 }); }); -test('a day with no positions at all reports zero rather than throwing', () => { - assert.deepEqual(coverageOf([]), { positioned: 0, total: 0, riders: 0 }); +test('an empty group reports zeroes rather than throwing', () => { + assert.deepEqual(coverageOf([]), { placed: 0, total: 0, withRider: 0 }); + assert.deepEqual(roundsOf([]), []); }); diff --git a/src/features/store-admin/deliveryTrack.ts b/src/features/store-admin/deliveryTrack.ts index 09e21a9..3e9d8c9 100644 --- a/src/features/store-admin/deliveryTrack.ts +++ b/src/features/store-admin/deliveryTrack.ts @@ -1,200 +1,242 @@ /** - * Where riders actually were, from the delivery rows. + * The three places a delivery happens, and where the rider was. * - * ── Why this and not the rider log ────────────────────────────────────────── + * ── What is actually on the row ───────────────────────────────────────────── * - * `riderlogs` looks like the obvious source and is useless for position: every - * ping a rider sends carries the same coordinate. One rider's 2,404 pings on - * 14 August 2026 all read 11.052998, 76.929958, and the same holds on every day - * and every region checked. The app stamps a position once and repeats it. + * Measured on 2026-09-09 over 500 rows each for tenants 916 and 908: * - * `deliveries.riderslat` / `riderslon` are written by `updatedelivery` when a - * rider moves a job along, and they DO vary — 353 distinct positions across the - * 461 positioned rows for tenant 916. Sparse (one point per delivery, not a - * trail) but real, and tenant-scoped, so a shop may see them. + * pickup (the shop) 500/500 8 distinct branches on 916, 6 on 908 + * drop (the customer) 500/500 145 distinct on 916, 341 on 908 + * rider (last reported) 461/500 on 916, 324/500 on 908 * - * ── These are event locations, not samples ────────────────────────────────── + * Shop and drop are on every row, so the map is never empty on a day with + * deliveries in it. The rider position is the sparse one, and it is real: on + * delivered rows it sits a median 134 m from the drop, which is a GPS fix taken + * at the customer's door. * - * Each point is where the rider stood when a delivery reached its current - * status. Nothing is smoothed or interpolated: a smoother would slide a - * "delivered" pin off the customer's door to make a line look better, and the - * door is the only thing on this map anybody needs to trust. The points are - * joined in time order so a round's shape is visible, and the line between two - * of them is explicitly not a route — see `RiderPath.isSampled`. + * Not to be confused with `riderlogs`, which carries a coordinate on every ping + * and repeats the SAME one for a rider all day — see `riderShifts`. + * + * ── The two endpoints spell the columns differently ───────────────────────── + * + * getdeliveries pickuplat / pickuplon droplat / droplon deliverylat / deliverylong + * getorders pickuplat / pickuplong (both empty) deliverylat / deliverylong + * + * `pickuplon` versus `pickuplong` is one letter, and reading the wrong one puts + * every shop on the prime meridian off the coast of Ghana. `droplat`/`droplon` + * are filled on deliveries and empty on orders, so the delivery coordinates are + * the fallback rather than the primary. `coordOf` accepts every spelling, which + * is why a stop can be an order or a delivery without the caller caring. */ -import type { DeliveryRow } from '@/api/types'; -import { DELIVERY_STATUS, ORDER_STATUS } from './orderStatus'; +import type { DeliveryRow, OrderRow } from '@/api/types'; +import type { Stop } from './dispatchModel'; +import { isRealName } from './orderStatus'; -/** One place a rider was known to be, and why we know. */ -export interface Fix { - deliveryid: number; - orderid: string; +/** A point that is actually a point. */ +export interface Point { lat: number; lng: number; - /** The delivery's status when the position was written. */ +} + +export type PlaceKind = 'shop' | 'drop' | 'rider'; + +/** One stop, as far as the map is concerned. */ +export interface MappedStop { + key: string; + orderid: string; status: string; - /** The most recent stamp on the row — when the position was most likely taken. */ - at: number | null; + /** The shop it came from. */ + shop: Point | null; + /** Where it is going. The pin that matters. */ + drop: Point | null; + /** Where the rider was when the job last moved. Absent on most waiting orders. */ + rider: Point | null; customer: string; address: string; -} - -export interface RiderPath { + value: number; + /** The rider's `app_users.userid`, or 0 when nobody is carrying it. */ userid: number; - rider: string; - fixes: Fix[]; - /** - * Always true, and named so it cannot be forgotten: the line joining these - * points is drawn between events minutes apart, not sampled along a road. It - * shows the order a round was worked, never the route it took. - */ - isSampled: true; -} - -/** A coordinate that is actually a coordinate. */ -function coord(value: string | undefined): number | null { - if (!value) return null; - const n = Number(value); - return Number.isFinite(n) && n !== 0 ? n : null; + ridername: string; + /** For ordering a round: the newest lifecycle stamp on the row. */ + at: number | null; } /** - * When a delivery's position was most likely written. + * One coordinate, from whichever of its spellings the endpoint used. * - * `updatedelivery` writes the position alongside whichever stamp the new status - * sets, so the latest stamp on the row is the best available answer. Read in - * the ladder's order — assign, arrive, pickup, start, deliver — and the last - * one present wins. + * Zero is rejected as well as blank: a half-written pair reads as 0, and a pin + * on the equator is a confident lie where a missing pin is an honest gap. */ -function fixedAt(row: DeliveryRow): number | null { - const stamps = [ - row.assigntime, - row.arrivaltime, - row.pickuptime, - row.starttime, - row.deliverytime, - row.canceltime, +function coordOf(row: Record, ...names: string[]): number | null { + for (const name of names) { + const raw = row[name]; + if (raw === undefined || raw === null || raw === '') continue; + const n = Number(raw); + if (Number.isFinite(n) && n !== 0) return n; + } + return null; +} + +function pointOf( + row: Record, + lats: string[], + lngs: string[], +): Point | null { + const lat = coordOf(row, ...lats); + const lng = coordOf(row, ...lngs); + // Both, or neither. + return lat !== null && lng !== null ? { lat, lng } : null; +} + +/** + * When this stop last moved. + * + * The ladder's order — assign, arrive, pickup, start, deliver — with the latest + * present stamp winning, because `updatedelivery` writes the rider position + * alongside whichever stamp the new status sets. An order that nobody has + * touched falls back to when it was placed. + */ +function stampOf(row: Record): number | null { + const candidates = [ + 'assigntime', + 'arrivaltime', + 'pickuptime', + 'starttime', + 'deliverytime', + 'canceltime', + 'orderdate', ]; let latest: number | null = null; - for (const stamp of stamps) { - if (!stamp) continue; - const at = Date.parse(stamp.replace(' ', 'T')); + for (const name of candidates) { + const raw = row[name]; + if (typeof raw !== 'string' || raw === '') continue; + const at = Date.parse(raw.replace(' ', 'T')); if (Number.isFinite(at) && (latest === null || at > latest)) latest = at; } return latest; } -/** Every positioned delivery in the batch, as a fix. */ -export function fixesOf(rows: readonly DeliveryRow[]): Fix[] { - const fixes: Fix[] = []; - for (const row of rows) { - const lat = coord(row.riderslat); - const lng = coord(row.riderslon); - // Both, or neither. A row with one of the pair is a half-written position - // and placing it on the equator would be worse than leaving it out. - if (lat === null || lng === null) continue; - fixes.push({ - deliveryid: row.deliveryid, - orderid: row.orderid ?? `#${row.deliveryid}`, - lat, - lng, - status: row.orderstatus ?? 'unknown', - at: fixedAt(row), - customer: row.deliverycustomer?.trim() || '', - address: row.deliveryaddress?.trim() || row.deliverysuburb?.trim() || '', - }); - } - return fixes; -} +/** One stop, read for the map. */ +export function mapStop(stop: Stop): MappedStop { + const row = stop.row as unknown as Record; + const delivery = stop.kind === 'delivery' ? (stop.row as DeliveryRow) : undefined; + const order = stop.kind === 'order' ? (stop.row as OrderRow) : undefined; -/** - * A rider's name, from a column that sometimes holds a status instead. - * - * `ridername` is not reliably a name. Every rider on tenant 916 has BOTH their - * name and a delivery status in it, and for two of the five the status is the - * more common value: - * - * 883 { "Rajan": 21, "delivered": 42 } - * 897 { "Varun": 69, "delivered": 75 } - * 1111 { "Murali": 33, "delivered": 74, "cancelled": 1 } - * 1114 { "Tamilazhagan": 37, "delivered": 63 } - * - * So neither "first non-empty" nor "most common" finds the name — the first - * picks whichever row happens to sort first, the second picks "delivered" for - * two riders out of five. Statuses are excluded by vocabulary first, and the - * most common of what survives is the name. - */ -const NOT_A_NAME = new Set([ - ...Object.keys(DELIVERY_STATUS), - ...Object.keys(ORDER_STATUS), -]); - -function nameFor(rows: readonly DeliveryRow[], userid: number): string { - const counts = new Map(); - for (const row of rows) { - if (row.userid !== userid) continue; - const name = row.ridername?.trim(); - if (!name || NOT_A_NAME.has(name.toLowerCase())) continue; - counts.set(name, (counts.get(name) ?? 0) + 1); - } - let best = ''; - let most = 0; - for (const [name, count] of counts) { - if (count > most) { - best = name; - most = count; - } - } - // A rider whose every row carried a status is nameless rather than called - // "delivered" — the id is at least honest about being an id. - return best || (userid > 0 ? `Rider ${userid}` : 'Unassigned'); -} - -/** - * Fixes grouped into one path per rider, in time order. - * - * Grouped on `userid`, never on `ridername` — see `nameFor` for why that column - * cannot be trusted to identify anybody. Riders with a single fix are kept: one - * known position is still worth a pin, it just has no line. - */ -export function pathsOf(rows: readonly DeliveryRow[]): RiderPath[] { - const byRider = new Map(); - const fixes = fixesOf(rows); - const rowById = new Map(rows.map((row) => [row.deliveryid, row])); - - for (const fix of fixes) { - const userid = rowById.get(fix.deliveryid)?.userid ?? 0; - const entry = byRider.get(userid); - if (entry) entry.fixes.push(fix); - else byRider.set(userid, { rider: nameFor(rows, userid), fixes: [fix] }); - } - - return [...byRider.entries()] - .map(([userid, entry]) => ({ - userid, - rider: entry.rider, - fixes: entry.fixes.sort((a, b) => (a.at ?? 0) - (b.at ?? 0)), - isSampled: true as const, - })) - .sort((a, b) => b.fixes.length - a.fixes.length); -} - -/** - * How much of the day the map can actually show. - * - * Stated on the page rather than implied by a sparse map: "6 of 29 deliveries - * carry a position" is the difference between a quiet day and a reporting gap, - * and a map with six pins on it looks the same either way. - */ -export function coverageOf(rows: readonly DeliveryRow[]): { - positioned: number; - total: number; - riders: number; -} { - const paths = pathsOf(rows); return { - positioned: paths.reduce((total, path) => total + path.fixes.length, 0), - total: rows.length, - riders: paths.length, + key: delivery ? `d${delivery.deliveryid}` : `o${order?.orderheaderid ?? 0}`, + orderid: (row['orderid'] as string) || `#${delivery?.deliveryid ?? order?.orderheaderid ?? 0}`, + status: (row['orderstatus'] as string)?.trim().toLowerCase() || 'unknown', + shop: pointOf(row, ['pickuplat'], ['pickuplon', 'pickuplong']), + // `droplat` on deliveries, `deliverylat` on orders — and both carry the + // same value where both are filled. + drop: pointOf(row, ['droplat', 'deliverylat'], ['droplon', 'deliverylong']), + rider: pointOf(row, ['riderslat'], ['riderslon']), + customer: ((row['deliverycustomer'] as string) ?? '').trim(), + address: + ((row['deliveryaddress'] as string) ?? '').trim() || + ((row['deliverysuburb'] as string) ?? '').trim(), + value: Number(row['deliveryamt'] ?? row['orderamount'] ?? 0) || 0, + userid: Number(row['userid'] ?? 0) || 0, + ridername: (row['ridername'] as string) ?? '', + at: stampOf(row), + }; +} + +/** One rider's round: their stops, in the order they were worked. */ +export interface Round { + userid: number; + rider: string; + stops: MappedStop[]; + /** The rider positions along the round, for the trail. */ + fixes: Point[]; +} + +/** + * The day's stops, grouped into rounds. + * + * Grouped on `userid`, never on `ridername` — see `isRealName` for why that + * column cannot identify anybody. Work nobody is carrying lands under userid 0 + * as "Not assigned", which is a legitimate group on this map: those drops still + * have an address, and seeing where the unassigned work is clustered is the + * point of looking. + */ +export function roundsOf(stops: readonly Stop[]): Round[] { + const mapped = stops.map(mapStop); + const byRider = new Map(); + for (const stop of mapped) { + const list = byRider.get(stop.userid); + if (list) list.push(stop); + else byRider.set(stop.userid, [stop]); + } + + const nameOf = (userid: number) => { + if (userid === 0) return 'Not assigned'; + const counts = new Map(); + for (const stop of mapped) { + if (stop.userid !== userid || !isRealName(stop.ridername)) continue; + const name = stop.ridername.trim(); + counts.set(name, (counts.get(name) ?? 0) + 1); + } + let best = ''; + let most = 0; + for (const [name, count] of counts) { + if (count > most) { + best = name; + most = count; + } + } + // Better a plain id than a confident wrong name. + return best || `Rider ${userid}`; + }; + + return [...byRider.entries()] + .map(([userid, list]) => { + const ordered = list.sort((a, b) => (a.at ?? 0) - (b.at ?? 0)); + return { + userid, + rider: nameOf(userid), + stops: ordered, + fixes: ordered.flatMap((stop) => (stop.rider ? [stop.rider] : [])), + }; + }) + .sort((a, b) => { + // Unassigned work last: it is a real group, but it is not a round and it + // should not lead the legend. + if (a.userid === 0) return 1; + if (b.userid === 0) return -1; + return b.stops.length - a.stops.length; + }); +} + +/** The distinct shops a set of stops was collected from. */ +export function shopsOf(stops: readonly MappedStop[]): Point[] { + const seen = new Map(); + for (const stop of stops) { + if (!stop.shop) continue; + // Rounded to about a metre before de-duplicating: the same branch arrives + // as both 11.016327 and 11.0163268, and two pins on one shop look like two + // shops. + const key = `${stop.shop.lat.toFixed(5)},${stop.shop.lng.toFixed(5)}`; + if (!seen.has(key)) seen.set(key, stop.shop); + } + return [...seen.values()]; +} + +/** + * How much of the group the map can actually place. + * + * Stated on the page rather than implied by a sparse map: "6 of 29 stops can be + * placed" is the difference between a quiet day and a reporting gap, and a map + * with six pins looks the same either way. + */ +export function coverageOf(stops: readonly MappedStop[]): { + placed: number; + total: number; + withRider: number; +} { + return { + placed: stops.filter((stop) => stop.drop !== null).length, + total: stops.length, + withRider: stops.filter((stop) => stop.rider !== null).length, }; } diff --git a/src/features/store-admin/dispatchModel.test.ts b/src/features/store-admin/dispatchModel.test.ts index e4efa3c..d15f012 100644 --- a/src/features/store-admin/dispatchModel.test.ts +++ b/src/features/store-admin/dispatchModel.test.ts @@ -297,6 +297,33 @@ test('the waiting queue is oldest first — it is a work queue, not a log', () = assert.deepEqual(groups[0]?.stops.map((s) => s.row.orderheaderid), [1, 2]); }); +// The rail is the list an operator picks a rider from, so a wrong label there +// is a phantom rider standing next to the real ones. `ridername` holds a +// delivery status on more rows than it holds a name for two riders in five on +// tenant 916, and naming the group from its first stop put "delivered" in the +// rail carrying 100 of the day's stops. +test('a rider is named from the whole round, so a status never labels the rail', () => { + const groups = groupByRider( + toStops( + [ + delivery({ deliveryid: 1, userid: 897, ridername: 'delivered' }), + delivery({ deliveryid: 2, userid: 897, ridername: 'delivered' }), + delivery({ deliveryid: 3, userid: 897, ridername: 'Varun' }), + ], + [], + ), + ); + assert.equal(groups.length, 1, 'grouping is on userid and was never the problem'); + assert.equal(groups[0]?.name, 'Varun'); +}); + +test('a rider whose every row carried a status keeps their id as the label', () => { + const groups = groupByRider( + toStops([delivery({ userid: 950, ridername: 'cancelled' })], []), + ); + assert.equal(groups[0]?.name, 'Rider 950'); +}); + test('riders are keyed on userid, which survives a missing name', () => { // `ridername` comes from a join and is blank whenever that join misses. const groups = groupByRider(toStops([delivery({ userid: 1427, ridername: '' })], [])); diff --git a/src/features/store-admin/dispatchModel.ts b/src/features/store-admin/dispatchModel.ts index 204e17e..93c6e03 100644 --- a/src/features/store-admin/dispatchModel.ts +++ b/src/features/store-admin/dispatchModel.ts @@ -1,6 +1,7 @@ import type { CustomerInfo } from '@/api/customers'; import type { DeliveryRow, OrderRow, TenantLocation } from '@/api/types'; import { awaitingRider, assignedFrom } from './assignDelivery'; +import { isRealName } from './orderStatus'; /** * A day's dispatch, grouped three ways. @@ -180,6 +181,31 @@ function fold(group: Group, stop: Stop): void { if (rider) group.riders.add(String(rider)); } +/** + * The most common value in `ridername` that is not a delivery status. + * + * Returns undefined when the column held nothing but statuses, so the caller + * keeps whatever fallback it had rather than being handed a wrong name. + */ +function commonRiderName(stops: readonly Stop[]): string | undefined { + const counts = new Map(); + for (const stop of stops) { + if (stop.kind !== 'delivery') continue; + const name = stop.row.ridername?.trim(); + if (!name || !isRealName(name)) continue; + counts.set(name, (counts.get(name) ?? 0) + 1); + } + let best: string | undefined; + let most = 0; + for (const [name, count] of counts) { + if (count > most) { + best = name; + most = count; + } + } + return best; +} + /** Busiest first, then alphabetical, so the board does not reshuffle randomly. */ function order(groups: Group[]): Group[] { return groups.sort((a, b) => b.stops.length - a.stops.length || a.name.localeCompare(b.name)); @@ -298,19 +324,39 @@ export function groupByRider(stops: readonly Stop[]): Group[] { const id = rider ? String(rider) : UNASSIGNED; let group = map.get(id); if (!group) { - const name = - id === UNASSIGNED - ? 'Waiting for a rider' - : (stop.kind === 'delivery' ? stop.row.ridername : '') || `Rider ${id}`; const detail = stop.kind === 'delivery' ? stop.row.ridercontact : undefined; - group = blank(id, name, detail); + // Named once the group is complete — see below. + group = blank(id, id === UNASSIGNED ? 'Waiting for a rider' : `Rider ${id}`, detail); map.set(id, group); } fold(group, stop); } + /* + Named from the whole group, not from whichever stop arrived first. + + `ridername` is not reliably a name: every rider on tenant 916 has BOTH their + name and a delivery status in that column, and for two of the five the status + is the MORE common value — + + 883 { "Rajan": 21, "delivered": 42 } + 897 { "Varun": 69, "delivered": 75 } + 1111 { "Murali": 33, "delivered": 74, "cancelled": 1 } + 1114 { "Tamilazhagan": 37, "delivered": 63 } + + Taking the first stop's value put a rider called "delivered" in the rail, next + to the real riders, carrying 100 of the day's stops. Grouping was never + affected — that is on `userid` — but the label was, and the rail is the list + an operator picks from. So statuses are excluded by vocabulary and the most + common of what survives is the name; a rider whose every row carried a status + keeps their id, which is at least honest about being an id. + */ for (const group of map.values()) { group.stops.sort((a, b) => stopTime(a).localeCompare(stopTime(b))); + if (group.id !== UNASSIGNED) { + const name = commonRiderName(group.stops); + if (name) group.name = name; + } } const groups = order([...map.values()]); diff --git a/src/features/store-admin/orderStatus.ts b/src/features/store-admin/orderStatus.ts index b223041..d9acd0d 100644 --- a/src/features/store-admin/orderStatus.ts +++ b/src/features/store-admin/orderStatus.ts @@ -37,6 +37,39 @@ export const DELIVERY_STATUS: Record = { cancelled: '#ef4444', }; +/** + * `waiting` is not in either map — it is the dispatch board's own word for work + * nobody has touched — but it is just as wrong as a name. + */ +const WAITING_WORD = 'waiting'; + +/** + * True when a value is a person's name and not a status wearing one. + * + * `deliveries.ridername` is not reliably a name. Every rider on tenant 916 has + * BOTH their name and a delivery status in that column, and for two of the five + * the status is the MORE common value (measured 2026-09-09): + * + * 883 { "Rajan": 21, "delivered": 42 } + * 897 { "Varun": 69, "delivered": 75 } + * 1111 { "Murali": 33, "delivered": 74, "cancelled": 1 } + * 1114 { "Tamilazhagan": 37, "delivered": 63 } + * + * So neither "first non-empty" nor "most common" finds the name on its own. The + * statuses have to be excluded first, and they are excluded by VOCABULARY — the + * two status maps above — rather than by a hand-written list, so a status added + * to the ladder is excluded here the same day. + * + * It lives beside the vocabularies rather than with either caller: the dispatch + * rail and the map both need it, and one importing it from the other would put + * a cycle between them. + */ +export function isRealName(value: string | undefined): boolean { + const name = value?.trim().toLowerCase(); + if (!name) return false; + return !(name in DELIVERY_STATUS) && !(name in ORDER_STATUS) && name !== WAITING_WORD; +} + /** Unknown statuses fall to meta grey rather than to a colour that means something. */ export const statusColor = (map: Record, status: string | undefined): string => map[(status ?? '').trim().toLowerCase()] ?? 'var(--color-ink-3)'; diff --git a/src/features/store-admin/pages/DispatchPage.tsx b/src/features/store-admin/pages/DispatchPage.tsx index d4418df..d7da91e 100644 --- a/src/features/store-admin/pages/DispatchPage.tsx +++ b/src/features/store-admin/pages/DispatchPage.tsx @@ -6,7 +6,6 @@ import { VStack } from '@astryxdesign/core/VStack'; import { Bike, IndianRupee, - Map, MapPin, Package, Store, @@ -29,7 +28,7 @@ import { useBranchScope } from '../BranchScope'; import { count, money, moneyExact } from '../format'; import { DELIVERY_STATUS, statusColor } from '../orderStatus'; import { shortAge } from '../posStatus'; -import { DispatchMap } from '../DispatchMap'; +import { GroupMap } from '../GroupMap'; import { OrderDetailDrawer } from '../OrderDetailDrawer'; import { PlanVsActualPanel } from '../PlanVsActualPanel'; import { @@ -74,13 +73,21 @@ import './dispatch.css'; * Deliveries tab could never answer, since it lists jobs flat with a rider * column and no way to see a round. * - * ── What this page deliberately is not ────────────────────────────────────── + * ── Why two of the three views draw a map instead of a table ──────────────── * - * There is no map yet. Every drop coordinate in production today sits within - * about four metres of every other — one test customer ordering repeatedly — - * so a map would render one pin with six underneath and imply a precision the - * data does not have. The grouping, the rounds and the rider positions all work - * without one, and the map is worth adding the day real drops spread out. + * A round and a shop's day are both SHAPES: where the work is, in what order, + * how far apart. A table of addresses cannot show either — you read twenty rows + * and still cannot see that a rider crossed the city twice. So picking a rider + * or a shop opens the map, and the table stays where a list is the right + * answer: the waiting queue, whose rows are ticked to assign them, and the + * customer view, where one customer's drops are all at one address. + * + * The coordinates support it. `pickuplat`/`pickuplon` and `droplat`/`droplon` + * are filled on 500 of 500 rows for both tenants measured, so the map is never + * empty on a day with work in it; the rider's own position is the sparse one, + * at 461 and 324 of 500. (An earlier note here said drops all sat within four + * metres of each other. That was one quiet test tenant: the live spread is 145 + * distinct drops on tenant 916 and 341 on 908.) */ /** * The delivery ladder plus the one status that is not on it. @@ -102,7 +109,7 @@ const DISPATCH_STATUS: Record = { ...DELIVERY_STATUS, [WAITING]: * fourth `ViewMode`, so the grouping functions never have to answer for a value * that is not a grouping. */ -type Board = ViewMode | 'timing' | 'map'; +type Board = ViewMode | 'timing'; export function DispatchPage() { const { branches, selected, tenantid } = useBranchScope(); @@ -157,7 +164,7 @@ export function DispatchPage() { ); const groups = useMemo(() => { - if (mode === 'timing' || mode === 'map') return []; + if (mode === 'timing') return []; if (mode === 'stores') return groupByStore(stops, locations.data ?? branches); if (mode === 'customers') return groupByCustomer(stops, customers.data ?? [], branchName); return groupByRider(stops); @@ -246,12 +253,6 @@ export function DispatchPage() { isActive={mode === 'timing'} onClick={() => changeMode('timing')} /> - } - isActive={mode === 'map'} - onClick={() => changeMode('map')} - /> } /> @@ -287,12 +288,7 @@ export function DispatchPage() { /> - {mode === 'map' ? ( - /* The one place on the platform where rider positions actually move. - Fed by the delivery rows, which are tenant-scoped, so a shop sees its - own rounds and nobody else's. */ - - ) : mode === 'timing' ? ( + {mode === 'timing' ? ( /* A different question of the same rows, so it replaces the board rather than sitting beside it: promised against delivered, and where the hours between accepting an order and dropping it actually went. */ @@ -491,6 +487,21 @@ function GroupDetail({ // rider's round means nothing on this one. const paged = usePaged(group.stops, { resetKey: group.id }); + /* + A round and a shop's day are both SHAPES — where the work is, in what order, + how far apart — and a table of addresses cannot show either. So those two + views draw the map instead of the list. + + Never on the waiting queue. That is the only group whose rows are ticked to + assign them, a checkbox cannot live on a map pin, and nothing in it has been + worked yet so there is no shape to see. `selection` is passed only for that + group, so its presence is the test. + + "By customer" keeps its table too: one customer's drops are all at one + address, so the map would be a single pin with the rest hidden underneath it. + */ + const showMap = (mode === 'riders' || mode === 'stores') && !selection; + return ( @@ -512,13 +523,10 @@ function GroupDetail({ - {/* Rider position, only in the rider view and only when reported. - Labelled with its age and the status that produced it, because it - is a last-known point and not a live feed — drawing it as a dot on - a map would claim otherwise. */} - {/* Never on the waiting queue: nobody is carrying that work, so + {/* The rider's last reported point, but only when the map is not + drawing it. On the waiting queue nobody is carrying the work, so "No position reported" states the obvious and reads as a fault. */} - {mode === 'riders' && group.id !== UNASSIGNED ? ( + {!showMap && mode === 'riders' && group.id !== UNASSIGNED ? ( seen ? (
@@ -532,13 +540,6 @@ function GroupDetail({ {seen.orderid ? ` · ${seen.orderid}` : ''}
- - Open map - ) : (
@@ -561,98 +562,106 @@ function GroupDetail({ is free, not on a screen you have to leave the list for. */} {assignBar} - -
- - - - {selection ? ( - - ) : null} - - - - {mode !== 'riders' ? : null} - - - - - - - {paged.rows.map((stop, rowIndex) => { - const status = stopStatus(stop); - const tint = statusColor(DISPATCH_STATUS, status); - const order = stop.kind === 'order' ? stop.row : undefined; - const verdict = order && verdictOf ? verdictOf(order) : undefined; - const pickable = Boolean(verdict?.canAssign); - return ( - onOpen(stop)}> - {selection ? ( - /* The click is stopped here so ticking a row does not - also open its drawer — two different intentions on the - same row, and the drawer would bury the bar. */ - - ) : null} - - - - {mode !== 'riders' ? ( - - ) : null} - - - + + + {mode !== 'riders' ? : null} + + + + + + + {paged.rows.map((stop, rowIndex) => { + const status = stopStatus(stop); + const tint = statusColor(DISPATCH_STATUS, status); + const order = stop.kind === 'order' ? stop.row : undefined; + const verdict = order && verdictOf ? verdictOf(order) : undefined; + const pickable = Boolean(verdict?.canAssign); + return ( + onOpen(stop)}> + {selection ? ( + /* The click is stopped here so ticking a row does not + also open its drawer — two different intentions on the + same row, and the drawer would bury the bar. */ + + ) : null} + + + + {mode !== 'riders' ? ( + + ) : null} + + + + + ); + })} + +
- { - // Some can go and some cannot: neither ticked nor - // empty, and the box should say so rather than pick one. - if (node) node.indeterminate = selection.someChosen; - }} - onChange={selection.toggleAll} - /> - #OrderGoing toRiderKMValueStatus
event.stopPropagation()}> - order && selection.toggle(order.orderheaderid)} - /> - {paged.from + rowIndex} - {stopOrderId(stop) || `#${stop.row.orderheaderid}`} - {stopTime(stop) ? shortTime(stopTime(stop)) : ''} - - {stopCustomer(stop) || '—'} - {stopAddress(stop) || stop.row.deliverysuburb} - - {stop.kind === 'delivery' && stop.row.ridername ? ( - stop.row.ridername - ) : ( - not assigned - )} - {stop.kind === 'delivery' ? km(stop.row) : '—'}{moneyExact(stopValue(stop))} - + + + + + ) : ( + +
+ + + + {selection ? ( + - ); - })} - -
+ { + // Some can go and some cannot: neither ticked nor + // empty, and the box should say so rather than pick one. + if (node) node.indeterminate = selection.someChosen; }} - > - {status} - - -
-
- -
+ onChange={selection.toggleAll} + /> + + ) : null} +
#OrderGoing toRiderKMValueStatus
event.stopPropagation()}> + order && selection.toggle(order.orderheaderid)} + /> + {paged.from + rowIndex} + {stopOrderId(stop) || `#${stop.row.orderheaderid}`} + {stopTime(stop) ? shortTime(stopTime(stop)) : ''} + + {stopCustomer(stop) || '—'} + {stopAddress(stop) || stop.row.deliverysuburb} + + {stop.kind === 'delivery' && stop.row.ridername ? ( + stop.row.ridername + ) : ( + not assigned + )} + {stop.kind === 'delivery' ? km(stop.row) : '—'}{moneyExact(stopValue(stop))} + + {status} + +
+
+ +
+ )} ); } diff --git a/src/features/store-admin/pages/dispatch.css b/src/features/store-admin/pages/dispatch.css index f3509d2..39efed2 100644 --- a/src/features/store-admin/pages/dispatch.css +++ b/src/features/store-admin/pages/dispatch.css @@ -471,3 +471,41 @@ 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); +}