This commit is contained in:
2026-09-08 15:40:48 +05:30
parent 024fc4adfd
commit 59828811f9
12 changed files with 437 additions and 71 deletions

View File

@@ -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);
});

View File

@@ -518,13 +518,72 @@ export function isSettled(batch: IngestBatch): boolean {
);
}
/** Sleeps, unless the caller aborts first. */
function wait(ms: number, signal?: AbortSignal): Promise<void> {
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<void> {
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);
}
}

View File

@@ -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<string | null>(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<AbortController | null>(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<string | null>(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);

View File

@@ -85,12 +85,28 @@ export function Drawer({
<div className="drawer-panel dialog-panel" style={{ width }}>
<header className="drawer-head">
<div className="drawer-head-text">
<h2 className="drawer-title" {...(isTitleMono ? { 'data-mono': 'true' } : {})}>
{/* `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. */}
<h2
className="drawer-title"
title={title}
{...(isTitleMono ? { 'data-mono': 'true' } : {})}
>
{title}
</h2>
{subtitle ? <span className="drawer-sub">{subtitle}</span> : null}
{meta ? <div className="drawer-meta">{meta}</div> : null}
{subtitle ? (
<span className="drawer-sub" title={subtitle}>
{subtitle}
</span>
) : null}
</div>
{/* 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 ? <div className="drawer-meta">{meta}</div> : null}
<button type="button" className="drawer-close" aria-label="Close" onClick={onClose}>
<X size={16} />
</button>

View File

@@ -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<string, string | undefined>;
/* 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<string, string | undefined>)[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',

View File

@@ -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 ───────────────────────────────────────────────────────────── */

View File

@@ -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 (
<span
className="drawer-badge"
style={{ color: colour, background: `color-mix(in oklab, ${colour} 12%, transparent)` }}
/* The colour goes in as a CUSTOM PROPERTY rather than as `color` and
`background` directly, so the stylesheet can decide how to spend it.
An inline colour would win over every rule, and the badge has to look
different in the purple header than it does on a white card — see
`.drawer-head .drawer-badge`. */
style={{ '--badge': colour } as CSSProperties}
>
{label}
</span>

View File

@@ -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());
});

View File

@@ -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;
}

View File

@@ -27,6 +27,7 @@ import {
orderValue,
statusColor,
billedAtMs,
stampAfterMs,
syncLagMs,
type StatusKey,
} from '../orderStatus';
@@ -770,7 +771,11 @@ function DeliveriesTable({
<Primary>
<Mono>{row.orderid || `DLV-${row.deliveryid}`}</Mono>
</Primary>
<Secondary>{stamp(row.assigntime || row.deliverydate)}</Secondary>
{/* Measured against the order own time: deliverydate
arrives 5:30 early on every row - see stampAfterMs. */}
<Secondary>
{stamp(stampAfterMs(row.assigntime || row.deliverydate, row.deliverydate))}
</Secondary>
</Td>
{showBranch ? (
<Td isMuted>
@@ -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)}`;
}

View File

@@ -146,6 +146,7 @@ export function StoreProductsPage() {
return (
<VStack gap={3}>
<PageHeader
isTabsInline
title="Products"
actions={
tab === 'requests' ? null : (

View File

@@ -33,7 +33,7 @@ import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { FileSpreadsheet, RefreshCw, UploadCloud } from 'lucide-react';
import { FileSpreadsheet, UploadCloud } from 'lucide-react';
import { errorMessage } from '@/api/client';
import {
fetchBatch,
@@ -119,7 +119,6 @@ export function UploadsPanel({
const [search, setSearch] = useState('');
const from = range?.fromdate ?? '';
const to = range?.todate ?? '';
const [isRefreshing, setIsRefreshing] = useState(false);
const load = useCallback(async () => {
setError(null);
@@ -151,7 +150,6 @@ export function UploadsPanel({
*/
const refreshLive = useCallback(
async (rows: UploadReceipt[]) => {
setIsRefreshing(true);
const readings: Record<string, LiveReading> = {};
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<typeof setTimeout>;
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)}
/>
<Button
label={isRefreshing ? 'Checking…' : 'Check again'}
variant="secondary"
size="sm"
isLoading={isRefreshing}
isDisabled={isRefreshing}
icon={<RefreshCw size={13} />}
onClick={() => void refreshLive(rows)}
/>
{/* "Check again" stood here. It is gone because the page reads the
moving rows by itself now — every fifteen seconds while something is
unfinished, and again the moment the tab comes forward. A refresh
button beside a list that refreshes itself is a button that never
changes anything, and pressing it to find out whether it did is
worse than not having it. */}
{onUpload ? (
<Button
label="Upload spreadsheet"