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

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