Files
daily_console_web/src/features/store-admin/plannedVsActual.test.ts
abhishek 34bf7989f7 dispatch map, plan vs actual, and a fleet page
Three of the features the old console had and ours did not, built on what
the data can actually support rather than on what the column names imply.

Two findings changed the shape of the work:

`riderlogs` is not a GPS trail. Every ping a rider sends carries the SAME
coordinate — one rider's 2,404 pings on 14 August all read 11.052998,
76.929958, and the same holds on every day and region checked. Distance,
speed and "time moving" cannot come from it. The Fleet page therefore
reports presence only: who was online and for how long, inferred from the
gaps between check-ins, because `login`, `logout` and `workhours` are empty
on all 320,132 August rows. It says out loud that it cannot tell a rider
parked all day from one who crossed the city.

The delivery ladder is not the order its columns are in. `starttime` is
later than `arrivaltime` on 316 of 316 rows, which looks corrupt and is not:
`starttime` is per-DROP, stamped when the rider sets off for that address
having finished the last one. Read as assign -> arrive -> pickup -> start ->
deliver, every duration is positive. On tenant 916 that shows the bottleneck
is not the riding: a median 69.5 minutes passes between handing an order to
a rider and that rider reaching the shop, against 0.6 minutes at the counter.

- Map tab on dispatch, fed by `deliveries.riderslat/lon` — the only rider
  positions that move (353 distinct across 461 rows). Tenant-scoped, so a
  shop sees its own rounds. The line joins stops in worked order and says
  it is not a route.
- Plan vs actual tab: promised against delivered, and a step breakdown of
  where the hours go. `actualkms` is excluded — it equals the planned `kms`
  to the decimal on every delivered row, so it is a copy, not a measurement.
- Fleet page in the platform console: a presence gantt and a map of where
  each rider is registered.
- `ridername` holds a delivery status on more rows than it holds a name for
  two riders in five, so names are resolved by excluding the status
  vocabulary first.
- leaflet, wrapped directly rather than via react-leaflet, lazy-loaded so
  only the pages with a map pay for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JYEsb8PNZ19G9R8gUjTU7n
2026-09-09 17:02:21 +05:30

251 lines
9.9 KiB
TypeScript

