/** * Order and delivery status, taken verbatim from the old console. * * Two ladders, not one, and that is the important part. An order walks * created → pending → processing → confirmed/ready → delivered, while its * delivery job walks pending → accepted → arrived → picked → active → * delivered. Colouring a delivery with the order map paints "picked" and * "arrived" grey, which is exactly the middle of the journey where an operator * most needs to see progress. * * Values are the old console's `consoleUi.tsx` hex codes, unchanged, so a row * that was amber there is amber here. */ /** The order lifecycle. */ export const ORDER_STATUS: Record = { created: '#0ea5e9', pending: '#f59e0b', processing: '#0ea5e9', modified: '#06b6d4', confirmed: '#10b981', accepted: '#6366f1', ready: '#6366f1', delivered: '#10b981', cancelled: '#ef4444', }; /** The delivery lifecycle. Different words, different colours. */ export const DELIVERY_STATUS: Record = { pending: '#f59e0b', accepted: '#6366f1', arrived: '#06b6d4', picked: '#8b5cf6', active: '#14b8a6', 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', cancelled: '#ef4444', }; /** * `waiting` is not in either map — it is the dispatch board's own word for work * nobody has touched — but it is just as wrong as a name. */ const WAITING_WORD = 'waiting'; /** * True when a value is a person's name and not a status wearing one. * * `deliveries.ridername` is not reliably a name. Every rider on tenant 916 has * BOTH their name and a delivery status in that column, and for two of the five * the status is the MORE common value (measured 2026-09-09): * * 883 { "Rajan": 21, "delivered": 42 } * 897 { "Varun": 69, "delivered": 75 } * 1111 { "Murali": 33, "delivered": 74, "cancelled": 1 } * 1114 { "Tamilazhagan": 37, "delivered": 63 } * * So neither "first non-empty" nor "most common" finds the name on its own. The * statuses have to be excluded first, and they are excluded by VOCABULARY — the * two status maps above — rather than by a hand-written list, so a status added * to the ladder is excluded here the same day. * * It lives beside the vocabularies rather than with either caller: the dispatch * rail and the map both need it, and one importing it from the other would put * a cycle between them. */ export function isRealName(value: string | undefined): boolean { const name = value?.trim().toLowerCase(); if (!name) return false; return !(name in DELIVERY_STATUS) && !(name in ORDER_STATUS) && name !== WAITING_WORD; } /** Unknown statuses fall to meta grey rather than to a colour that means something. */ export const statusColor = (map: Record, status: string | undefined): string => map[(status ?? '').trim().toLowerCase()] ?? 'var(--color-ink-3)'; /** * The status tabs — one set per kind of row, because they are different ladders. * * ── Why not one shared set ────────────────────────────────────────────────── * * An order and a delivery move through different lifecycles, and a single strip * shown over both leaves half its tabs permanently at zero. Measured across * every tenant on 2026-09-10: * * orders delivered 4240 · pending 876 · cancelled 827 · created 788 * deliveries delivered 1528 · pending 209 · cancelled 146 · picked 42 · * rejected 30 · active 22 · skipped 11 · accepted 6 · arrived 3 * * So an order is never "arrived" or "picked", and a delivery is never * "created". The previous six-tab set was shown over both and had the worse * problem: "processing" swallowed accepted, picked, active and arrived into one * bucket, so a dispatcher could not tell a rider who had reached the shop from * one already riding, and "cancelled" quietly included skipped. * * ── Every status has exactly one tab ──────────────────────────────────────── * * `rejected` is on the delivery strip even though it is easy to forget: 30 live * rows carry it, and a status with no tab appears under "All" and under nothing * else, so an operator working through the tabs never sees those rows at all. * That is the failure this list is checked against. */ export const ORDER_STATUS_TABS = [ { key: 'all', label: 'All' }, { key: 'created', label: 'Created' }, { key: 'pending', label: 'Pending' }, { key: 'delivered', label: 'Delivered' }, { key: 'cancelled', label: 'Cancelled' }, ] as const; export const DELIVERY_STATUS_TABS = [ { key: 'all', label: 'All' }, { key: 'pending', label: 'Pending' }, { key: 'accepted', label: 'Accepted' }, { key: 'arrived', label: 'Arrived' }, { key: 'picked', label: 'Picked' }, { key: 'active', label: 'Active' }, { key: 'skipped', label: 'Skipped' }, { key: 'rejected', label: 'Rejected' }, { key: 'delivered', label: 'Delivered' }, { key: 'cancelled', label: 'Cancelled' }, ] as const; export type StatusKey = | (typeof ORDER_STATUS_TABS)[number]['key'] | (typeof DELIVERY_STATUS_TABS)[number]['key']; /** * Does a row belong under this tab? * * Substring matching, lowercased. Fiesta stores status as free text and the * casing is inconsistent between writers, so an exact match is how a * "Delivered" row silently stops counting the day someone writes "delivered". * * Each status now lands in exactly ONE tab. The order of these cases matters * where words overlap: `undelivered` must not count as delivered, and * `out for delivery` is a delivery in progress rather than a completed one. */ export function matchesStatus(key: StatusKey, status: string | undefined): boolean { if (key === 'all') return true; const s = (status ?? '').trim().toLowerCase(); if (!s) return false; switch (key) { case 'created': return s.includes('created') || s.includes('new'); case 'pending': return s.includes('pending'); case 'accepted': // Not "accept" alone — that would also catch "unaccepted" if it appears. return s.includes('accepted'); case 'arrived': return s.includes('arrived'); case 'picked': return s.includes('picked') || s.includes('pickup'); case 'active': // Everything between collecting and dropping: the platform writes // `active`, and `out for delivery` means the same thing. return s.includes('active') || s.includes('out for') || s.includes('transit'); case 'skipped': // Its own status and its own tab. The rider reached the address and // nobody was in — which is not a cancellation, and used to be filed as // one. return s.includes('skip'); case 'rejected': return s.includes('reject') || s.includes('declin'); case 'delivered': return s.includes('deliver') && !s.includes('undeliver') && !s.includes('out for'); case 'cancelled': return s.includes('cancel'); } } /** * 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. * * `ordervalue` is the full figure (goods + tax + charges − promo), `orderamount` * is the goods alone, and `deliveryamt` is what a delivery job carries. Which * one is populated depends on which endpoint produced the row, so this walks * them rather than picking one and showing blanks half the time. */ export function orderValue(row: { ordervalue?: number; orderamount?: number; deliveryamt?: number; }): number { return row.ordervalue || row.orderamount || row.deliveryamt || 0; } /** Items on an order. `quantity` first, `itemcount` as the fallback. */ export function orderQuantity(row: { quantity?: number; itemcount?: number }): number { return row.quantity || row.itemcount || 0; } /** * A till clock running a few minutes off is ordinary and not worth correcting. * Beyond this, a `billedat` in the future is a timezone fault, not drift. */ const CLOCK_TOLERANCE_MS = 5 * 60_000; /** * When the sale was actually rung, as a real instant. * * `billedat` cannot be trusted as sent. Most terminals stamp it with LOCAL * wall-clock time and then label it `Z`, so a sale rung at 17:17 IST arrives as * `2026-08-27T17:17:39Z` — five and a half hours in the future. Measured across * location 1185: 19 of 20 bills had `billedat` ahead of `receivedat`, which is * impossible, since a bill cannot be rung after the server received it. One * terminal (`TB0B5`) sends correct UTC, so the fleet cannot be corrected * wholesale either. * * `receivedat` is stamped by Fiesta and is therefore sound, and it gives a * test that needs no knowledge of which terminal is which: if `billedat` is * later than `receivedat` by more than clock drift, the digits are local time * wearing a `Z`, and reading them back in the viewer's zone recovers the * instant. The console and the shops it serves are in the same zone, so * `getTimezoneOffset` is the offset that was dropped. * * The correction is applied only when it actually resolves the impossibility; * a bill that is still in the future afterwards is something else, and guessing * further would be inventing data. Everything else passes through untouched — * which means this quietly stops correcting the day the POS team stamps UTC. */ export function billedAtMs(bill: { billedat?: string; receivedat?: string }): number | null { if (!bill.billedat) return null; const billed = new Date(bill.billedat).getTime(); if (Number.isNaN(billed)) return null; if (!bill.receivedat) return billed; const received = new Date(bill.receivedat).getTime(); if (Number.isNaN(received)) return billed; if (billed <= received + CLOCK_TOLERANCE_MS) return billed; // `getTimezoneOffset` is (UTC − local) in minutes: −330 for IST. Adding it // turns wall-clock-read-as-UTC back into the instant it stood for. const corrected = billed + new Date(billed).getTimezoneOffset() * 60_000; return corrected <= received + CLOCK_TOLERANCE_MS ? corrected : billed; } /** * How far behind a counter bill was when it reached us. * * `billedat` is when the sale was rung; `receivedat` is when it landed here. A * gap means the till was offline and caught up later, and it is worth showing * on the row: a day's takings that all arrived at 6pm did not happen at 6pm. * * Measured against `billedAtMs`, not the raw stamp. Read raw, every skewed bill * produced a NEGATIVE lag — which fell under the threshold and returned null, * so this reported "synced on time" for all twenty bills at location 1185 and * would have gone on doing so through a real outage. Silent wrong is worse * than blank. * * Returns null when the gap is under a minute (normal) or when either stamp is * missing — a bill with no `receivedat` predates the field, and calling that * "0 seconds late" would be a claim the data does not support. */ export function syncLagMs(bill: { billedat?: string; receivedat?: string }): number | null { if (!bill.billedat || !bill.receivedat) return null; const billed = billedAtMs(bill); const received = new Date(bill.receivedat).getTime(); if (billed === null || Number.isNaN(received)) return null; const lag = received - billed; return lag >= 60_000 ? lag : null; } /** * A lifecycle stamp that cannot have happened before the order did. * * The same class of fault as `billedAtMs`, in a second place. `getorders` * returns `deliverydate` — which is `orders.deliverytime` under an alias — and * on live tenant 1147 it is 5h29m48s behind `orderdate` on ALL FIFTY rows read: * * orderdate 2026-09-05T14:48:42+05:30 * deliverydate 2026-09-05T09:18:54+05:30 * * A delivery cannot be stamped before the order it belongs to. The gap is one * IST offset less a few seconds of processing, which is what a UTC instant * looks like when it is written into a column whose other writer uses server * local time and then serialised with a +05:30 tag. Fiesta's own default for * `orderdate` is `time.Now()` in the server's zone, so `orderdate` is the sound * reference and the one to measure against. * * Corrected only when the correction actually resolves the impossibility — a * stamp still earlier than the order afterwards is something else, and guessing * further would be inventing data. So this stops correcting by itself the day * the caller sends a real instant. */ export function stampAfterMs(value: string | undefined, notBefore: string | undefined): number | null { if (!value) return null; const at = new Date(value).getTime(); if (Number.isNaN(at)) return null; if (!notBefore) return at; const floor = new Date(notBefore).getTime(); if (Number.isNaN(floor)) return at; if (at >= floor - CLOCK_TOLERANCE_MS) return at; // `getTimezoneOffset` is (UTC − local) in minutes: −330 for IST. SUBTRACTING // it moves a UTC instant forward to the wall-clock reading it stood for, // which is the direction this fault runs — the opposite of `billedAtMs`, // where local digits were labelled `Z`. const corrected = at - new Date(at).getTimezoneOffset() * 60_000; return corrected >= floor - CLOCK_TOLERANCE_MS ? corrected : at; }