/** * 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 = { 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`; }