/**
* Promised against happened.
*
* The rows below are copied from production (tenant 916, 29 August 2026) rather
* than invented, because every awkward thing this module exists to handle is
* something the live data does and a hand-written fixture would not: a
* `starttime` later than `arrivaltime`, an `actualkms` identical to `kms`, a
* `riderkms` of 0.0026, and a promise written in twelve-hour time.
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
import type { DeliveryRow } from '@/api/types';
import { compare, journeyOf, lateness, parsePromise, span } from './plannedVsActual';
/** One batch of four, assigned together — the rows that revealed the ladder. */
const BATCH: DeliveryRow[] = [
{
deliveryid: 1,
orderid: '916-1',
ridername: 'Varun',
orderstatus: 'delivered',
assigntime: '2026-08-29 11:22:36',
arrivaltime: '2026-08-29 12:37:26',
pickuptime: '2026-08-29 12:41:20',
starttime: '2026-08-29 13:16:23',
deliverytime: '2026-08-29 13:16:57',
expecteddeliverytime: '2026-08-29 01:00 PM',
kms: '6',
actualkms: '6.0',
riderkms: '4.2',
},
{
deliveryid: 2,
orderid: '916-2',
ridername: 'Varun',
orderstatus: 'delivered',
assigntime: '2026-08-29 11:22:36',
arrivaltime: '2026-08-29 12:37:29',
pickuptime: '2026-08-29 12:41:21',
starttime: '2026-08-29 13:17:19',
deliverytime: '2026-08-29 13:38:26',
expecteddeliverytime: '2026-08-29 01:30 PM',
kms: '8',
actualkms: '8.0',
riderkms: '6.1',
},
{
deliveryid: 3,
orderid: '916-3',
ridername: 'Varun',
orderstatus: 'delivered',
assigntime: '2026-08-29 11:22:36',
arrivaltime: '2026-08-29 12:37:30',
pickuptime: '2026-08-29 12:41:23',
starttime: '2026-08-29 13:38:37',
deliverytime: '2026-08-29 13:44:14',
expecteddeliverytime: '2026-08-29 01:20 PM',
kms: '2',
actualkms: '2.0',
// A failed GPS reading, exactly as production sends it.
riderkms: '0.0026',
},
{
deliveryid: 4,
orderid: '916-4',
ridername: 'Varun',
orderstatus: 'delivered',
assigntime: '2026-08-29 11:22:36',
arrivaltime: '2026-08-29 12:37:33',
pickuptime: '2026-08-29 12:41:26',
starttime: '2026-08-29 13:44:56',
deliverytime: '2026-08-29 13:59:19',
expecteddeliverytime: '2026-08-29 02:10 PM',
kms: '6',
actualkms: '6.0',
riderkms: '5.5',
},
];
/* ── Reading one delivery ────────────────────────────────────────────────── */
// The whole point. Read in column order the ladder gives negative steps; read
// as assign → arrive → pickup → start → deliver every step is positive.
test('every step of the ladder is a positive duration', () => {
for (const row of BATCH) {
for (const step of journeyOf(row).steps) {
assert.ok(step.ms !== null && step.ms >= 0, `${row.orderid} ${step.key} was ${step.ms}`);
}
}
});
test('the steps mean what the ladder says they mean', () => {
const journey = journeyOf(BATCH[1] as DeliveryRow);
const of = (key: string) => journey.steps.find((step) => step.key === key)!.ms!;
// 11:22:36 → 12:37:29
assert.equal(Math.round(of('toShop') / 60_000), 75);
// 12:37:29 → 12:41:21
assert.equal(Math.round(of('counter') / 60_000), 4);
// 12:41:21 → 13:17:19, waiting while the first drop was done
assert.equal(Math.round(of('inRound') / 60_000), 36);
// 13:17:19 → 13:38:26
assert.equal(Math.round(of('onRoad') / 60_000), 21);
});
// A round's first drop gets its `starttime` written moments before delivery.
test("a round's first drop is flagged rather than believed", () => {
assert.equal(journeyOf(BATCH[0] as DeliveryRow).isFirstLeg, true, '34 seconds is not a ride');
assert.equal(journeyOf(BATCH[1] as DeliveryRow).isFirstLeg, false);
});
// `actualkms` equals `kms` to the decimal on 498 of 498 production rows. Showing
// it as "actual" would draw two identical bars and call the round perfect.
test('the measured distance is riderkms, never actualkms', () => {
const journey = journeyOf(BATCH[1] as DeliveryRow);
assert.equal(journey.plannedKm, 8);
assert.equal(journey.riddenKm, 6.1, 'actualkms (8.0) was used as the measurement');
});
test('a GPS reading of three metres is treated as absent, not as a short trip', () => {
const journey = journeyOf(BATCH[2] as DeliveryRow);
assert.equal(journey.riddenKm, null);
assert.equal(journey.plannedKm, 2, 'the plan is still known');
});
test('a stamp written out of order leaves a blank, not a negative bar', () => {
const journey = journeyOf({
deliveryid: 9,
assigntime: '2026-08-29 13:00:00',
arrivaltime: '2026-08-29 12:00:00',
pickuptime: '2026-08-29 12:05:00',
} as DeliveryRow);
assert.equal(journey.steps.find((step) => step.key === 'toShop')!.ms, null);
assert.equal(journey.steps.find((step) => step.key === 'counter')!.ms, 5 * 60_000);
});
test('a delivery nobody has finished has a blank total, not a running one', () => {
const journey = journeyOf({
deliveryid: 9,
assigntime: '2026-08-29 13:00:00',
orderstatus: 'pending',
} as DeliveryRow);
assert.equal(journey.totalMs, null);
assert.equal(journey.lateMs, null);
assert.equal(journey.steps.every((step) => step.ms === null), true);
});
/* ── The promise ─────────────────────────────────────────────────────────── */
// The fourth timestamp format Fiesta sends, and the only twelve-hour one. Only
// the ISO format is specified; a meridiem string is implementation-defined, so
// this is pinned against an explicitly constructed instant rather than against
// whatever the running engine happens to do with the same string.
test('a twelve-hour promise is read as the instant it names', () => {
assert.equal(parsePromise('2026-08-29 07:27 PM'), Date.parse('2026-08-29T19:27:00'));
});
test('midnight and noon do not swap', () => {
assert.equal(parsePromise('2026-08-29 12:15 AM'), Date.parse('2026-08-29T00:15:00'));
assert.equal(parsePromise('2026-08-29 12:15 PM'), Date.parse('2026-08-29T12:15:00'));
});
test('a twenty-four-hour promise is read too, so a format change does not blank the panel', () => {
assert.equal(parsePromise('2026-08-29 19:27:30'), Date.parse('2026-08-29T19:27:30'));
});
test('no promise, or an unreadable one, is null rather than a guess', () => {
assert.equal(parsePromise(''), null);
assert.equal(parsePromise(undefined), null);
assert.equal(parsePromise('soon'), null);
});
test('lateness is measured against the promise, either way', () => {
// Promised 01:00 PM, delivered 13:16:57.
assert.ok(journeyOf(BATCH[0] as DeliveryRow).lateMs! > 16 * 60_000);
// Promised 02:10 PM, delivered 13:59:19 — early.
assert.ok(journeyOf(BATCH[3] as DeliveryRow).lateMs! < 0);
});
/* ── Many journeys ───────────────────────────────────────────────────────── */
test('the summary counts what was promised and what landed on time', () => {
const result = compare(BATCH);
assert.equal(result.journeys, 4);
assert.equal(result.promised, 4);
assert.equal(result.onTime, 1, 'only the last drop beat its promise');
});
// Getting to the shop was 75 minutes; everything else was minutes. Naming the
// bottleneck is the one thing this panel is for.
test('the bottleneck is the step that actually costs the time', () => {
assert.equal(compare(BATCH).bottleneck, 'toShop');
});
test("the first drop's artefact leg is kept out of the on-road figure", () => {
const result = compare(BATCH);
const onRoad = result.steps.find((step) => step.key === 'onRoad')!;
assert.equal(result.firstLegs, 1);
assert.equal(onRoad.measured, 3, "the 34-second leg was averaged in");
assert.ok(
onRoad.medianMs! > 5 * 60_000,
`a real ride, not ${Math.round(onRoad.medianMs! / 1000)}s`,
);
});
test('shares add up to the whole, so the bars fill the bar', () => {
const total = compare(BATCH).steps.reduce((sum, step) => sum + step.share, 0);
assert.ok(Math.abs(total - 1) < 0.0001);
});
test('a step nobody stamped is measured zero times rather than counted as instant', () => {
const result = compare([
{ deliveryid: 1, assigntime: '2026-08-29 11:00:00', arrivaltime: '2026-08-29 11:30:00' } as DeliveryRow,
]);
const counter = result.steps.find((step) => step.key === 'counter')!;
assert.equal(counter.measured, 0);
assert.equal(counter.medianMs, null);
assert.equal(counter.share, 0);
});
test('distance compares plan against measurement, over the rows that have both', () => {
const { distance } = compare(BATCH);
assert.equal(distance.measured, 3, 'the 0.0026 km row has no measurement');
assert.equal(distance.medianPlannedKm, 6);
assert.equal(distance.medianRiddenKm, 5.5);
});
test('an empty day summarises to blanks, not zeroes', () => {
const result = compare([]);
assert.equal(result.journeys, 0);
assert.equal(result.medianLateMs, null);
assert.equal(result.bottleneck, null);
assert.equal(result.distance.medianRiddenKm, null);
assert.equal(result.steps.every((step) => step.medianMs === null), true);
});
/* ── Wording ─────────────────────────────────────────────────────────────── */
test('spans read at the scale they are', () => {
assert.equal(span(null), '—');
assert.equal(span(34_000), '34s');
assert.equal(span(42 * 60_000), '42m');
assert.equal(span(69 * 60_000), '1h 09m');
});
test('lateness reads as a sentence, not a signed number', () => {
assert.equal(lateness(null), '—');
assert.equal(lateness(30_000), 'on time');
assert.equal(lateness(48 * 60_000), '48m late');
assert.equal(lateness(-12 * 60_000), '12m early');
});