Files
daily_console_web/src/features/store-admin/plannedVsActual.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

299 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* What was promised against what happened.
*
* ── The ladder is not the one the column names imply ────────────────────────
*
* `deliveries` carries five stamps — assign, start, arrival, pickup, delivery —
* and read in that order they are nonsense: `starttime` is later than
* `arrivaltime` on 316 of 316 rows measured on tenant 916. That looks like
* corrupt data and is not. Reading a batch out in sequence shows what the app
* is actually doing (tenant 916, 29 August, four drops assigned together):
*
* assign 11:22:36 all four handed over at once
* arrive 12:37:2x the rider reached the SHOP — once, for all four
* pickup 12:41:2x collected all four at the counter
* start 13:16:23 → deliver 13:16:57
* start 13:17:19 → deliver 13:38:26
* start 13:38:37 → deliver 13:44:14
* start 13:44:56 → deliver 13:59:19
*
* Each `starttime` lands twenty to forty seconds after the PREVIOUS drop was
* delivered. So `starttime` is per-DROP, not per-round: it is when the rider
* set off for this particular address, having finished the last one. The ladder
* is assign → arrive → pickup → start → deliver, and read that way every
* duration is positive and every step means something:
*
* assign → arrive getting to the shop median 69.5 min (tenant 916)
* arrive → pickup at the counter median 0.6 min
* pickup → start waiting its turn median 28.0 min
* start → deliver on the road median 0.5 min
*
* That last median is not a half-minute ride. A round's FIRST drop gets a
* `starttime` written moments before its delivery — the app stamps it late —
* so the first leg of every round reads as near-zero. It is flagged rather than
* averaged away; see `Journey.isFirstLeg`.
*
* ── `actualkms` is not the actual kilometres ────────────────────────────────
*
* It equals `kms` to the decimal on 498 of 498 delivered rows for tenant 916
* and 464 of 474 for tenant 908. It is a copy of the plan, and showing it
* beside `kms` as "planned vs actual" would draw two identical numbers and call
* the round perfectly executed. `riderkms` is the only measured distance — GPS
* derived, present on about two thirds of rows, and lossy: a quarter of the
* values it carries are under 3 metres. Below `MEASURED_KM_FLOOR` it is treated
* as absent, because "0.0026 km" is a failed reading, not a short trip.
*/
import type { DeliveryRow } from '@/api/types';
/** Under this, `riderkms` is a failed GPS reading rather than a short trip. */
const MEASURED_KM_FLOOR = 0.1;
/** A leg this short is the app stamping late, not a ride. See the note above. */
const IMPLAUSIBLE_LEG_MS = 60_000;
export type StepKey = 'toShop' | 'counter' | 'inRound' | 'onRoad';
export interface Step {
key: StepKey;
label: string;
/** Null when either stamp is missing — a blank, never a zero. */
ms: number | null;
}
export const STEP_LABEL: Record<StepKey, string> = {
toShop: 'Getting to the shop',
counter: 'At the counter',
inRound: 'Waiting its turn',
onRoad: 'On the road',
};
export interface Journey {
deliveryid: number;
orderid: string;
rider: string;
steps: Step[];
/** assign → deliver, the whole thing. Null unless both ends are stamped. */
totalMs: number | null;
/** `kms` — what the shop was quoted. */
plannedKm: number | null;
/** `riderkms` — the only measured distance, and only when it reads plausibly. */
riddenKm: number | null;
/** The promise made to the customer. */
promisedAt: number | null;
deliveredAt: number | null;
/** Positive is late. Null when nothing was promised or nothing was delivered. */
lateMs: number | null;
/**
* True when this drop's `onRoad` leg is too short to be a ride — the round's
* first drop, whose `starttime` the app writes moments before delivery.
* Excluded from the on-the-road median rather than dragging it to zero.
*/
isFirstLeg: boolean;
}
/** A naive `2026-08-29 16:22:59`, read in the viewer's zone. */
function stamp(value: string | undefined): number | null {
if (!value) return null;
const at = Date.parse(value.replace(' ', 'T'));
return Number.isFinite(at) ? at : null;
}
/**
* `expecteddeliverytime`, which arrives as `2026-08-29 07:27 PM`.
*
* The fourth timestamp format Fiesta sends and the only twelve-hour one, so it
* gets its own parser rather than being handed to `Date.parse`. ECMAScript
* mandates only the ISO format; everything else is implementation-defined, and
* a twelve-hour string with a meridiem is squarely in that territory. V8 does
* happen to read it correctly — so relying on `Date.parse` would work in Chrome
* and in the test runner, and could silently return Invalid Date in another
* engine, turning "45 minutes late" into "no promise recorded" on every row
* that has one. Parsed here so the answer does not depend on the browser.
*/
export function parsePromise(value: string | undefined): number | null {
if (!value) return null;
const match = value.match(/(\d{4})-(\d{2})-(\d{2})[T\s]+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM)?/i);
if (!match) return null;
const [, year, month, day, rawHour, minute, second, meridiem] = match;
let hour = Number(rawHour);
if (meridiem) {
hour %= 12;
if (/pm/i.test(meridiem)) hour += 12;
}
const at = Date.parse(
`${year}-${month}-${day}T${String(hour).padStart(2, '0')}:${minute}:${second ?? '00'}`,
);
return Number.isFinite(at) ? at : null;
}
function gap(from: number | null, to: number | null): number | null {
if (from === null || to === null) return null;
const ms = to - from;
// A negative step is a stamp written out of order. Blank, not a negative
// duration on the bar — the ladder above is what the stamps mean, and a row
// that contradicts it is a row this cannot describe.
return ms >= 0 ? ms : null;
}
/** One delivery, read as the journey it was. */
export function journeyOf(row: DeliveryRow): Journey {
const assigned = stamp(row.assigntime);
const arrived = stamp(row.arrivaltime);
const picked = stamp(row.pickuptime);
const started = stamp(row.starttime);
const delivered = stamp(row.deliverytime);
const onRoad = gap(started, delivered);
const promisedAt = parsePromise(row.expecteddeliverytime);
const ridden = Number(row.riderkms);
const planned = Number(row.kms);
return {
deliveryid: row.deliveryid,
orderid: row.orderid ?? `#${row.deliveryid}`,
rider: row.ridername?.trim() || '—',
steps: [
{ key: 'toShop', label: STEP_LABEL.toShop, ms: gap(assigned, arrived) },
{ key: 'counter', label: STEP_LABEL.counter, ms: gap(arrived, picked) },
{ key: 'inRound', label: STEP_LABEL.inRound, ms: gap(picked, started) },
{ key: 'onRoad', label: STEP_LABEL.onRoad, ms: onRoad },
],
totalMs: gap(assigned, delivered),
plannedKm: Number.isFinite(planned) && planned > 0 ? planned : null,
riddenKm: Number.isFinite(ridden) && ridden >= MEASURED_KM_FLOOR ? ridden : null,
promisedAt,
deliveredAt: delivered,
lateMs: promisedAt !== null && delivered !== null ? delivered - promisedAt : null,
isFirstLeg: onRoad !== null && onRoad < IMPLAUSIBLE_LEG_MS,
};
}
/** A step's shape across many journeys. */
export interface StepSummary {
key: StepKey;
label: string;
/** Journeys with both stamps. The rest contribute nothing rather than a zero. */
measured: number;
medianMs: number | null;
p90Ms: number | null;
/** This step's share of the median total, 0–1, for the bar widths. */
share: number;
}
export interface Comparison {
journeys: number;
/** Journeys carrying a promise AND a delivery — the on-time denominator. */
promised: number;
onTime: number;
/** Median lateness across `promised`. Positive is late. Null when none. */
medianLateMs: number | null;
steps: StepSummary[];
/** The step with the largest median. Where the time actually goes. */
bottleneck: StepKey | null;
/** Journeys with a usable `riderkms`, and the medians to compare. */
distance: {
measured: number;
medianPlannedKm: number | null;
medianRiddenKm: number | null;
};
/** Rounds' first drops, excluded from the on-the-road figure. */
firstLegs: number;
}
function median(values: number[]): number | null {
if (values.length === 0) return null;
const sorted = [...values].sort((a, b) => a - b);
return sorted[sorted.length >> 1] as number;
}
function percentile(values: number[], p: number): number | null {
if (values.length === 0) return null;
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] as number;
}
/**
* Many journeys, summarised.
*
* Medians rather than means throughout. One order assigned in the morning and
* delivered at closing drags a mean into uselessness, and the whole point of
* this panel is to say where the typical hour goes.
*/
export function compare(rows: readonly DeliveryRow[]): Comparison {
const journeys = rows.map(journeyOf);
const promised = journeys.filter(
(journey) => journey.lateMs !== null,
);
const lateness = promised.map((journey) => journey.lateMs as number);
const steps: StepSummary[] = (['toShop', 'counter', 'inRound', 'onRoad'] as StepKey[]).map(
(key) => {
const values = journeys
// The first drop of a round has a `starttime` written moments before
// its delivery, so its on-road leg is an artefact. Included, it pulls
// the median to half a minute and the panel reports that riders spend
// no time riding.
.filter((journey) => !(key === 'onRoad' && journey.isFirstLeg))
.map((journey) => journey.steps.find((step) => step.key === key)?.ms)
.filter((ms): ms is number => ms !== null && ms !== undefined);
return {
key,
label: STEP_LABEL[key],
measured: values.length,
medianMs: median(values),
p90Ms: percentile(values, 0.9),
share: 0,
};
},
);
const totalMedian = steps.reduce((total, step) => total + (step.medianMs ?? 0), 0);
for (const step of steps) {
step.share = totalMedian > 0 ? (step.medianMs ?? 0) / totalMedian : 0;
}
const withDistance = journeys.filter((journey) => journey.riddenKm !== null);
const bottleneck =
steps
.filter((step) => step.medianMs !== null)
.sort((a, b) => (b.medianMs as number) - (a.medianMs as number))[0]?.key ?? null;
return {
journeys: journeys.length,
promised: promised.length,
onTime: lateness.filter((ms) => ms <= 0).length,
medianLateMs: median(lateness),
steps,
bottleneck,
distance: {
measured: withDistance.length,
medianPlannedKm: median(
withDistance
.map((journey) => journey.plannedKm)
.filter((km): km is number => km !== null),
),
medianRiddenKm: median(withDistance.map((journey) => journey.riddenKm as number)),
},
firstLegs: journeys.filter((journey) => journey.isFirstLeg).length,
};
}
/** "1h 09m", "42m", "38s". Durations here span seconds to hours. */
export function span(ms: number | null): string {
if (ms === null) return '—';
if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
const minutes = Math.round(ms / 60_000);
if (minutes < 60) return `${minutes}m`;
return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m`;
}
/** "48m late", "12m early", "on time". Reads as a sentence, not a signed number. */
export function lateness(ms: number | null): string {
if (ms === null) return '—';
if (Math.abs(ms) < 60_000) return 'on time';
return ms > 0 ? `${span(ms)} late` : `${span(-ms)} early`;
}