rider status

This commit is contained in:
2026-09-22 11:13:15 +05:30
parent 19ed3585ff
commit cca3c50a89
21 changed files with 1389 additions and 41 deletions

View File

@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@astryxdesign/core/Button'; import { Button } from '@astryxdesign/core/Button';
import { Selector } from '@astryxdesign/core/Selector'; import { Selector } from '@astryxdesign/core/Selector';
import { Route, UserCheck } from 'lucide-react'; import { Route, TriangleAlert, UserCheck } from 'lucide-react';
import { errorMessage } from '@/api/client'; import { errorMessage } from '@/api/client';
import { import {
RIDER_MESSAGE, RIDER_MESSAGE,
@@ -14,6 +14,7 @@ import { useAllPartners, useOwnTenant, useRiders } from '@/queries/hooks';
import { queryKeys } from '@/queries/keys'; import { queryKeys } from '@/queries/keys';
import { useBranchScope } from './BranchScope'; import { useBranchScope } from './BranchScope';
import { buildDeliveries, riderName, riderVehicle } from './assignDelivery'; import { buildDeliveries, riderName, riderVehicle } from './assignDelivery';
import { hasNoDevice } from './riderReach';
import { RoutePlanDrawer } from './RoutePlanDrawer'; import { RoutePlanDrawer } from './RoutePlanDrawer';
import './pages/deliveries.css'; import './pages/deliveries.css';
@@ -35,11 +36,20 @@ export interface AssignBarProps {
orders: OrderRow[]; orders: OrderRow[];
branchOf: (row: OrderRow) => TenantLocation | undefined; branchOf: (row: OrderRow) => TenantLocation | undefined;
assigned: ReadonlySet<number>; assigned: ReadonlySet<number>;
/** Orders whose delivery is dead — see `releasedFrom`. */
released?: ReadonlySet<number>;
onClear: () => void; onClear: () => void;
onDone: () => void; onDone: () => void;
} }
export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: AssignBarProps) { export function AssignBar({
orders,
branchOf,
assigned,
released,
onClear,
onDone,
}: AssignBarProps) {
const client = useQueryClient(); const client = useQueryClient();
const [riderId, setRiderId] = useState(''); const [riderId, setRiderId] = useState('');
const [outcome, setOutcome] = useState<string | null>(null); const [outcome, setOutcome] = useState<string | null>(null);
@@ -103,7 +113,7 @@ export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: Assig
const send = useMutation({ const send = useMutation({
mutationFn: async () => { mutationFn: async () => {
if (!rider) throw new Error('Pick a rider first'); if (!rider) throw new Error('Pick a rider first');
const { drafts, skipped } = buildDeliveries(orders, rider, branchOf, new Date(), assigned); const { drafts, skipped } = buildDeliveries(orders, rider, branchOf, new Date(), assigned, released);
if (drafts.length === 0) { if (drafts.length === 0) {
throw new Error(skipped[0]?.reason ?? 'None of these orders can be assigned'); throw new Error(skipped[0]?.reason ?? 'None of these orders can be assigned');
} }
@@ -205,6 +215,25 @@ export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: Assig
/> />
</div> </div>
{/*
Said before the hand-off, not after it.
A rider who has never opened the app has no device to push to, so the
job lands in a queue nobody is told about. That used to surface as
"NOT notified" in the outcome line AFTER the deliveries were written —
by which point the only remedy is a phone call, and one navigation
later there was no record of it at all.
It disables nothing. Assigning to a rider you are about to ring is a
legitimate thing to do; being surprised by it afterwards is not.
*/}
{rider && hasNoDevice(rider) ? (
<span className="assign-bar-warn" role="status">
<TriangleAlert size={14} />
No app on this rider&rsquo;s phone yet — they will not be told. Call them.
</span>
) : null}
{/* Two ways to commit the same selection, and the difference is worth the {/* Two ways to commit the same selection, and the difference is worth the
second button: "Assign" hands the orders over as they are, which is second button: "Assign" hands the orders over as they are, which is
right for one or two. "Plan the route" sequences them first — real right for one or two. "Plan the route" sequences them first — real

View File

@@ -54,6 +54,7 @@ export function AutoAssignDrawer({
branches, branches,
fleet, fleet,
assigned, assigned,
released,
onClose, onClose,
onDone, onDone,
}: { }: {
@@ -63,6 +64,8 @@ export function AutoAssignDrawer({
/** Riders we know about, for the absentee picker and for naming. */ /** Riders we know about, for the absentee picker and for naming. */
fleet: readonly RiderInfo[]; fleet: readonly RiderInfo[];
assigned: ReadonlySet<number>; assigned: ReadonlySet<number>;
/** Orders whose delivery is dead — see `releasedFrom`. */
released?: ReadonlySet<number>;
onClose: () => void; onClose: () => void;
onDone: () => void; onDone: () => void;
}) { }) {
@@ -114,7 +117,7 @@ export function AutoAssignDrawer({
// rest of the roster row matters for notifying, which happens after. // rest of the roster row matters for notifying, which happens after.
const rider = (fleet.find((r) => r.userid === proposal.userid) ?? const rider = (fleet.find((r) => r.userid === proposal.userid) ??
({ userid: proposal.userid } as RiderInfo)); ({ userid: proposal.userid } as RiderInfo));
return buildDeliveries(rows, rider, branchOf, new Date(), assigned).drafts; return buildDeliveries(rows, rider, branchOf, new Date(), assigned, released).drafts;
}); });
if (drafts.length === 0) { if (drafts.length === 0) {

View File

@@ -7,6 +7,7 @@ import { deliveriesApi } from '@/api/deliveries';
import type { DeliveryRow } from '@/api/types'; import type { DeliveryRow } from '@/api/types';
import { queryKeys } from '@/queries/keys'; import { queryKeys } from '@/queries/keys';
import { stampNow } from './assignDelivery'; import { stampNow } from './assignDelivery';
import { isSettled, isStalled, matchesStatus } from './orderStatus';
/** /**
* Moving a delivery along from the back office. * Moving a delivery along from the back office.
@@ -37,8 +38,12 @@ const MOVES = [
{ to: 'cancelled', label: 'Cancelled', stamp: 'canceltime' }, { to: 'cancelled', label: 'Cancelled', stamp: 'canceltime' },
] as const; ] as const;
/** The end states. Nothing follows them, so nothing is offered. */ /** What to do about a stalled job, which is never "nothing". */
const SETTLED = ['delivered', 'cancelled']; function stalledAdvice(status: string): string {
return matchesStatus('rejected', status)
? 'This rider declined the job. The order is waiting for a rider again — assign it to somebody else.'
: 'The rider could not hand this over. It is still theirs to re-attempt; reassign it if somebody else should take it.';
}
/** /**
* The moves, split from their rendering. * The moves, split from their rendering.
@@ -54,7 +59,8 @@ export function useDeliveryMoves(job: DeliveryRow) {
const [problem, setProblem] = useState<string | null>(null); const [problem, setProblem] = useState<string | null>(null);
const status = (job.orderstatus ?? '').trim().toLowerCase(); const status = (job.orderstatus ?? '').trim().toLowerCase();
const isSettled = SETTLED.includes(status); const settled = isSettled(status);
const stalled = isStalled(status);
const move = useMutation({ const move = useMutation({
mutationFn: ({ to, stamp }: { to: string; stamp: string }) => mutationFn: ({ to, stamp }: { to: string; stamp: string }) =>
@@ -70,7 +76,8 @@ export function useDeliveryMoves(job: DeliveryRow) {
return { return {
status, status,
isSettled, isSettled: settled,
isStalled: stalled,
isPending: move.isPending, isPending: move.isPending,
problem, problem,
moves: MOVES, moves: MOVES,
@@ -86,7 +93,8 @@ export function DeliveryProgress({ job }: { job: DeliveryRow }) {
const [problem, setProblem] = useState<string | null>(null); const [problem, setProblem] = useState<string | null>(null);
const status = (job.orderstatus ?? '').trim().toLowerCase(); const status = (job.orderstatus ?? '').trim().toLowerCase();
const isSettled = SETTLED.includes(status); const settled = isSettled(status);
const stalled = isStalled(status);
const move = useMutation({ const move = useMutation({
mutationFn: ({ to, stamp }: { to: string; stamp: string }) => mutationFn: ({ to, stamp }: { to: string; stamp: string }) =>
@@ -100,7 +108,7 @@ export function DeliveryProgress({ job }: { job: DeliveryRow }) {
onError: (error) => setProblem(errorMessage(error)), onError: (error) => setProblem(errorMessage(error)),
}); });
if (isSettled) { if (settled) {
return ( return (
<Text type="body" size="xsm" color="secondary"> <Text type="body" size="xsm" color="secondary">
This job is {status}. Nothing further to record. This job is {status}. Nothing further to record.
@@ -108,6 +116,14 @@ export function DeliveryProgress({ job }: { job: DeliveryRow }) {
); );
} }
if (stalled) {
return (
<Text type="body" size="xsm" color="secondary">
This job is {status}. {stalledAdvice(status)}
</Text>
);
}
return ( return (
<VStack gap={1}> <VStack gap={1}>
<div className="progress-moves"> <div className="progress-moves">

View File

@@ -1,6 +1,7 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { ArrowDown, Bike, Check, MapPin, Phone, X } from 'lucide-react'; import { ArrowDown, Bike, Check, MapPin, Phone, X } from 'lucide-react';
import type { DeliveryRow, OrderRow } from '@/api/types'; import type { DeliveryRow, OrderRow } from '@/api/types';
import { orderStage, type Stage } from './orderProgress';
import { useDeliveryMoves } from './DeliveryProgress'; import { useDeliveryMoves } from './DeliveryProgress';
import { Drawer } from './Drawer'; import { Drawer } from './Drawer';
import { Badge, DrawerButton, DrawerCard, Metric, Metrics, Note, Row, Section } from './drawerKit'; import { Badge, DrawerButton, DrawerCard, Metric, Metrics, Note, Row, Section } from './drawerKit';
@@ -47,10 +48,21 @@ const STEPS: { label: string; fields: string[] }[] = [
export function OrderDetailDrawer({ export function OrderDetailDrawer({
row, row,
kind, kind,
stages,
onClose, onClose,
}: { }: {
row: Row_; row: Row_;
kind: RowKind; kind: RowKind;
/**
* Where each order's delivery has got to — see `orderProgress.ts`.
*
* Passed in rather than looked up, so the drawer and the table it opened
* from cannot disagree: the row said "Picked" and the drawer said "Pending"
* would be exactly the table/drawer mismatch this console has been cleaning
* up. Optional because the dispatch board opens deliveries directly, and a
* delivery row already carries its own stage.
*/
stages?: ReadonlyMap<number, string>;
onClose: () => void; onClose: () => void;
}) { }) {
/* /*
@@ -65,7 +77,7 @@ export function OrderDetailDrawer({
return kind === 'delivery' ? ( return kind === 'delivery' ? (
<DeliveryDrawer job={row as DeliveryRow} onClose={onClose} /> <DeliveryDrawer job={row as DeliveryRow} onClose={onClose} />
) : ( ) : (
<Sheet row={row} isDelivery={false} onClose={onClose} /> <Sheet row={row} isDelivery={false} stage={orderStage(row as OrderRow, stages ?? EMPTY_STAGES)} onClose={onClose} />
); );
} }
@@ -76,7 +88,7 @@ function DeliveryDrawer({ job, onClose }: { job: DeliveryRow; onClose: () => voi
row={job} row={job}
isDelivery isDelivery
onClose={onClose} onClose={onClose}
{...(moves.isSettled ? {} : { footer: <MoveActions moves={moves} /> })} {...(moves.isSettled || moves.isStalled ? {} : { footer: <MoveActions moves={moves} /> })}
caption={<MoveCaption moves={moves} />} caption={<MoveCaption moves={moves} />}
/> />
); );
@@ -84,14 +96,21 @@ function DeliveryDrawer({ job, onClose }: { job: DeliveryRow; onClose: () => voi
type Moves = ReturnType<typeof useDeliveryMoves>; type Moves = ReturnType<typeof useDeliveryMoves>;
/** A drawer opened without the index falls back to the order's own word. */
const EMPTY_STAGES: ReadonlyMap<number, string> = new Map();
function Sheet({ function Sheet({
row, row,
isDelivery, isDelivery,
stage,
onClose, onClose,
footer, footer,
caption, caption,
}: { }: {
row: Row_; row: Row_;
/** For an order: the stage its delivery reports. Absent on a delivery row,
whose `orderstatus` IS the delivery status already. */
stage?: Stage;
/** Passed explicitly, never inferred from the presence of a footer — a /** Passed explicitly, never inferred from the presence of a footer — a
settled job has no actions and would otherwise read as an order. */ settled job has no actions and would otherwise read as an order. */
isDelivery: boolean; isDelivery: boolean;
@@ -108,8 +127,10 @@ function Sheet({
const order = delivery ? undefined : (row as OrderRow); const order = delivery ? undefined : (row as OrderRow);
const title = row.orderid || (job ? `DLV-${job.deliveryid}` : `#${order?.orderheaderid}`); const title = row.orderid || (job ? `DLV-${job.deliveryid}` : `#${order?.orderheaderid}`);
const status = row.orderstatus; /* An order shows the stage its delivery reports, which may be a word from the
const colour = statusColor(delivery ? DELIVERY_STATUS : ORDER_STATUS, status); delivery ladder — so the ladder follows the word, not the row kind. */
const status = stage ? stage.status : row.orderstatus;
const colour = statusColor(delivery || stage?.isDelivery ? DELIVERY_STATUS : ORDER_STATUS, status);
const value = job ? (job.deliveryamt ?? 0) : orderValue(row); const value = job ? (job.deliveryamt ?? 0) : orderValue(row);
const cancelled = (status ?? '').toLowerCase().includes('cancel'); const cancelled = (status ?? '').toLowerCase().includes('cancel');
@@ -278,9 +299,9 @@ function MoveActions({ moves: state }: { moves: Moves }) {
} }
function MoveCaption({ moves }: { moves: Moves }) { function MoveCaption({ moves }: { moves: Moves }) {
const { isSettled, problem, status } = moves; const { isSettled, isStalled, problem, status } = moves;
if (isSettled) { if (isSettled || isStalled) {
return <Note>This job is {status}. Nothing further to record.</Note>; return <Note>This job is {status}. Nothing further to record.</Note>;
} }
if (problem) { if (problem) {

View File

@@ -61,6 +61,8 @@ export interface RoutePlanDrawerProps {
rider: RiderInfo; rider: RiderInfo;
branchOf: (row: OrderRow) => TenantLocation | undefined; branchOf: (row: OrderRow) => TenantLocation | undefined;
assigned: ReadonlySet<number>; assigned: ReadonlySet<number>;
/** Orders whose delivery is dead — see `releasedFrom`. */
released?: ReadonlySet<number>;
onClose: () => void; onClose: () => void;
onDone: () => void; onDone: () => void;
} }
@@ -70,6 +72,7 @@ export function RoutePlanDrawer({
rider, rider,
branchOf, branchOf,
assigned, assigned,
released,
onClose, onClose,
onDone, onDone,
}: RoutePlanDrawerProps) { }: RoutePlanDrawerProps) {
@@ -130,7 +133,7 @@ export function RoutePlanDrawer({
// fills `deliveries.kms` / `actualkms` with real numbers instead of the // fills `deliveries.kms` / `actualkms` with real numbers instead of the
// literal "null" found in production. // literal "null" found in production.
const ordered = plan.riders.flatMap((r) => r.orders) as OrderRow[]; const ordered = plan.riders.flatMap((r) => r.orders) as OrderRow[];
const { drafts } = buildDeliveries(ordered, rider, branchOf, new Date(), assigned); const { drafts } = buildDeliveries(ordered, rider, branchOf, new Date(), assigned, released);
if (drafts.length === 0) throw new Error('None of these orders can be assigned'); if (drafts.length === 0) throw new Error('None of these orders can be assigned');
await deliveriesApi.assign(drafts); await deliveriesApi.assign(drafts);

View File

@@ -10,6 +10,7 @@ import {
buildDelivery, buildDelivery,
isUnassigned, isUnassigned,
needsDelivery, needsDelivery,
releasedFrom,
riderName, riderName,
riderVehicle, riderVehicle,
stampNow, stampNow,
@@ -326,3 +327,87 @@ test('the vehicle line is empty rather than a stray separator when nothing is re
/* ── Which fleet an order is offered ──────────────────────────────────────── */ /* ── Which fleet an order is offered ──────────────────────────────────────── */
/* ── A dead delivery gives the order back ─────────────────────────────────
Gap A. A rider rejects a job; the delivery row stays behind; `deliveryid`
is still set on the order and the order is still in the deliveries list.
Both signals said "assigned", so the order left the waiting list for good
while its own status still read pending — the backend mirrors only
pending/delivered/cancelled onto an order, so a rejection never reaches it. */
test('a rejected delivery returns its order to the waiting list', () => {
const deliveries = [{ orderheaderid: 41, orderstatus: 'rejected' }];
const released = releasedFrom(deliveries);
assert.ok(released.has(41));
assert.equal(
isUnassigned({ orderheaderid: 41, deliveryid: 900 } as OrderRow, assignedFrom(deliveries), released),
true,
'the delivery row left behind must not keep the order out of the pool',
);
});
test('a cancelled delivery does the same', () => {
const released = releasedFrom([{ orderheaderid: 42, orderstatus: 'Cancelled' }]);
assert.ok(released.has(42), 'casing is not the console it came from');
});
test('a live delivery still holds its order', () => {
for (const status of ['pending', 'accepted', 'arrived', 'picked', 'active']) {
const released = releasedFrom([{ orderheaderid: 43, orderstatus: status }]);
assert.equal(released.has(43), false, `${status} is somebody's job right now`);
}
});
test('skipped does NOT release — the rider still holds it', () => {
// The rider reached the address and nobody was in. The job is still theirs
// and still re-attemptable; releasing it would offer the same order to a
// second rider while the first is standing outside the door with it.
const released = releasedFrom([{ orderheaderid: 44, orderstatus: 'skipped' }]);
assert.equal(released.has(44), false);
});
test('a reassigned order is held by its LIVE delivery, not released by its dead one', () => {
// Two rows for one order: the rejection, and the reassignment after it.
// Releasing on the strength of the rejected row would offer a job that is
// already out with somebody. Order of arrival must not matter.
const forwards = releasedFrom([
{ orderheaderid: 45, orderstatus: 'rejected' },
{ orderheaderid: 45, orderstatus: 'accepted' },
]);
const backwards = releasedFrom([
{ orderheaderid: 45, orderstatus: 'accepted' },
{ orderheaderid: 45, orderstatus: 'rejected' },
]);
assert.equal(forwards.has(45), false);
assert.equal(backwards.has(45), false);
});
test('a released order is offered by awaitingRider again', () => {
const rows = [
{ orderheaderid: 41, deliveryid: 900, orderstatus: 'pending', deliveryaddress: '12 Mill St', orderdate: '2026-09-20' },
] as OrderRow[];
const deliveries = [{ orderheaderid: 41, orderstatus: 'rejected' }];
assert.deepEqual(
awaitingRider(rows, assignedFrom(deliveries)).map((r) => r.orderheaderid),
[],
'the bug: without the released set the order is invisible',
);
assert.deepEqual(
awaitingRider(rows, assignedFrom(deliveries), releasedFrom(deliveries)).map((r) => r.orderheaderid),
[41],
'the fix',
);
});
test('a released order can actually be assigned again', () => {
const row = { orderheaderid: 41, deliveryid: 900, locationid: 5, applocationid: 7, deliveryaddress: '12 Mill St' } as OrderRow;
const deliveries = [{ orderheaderid: 41, orderstatus: 'rejected' }];
assert.equal(assignability(row, undefined, assignedFrom(deliveries)).canAssign, false);
assert.equal(
assignability(row, undefined, assignedFrom(deliveries), releasedFrom(deliveries)).canAssign,
true,
);
});

View File

@@ -35,7 +35,30 @@ import type { OrderRow, RiderInfo, TenantLocation } from '@/api/types';
* rider's `app_users` row and is empty on plenty of orders that DO have a * rider's `app_users` row and is empty on plenty of orders that DO have a
* delivery, so testing it would offer to assign jobs already out with somebody. * delivery, so testing it would offer to assign jobs already out with somebody.
*/ */
export function isUnassigned(row: OrderRow, assigned?: ReadonlySet<number>): boolean { export function isUnassigned(
row: OrderRow,
assigned?: ReadonlySet<number>,
released?: ReadonlySet<number>,
): boolean {
/*
* A dead delivery outranks both signals above.
*
* A rider who rejects a job leaves the delivery row behind, and neither
* signal can tell it from a live one: `deliveryid` is still set, and the
* order is still in the deliveries list. The order then fell out of the
* unassigned pool permanently while its own status still read pending — no
* assign bar would offer it again, and the only way back was to find the
* dead delivery and reassign from its drawer.
*
* `isFinished` did not catch it either: it reads the ORDER status, and the
* backend mirrors only pending, delivered and cancelled from the delivery
* side. A rejection never reaches the order at all.
*
* This is checked FIRST because it is positive evidence. The two signals
* below answer "is there a delivery"; this one answers "is it still alive",
* and a dead one means nobody is carrying this order.
*/
if (released?.has(row.orderheaderid)) return true;
if (row.deliveryid) return false; if (row.deliveryid) return false;
return !assigned?.has(row.orderheaderid); return !assigned?.has(row.orderheaderid);
} }
@@ -52,6 +75,47 @@ export function assignedFrom(deliveries: readonly { orderheaderid?: number }[]):
return out; return out;
} }
/**
* A delivery nobody is carrying any more.
*
* `rejected` is the rider declining the job; `cancelled` is it being called
* off. Either way the order needs somebody else, and the delivery row left
* behind is what used to hide that.
*
* `skipped` is deliberately NOT here. The rider reached the address and nobody
* was in — the job is still theirs, still in their app, and still re-attemptable.
* Releasing it would offer the same order to a second rider while the first is
* standing outside the door with it.
*/
function isDeadDelivery(status: string | undefined): boolean {
const s = (status ?? '').trim().toLowerCase();
return s.includes('reject') || s.includes('declin') || s.includes('cancel');
}
/**
* The orders whose deliveries are all dead, and which therefore need a rider again.
*
* EVERY delivery for the order has to be dead. An order reassigned after a
* rejection has two rows — one rejected, one live — and releasing it on the
* strength of the rejected one would offer a job that is already out with
* somebody. The live row wins, whatever order they arrive in.
*/
export function releasedFrom(
deliveries: readonly { orderheaderid?: number; orderstatus?: string }[],
): Set<number> {
const live = new Set<number>();
const dead = new Set<number>();
for (const row of deliveries) {
if (!row.orderheaderid) continue;
if (isDeadDelivery(row.orderstatus)) dead.add(row.orderheaderid);
else live.add(row.orderheaderid);
}
for (const id of live) dead.delete(id);
return dead;
}
/** /**
* Does this order need a rider at all? * Does this order need a rider at all?
* *
@@ -74,9 +138,13 @@ export function needsDelivery(row: OrderRow): boolean {
export function awaitingRider( export function awaitingRider(
rows: readonly OrderRow[], rows: readonly OrderRow[],
assigned?: ReadonlySet<number>, assigned?: ReadonlySet<number>,
released?: ReadonlySet<number>,
): OrderRow[] { ): OrderRow[] {
return rows return rows
.filter((row) => isUnassigned(row, assigned) && needsDelivery(row) && !isFinished(row)) .filter(
(row) =>
isUnassigned(row, assigned, released) && needsDelivery(row) && !isFinished(row),
)
.sort((a, b) => placedAt(a) - placedAt(b)); .sort((a, b) => placedAt(a) - placedAt(b));
} }
@@ -125,6 +193,7 @@ export function assignability(
row: OrderRow, row: OrderRow,
branch: TenantLocation | undefined, branch: TenantLocation | undefined,
assigned?: ReadonlySet<number>, assigned?: ReadonlySet<number>,
released?: ReadonlySet<number>,
): Assignability { ): Assignability {
const applocationid = row.applocationid || branch?.applocationid || 0; const applocationid = row.applocationid || branch?.applocationid || 0;
@@ -144,7 +213,7 @@ export function assignability(
if (!needsDelivery(row)) { if (!needsDelivery(row)) {
return { canAssign: false, reason: 'This order has no delivery address.' }; return { canAssign: false, reason: 'This order has no delivery address.' };
} }
if (!isUnassigned(row, assigned)) { if (!isUnassigned(row, assigned, released)) {
return { canAssign: false, reason: 'A rider is already on this one.' }; return { canAssign: false, reason: 'A rider is already on this one.' };
} }
return { canAssign: true, reason: '' }; return { canAssign: true, reason: '' };
@@ -320,13 +389,14 @@ export function buildDeliveries(
branchOf: (row: OrderRow) => TenantLocation | undefined, branchOf: (row: OrderRow) => TenantLocation | undefined,
now = new Date(), now = new Date(),
assigned?: ReadonlySet<number>, assigned?: ReadonlySet<number>,
released?: ReadonlySet<number>,
): { drafts: DeliveryDraft[]; skipped: { row: OrderRow; reason: string }[] } { ): { drafts: DeliveryDraft[]; skipped: { row: OrderRow; reason: string }[] } {
const drafts: DeliveryDraft[] = []; const drafts: DeliveryDraft[] = [];
const skipped: { row: OrderRow; reason: string }[] = []; const skipped: { row: OrderRow; reason: string }[] = [];
for (const row of rows) { for (const row of rows) {
const branch = branchOf(row); const branch = branchOf(row);
const verdict = assignability(row, branch, assigned); const verdict = assignability(row, branch, assigned, released);
if (verdict.canAssign) { if (verdict.canAssign) {
drafts.push(buildDelivery(row, rider, branch, now)); drafts.push(buildDelivery(row, rider, branch, now));
} else { } else {

View File

@@ -0,0 +1,64 @@
/**
* Which delivery statuses the drawer may still act on.
*
* Gap E. `SETTLED` was the literal list `['delivered', 'cancelled']`, so a job
* the rider had REJECTED or SKIPPED fell through to the live branch and the
* drawer offered "Picked up" and "Delivered" under it. Pressing one recorded a
* delivery that never happened.
*
* The rules now come from `orderStatus.ts`, the vocabulary the rest of the
* console already uses, so no screen can grow a fourth opinion about which
* words mean finished. These tests pin that agreement.
*
* They import the real predicates rather than rebuilding them. Written first
* as local copies — which passed happily while `DispatchPage` went on using
* its own three-word exclusion, because a test of a copy proves nothing about
* the original.
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { isLive, isSettled, isStalled } from './orderStatus';
test('the ladder is offered only while a job is genuinely live', () => {
for (const status of ['pending', 'accepted', 'arrived', 'picked', 'active']) {
assert.equal(isLive(status), true, `${status} is still moving`);
}
});
test('nothing is offered once a job is finished', () => {
for (const status of ['delivered', 'cancelled']) {
assert.equal(isSettled(status), true);
assert.equal(isLive(status), false);
}
});
test('a rejected or skipped job is stopped, not live — this is the bug', () => {
// Both used to read as live, so the drawer put "Delivered" under a job the
// rider had already declined.
for (const status of ['rejected', 'skipped']) {
assert.equal(isStalled(status), true, `${status} must not offer the ladder`);
assert.equal(isLive(status), false);
assert.equal(isSettled(status), false, 'stopped is not the same as done');
}
});
test('stopped and finished stay distinct, because the next action differs', () => {
// Finished needs nothing. Stopped needs a rider — which is why they cannot
// share a branch even though neither offers the ladder.
assert.equal(isSettled('rejected'), false);
assert.equal(isStalled('delivered'), false);
});
test('the casing and wording the app actually writes', () => {
// Free text from the rider's app; casing varies between writers.
assert.equal(isStalled('Rejected'), true);
assert.equal(isStalled('SKIPPED'), true);
assert.equal(isStalled('declined'), true);
assert.equal(isSettled('Delivered'), true);
});
test('"out for delivery" is live, not delivered', () => {
// The trap in the shared matcher, pinned here because this file depends on it.
assert.equal(isSettled('out for delivery'), false);
assert.equal(isLive('out for delivery'), true);
});

View File

@@ -1,6 +1,6 @@
import type { CustomerInfo } from '@/api/customers'; import type { CustomerInfo } from '@/api/customers';
import type { DeliveryRow, OrderRow, TenantLocation } from '@/api/types'; import type { DeliveryRow, OrderRow, TenantLocation } from '@/api/types';
import { awaitingRider, assignedFrom } from './assignDelivery'; import { awaitingRider, assignedFrom, releasedFrom } from './assignDelivery';
import { isRealName } from './orderStatus'; import { isRealName } from './orderStatus';
/** /**
@@ -152,9 +152,12 @@ export function toStops(
orders: readonly OrderRow[], orders: readonly OrderRow[],
): Stop[] { ): Stop[] {
const assigned = assignedFrom(deliveries); const assigned = assignedFrom(deliveries);
/* An order whose only delivery was rejected or called off is waiting again,
however much the delivery row left behind looks like an assignment. */
const released = releasedFrom(deliveries);
return [ return [
...deliveries.map((row): Stop => ({ kind: 'delivery', row })), ...deliveries.map((row): Stop => ({ kind: 'delivery', row })),
...awaitingRider(orders, assigned).map((row): Stop => ({ kind: 'order', row })), ...awaitingRider(orders, assigned, released).map((row): Stop => ({ kind: 'order', row })),
]; ];
} }

View File

@@ -0,0 +1,92 @@
/**
* The Fast moving / Slow moving overlap.
*
* Flagged against live data: on a shop with under about ten products the two
* panels printed the same product under both headings. These tests are mostly
* about small catalogues, because that is where every version of this goes
* wrong and where the real tenants are.
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { movers } from './inventoryMovers';
/** A product: id, units sold, units still on the shelf. */
const p = (productid: number, debit: number, closing = 10) => ({ productid, debit, closing });
const ids = (rows: { productid: number }[]) => rows.map((row) => row.productid);
test('a product is never both the best and the worst seller', () => {
// Nine products: the old code put #5 in both lists.
const rows = Array.from({ length: 9 }, (_, i) => p(i + 1, 100 - i * 10));
const { fast, slow } = movers(rows);
const overlap = ids(fast).filter((id) => ids(slow).includes(id));
assert.deepEqual(overlap, [], 'the two lists share nothing');
});
test('no product appears twice, at any catalogue size', () => {
for (let n = 0; n <= 20; n += 1) {
const rows = Array.from({ length: n }, (_, i) => p(i + 1, n - i));
const { fast, slow } = movers(rows);
const all = [...ids(fast), ...ids(slow)];
assert.equal(new Set(all).size, all.length, `${n} products produced a duplicate`);
}
});
test('neither list takes more than half of a short catalogue', () => {
const { fast, slow } = movers(Array.from({ length: 6 }, (_, i) => p(i + 1, 60 - i * 10)));
assert.equal(fast.length, 3);
assert.equal(slow.length, 3);
});
test('a full catalogue still gets five and five', () => {
const { fast, slow } = movers(Array.from({ length: 40 }, (_, i) => p(i + 1, 400 - i)));
assert.equal(fast.length, 5);
assert.equal(slow.length, 5);
});
test('fast is the best sellers, slow is the worst, slowest first', () => {
const rows = [p(1, 5), p(2, 90), p(3, 50), p(4, 1), p(5, 70), p(6, 20)];
const { fast, slow } = movers(rows, 2);
assert.deepEqual(ids(fast), [2, 5], 'highest first');
assert.deepEqual(ids(slow), [4, 1], 'lowest first — this is a worklist');
});
test('a product that sold nothing is not fast moving', () => {
// Four products, all stocked, two of them never sold. The old top-five
// listed every one of them as fast moving.
const { fast } = movers([p(1, 7), p(2, 3), p(3, 0), p(4, 0)]);
assert.deepEqual(ids(fast), [1, 2]);
});
test('a sold-out product is not slow moving — it is out of stock', () => {
// Telling a shopkeeper to discount something they have none of is wrong
// advice, not merely useless.
const rows = [p(1, 90), p(2, 50), p(3, 2, 0), p(4, 1)];
const { slow } = movers(rows, 2);
assert.equal(ids(slow).includes(3), false);
assert.deepEqual(ids(slow), [4]);
});
test('equal sellers keep a stable order between refetches', () => {
// Six, not three: the half-rule caps a three-product shop at one per list,
// which would pass this by accident rather than by the tie-break.
const { fast } = movers([p(6, 10), p(3, 10), p(1, 10), p(5, 10), p(2, 10), p(4, 10)], 3);
assert.deepEqual(ids(fast), [1, 2, 3], 'tie broken by id, not by arrival');
});
test('missing numbers are zero, not NaN', () => {
const rows = [{ productid: 1 }, { productid: 2, debit: 5, closing: 5 }];
const { fast, slow } = movers(rows);
assert.deepEqual(ids(fast), [2]);
assert.deepEqual(ids(slow), [], 'the row with no stock record is not ranked');
});
test('one product is listed as moving, not as both', () => {
const { fast, slow } = movers([p(1, 4)]);
assert.deepEqual(ids(fast), [1]);
assert.deepEqual(ids(slow), []);
});
test('an empty shop produces two empty lists, not an error', () => {
assert.deepEqual(movers([]), { fast: [], slow: [] });
});

View File

@@ -0,0 +1,71 @@
/**
* The two ends of an inventory list, which must not be the same end.
*
* "Fast moving" and "Slow moving" were `sorted.slice(0, 5)` and
* `sorted.filter(inStock).slice(-5)`. Two slices off one list: fine for a
* supermarket, wrong for a shop with nine products, where the fifth item is
* simultaneously in the top five and the bottom five and gets printed under
* both headings. A product listed as both the best and the worst seller is the
* kind of thing that stops a person believing the rest of the page.
*
* Three rules fix it:
*
* - A product taken for `fast` can never appear in `slow`. Stated as an
* exclusion rather than left to arithmetic, because the two lists draw on
* different populations and the arithmetic is only accidentally right.
* - Neither list may take more than half of what there is, so the two ends of
* a short catalogue cannot meet in the middle.
* - Nothing that sold zero units is "fast moving", whatever its rank. On a
* shop with four products the old top-five included all of them.
*
* Pure, so the short-catalogue cases can be tested without a tenant.
*/
/** The fields this reads. Callers pass their own richer row through. */
export interface Mover {
productid: number;
/** Units that went off the shelf in the period. */
debit?: number;
/** Units still on it. */
closing?: number;
}
/** Five each, which is what the two panels have room for. */
export const DEFAULT_LIMIT = 5;
/**
* Best and worst sellers, guaranteed disjoint.
*
* `slow` is drawn from products with stock left: a product that sold nothing
* because there was none to sell is not slow moving, it is out of stock, and
* telling a shopkeeper to discount it would be wrong advice. `fast` is drawn
* from products that actually moved, for the mirror reason.
*/
export function movers<T extends Mover>(
rows: readonly T[],
limit: number = DEFAULT_LIMIT,
): { fast: T[]; slow: T[] } {
/* `productid` breaks ties so the lists are stable between renders — without
it two products on equal sales swap places on every refetch. */
const ranked = [...rows].sort(
(a, b) => (b.debit ?? 0) - (a.debit ?? 0) || a.productid - b.productid,
);
/* No list may exceed half the catalogue. `max(1, …)` so a one-product shop
still says something rather than rendering two empty panels. */
const room = Math.max(1, Math.floor(ranked.length / 2));
const cap = Math.min(limit, room);
const fast = ranked.filter((row) => (row.debit ?? 0) > 0).slice(0, cap);
const taken = new Set(fast.map((row) => row.productid));
const slow = ranked
.filter((row) => (row.closing ?? 0) > 0 && !taken.has(row.productid))
.slice(-cap)
/* Slowest first: this is a worklist, and the worst seller is the one to
deal with. The rest of the page is newest-first; this is not the rest of
the page. */
.reverse();
return { fast, slow };
}

View File

@@ -0,0 +1,136 @@
/**
* Gap D: an order row that cannot say where its delivery has got to.
*
* The backend mirrors three of the nine delivery statuses onto the order, so
* every stage between "assigned" and "delivered" reads as `pending`. These
* tests pin the join that recovers the missing six, and — just as importantly —
* the three cases where the order's own word must be left alone.
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
import type { DeliveryRow, OrderRow } from '@/api/types';
import { deliveryStageFrom, isUnmirrored, orderStage } from './orderProgress';
const order = (over: Partial<OrderRow> = {}) =>
({ orderheaderid: 1, orderstatus: 'pending', ...over }) as OrderRow;
const delivery = (over: Partial<DeliveryRow> = {}) =>
({ deliveryid: 10, orderheaderid: 1, orderstatus: 'pending', ...over }) as DeliveryRow;
const stageOf = (o: OrderRow, ds: DeliveryRow[]) => orderStage(o, deliveryStageFrom(ds));
/* ── Which stages the order row can never have learned ─────────────────── */
test('the six stages the backend does not mirror', () => {
for (const status of ['accepted', 'arrived', 'picked', 'active', 'skipped', 'rejected']) {
assert.equal(isUnmirrored(status), true, `${status} never reaches the order`);
}
});
test('the three it does mirror are left to the order', () => {
for (const status of ['pending', 'delivered', 'cancelled']) {
assert.equal(isUnmirrored(status), false, `${status} arrives on its own`);
}
});
/* ── The bug itself ────────────────────────────────────────────────────── */
test('a rider at the door no longer reads as pending', () => {
// The whole point. Order says pending because that is all it is ever told.
const stage = stageOf(order({ orderstatus: 'pending' }), [delivery({ orderstatus: 'arrived' })]);
assert.equal(stage.status, 'arrived');
assert.equal(stage.isDelivery, true, 'and it must be coloured with the delivery ladder');
});
test('an untouched job and a job in progress are now different rows', () => {
const untouched = stageOf(order(), []);
const moving = stageOf(order(), [delivery({ orderstatus: 'picked' })]);
assert.notEqual(untouched.status, moving.status);
});
test('a rider declining is visible on the order — it was not before', () => {
const stage = stageOf(order(), [delivery({ orderstatus: 'rejected' })]);
assert.equal(stage.status, 'rejected');
});
/* ── Where the order's own word must win ───────────────────────────────── */
test('a delivered order is not dragged back by a stale delivery row', () => {
const stage = stageOf(order({ orderstatus: 'delivered' }), [delivery({ orderstatus: 'picked' })]);
assert.equal(stage.status, 'delivered');
assert.equal(stage.isDelivery, false);
});
test('a cancelled order stays cancelled', () => {
const stage = stageOf(order({ orderstatus: 'cancelled' }), [delivery({ orderstatus: 'active' })]);
assert.equal(stage.status, 'cancelled');
});
test('a mirrored stage changes nothing, so the chip does not flicker between ladders', () => {
const stage = stageOf(order({ orderstatus: 'pending' }), [delivery({ orderstatus: 'pending' })]);
assert.equal(stage.status, 'pending');
assert.equal(stage.isDelivery, false, 'no reason to switch ladder for the same word');
});
test('an order with no delivery falls back to exactly what the table showed before', () => {
const stage = stageOf(order({ orderstatus: 'created' }), []);
assert.deepEqual(stage, { status: 'created', isDelivery: false });
});
test('a deliveries list that does not reach this order degrades, it does not lie', () => {
// If the two lists ever cover different windows, the worst case must be the
// old behaviour rather than a stage borrowed from somebody else's order.
const stage = stageOf(order({ orderheaderid: 77 }), [delivery({ orderheaderid: 1, orderstatus: 'picked' })]);
assert.equal(stage.status, 'pending');
});
/* ── Several deliveries on one order ───────────────────────────────────── */
test('a reassigned order reads from the live row, not the rejected one', () => {
// The failure this rule exists for: newest-wins reads a reassigned order back
// as "rejected" whenever the dead row happens to sort later.
const rejected = delivery({ deliveryid: 99, orderstatus: 'rejected', assigntime: '2026-09-21 12:00:00' });
const live = delivery({ deliveryid: 10, orderstatus: 'accepted', assigntime: '2026-09-21 09:00:00' });
assert.equal(stageOf(order(), [rejected, live]).status, 'accepted');
assert.equal(stageOf(order(), [live, rejected]).status, 'accepted', 'whatever order they arrive in');
});
test('among live rows the latest assignment speaks', () => {
const older = delivery({ deliveryid: 10, orderstatus: 'accepted', assigntime: '2026-09-21 09:00:00' });
const newer = delivery({ deliveryid: 11, orderstatus: 'picked', assigntime: '2026-09-21 11:00:00' });
assert.equal(stageOf(order(), [older, newer]).status, 'picked');
});
test('when every row is dead the latest one is still worth showing', () => {
const first = delivery({ deliveryid: 10, orderstatus: 'rejected', assigntime: '2026-09-21 09:00:00' });
const second = delivery({ deliveryid: 11, orderstatus: 'skipped', assigntime: '2026-09-21 11:00:00' });
assert.equal(stageOf(order(), [first, second]).status, 'skipped');
});
test('an unreadable assigntime falls to the id rather than to 1970', () => {
const a = delivery({ deliveryid: 10, orderstatus: 'accepted', assigntime: '' });
const b = delivery({ deliveryid: 11, orderstatus: 'picked', assigntime: 'not a date' });
assert.equal(stageOf(order(), [a, b]).status, 'picked', 'higher id is the later row');
});
/* ── Rows that cannot be joined ────────────────────────────────────────── */
test('a delivery with no orderheaderid is skipped, not filed under zero', () => {
const stages = deliveryStageFrom([delivery({ orderheaderid: 0, orderstatus: 'picked' })]);
assert.equal(stages.size, 0);
});
test('a blank status is not an answer', () => {
const stages = deliveryStageFrom([delivery({ orderstatus: ' ' })]);
assert.equal(stages.size, 0, 'an empty word would erase the order\'s own');
});
test('the casing the rider app actually writes', () => {
// Fiesta stores status as free text; `matchesStatus` is why this works.
assert.equal(stageOf(order(), [delivery({ orderstatus: 'Picked' })]).status, 'Picked');
assert.equal(stageOf(order(), [delivery({ orderstatus: 'Out for delivery' })]).status, 'Out for delivery');
});
test('an empty board produces an empty index', () => {
assert.equal(deliveryStageFrom([]).size, 0);
});

View File

@@ -0,0 +1,146 @@
/**
* What stage an order has really reached.
*
* Gap D, and the largest of the six. Fiesta mirrors only THREE delivery
* statuses back onto the order row — `pending`, `delivered`, `cancelled`. The
* other six never arrive, so `orders.orderstatus` reads "pending" from the
* moment a rider is assigned until the moment the job completes. On the Orders
* table a rider standing at the customer's door and a job nobody has been told
* about are the same row.
*
* `orders.deliverystatus` exists on the wire and is NOT the answer: it is an
* empty string on all 181 of tenant 1147's rows — see `branchStats.ts`, which
* had to fall back to `orderstatus` for the same reason.
*
* So the stage is derived here instead, by joining the deliveries list the page
* already holds onto the orders list by `orderheaderid`. Nothing is fetched for
* this and nothing is remembered: both lists come from the same query, which is
* the pairing the Sales page has had in hand since assignment was built.
*
* Pure, so the join can be tested without either endpoint.
*/
import type { DeliveryRow, OrderRow } from '@/api/types';
import { isLive, isSettled, matchesStatus, type StatusKey } from './orderStatus';
/**
* The delivery stages that never reach the order row.
*
* Written out rather than derived from the two status maps, because the maps
* cannot tell you this. `accepted` is in BOTH vocabularies and means different
* events in each — the shop accepting the order, and the rider accepting the
* job — so a test like "absent from ORDER_STATUS" would silently skip the one
* stage an operator most wants to see early. The list is a fact about what the
* backend writes, so it is stated as one.
*
* `pending`, `delivered` and `cancelled` are deliberately absent: those three
* ARE mirrored, so the order's own word is already right and overriding it
* would only add churn.
*/
export const UNMIRRORED_STAGES: readonly StatusKey[] = [
'accepted',
'arrived',
'picked',
'active',
'skipped',
'rejected',
] as const;
/** Is this a stage the order row could never have learned about by itself? */
export function isUnmirrored(status: string | undefined): boolean {
return UNMIRRORED_STAGES.some((key) => matchesStatus(key, status));
}
/**
* When a delivery was handed out, for picking between several on one order.
*
* `assigntime` is local wall-clock as `stampNow` writes it, which is fine for
* ordering rows that came from the same writer. `deliveryid` is the tiebreak
* and the fallback: it ascends, so the higher id is the later row even when the
* stamp is missing or unparseable.
*/
function assignedAt(row: DeliveryRow): number {
const at = row.assigntime ? new Date(row.assigntime).getTime() : Number.NaN;
return Number.isNaN(at) ? 0 : at;
}
/** Later of two deliveries for the same order. */
function isLaterThan(a: DeliveryRow, b: DeliveryRow): boolean {
const byTime = assignedAt(a) - assignedAt(b);
return byTime === 0 ? a.deliveryid > b.deliveryid : byTime > 0;
}
/**
* The delivery that speaks for each order, keyed by `orderheaderid`.
*
* An order can have SEVERAL deliveries — a rejection followed by a reassignment
* leaves both rows behind — and they disagree, so one has to be chosen.
*
* A live row always wins, however old it is. The alternative, newest-wins,
* reads a reassigned order back as "rejected" whenever the rejected row happens
* to sort later, which is the exact failure `releasedFrom` was written to avoid
* on the assignment side; this keeps the two halves telling the same story.
* Among several live rows, or when every row is dead, the latest one wins —
* a dead row is still worth showing, since "rejected" is precisely the state
* that is invisible today.
*/
export function deliveryStageFrom(deliveries: readonly DeliveryRow[]): Map<number, string> {
const best = new Map<number, DeliveryRow>();
for (const row of deliveries) {
const key = row.orderheaderid;
if (!key) continue;
const held = best.get(key);
if (!held) {
best.set(key, row);
continue;
}
/* `orderstatus` on a DELIVERY row carries the delivery's own status — the
two structs reuse the field name for different ladders. */
const heldIsLive = isLive(held.orderstatus);
const rowIsLive = isLive(row.orderstatus);
if (rowIsLive !== heldIsLive) {
if (rowIsLive) best.set(key, row);
continue;
}
if (isLaterThan(row, held)) best.set(key, row);
}
const stages = new Map<number, string>();
for (const [key, row] of best) {
const status = (row.orderstatus ?? '').trim();
if (status) stages.set(key, status);
}
return stages;
}
/** What an order row should actually say, and which ladder to colour it with. */
export interface Stage {
status: string | undefined;
/** True when the word came from the delivery ladder and must be coloured with it. */
isDelivery: boolean;
}
/**
* The status to show for one order.
*
* Three rules, in order:
*
* 1. A settled order keeps its own word. `delivered` and `cancelled` on the
* ORDER are final and are mirrored correctly; letting a stale delivery row
* drag a completed order back to "picked" would be a straight regression.
* 2. Otherwise, if a delivery reports a stage the order could never have
* learned (`UNMIRRORED_STAGES`), that stage is the truth.
* 3. Otherwise the order's own word stands.
*
* An order with no delivery in the index falls to rule 3, which is exactly what
* the table did before this existed. That matters: if the deliveries list ever
* covers a narrower window than the orders list, the worst case is the old
* behaviour rather than a wrong claim.
*/
export function orderStage(row: OrderRow, stages: ReadonlyMap<number, string>): Stage {
const own: Stage = { status: row.orderstatus, isDelivery: false };
if (isSettled(row.orderstatus)) return own;
const stage = stages.get(row.orderheaderid);
return stage && isUnmirrored(stage) ? { status: stage, isDelivery: true } : own;
}

View File

@@ -33,6 +33,20 @@ export const DELIVERY_STATUS: Record<string, string> = {
picked: '#8b5cf6', picked: '#8b5cf6',
active: '#14b8a6', active: '#14b8a6',
skipped: '#f97316', skipped: '#f97316',
/*
* Not from the old console — it never had one, so 30 live rows rendered in
* the neutral grey that `statusColor` falls back to. That was survivable
* while `rejected` only appeared on the deliveries table; it stopped being
* survivable when `orderProgress.ts` started surfacing it on ORDERS, where
* grey is the one colour that means "nothing to do here" about the one
* status that means "this order needs a rider now".
*
* Measured against every neighbour in this map before it was chosen: dE 39.5
* from `cancelled`, 72.6 from `skipped`, 73.5 from `picked` — all well clear
* of the 15 floor. The near misses were #e11d48 (13.9 from cancelled) and
* #be123c (20.4), either of which would read as a slightly-off red.
*/
rejected: '#db2777',
delivered: '#10b981', delivered: '#10b981',
cancelled: '#ef4444', cancelled: '#ef4444',
}; };
@@ -171,6 +185,49 @@ export function matchesStatus(key: StatusKey, status: string | undefined): boole
} }
/**
* Finished, stopped, or still moving — asked once, in the vocabulary.
*
* Three screens needed this and three screens wrote their own version. The
* drawer's read it from a `SETTLED` array that listed two words; the dispatch
* board's was an inline `!== 'delivered' && !== 'cancelled' && !== 'skipped'`.
* They disagreed about `rejected`, which is how a job the rider had DECLINED
* came to be the one the board called "in progress".
*
* Routing through `matchesStatus` fixes a second fault the inline comparisons
* shared: they were exact matches on free text. Fiesta's casing is inconsistent
* between writers, so `Delivered` was silently outstanding forever.
*/
/** Nothing follows these. The job is over, however it ended. */
export function isSettled(status: string | undefined): boolean {
return matchesStatus('delivered', status) || matchesStatus('cancelled', status);
}
/**
* Stopped, but not finished — and the difference matters to the operator.
*
* `rejected` is the rider declining the job; `skipped` is a door nobody
* answered. Neither is going to progress on its own, so both need a person,
* but the person does something DIFFERENT about each — which is why this is
* its own state and not folded into settled.
*/
export function isStalled(status: string | undefined): boolean {
return matchesStatus('rejected', status) || matchesStatus('skipped', status);
}
/**
* Work actually under way: somebody is carrying it and it is still moving.
*
* The complement of the two above, deliberately — a status nobody has taught
* this module about counts as live, so a new word on the ladder shows up as
* outstanding work rather than vanishing from a board quietly.
*/
export function isLive(status: string | undefined): boolean {
return !isSettled(status) && !isStalled(status);
}
/** /**
* The money on an order row, in the order the old console reads it. * The money on an order row, in the order the old console reads it.
* *

View File

@@ -18,8 +18,9 @@ import {
Navigation, Navigation,
RefreshCw, RefreshCw,
Ruler, Ruler,
TriangleAlert,
} from 'lucide-react'; } from 'lucide-react';
import type { DeliveryRow, OrderRow, TenantLocation } from '@/api/types'; import type { DeliveryRow, OrderRow, RiderInfo, TenantLocation } from '@/api/types';
import { KpiCard } from '@/components/KpiCard'; import { KpiCard } from '@/components/KpiCard';
import { Tab, TabBar } from '@/components/TabBar'; import { Tab, TabBar } from '@/components/TabBar';
import { StickyRow } from '@/components/StickyRow'; import { StickyRow } from '@/components/StickyRow';
@@ -29,13 +30,15 @@ import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged'; import { usePaged } from '@/components/usePaged';
import { AssignBar } from '../AssignBar'; import { AssignBar } from '../AssignBar';
import { AutoAssignDrawer } from '../AutoAssignDrawer'; import { AutoAssignDrawer } from '../AutoAssignDrawer';
import { assignability, assignedFrom, type Assignability } from '../assignDelivery'; import { assignability, assignedFrom, releasedFrom, type Assignability } from '../assignDelivery';
import { riderName } from '../assignDelivery';
import { reachProblems } from '../riderReach';
import { useCustomers, useDeliveries, useOrders, useRiders, useTenantLocations } from '@/queries/hooks'; import { useCustomers, useDeliveries, useOrders, useRiders, useTenantLocations } from '@/queries/hooks';
import { useDateScope } from '@/components/shell/DateScope'; import { useDateScope } from '@/components/shell/DateScope';
import { useBranchScope } from '../BranchScope'; import { useBranchScope } from '../BranchScope';
import { canAssign, scopeBranches, scopeLabel, scopeQuery, type DispatchScope } from '../dispatchScope'; import { canAssign, scopeBranches, scopeLabel, scopeQuery, type DispatchScope } from '../dispatchScope';
import { count, money, moneyExact } from '../format'; import { count, money, moneyExact, plural } from '../format';
import { DELIVERY_STATUS, statusColor } from '../orderStatus'; import { DELIVERY_STATUS, isLive, statusColor } from '../orderStatus';
import { ActivePanel } from '../ActivePanel'; import { ActivePanel } from '../ActivePanel';
import { GroupMap } from '../GroupMap'; import { GroupMap } from '../GroupMap';
import { ProfitPanel } from '../ProfitPanel'; import { ProfitPanel } from '../ProfitPanel';
@@ -194,6 +197,10 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) {
const open = groups.find((group) => group.id === focused) ?? null; const open = groups.find((group) => group.id === focused) ?? null;
const assigned = useMemo(() => assignedFrom(filteredDeliveries), [filteredDeliveries]); const assigned = useMemo(() => assignedFrom(filteredDeliveries), [filteredDeliveries]);
/* Orders whose only delivery was rejected or called off. They are waiting
for a rider again, and without this the delivery row left behind keeps
them out of every assign surface for good — see `releasedFrom`. */
const released = useMemo(() => releasedFrom(filteredDeliveries), [filteredDeliveries]);
const branchOf = useMemo( const branchOf = useMemo(
() => (row: OrderRow): TenantLocation | undefined => () => (row: OrderRow): TenantLocation | undefined =>
@@ -212,7 +219,7 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) {
const selectable = useMemo( const selectable = useMemo(
() => () =>
waiting waiting
.filter((row) => assignability(row, branchOf(row), assigned).canAssign) .filter((row) => assignability(row, branchOf(row), assigned, released).canAssign)
.map((row) => row.orderheaderid), .map((row) => row.orderheaderid),
[waiting, branchOf, assigned], [waiting, branchOf, assigned],
); );
@@ -345,6 +352,16 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) {
/> />
</div> </div>
{/*
Jobs that are not reaching the rider carrying them.
Above the board rather than inside it, because it is about work that
is NOT progressing and the board is organised around work that is.
Silent when there is nothing wrong — a strip that always says
"0 problems" is a strip people stop reading.
*/}
<ReachStrip jobs={filteredDeliveries} fleet={fleet.data ?? []} />
{/* ── Main Operations Workspace ──────────────────────────────────────── */} {/* ── Main Operations Workspace ──────────────────────────────────────── */}
<div className="dispatch-content-area"> <div className="dispatch-content-area">
{mode === 'active' ? ( {mode === 'active' ? (
@@ -425,7 +442,7 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) {
{...(open.id === UNASSIGNED && canAssign(scope) {...(open.id === UNASSIGNED && canAssign(scope)
? { ? {
selection: picked, selection: picked,
verdictOf: (row: OrderRow) => assignability(row, branchOf(row), assigned), verdictOf: (row: OrderRow) => assignability(row, branchOf(row), assigned, released),
assignBar: ( assignBar: (
<> <>
{waiting.length > 0 ? ( {waiting.length > 0 ? (
@@ -448,6 +465,7 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) {
orders={pickedOrders} orders={pickedOrders}
branchOf={branchOf} branchOf={branchOf}
assigned={assigned} assigned={assigned}
released={released}
onClear={picked.clear} onClear={picked.clear}
onDone={picked.clear} onDone={picked.clear}
/> />
@@ -542,6 +560,7 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) {
branches={branches} branches={branches}
fleet={fleet.data ?? []} fleet={fleet.data ?? []}
assigned={assigned} assigned={assigned}
released={released}
onClose={() => setAutoOpen(false)} onClose={() => setAutoOpen(false)}
onDone={picked.clear} onDone={picked.clear}
/> />
@@ -636,11 +655,11 @@ function EnhancedGroupList({
}, 0); }, 0);
const displayKm = totalKm > 0 ? totalKm.toFixed(1) : (total * 1.6).toFixed(1); const displayKm = totalKm > 0 ? totalKm.toFixed(1) : (total * 1.6).toFixed(1);
// Active order / in progress /* The stop somebody is actually working right now.
const activeStop = group.stops.find((s) => { `isLive` rather than a list of words: this read its own three-word
const st = stopStatus(s); exclusion and left `rejected` out of it, so a job the rider had
return st !== 'delivered' && st !== 'cancelled' && st !== 'skipped'; DECLINED was shown as the group's order in progress. */
}); const activeStop = group.stops.find((s) => isLive(stopStatus(s)));
return ( return (
<div <div
@@ -939,3 +958,57 @@ function shortTime(stamp: string): string {
const match = stamp.match(/(\d{1,2}):(\d{2})/); const match = stamp.match(/(\d{1,2}):(\d{2})/);
return match ? `${match[1]}:${match[2]}` : ''; return match ? `${match[1]}:${match[2]}` : '';
} }
/**
* What is not getting through, worst first.
*
* Two failures used to be invisible here. A rider with no app on their phone
* could be handed work all day — the push failure was a sentence in a toast
* that did not survive a navigation — and a job nobody accepted sat at
* `pending` with nothing counting how long. Both are derived per poll in
* `riderReach`, so neither depends on somebody having been watching when it
* happened.
*
* It renders nothing when the board is healthy. An always-present strip
* reading "all fine" is one the eye learns to skip, and then it is not there
* on the day it says something.
*/
function ReachStrip({ jobs, fleet }: { jobs: readonly DeliveryRow[]; fleet: RiderInfo[] }) {
/* Recomputed each render rather than memoised on a clock: the page already
polls, and a wait that freezes while the screen is open is worse than one
that costs a map lookup. */
const problems = reachProblems(jobs, fleet);
if (problems.length === 0) return null;
const urgent = problems.filter((entry) => entry.problem.bucket === 'now').length;
return (
<section className="reach-strip" role="status" aria-label="Jobs not reaching their rider">
<header className="reach-strip-head">
<TriangleAlert size={15} />
<strong>
{plural(problems.length, "job")} not reaching {problems.length === 1 ? "its" : "their"} rider
</strong>
{urgent > 0 ? <span className="reach-strip-now">{urgent} need someone now</span> : null}
</header>
<ul className="reach-strip-list">
{problems.slice(0, 6).map(({ job, problem }) => (
<li key={job.deliveryid} data-bucket={problem.bucket}>
<span className="reach-strip-who">
{riderName(fleet.find((r) => r.userid === job.userid) ?? { userid: job.userid ?? 0 })}
</span>
<span className="reach-strip-what">{problem.detail}</span>
<span className="reach-strip-do">{problem.action}</span>
</li>
))}
</ul>
{problems.length > 6 ? (
<p className="reach-strip-more">
and {plural(problems.length - 6, "more job")} — the rider list below has them all.
</p>
) : null}
</section>
);
}

View File

@@ -29,6 +29,7 @@ import { useAllOrders, usePosSalesByBranch, useStockStatementByBranch } from '@/
import { useBranchScope } from '../BranchScope'; import { useBranchScope } from '../BranchScope';
import { branchOrderStats, NO_ORDERS } from '../branchStats'; import { branchOrderStats, NO_ORDERS } from '../branchStats';
import { branchLabel, count, money, percent, plural, share } from '../format'; import { branchLabel, count, money, percent, plural, share } from '../format';
import { movers } from '../inventoryMovers';
import { BarChart as RechartsBarChart, Bar as RechartsBar, XAxis, Tooltip, ResponsiveContainer } from 'recharts'; import { BarChart as RechartsBarChart, Bar as RechartsBar, XAxis, Tooltip, ResponsiveContainer } from 'recharts';
import { Panel } from '@/components/Panel'; import { Panel } from '@/components/Panel';
import { TablePager } from '@/components/TablePager'; import { TablePager } from '@/components/TablePager';
@@ -521,9 +522,10 @@ function InventoryReport() {
} }
} }
const moving = [...rows].sort((a, b) => b.debit - a.debit); /* Two ends of one list, and they must not be the same end — on a shop with
const fast = moving.slice(0, 5); under about ten products the old two-slice version printed the same item
const slow = moving.filter((row) => row.closing > 0).slice(-5).reverse(); under both headings. See `inventoryMovers.ts`. */
const { fast, slow } = movers(rows);
const value = rows.reduce((sum, row) => sum + row.closing * row.retailprice, 0); const value = rows.reduce((sum, row) => sum + row.closing * row.retailprice, 0);
/* Every tile on this tab now covers the same thing, so say what that is on all /* Every tile on this tab now covers the same thing, so say what that is on all

View File

@@ -36,7 +36,8 @@ import {
import { OrderDetailDrawer, type RowKind } from '../OrderDetailDrawer'; import { OrderDetailDrawer, type RowKind } from '../OrderDetailDrawer';
import { shortAge } from '../posStatus'; import { shortAge } from '../posStatus';
import { AssignBar } from '../AssignBar'; import { AssignBar } from '../AssignBar';
import { assignability, assignedFrom } from '../assignDelivery'; import { assignability, assignedFrom, releasedFrom } from '../assignDelivery';
import { deliveryStageFrom, orderStage } from '../orderProgress';
import { useSelection } from '@/components/useSelection'; import { useSelection } from '@/components/useSelection';
import { Panel } from '@/components/Panel'; import { Panel } from '@/components/Panel';
import { TablePager } from '@/components/TablePager'; import { TablePager } from '@/components/TablePager';
@@ -140,6 +141,16 @@ export function SalesPage() {
const importedBills = useMemo(() => everyOrder.filter(isCounterSale), [everyOrder]); const importedBills = useMemo(() => everyOrder.filter(isCounterSale), [everyOrder]);
const allDeliveries = deliveries.data ?? []; const allDeliveries = deliveries.data ?? [];
/*
* Filtered on the ORDER ladder, deliberately, even though the chip now shows
* the delivery stage.
*
* `ORDER_STATUS_TABS` has no Picked or Arrived tab — an order never reaches
* those words — so filtering on the stage would drop every order in progress
* out of every tab except All. Under Pending the rows are the outstanding
* orders, which is what Pending means here, and the chip says how far along
* each one is. The tab counts stay on the same footing for the same reason.
*/
const orderRows = useMemo( const orderRows = useMemo(
() => allOrders.filter((row) => matchesStatus(status, row.orderstatus)), () => allOrders.filter((row) => matchesStatus(status, row.orderstatus)),
[allOrders, status], [allOrders, status],
@@ -270,6 +281,15 @@ export function SalesPage() {
* of production's delivery history a second time. * of production's delivery history a second time.
*/ */
const assigned = useMemo(() => assignedFrom(allDeliveries), [allDeliveries]); const assigned = useMemo(() => assignedFrom(allDeliveries), [allDeliveries]);
/* Orders whose only delivery was rejected or called off. They are waiting
for a rider again, and without this the delivery row left behind keeps
them out of every assign surface for good — see `releasedFrom`. */
const released = useMemo(() => releasedFrom(allDeliveries), [allDeliveries]);
/* How far each order has actually got. The order row is only ever told
pending/delivered/cancelled, so the six stages in between are read off the
deliveries list — same window, same query, no extra fetch. See gap D in
`orderProgress.ts`. */
const stages = useMemo(() => deliveryStageFrom(allDeliveries), [allDeliveries]);
const branchOf = useMemo( const branchOf = useMemo(
() => (row: OrderRow) => branches.find((branch) => branch.locationid === row.locationid), () => (row: OrderRow) => branches.find((branch) => branch.locationid === row.locationid),
@@ -288,7 +308,7 @@ export function SalesPage() {
const selectable = useMemo( const selectable = useMemo(
() => () =>
orderRows orderRows
.filter((row) => assignability(row, branchOf(row), assigned).canAssign) .filter((row) => assignability(row, branchOf(row), assigned, released).canAssign)
.map((row) => row.orderheaderid), .map((row) => row.orderheaderid),
[orderRows, branchOf, assigned], [orderRows, branchOf, assigned],
); );
@@ -410,6 +430,7 @@ export function SalesPage() {
orders={pickedOrders} orders={pickedOrders}
branchOf={branchOf} branchOf={branchOf}
assigned={assigned} assigned={assigned}
released={released}
onClear={picked.clear} onClear={picked.clear}
onDone={picked.clear} onDone={picked.clear}
/> />
@@ -422,6 +443,8 @@ export function SalesPage() {
selection={picked} selection={picked}
branchOf={branchOf} branchOf={branchOf}
assigned={assigned} assigned={assigned}
released={released}
stages={stages}
onOpen={(row) => setDetail({ row, kind: 'order' })} onOpen={(row) => setDetail({ row, kind: 'order' })}
/> />
{orderRows.length > 0 ? ( {orderRows.length > 0 ? (
@@ -564,6 +587,7 @@ export function SalesPage() {
<OrderDetailDrawer <OrderDetailDrawer
row={freshRow(detail, allOrders, allDeliveries)} row={freshRow(detail, allOrders, allDeliveries)}
kind={detail.kind} kind={detail.kind}
stages={stages}
onClose={() => setDetail(null)} onClose={() => setDetail(null)}
/> />
) : null} ) : null}
@@ -656,6 +680,8 @@ function OrdersTable({
selection, selection,
branchOf, branchOf,
assigned, assigned,
released,
stages,
onOpen, onOpen,
queryKey, queryKey,
}: { }: {
@@ -667,6 +693,10 @@ function OrdersTable({
selection: ReturnType<typeof useSelection>; selection: ReturnType<typeof useSelection>;
branchOf: (row: OrderRow) => TenantLocation | undefined; branchOf: (row: OrderRow) => TenantLocation | undefined;
assigned: ReadonlySet<number>; assigned: ReadonlySet<number>;
/** Orders whose delivery is dead — see `releasedFrom`. */
released?: ReadonlySet<number>;
/** Where each order's delivery has got to — see `orderProgress.ts`. */
stages: ReadonlyMap<number, string>;
onOpen: (row: OrderRow) => void; onOpen: (row: OrderRow) => void;
}) { }) {
// Above the early returns — a hook cannot be called conditionally. // Above the early returns — a hook cannot be called conditionally.
@@ -723,7 +753,8 @@ function OrdersTable({
{paged.rows.map((row, index) => { {paged.rows.map((row, index) => {
const cod = row.collectionamt ?? 0; const cod = row.collectionamt ?? 0;
const amount = orderValue(row); const amount = orderValue(row);
const verdict = assignability(row, branchOf(row), assigned); const verdict = assignability(row, branchOf(row), assigned, released);
const stage = orderStage(row, stages);
const isPicked = selection.has(row.orderheaderid); const isPicked = selection.has(row.orderheaderid);
return ( return (
<Row key={row.orderheaderid} onClick={() => onOpen(row)} isPicked={isPicked}> <Row key={row.orderheaderid} onClick={() => onOpen(row)} isPicked={isPicked}>
@@ -802,7 +833,14 @@ function OrdersTable({
<Mono>{amount > 0 ? moneyExact(amount) : '—'}</Mono> <Mono>{amount > 0 ? moneyExact(amount) : '—'}</Mono>
</Td> </Td>
<Td> <Td>
<Chip label={row.orderstatus} color={statusColor(ORDER_STATUS, row.orderstatus)} /> {/* The stage, not the order's own word — which sits on `pending`
from assignment until delivery. The ladder has to follow
it: colouring `picked` with the ORDER map paints it grey,
in the middle of the journey where it matters most. */}
<Chip
label={stage.status}
color={statusColor(stage.isDelivery ? DELIVERY_STATUS : ORDER_STATUS, stage.status)}
/>
</Td> </Td>
<Td> <Td>
<ViewHint /> <ViewHint />

View File

@@ -48,6 +48,26 @@
/* Pushed to the far end so the result reads as an outcome rather than as /* Pushed to the far end so the result reads as an outcome rather than as
another control. Wraps to its own line on a narrow window rather than another control. Wraps to its own line on a narrow window rather than
squeezing the picker. */ squeezing the picker. */
/* A rider with no app on their phone, said before the hand-off rather than
after it. Warning colours rather than error ones: assigning to somebody you
are about to ring is a legitimate thing to do, and this is not a refusal. */
.assign-bar-warn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border-radius: var(--card-radius-sm);
border: 1px solid var(--color-warning-line, #f0dfb5);
background: var(--color-warning-wash, #fffbf0);
color: var(--color-ink-2);
font: 500 12px/1.3 var(--font-sans);
}
.assign-bar-warn svg {
flex: none;
color: var(--color-warning, #b7860b);
}
.assign-bar-outcome { .assign-bar-outcome {
margin-left: auto; margin-left: auto;
font: 500 12px/1.4 var(--font-sans); font: 500 12px/1.4 var(--font-sans);

View File

@@ -758,3 +758,99 @@
background: #501b67; background: #501b67;
transform: translateY(-1px); transform: translateY(-1px);
} }
/* ── Jobs that are not reaching their rider ────────────────────────────────
Above the board, because the board is organised around work that IS moving
and this is about work that is not. Absent entirely when nothing is wrong:
a strip that always says "all fine" is one the eye learns to skip, and then
it is not there on the day it says something. */
.reach-strip {
border: 1px solid var(--color-warning-line, #f0dfb5);
border-radius: var(--card-radius);
background: var(--color-warning-wash, #fffbf0);
padding: 12px 16px;
}
.reach-strip-head {
display: flex;
align-items: center;
gap: 8px;
font: 500 13px/1.3 var(--font-sans);
color: var(--color-ink-1);
}
.reach-strip-head svg {
flex: none;
color: var(--color-warning, #b7860b);
}
/* The count that decides whether somebody puts their coffee down. */
.reach-strip-now {
margin-left: auto;
padding: 2px 9px;
border-radius: 999px;
background: var(--color-error-wash, #fff1f1);
color: var(--color-error, #b42318);
font: 600 11.5px/1.5 var(--font-sans);
white-space: nowrap;
}
.reach-strip-list {
margin: 10px 0 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 1px;
}
/* Three columns: who, what, what to do. The instruction is last because it is
what the reader acts on, and a row they have already decided about should
not make them read it again. */
.reach-strip-list li {
display: grid;
grid-template-columns: minmax(96px, 150px) minmax(0, 1fr) minmax(0, auto);
gap: 12px;
align-items: baseline;
padding: 7px 0;
border-top: 1px solid rgb(180 150 80 / 0.18);
font: 400 12.5px/1.45 var(--font-sans);
color: var(--color-ink-2);
}
.reach-strip-who {
font-weight: 600;
color: var(--color-ink-1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Only the urgent ones take the colour. If everything is red, nothing is. */
.reach-strip-list li[data-bucket='now'] .reach-strip-do {
color: var(--color-error, #b42318);
font-weight: 600;
}
.reach-strip-do {
text-align: right;
white-space: nowrap;
}
.reach-strip-more {
margin: 8px 0 0;
font: 400 12px/1.4 var(--font-sans);
color: var(--color-ink-3);
}
/* On a phone the three columns become three lines; the instruction keeps its
weight but stops being right-aligned against nothing. */
@media (max-width: 720px) {
.reach-strip-list li {
grid-template-columns: minmax(0, 1fr);
gap: 2px;
}
.reach-strip-do {
text-align: left;
}
}

View File

@@ -0,0 +1,157 @@
/**
* Gaps B and C: whether an assignment actually reached the rider.
*
* B — the push outcome existed only in a toast, so a job nobody was told about
* was indistinguishable from one in a rider's hand after one navigation.
* C — nothing computed how long a job had sat unaccepted; `assigntime` was
* read for sort order and nothing else.
*
* Both are derived here from data every poll already returns, so neither
* depends on a record the browser happens to be holding.
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
import type { DeliveryRow, RiderInfo } from '@/api/types';
import {
UNACCEPTED_LOOK_MS,
UNACCEPTED_NOW_MS,
hasNoDevice,
reachProblem,
reachProblems,
unacceptedMs,
} from './riderReach';
const NOW = new Date('2026-09-21T14:00:00').getTime();
/** `assigntime` is local wall-clock, as `stampNow` writes it. */
const at = (minutesAgo: number) => {
const d = new Date(NOW - minutesAgo * 60_000);
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
};
const job = (over: Partial<DeliveryRow> = {}) =>
({ deliveryid: 1, userid: 897, orderstatus: 'pending', assigntime: at(0), ...over }) as DeliveryRow;
const withPhone = { userid: 897, userfcmtoken: 'fcm-abc' } as RiderInfo;
const noPhone = { userid: 897, userfcmtoken: '' } as RiderInfo;
/* ── B: a rider nothing can be sent to ─────────────────────────────────── */
test('a rider who has never opened the app has no device', () => {
assert.equal(hasNoDevice(noPhone), true);
assert.equal(hasNoDevice({ userid: 1 } as RiderInfo), true, 'absent counts');
assert.equal(hasNoDevice({ userid: 1, userfcmtoken: ' ' } as RiderInfo), true, 'blank counts');
assert.equal(hasNoDevice(withPhone), false);
});
test('no device is reported the moment the job exists, not after a wait', () => {
// The whole point of deriving it: this is true on a job assigned seconds ago,
// which a stored push-failure could only say if somebody was watching.
const problem = reachProblem(job({ assigntime: at(0) }), noPhone, NOW);
assert.equal(problem?.code, 'no-device');
assert.equal(problem?.bucket, 'now');
assert.match(problem?.action ?? '', /call/i);
});
test('no device outranks a long wait — one problem, not two', () => {
const problem = reachProblem(job({ assigntime: at(90) }), noPhone, NOW);
assert.equal(problem?.code, 'no-device', 'the device is why it has not been accepted');
});
/* ── C: a job nobody picked up ─────────────────────────────────────────── */
test('a job only counts as unaccepted while it is pending', () => {
assert.equal(unacceptedMs(job({ orderstatus: 'pending', assigntime: at(30) }), NOW), 30 * 60_000);
assert.equal(unacceptedMs(job({ orderstatus: 'accepted', assigntime: at(30) }), NOW), null);
assert.equal(unacceptedMs(job({ orderstatus: 'picked', assigntime: at(30) }), NOW), null);
});
test('an unreadable assigntime answers null rather than 1970', () => {
assert.equal(unacceptedMs(job({ assigntime: '' }), NOW), null);
assert.equal(unacceptedMs(job({ assigntime: 'not a date' }), NOW), null);
});
test('a few minutes of silence is ordinary and is not flagged', () => {
// Flagging this would train people to ignore the flag.
assert.equal(reachProblem(job({ assigntime: at(3) }), withPhone, NOW), null);
assert.equal(reachProblem(job({ assigntime: at(9) }), withPhone, NOW), null);
});
test('ten minutes is worth a look; twenty-five needs somebody now', () => {
const look = reachProblem(job({ assigntime: at(12) }), withPhone, NOW);
assert.equal(look?.code, 'not-accepted');
assert.equal(look?.bucket, 'look');
const now = reachProblem(job({ assigntime: at(40) }), withPhone, NOW);
assert.equal(now?.bucket, 'now');
assert.match(now?.action ?? '', /somebody else/i);
});
test('the thresholds are the ones the module publishes', () => {
assert.equal(reachProblem(job({ assigntime: at(UNACCEPTED_LOOK_MS / 60_000) }), withPhone, NOW)?.bucket, 'look');
assert.equal(reachProblem(job({ assigntime: at(UNACCEPTED_NOW_MS / 60_000) }), withPhone, NOW)?.bucket, 'now');
});
test('the wait is spoken, not printed in milliseconds', () => {
assert.match(reachProblem(job({ assigntime: at(40) }), withPhone, NOW)?.detail ?? '', /40 minutes/);
});
/* ── What is not a problem ─────────────────────────────────────────────── */
test('finished work is nobody\'s problem, however it got there', () => {
for (const status of ['delivered', 'cancelled', 'rejected']) {
assert.equal(
reachProblem(job({ orderstatus: status, assigntime: at(300) }), noPhone, NOW),
null,
`${status} needs no chasing`,
);
}
});
test('a rider we cannot see yet is not reported as unreachable', () => {
// The fleet is a separate query and can be loading. An absent rider is a
// different conversation from a rider with no phone.
const problem = reachProblem(job({ assigntime: at(2) }), undefined, NOW);
assert.equal(problem, null);
});
test('an accepted job with no device is still flagged — they carry more work', () => {
const problem = reachProblem(job({ orderstatus: 'accepted' }), noPhone, NOW);
assert.equal(problem?.code, 'no-device');
});
/* ── The board ─────────────────────────────────────────────────────────── */
test('the board lists worst first, then longest waiting', () => {
const fleet = [withPhone, { userid: 55, userfcmtoken: '' } as RiderInfo];
const rows = [
job({ deliveryid: 1, userid: 897, assigntime: at(12) }), // look
job({ deliveryid: 2, userid: 897, assigntime: at(60) }), // now, oldest
job({ deliveryid: 3, userid: 55, assigntime: at(1) }), // now, no device
job({ deliveryid: 4, userid: 897, assigntime: at(2) }), // fine
];
const out = reachProblems(rows, fleet, NOW);
assert.deepEqual(out.map((r) => r.job.deliveryid), [2, 3, 1], 'now before look, longest first');
assert.equal(out.length, 3, 'the healthy job is absent, not listed as fine');
});
test('a long wait is flagged even when the rider cannot be identified', () => {
// The wait is a fact about the JOB. Whether we can name who is carrying it
// does not make ninety pending minutes any less of a problem — and a row
// with no rider on it is itself worth somebody looking at.
const out = reachProblems([job({ userid: 0, assigntime: at(90) })], [withPhone], NOW);
assert.equal(out.length, 1);
assert.equal(out[0]?.problem.code, 'not-accepted');
});
test('a device problem needs a rider to be about', () => {
// "This rider has never opened the app" is unsayable about a rider we
// cannot see, so it stays a wait rather than becoming a claim.
const out = reachProblems([job({ userid: 0, assigntime: at(90) })], [], NOW);
assert.equal(out[0]?.problem.code, 'not-accepted');
});
test('an empty board is empty, not an error', () => {
assert.deepEqual(reachProblems([], [], NOW), []);
});

View File

@@ -0,0 +1,166 @@
import type { DeliveryRow, RiderInfo } from '@/api/types';
import { spokenAge } from './posStatus';
import { matchesStatus } from './orderStatus';
/**
* Whether a job actually reached the rider carrying it.
*
* Assigning is two separate things, and the console only ever proved the first.
* `createdeliveries` writes the delivery and the queue row the rider's app
* reads; a push then tells them it is there. The push is fired separately and
* on purpose — a failed notification must not read as a failed assignment — but
* its outcome was a sentence in a toast and nothing else. One navigation later,
* a job nobody was told about looked exactly like a job in somebody's hand.
*
* ── Why this derives the answer instead of remembering it ───────────────────
*
* The obvious fix is to store the failure: stash the delivery ids whose push
* threw and render them later. It does not survive contact with the product.
* The session lives in `sessionStorage`, per TAB, so the supervisor who assigns
* on one machine and the one who watches the board on another see different
* lists. Reloading loses it. A second operator never had it.
*
* Both halves of "did this reach them" are already in data we fetch every
* poll, so neither has to be remembered:
*
* - **No device.** `userfcmtoken` is empty for a rider who has never opened
* the app (see `RiderInfo`). They cannot be pushed — not "were not", but
* *cannot be*, today and for every job until they sign in. That is a
* stronger statement than any record of a past failure, and the server
* already tells us.
*
* - **Never picked up.** A delivery sits at `pending` until the rider's app
* moves it to `accepted`. `pending` well past its `assigntime` is the
* symptom that matters regardless of WHY — a push that failed, a push that
* was swallowed, a dead battery, an app never opened. The console does not
* need to know which.
*
* The second is the more honest signal of the two: a successful push proves the
* message left, not that anyone saw it. Acceptance proves the rider did.
*/
/* ── When to start worrying ───────────────────────────────────────────────
A rider glances at the app within a couple of minutes of a job landing, so a
few minutes of silence is ordinary and flagging it would train people to
ignore the flag. Twenty-five minutes is a delivery that is not happening. */
export const UNACCEPTED_LOOK_MS = 10 * 60_000;
export const UNACCEPTED_NOW_MS = 25 * 60_000;
export type ReachBucket = 'now' | 'look';
export type ReachCode = 'no-device' | 'not-accepted';
export interface ReachProblem {
code: ReachCode;
bucket: ReachBucket;
/** One sentence of what is true. */
detail: string;
/** What to do about it. */
action: string;
/** How long it has been true. `null` when the stamp is unreadable. */
forMs: number | null;
}
/**
* A rider the platform cannot push to at all.
*
* Not a past failure — a standing fact. Worth saying before an assignment as
* much as after one, which is why it is exported on its own.
*/
export function hasNoDevice(rider: Pick<RiderInfo, 'userfcmtoken'> | undefined): boolean {
return !((rider?.userfcmtoken ?? '').trim());
}
/**
* How long a job has been waiting to be accepted, or `null`.
*
* `assigntime` is written as local wall-clock (`stampNow`), not an instant, so
* it is parsed the same way the rest of the console parses it and a garbled
* stamp answers `null` rather than 1970.
*/
export function unacceptedMs(job: DeliveryRow, now = Date.now()): number | null {
if (!matchesStatus('pending', job.orderstatus)) return null;
const at = new Date((job.assigntime ?? '').replace(' ', 'T')).getTime();
if (Number.isNaN(at)) return null;
return Math.max(0, now - at);
}
/**
* What is wrong with this job's chances of being done, if anything.
*
* At most one problem, worst first: a rider with no device who also has not
* accepted has ONE problem, and it is the device. Two lines saying the same
* thing twice is how a board stops being read.
*
* `rider` may be undefined — the fleet list is a separate query and can be
* loading, or the rider may have left. That is not a reach problem and must not
* be reported as one; an absent rider is a different conversation.
*/
export function reachProblem(
job: DeliveryRow,
rider: Pick<RiderInfo, 'userfcmtoken'> | undefined,
now = Date.now(),
): ReachProblem | null {
/* Settled work is nobody's problem, however it got there. */
if (
matchesStatus('delivered', job.orderstatus) ||
matchesStatus('cancelled', job.orderstatus) ||
matchesStatus('rejected', job.orderstatus)
) {
return null;
}
const waiting = unacceptedMs(job, now);
if (rider && hasNoDevice(rider)) {
return {
code: 'no-device',
bucket: 'now',
detail: 'This rider has never opened the app, so nothing can be sent to their phone.',
action: 'Call them, or have them sign in to the app before you assign more.',
forMs: waiting,
};
}
if (waiting !== null && waiting >= UNACCEPTED_LOOK_MS) {
const isNow = waiting >= UNACCEPTED_NOW_MS;
return {
code: 'not-accepted',
bucket: isNow ? 'now' : 'look',
detail: `Assigned ${spokenAge(waiting)} ago and the rider has not picked it up yet.`,
action: isNow
? 'Call them, or give this to somebody else.'
: 'Worth a call if it is still unaccepted in a few minutes.',
forMs: waiting,
};
}
return null;
}
/**
* Every job on the board that is not reaching its rider.
*
* Takes the fleet as a list rather than a map because that is what the pages
* already hold; building the index here keeps the join in one place instead of
* at each call site.
*/
export function reachProblems(
jobs: readonly DeliveryRow[],
fleet: readonly RiderInfo[],
now = Date.now(),
): { job: DeliveryRow; problem: ReachProblem }[] {
const byId = new Map<number, RiderInfo>();
for (const rider of fleet) byId.set(rider.userid, rider);
const out: { job: DeliveryRow; problem: ReachProblem }[] = [];
for (const job of jobs) {
const problem = reachProblem(job, job.userid ? byId.get(job.userid) : undefined, now);
if (problem) out.push({ job, problem });
}
/* Worst first, then longest waiting — the order somebody works down a list. */
return out.sort((a, b) => {
if (a.problem.bucket !== b.problem.bucket) return a.problem.bucket === 'now' ? -1 : 1;
return (b.problem.forMs ?? 0) - (a.problem.forMs ?? 0);
});
}