From 59828811f9f1e8a6fdaeb2db151a6d7405106c10 Mon Sep 17 00:00:00 2001 From: abhishek Date: Tue, 8 Sep 2026 15:40:48 +0530 Subject: [PATCH] timing --- src/api/ingest.test.ts | 34 +++++ src/api/ingest.ts | 78 +++++++++-- .../nearle-admin/import/SheetImportPanel.tsx | 43 +++++- src/features/store-admin/Drawer.tsx | 22 ++- .../store-admin/OrderDetailDrawer.tsx | 29 ++-- src/features/store-admin/drawer.css | 125 ++++++++++++++---- src/features/store-admin/drawerKit.tsx | 9 +- src/features/store-admin/orderStatus.test.ts | 45 ++++++- src/features/store-admin/orderStatus.ts | 40 ++++++ src/features/store-admin/pages/SalesPage.tsx | 13 +- .../store-user/pages/StoreProductsPage.tsx | 1 + src/features/uploads/UploadsPanel.tsx | 69 ++++++++-- 12 files changed, 437 insertions(+), 71 deletions(-) diff --git a/src/api/ingest.test.ts b/src/api/ingest.test.ts index 6106f68..d7c5d72 100644 --- a/src/api/ingest.test.ts +++ b/src/api/ingest.test.ts @@ -10,6 +10,7 @@ import { isAwaitingReview, isDismissed, isSettled, + pollDelayFor, currentStage, isStuckOnMissingRunner, productsOf, @@ -334,3 +335,36 @@ test('the run id is on the drop, and gone from the run it points to', () => { // cannot be written using the run's. assert.notEqual(drop.batch_id, run.batch_id); }); + +/* ── How often to look, and when to stop looking ──────────────────────────── */ + +/* +The console used to stop polling the moment a drop went to review, on the +reasoning that waiting for an admin is not progress. It is not — but the release +IS, and stopping there meant the panel said "waiting for review" until somebody +reloaded the page. A step-by-step panel that only advances on reload is the +thing the panel exists to replace. +*/ + +test('a review hold is polled slowly, not abandoned', () => { + assert.equal(isAwaitingReview(held), true); + assert.equal(pollDelayFor(held), 15000, 'a hold can last hours; 2s would be 1,800 reads an hour'); +}); + +test('a running batch is polled at a pace a person can watch', () => { + const running = { ...held, status: 'running' } satisfies IngestBatch; + assert.equal(isAwaitingReview(running), false); + assert.equal(pollDelayFor(running), 2000); +}); + +// A released drop is no longer waiting on anybody, so it goes back to the fast +// cadence even though its own status still reads "pending". +test('a released drop is followed at the running pace', () => { + const released = { + ...held, + files: [{ index: 0, filename: 'qa.csv', status: 'queued' as const, released_to: 'run-77' }], + } satisfies IngestBatch; + assert.equal(releasedRunId(released), 'run-77'); + assert.equal(isAwaitingReview(released), false); + assert.equal(pollDelayFor(released), 2000); +}); diff --git a/src/api/ingest.ts b/src/api/ingest.ts index f84efe0..d889bfa 100644 --- a/src/api/ingest.ts +++ b/src/api/ingest.ts @@ -518,13 +518,72 @@ export function isSettled(batch: IngestBatch): boolean { ); } +/** Sleeps, unless the caller aborts first. */ +function wait(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + const timer = setTimeout(finish, ms); + function finish() { + clearTimeout(timer); + signal?.removeEventListener('abort', finish); + resolve(); + } + signal?.addEventListener('abort', finish, { once: true }); + }); +} + +/** Resolves the moment the tab is visible again — immediately if it already is. */ +function whenVisible(signal?: AbortSignal): Promise { + if (typeof document === 'undefined' || document.visibilityState === 'visible') { + return Promise.resolve(); + } + return new Promise((resolve) => { + const finish = () => { + if (document.visibilityState !== 'visible' && !signal?.aborted) return; + document.removeEventListener('visibilitychange', finish); + signal?.removeEventListener('abort', finish); + resolve(); + }; + document.addEventListener('visibilitychange', finish); + signal?.addEventListener('abort', finish, { once: true }); + }); +} + +/** How long to wait before the next reading, given where the batch has got to. */ +export function pollDelayFor(batch: IngestBatch): number { + // A run is minutes and a person is watching the stage name move. + if (!isAwaitingReview(batch)) return 2000; + // A review hold is however long their admin takes — sometimes hours. Two + // seconds against that is 1,800 requests an hour to be told "still waiting". + return 15000; +} + /** - * Polls until the batch settles. + * Polls until the batch is finished, THROUGH the review hold. * - * Every two seconds. The pipeline's own stages take far longer than that, and a - * person is watching a progress bar — a slower cadence buys nothing but a - * screen that looks stuck. `onTick` fires on each reading so the caller can - * render the stage name and row counts as they move. + * ── Why it no longer stops at "awaiting review" ───────────────────────────── + * + * It used to return there, on the reasoning that waiting for an admin is not + * progress. True, but it left the console showing "waiting for review" forever + * once that admin released the drop — the steps only moved when the operator + * reloaded the page, which is the one thing a step-by-step progress panel is + * supposed to save them from. The release is exactly the transition worth + * watching: it is when the drop becomes a run and the products start arriving. + * + * So the hold is polled too, at `pollDelayFor`'s slower cadence — 15s rather + * than 2s, because a hold can last hours and a person is not watching a bar + * during one. + * + * ── And why a hidden tab costs nothing ────────────────────────────────────── + * + * Polling pauses entirely while the tab is in the background and takes a + * reading the instant it comes forward. So a sheet left open in another tab all + * afternoon makes no requests, and is up to date by the time the operator has + * looked at it — which is the same thing they used to get from reloading, minus + * the reload. + * + * `onTick` fires on each reading so the caller renders stage names and row + * counts as they move. It stops on a result and on a dismissal: a declined drop + * will never produce one. */ export async function pollBatch( batchId: string, @@ -539,13 +598,10 @@ export async function pollBatch( // "released" forever. const batch = await resolveBatch(await fetchBatch(batchId, signal), signal); onTick(batch); - // Stops on a review hold and on a dismissal as well as on a result. Waiting - // for an admin is not progress, a declined drop will never produce one, and - // a browser tab cannot outlast either — the drop id is what the operator - // comes back with. - if (isSettled(batch) || isAwaitingReview(batch) || isDismissed(batch)) return batch; + if (isSettled(batch) || isDismissed(batch)) return batch; - await new Promise((resolve) => setTimeout(resolve, 2000)); + await wait(pollDelayFor(batch), signal); + await whenVisible(signal); } } diff --git a/src/features/nearle-admin/import/SheetImportPanel.tsx b/src/features/nearle-admin/import/SheetImportPanel.tsx index 8062dc4..2d94aa6 100644 --- a/src/features/nearle-admin/import/SheetImportPanel.tsx +++ b/src/features/nearle-admin/import/SheetImportPanel.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { Badge } from '@astryxdesign/core/Badge'; import { Button } from '@astryxdesign/core/Button'; import { Card } from '@astryxdesign/core/Card'; @@ -118,6 +118,41 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps */ const [dropId, setDropId] = useState(null); const [isWorking, setIsWorking] = useState(false); + /** + * The live watch on the current batch. + * + * Held so it can be called off — when a new file replaces this one, when the + * panel resets, and when the drawer closes. Without that, closing the drawer + * mid-run left a poll reading a batch nothing was rendering. + */ + const watcher = useRef(null); + + useEffect(() => () => watcher.current?.abort(), []); + + /** + * Follows a batch until it finishes, updating the steps as it goes. + * + * NOT awaited by the caller, and that is the point. The upload is over once + * the service has the file; what follows is a wait — often on their admin to + * release the drop — and holding the submit handler open for it would keep + * the button spinning for hours. Every reading lands through `setBatch`, so + * the panel re-renders on each one. + */ + function watch(batchId: string) { + watcher.current?.abort(); + const controller = new AbortController(); + watcher.current = controller; + void pollBatch(batchId, setBatch, controller.signal) + .then((settled) => { + if (!controller.signal.aborted) setBatch(settled); + }) + .catch(() => { + /* Aborted, or the service stopped answering. The last reading stays on + screen with its batch id, which is what the operator comes back + with — inventing an error over a poll that was cancelled would + report a failure that did not happen. */ + }); + } /** Set when the upload succeeded but its receipt could not be filed. */ const [receiptError, setReceiptError] = useState(null); @@ -143,6 +178,8 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps setFile(chosen); setParsed(null); setParseError(null); + // A new file is a new upload, so stop watching the old one first. + watcher.current?.abort(); setBatch(null); setDropId(null); setReceiptError(null); @@ -234,7 +271,8 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps } } - setBatch(await pollBatch(submitted.batch_id, setBatch)); + // The steps from here on arrive by themselves — see `watch`. + watch(submitted.batch_id); } catch (cause) { setParseError(errorMessage(cause)); } finally { @@ -695,6 +733,7 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps label="Send another file" variant="secondary" onClick={() => { + watcher.current?.abort(); setBatch(null); setParsed(null); setFile(null); diff --git a/src/features/store-admin/Drawer.tsx b/src/features/store-admin/Drawer.tsx index f397116..31be8b4 100644 --- a/src/features/store-admin/Drawer.tsx +++ b/src/features/store-admin/Drawer.tsx @@ -85,12 +85,28 @@ export function Drawer({
-

+ {/* `title` on both, because the header is one row now and a long + product name or a branch list is clipped to fit it. The full + text stays reachable on hover; it is also, always, on the row + the drawer was opened from. */} +

{title}

- {subtitle ? {subtitle} : null} - {meta ?
{meta}
: null} + {subtitle ? ( + + {subtitle} + + ) : null}
+ + {/* Beside the title, not beneath it. Stacked under the subtitle it was + a third line, and a third line is what made this header twice the + height of the nav bar it now matches. */} + {meta ?
{meta}
: null} diff --git a/src/features/store-admin/OrderDetailDrawer.tsx b/src/features/store-admin/OrderDetailDrawer.tsx index ef5b1f9..8df7920 100644 --- a/src/features/store-admin/OrderDetailDrawer.tsx +++ b/src/features/store-admin/OrderDetailDrawer.tsx @@ -5,7 +5,14 @@ import { useDeliveryMoves } from './DeliveryProgress'; import { Drawer } from './Drawer'; import { Badge, DrawerButton, DrawerCard, Metric, Metrics, Note, Row, Section } from './drawerKit'; import { branchLabel, moneyExact } from './format'; -import { DELIVERY_STATUS, ORDER_STATUS, orderQuantity, orderValue, statusColor } from './orderStatus'; +import { + DELIVERY_STATUS, + ORDER_STATUS, + orderQuantity, + orderValue, + stampAfterMs, + statusColor, +} from './orderStatus'; type Row_ = OrderRow | DeliveryRow; @@ -303,15 +310,21 @@ function MoveCaption({ moves }: { moves: Moves }) { * data-quality problem the operator opened this sheet to find. */ function Timeline({ row, isCancelled }: { row: Row_; isCancelled: boolean }) { - const steps = STEPS.map((step) => ({ - label: step.label, + const fields = row as unknown as Record; + /* Every later stamp is measured against when the order was placed, because no + step of the journey can have happened before it — see `stampAfterMs`. + `getorders` returns a `deliverydate` 5:30 BEHIND `orderdate` on every row, + and drawn raw this timeline read backwards. */ + const placed = fields['orderdate']; + + const steps = STEPS.map((step) => { // Read by name: the two row shapes do not carry the same stamps — a // delivery has no `packtime`, an order has no `arrivaltime` — so a step // whose field is absent simply reads undefined and renders as not-yet. - at: step.fields - .map((field) => (row as unknown as Record)[field]) - .find(Boolean), - })); + const raw = step.fields.map((field) => fields[field]).find(Boolean); + const at = stampAfterMs(raw, placed); + return { label: step.label, at: at === null ? undefined : at }; + }); /* The step the job is sitting on: the first one without a stamp. Marked as "current" rather than merely "not done", so the eye lands on where the @@ -400,7 +413,7 @@ function Party({ ); } -const when = (at: string): string => +const when = (at: string | number): string => new Date(at).toLocaleString('en-IN', { day: '2-digit', month: 'short', diff --git a/src/features/store-admin/drawer.css b/src/features/store-admin/drawer.css index c2f74b7..e2251b2 100644 --- a/src/features/store-admin/drawer.css +++ b/src/features/store-admin/drawer.css @@ -57,61 +57,111 @@ .drawer-panel, .drawer-scrim { animation: none; } } -/* ── Header ─────────────────────────────────────────────────────────────── */ +/* ── Header ───────────────────────────────────────────────────────────────── + + ONE BAR HIGH, and the same bar the page wears. + + `AppShell` sets its nav bar to a 56px row plus a 1px rule, so the drawer's + header is 56 + 1 too: open a drawer and its header sits on exactly the line + the nav bar sits on, edge to edge across the screen, instead of a 116px block + starting level with it and finishing 60px lower. + + Getting there meant the header becoming a ROW rather than a stack: title and + subtitle share a line-and-a-half in the middle, the status badge sits beside + them, and the close button ends the row. Nothing was dropped to make it fit — + the same four things are in it. + + It is painted in the brand purple. The one place a drawer needs to announce + itself is the moment it slides in over the page, and the purple does that + without the panel needing a border or a heavier shadow. */ .drawer-head { flex: none; - display: grid; - grid-template-columns: minmax(0, 1fr) auto; + display: flex; + align-items: center; gap: 12px; - padding: 20px 24px 16px; - border-bottom: 1px solid var(--color-line); - background: var(--color-surface); + /* 57, not 56: box-sizing is border-box across the app, so the 1px rule + below eats into the height. The nav bar is a 56px row PLUS its rule, and + the two have to finish on the same pixel. */ + height: 57px; + padding: 0 12px 0 20px; + background: var(--color-brand); + /* A darker purple, not the grey page rule: a grey seam across a saturated + ground reads as a rendering fault rather than an edge. */ + border-bottom: 1px solid var(--color-brand-strong); } -.drawer-head-text { display: flex; flex-direction: column; gap: 4px; min-width: 0; } +.drawer-head-text { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; + flex: 1 1 auto; +} +/* One line, with the full text on hover and in the accessible name. + + A product name can run to sixty characters and the row is 56px tall, so it + truncates — but the drawer is always opened FROM the thing it names, so the + full text is on the page behind it as well as in the tooltip. */ .drawer-title { margin: 0; - font: 600 21px/1.25 var(--font-sans); - letter-spacing: -0.01em; - color: var(--color-ink-1); - overflow-wrap: anywhere; + font: 600 15px/1.3 var(--font-sans); + letter-spacing: -0.005em; + color: #fff; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } /* An order id is a code, and a code reads better tabular. */ -.drawer-title[data-mono='true'] { font-family: var(--font-mono); font-size: 20px; } +.drawer-title[data-mono='true'] { font-family: var(--font-mono); font-size: 14px; } .drawer-sub { - font: 400 13.5px/1.4 var(--font-sans); - color: var(--color-ink-3); - overflow-wrap: anywhere; + font: 400 11.5px/1.35 var(--font-sans); + /* Not a grey: a grey on purple goes muddy. White at 72% keeps the same + "quieter than the title" relationship the page's ink scale gives. */ + color: rgb(255 255 255 / 0.72); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -/* The row under the title: badge, then any metadata. */ +/* Beside the title now, not under it — that stacked row is what made the + header three lines tall. It never wraps: a second badge shortens the title + rather than pushing the row taller. */ .drawer-meta { display: flex; align-items: center; gap: 8px; - flex-wrap: wrap; - margin-top: 2px; + flex: none; + flex-wrap: nowrap; +} +/* Capped, because the meta keeps its width and the title gives way. A branch + named 'R mart - RS Puram' would otherwise eat the order id it sits beside. */ +.drawer-meta-text { + font: 400 12px/1.4 var(--font-sans); + color: rgb(255 255 255 / 0.72); + max-width: 30ch; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.drawer-meta-text { font: 400 13px/1.4 var(--font-sans); color: var(--color-ink-3); } .drawer-close { - width: 32px; - height: 32px; - border-radius: 10px; + width: 30px; + height: 30px; + border-radius: 9px; border: 0; flex: none; display: grid; place-items: center; - background: var(--color-surface-sunken); - color: var(--color-ink-3); + background: rgb(255 255 255 / 0.14); + color: #fff; cursor: pointer; - transition: background 160ms ease, color 160ms ease; + transition: background 160ms ease; } -.drawer-close:hover { background: var(--color-slate-200); color: var(--color-ink-1); } -.drawer-close:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; } +.drawer-close:hover { background: rgb(255 255 255 / 0.26); } +.drawer-close:focus-visible { outline: 2px solid #fff; outline-offset: 2px; } /* ── Body ───────────────────────────────────────────────────────────────── */ @@ -273,6 +323,27 @@ font: 600 12px/1.5 var(--font-sans); text-transform: capitalize; white-space: nowrap; + color: var(--badge); + background: color-mix(in oklab, var(--badge) 12%, transparent); +} + +/* In the purple header the status colour cannot be the text colour — a 12% + tint of green on purple is unreadable, and several statuses are darker than + the ground. The label goes white on a translucent chip and the colour moves + to a dot, lightened so it still separates from the purple. The status is + still distinguishable at a glance, which is the badge's whole job. */ +.drawer-head .drawer-badge { + color: #fff; + background: rgb(255 255 255 / 0.16); + padding-left: 8px; +} +.drawer-head .drawer-badge::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 999px; + flex: none; + background: color-mix(in oklab, var(--badge) 55%, white); } /* ── Timeline ───────────────────────────────────────────────────────────── */ diff --git a/src/features/store-admin/drawerKit.tsx b/src/features/store-admin/drawerKit.tsx index 4c393af..bda4fff 100644 --- a/src/features/store-admin/drawerKit.tsx +++ b/src/features/store-admin/drawerKit.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from 'react'; +import type { CSSProperties, ReactNode } from 'react'; import { Info } from 'lucide-react'; import './drawer.css'; @@ -132,7 +132,12 @@ export function Badge({ label, colour }: { label: string; colour: string }) { return ( {label} diff --git a/src/features/store-admin/orderStatus.test.ts b/src/features/store-admin/orderStatus.test.ts index 415fb6e..912330b 100644 --- a/src/features/store-admin/orderStatus.test.ts +++ b/src/features/store-admin/orderStatus.test.ts @@ -11,7 +11,8 @@ */ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { billedAtMs, syncLagMs } from './orderStatus'; +import { billedAtMs, syncLagMs , + stampAfterMs,} from './orderStatus'; /* Set after the imports on purpose, and it still takes: `billedAtMs` reads the zone when it is CALLED, not when this module loads, and assigning TZ calls @@ -115,3 +116,45 @@ test('survives the fields being absent or unparseable', () => { assert.equal(syncLagMs({ billedat: '2026-08-27T11:15:00Z' }), null); assert.equal(syncLagMs({ receivedat: '2026-08-27T11:15:00Z' }), null); }); + +/* ── A delivery stamped before its own order ──────────────────────────────── */ + +/* +Measured on live tenant 1147, 50 of 50 rows: `deliverydate` sits 5h29m48s behind +`orderdate`. That is one IST offset less a few seconds of processing — a UTC +instant written into a column whose other writer uses server local time, then +served with a +05:30 tag. A delivery cannot precede its order. +*/ + +test('a delivery stamped 5:30 before its order is read forward', () => { + const at = stampAfterMs('2026-09-05T09:18:54+05:30', '2026-09-05T14:48:42+05:30'); + assert.equal( + new Date(at as number).toISOString(), + new Date('2026-09-05T14:48:54+05:30').toISOString(), + 'the same wall clock the order was placed at, twelve seconds later', + ); +}); + +test('a stamp that is already after the order is left alone', () => { + const raw = '2026-09-05T15:10:00+05:30'; + assert.equal(stampAfterMs(raw, '2026-09-05T14:48:42+05:30'), new Date(raw).getTime()); +}); + +// Correcting only when it resolves the impossibility is what makes this stop by +// itself: the day the caller sends a real instant, nothing here fires. +test('a stamp still before the order after correcting is left as sent', () => { + const raw = '2026-09-01T10:00:00+05:30'; + assert.equal(stampAfterMs(raw, '2026-09-05T14:48:42+05:30'), new Date(raw).getTime()); +}); + +test('no reference means nothing to check against, so nothing is changed', () => { + const raw = '2026-09-05T09:18:54+05:30'; + assert.equal(stampAfterMs(raw, undefined), new Date(raw).getTime()); + assert.equal(stampAfterMs(undefined, raw), null); +}); + +// Clock drift of a few minutes is ordinary and must not trigger a 5.5h shift. +test('a couple of minutes of drift is drift, not a timezone fault', () => { + const raw = '2026-09-05T14:46:00+05:30'; + assert.equal(stampAfterMs(raw, '2026-09-05T14:48:42+05:30'), new Date(raw).getTime()); +}); diff --git a/src/features/store-admin/orderStatus.ts b/src/features/store-admin/orderStatus.ts index 956413f..b223041 100644 --- a/src/features/store-admin/orderStatus.ts +++ b/src/features/store-admin/orderStatus.ts @@ -193,3 +193,43 @@ export function syncLagMs(bill: { billedat?: string; receivedat?: string }): num 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; +} diff --git a/src/features/store-admin/pages/SalesPage.tsx b/src/features/store-admin/pages/SalesPage.tsx index dbb4976..a4a8e49 100644 --- a/src/features/store-admin/pages/SalesPage.tsx +++ b/src/features/store-admin/pages/SalesPage.tsx @@ -27,6 +27,7 @@ import { orderValue, statusColor, billedAtMs, + stampAfterMs, syncLagMs, type StatusKey, } from '../orderStatus'; @@ -770,7 +771,11 @@ function DeliveriesTable({ {row.orderid || `DLV-${row.deliveryid}`} - {stamp(row.assigntime || row.deliverydate)} + {/* Measured against the order own time: deliverydate + arrives 5:30 early on every row - see stampAfterMs. */} + + {stamp(stampAfterMs(row.assigntime || row.deliverydate, row.deliverydate))} + {showBranch ? ( @@ -988,10 +993,10 @@ function BillsTable({ /* ── Primitives ──────────────────────────────────────────────────────────── */ /** "21 Aug, 14:32". Falls back to the raw string rather than to "Invalid Date". */ -function stamp(value: string | undefined): string { - if (!value) return '—'; +function stamp(value: string | number | null | undefined): string { + if (value === null || value === undefined || value === '') return '—'; const parsed = new Date(value); - if (Number.isNaN(parsed.getTime())) return value; + if (Number.isNaN(parsed.getTime())) return String(value); return `${parsed.toLocaleDateString('en-IN', { day: '2-digit', month: 'short' })}, ${clockOf(value)}`; } diff --git a/src/features/store-user/pages/StoreProductsPage.tsx b/src/features/store-user/pages/StoreProductsPage.tsx index 521cf8a..4474bbb 100644 --- a/src/features/store-user/pages/StoreProductsPage.tsx +++ b/src/features/store-user/pages/StoreProductsPage.tsx @@ -146,6 +146,7 @@ export function StoreProductsPage() { return ( { setError(null); @@ -151,7 +150,6 @@ export function UploadsPanel({ */ const refreshLive = useCallback( async (rows: UploadReceipt[]) => { - setIsRefreshing(true); const readings: Record = {}; for (const receipt of rows) { // Nothing further will arrive for these; the receipt is the answer. @@ -191,7 +189,6 @@ export function UploadsPanel({ } } setLive((prev) => ({ ...prev, ...readings })); - setIsRefreshing(false); }, [], ); @@ -200,6 +197,55 @@ export function UploadsPanel({ if (receipts && receipts.length > 0) void refreshLive(receipts); }, [receipts, refreshLive]); + /** Receipts that could still change. A finished, shelved one never will. */ + const moving = useMemo( + () => (receipts ?? []).filter((receipt) => !(receipt.laststatus === 'done' && receipt.shelvedat)), + [receipts], + ); + + /** + * Keeps the moving rows current without anyone reloading the page. + * + * The list used to be read once, when the page loaded. Everything after that + * — their admin releasing a drop, the run finishing, products reaching the + * shelf — arrived only if the operator reloaded the page, which is exactly + * what a status page exists to avoid. The "Check again" button that used to + * paper over it is gone with the same change. + * + * Fifteen seconds, and only while something is unfinished: a page of forty + * settled receipts makes no requests at all. It also pauses entirely while + * the tab is in the background and takes a reading the moment it comes + * forward, so a tab left open all afternoon costs nothing and is up to date + * by the time it is looked at. + */ + useEffect(() => { + if (moving.length === 0) return; + + let stopped = false; + let timer: ReturnType; + + const read = () => { + if (stopped || document.visibilityState !== 'visible') return; + void refreshLive(moving); + }; + const loop = () => { + read(); + timer = setTimeout(loop, 15000); + }; + timer = setTimeout(loop, 15000); + + // Coming back to the tab is worth a reading straight away — that is the + // moment somebody wants the answer. + const onVisible = () => read(); + document.addEventListener('visibilitychange', onVisible); + + return () => { + stopped = true; + clearTimeout(timer); + document.removeEventListener('visibilitychange', onVisible); + }; + }, [moving, refreshLive]); + const rows = useMemo(() => receipts ?? [], [receipts]); const showTenant = !tenantid; @@ -312,15 +358,12 @@ export function UploadsPanel({ aria-label="Search by file name" onChange={(event) => setSearch(event.target.value)} /> -