WFDASH-WEBAPP/docs/superpowers/specs/2026-09-01-delivery-row-info-and-order-age-badge-design.md
Dave 3fc9c576bf docs: spec for compact DN row + order age badge
Two small, independent rendering improvements for /deliveries and /orders:

  1. /deliveries — each delivery-note row gets a compact subline with
     customer name, company name, parent order number, and a signed-copy
     indicator (paperclip + filename tooltip). All data already on the
     row payload — no server-side changes.

  2. /orders — order card right-aligned metadata slot gets a colour-
     coded age badge (today / Xd old) replacing the bare date string.
     New src/lib/order-age.ts as a pure function with bucket thresholds
     (green <3d, amber 3-7d, orange 8-14d, red 15+d) and a unit test.

No PB schema changes. No /api/v1 route changes. No MCP tool changes.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-01 01:56:28 +02:00

305 lines
No EOL
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Compact DN Row + Order Age Badge — Design
## Purpose
Two small, independent rendering improvements for two pages the
operator uses every day:
1. **`/deliveries`** — each delivery-note row currently renders only
`DN {number}` + a one-line subline (delivery date or PO ref). The
operator can't tell *who* a delivery note is for or whether the
signed copy is back without scrolling up to the customer/order
group header. This spec adds a compact subline with customer name,
company, parent order number, and a signed-copy indicator — all
data the service already returns, no new queries.
2. **`/orders`** — the order card subtitle already shows
`{formatDate(order.created)}` but as a bare date string. The
operator wants to triage by **age** ("how long has this been on my
board") at a glance, not parse dates. This spec adds a colour-coded
age badge (`today`, `3d old`, `7d old`, etc.) replacing the bare
date string in the right-aligned metadata slot.
Both features share no code; they're bundled in one spec only because
they were brainstormed together.
## Scope decisions (confirmed during brainstorming)
- **DN row enrichment** — compact option from the brainstorm:
customer name, company name, parent order number, signed-copy
indicator. No item count, no total value (those would require a
server-side aggregate; rejected for now).
- **Age surfacing** — colour-coded badge, not auto-sort, not
raw-age-only. Replaces the existing date string; date is dropped
because the badge text already conveys the date implicitly ("3d old
on 2026-08-29" is redundant).
- **Age thresholds** — `<24h → today (green)`, `1–2d → green`,
`3–7d → amber`, `8–14d → orange`, `>14d → red`. Tunable later.
- **No server changes** — both features consume data already
returned by `/api/v1/deliveries` and `/api/v1/orders` (or, in
`/orders`'s case, the page-level fetcher).
- **No PB schema changes** — purely UI/rendering work.
## Architecture
**New files:**
```
src/lib/order-age.ts — pure age math + bucket → tailwind class
src/lib/order-age.test.ts — unit tests for the util
```
**Modified files:**
```
src/app/(app)/deliveries/page.tsx — DeliveryNoteRowComponent only
src/app/(app)/orders/page.tsx — order card right-aligned metadata slot
```
No service-layer or `/api/v1` route changes. No MCP tool changes.
---
## Feature 1: Compact info on delivery-note rows
### File: `src/app/(app)/deliveries/page.tsx`
Scope: **only** the `DeliveryNoteRowComponent` function (lines
~1475-1588). No change to the grouping logic, the search filter, the
loading endpoint, or any other component on the page.
### Target row rendering
```
DN DN26083101
Acme Mining (Pty) Ltd · Master Drilling Co · ORD-2026-08-058 · 📎 signed copy
[status badge] [Mark loaded] [Signed copy]
```
The existing `<p>` subline at line 1537-1547 is replaced with a new
piece list:
1. **Customer name** — `dn.order?.customer_name` ?? `"Counter pickup"`.
Always rendered (counter-pickup is the explicit no-order case).
2. **Company name** — `dn.order?.company_name`. Hidden if falsy.
3. **Order number** — `dn.order?.order_number`. Hidden if falsy.
4. **Signed-copy indicator** — `<Paperclip />` icon + `"signed copy"`
text. Hidden if `!dn.signed_copy || dn.signed_copy.length === 0`.
The whole indicator carries `title={dn.signed_copy.join(", ")}` so
the operator can see the filenames on hover.
Joined by `·` (same separator convention as the order card subtitle),
in `text-muted-foreground` (matches the existing subline colour).
### Edge cases
- **Counter-pickup DN** (`dn.order === null`): customer/company/order
pieces all collapse to a single `"Counter pickup"` label (no
separator emitted for the missing pieces). Signed-copy indicator
still appears if files were uploaded directly to the DN.
- **Long customer names**: the customer name piece carries
`truncate max-w-[40ch]` + `title` to preserve hover-access. Other
pieces are short and don't need truncation.
- **Multi-DN under one order**: each DN row still renders its own
compact subline. The group header (customer → order → list of rows)
is unchanged.
### Acceptance
- A DN with order attached shows: `DN {n}`, customer name, company
name, order number, paperclip if signed copy uploaded.
- A counter-pickup DN shows: `DN {n}`, `Counter pickup`, paperclip if
signed copy uploaded.
- A DN with no signed copy shows no paperclip.
- The page's existing search, grouping, and tab logic are unaffected.
- No new network request is made when rendering a DN row.
---
## Feature 2: Order age badge
### New file: `src/lib/order-age.ts`
```typescript
export type OrderAgeBucket = "fresh" | "recent" | "slipping" | "stale";
export interface OrderAge {
/** Integer calendar-day delta. 0 means "today". Never negative. */
daysOld: number;
bucket: OrderAgeBucket;
/** Human-readable badge label. */
label: string;
}
/**
* Compute the age of an order relative to `now` (defaults to
* `new Date()`). The `daysOld` value is a **calendar-day** delta in
* the office's local timezone (Africa/Johannesburg, hardcoded for
* now) — an order created at 23:00 yesterday read at 01:00 today is
* `daysOld = 0` ("today"), not 1. The day delta is computed as
* `floor((localEndOfDay - localStartOfCreatedDay) / 1 day)` and is
* always returned as a non-negative integer.
*
* Future-dated `created` values (clock skew, bad data) clamp to 0
* to avoid showing "−1d old" or similar nonsense.
*
* Implementation note: use `Intl.DateTimeFormat` with the
* Africa/Johannesburg timezone to get the local YYYY-MM-DD for both
* dates, then subtract. Don't divide millisecond deltas by
* 86_400_000 — that fails across DST boundaries (SA doesn't observe
* DST but the pattern is fragile).
*/
export function getOrderAge(
created: string | Date,
now?: Date,
): OrderAge;
/**
* Map an age bucket to a tailwind className for the badge.
* Centralised so a future palette tweak is a one-line change.
*/
export function bucketToBadgeClass(bucket: OrderAgeBucket): string;
```
**Bucket thresholds:**
| `daysOld` | bucket | label |
|-----------|--------|-------|
| 0 | `fresh` | `today` |
| 1–2 | `fresh` | `{daysOld}d old` |
| 3–7 | `recent` | `{daysOld}d old` |
| 8–14 | `slipping` | `{daysOld}d old` |
| ≥ 15 | `stale` | `{daysOld}d old` |
**Badge class names** (initial palette, tunable):
- `fresh` → `bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300`
- `recent` → `bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300`
- `slipping` → `bg-orange-100 text-orange-800 dark:bg-orange-950 dark:text-orange-300`
- `stale` → `bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300`
### New file: `src/lib/order-age.test.ts`
Unit tests, all pure-function:
- `daysOld = 0` (created today) → `fresh`, label `"today"`
- `daysOld = 1, 2` → `fresh`, label `"1d old"`, `"2d old"`
- `daysOld = 3, 7` → `recent`, label `"3d old"`, `"7d old"`
- `daysOld = 8, 14` → `slipping`, label `"8d old"`, `"14d old"`
- `daysOld = 15, 30, 365` → `stale`, label `"15d old"`, `"30d old"`,
`"365d old"`
- `daysOld = -1` (future date) → clamps to `fresh`, label `"today"`
- **Local-timezone edge case**: an order created at 23:00 JNB yesterday
read at 01:00 JNB today must return `daysOld = 0` (calendar-day delta,
not millisecond delta). Test this with `now` injected explicitly.
- All four `bucketToBadgeClass` outputs return non-empty strings
- `bucketToBadgeClass` is stable (same input → same output)
No React rendering tests for the badge — that would be a separate
playwright/e2e concern and is out of scope.
### Modified file: `src/app/(app)/orders/page.tsx`
Scope: the right-aligned metadata line in the order card (current line
~865-867). The exact JSX currently is:
```tsx
<span className="ml-auto text-xs text-muted-foreground hidden sm:inline">
{formatDate(order.created)}
</span>
```
Replace with:
```tsx
<OrderAgeBadge created={order.created} className="ml-auto hidden sm:inline-flex" />
```
Where `OrderAgeBadge` is a tiny new inline component in the same
file (kept local — used only here for now; if a second page needs it,
it can be hoisted to `src/components/order-age-badge.tsx`):
```tsx
function OrderAgeBadge({
created,
className,
}: {
created: string;
className?: string;
}) {
const age = getOrderAge(created);
return (
<span
className={cn(
"rounded-full px-2 py-0.5 text-[10px] font-medium tabular-nums",
bucketToBadgeClass(age.bucket),
className,
)}
title={`Created ${formatDate(created)}`}
>
{age.label}
</span>
);
}
```
The `title=` attribute preserves the absolute date for operators who
want to know the exact day — addresses the brainstorm's "drop raw
date" option concern without keeping it as primary UI.
The existing `formatDate` import (currently from
`@/lib/pb-helpers`, line ~1596 — `formatDate(dateStr: string)`) is
kept on the page. `getOrderAge` itself does **not** import
`formatDate`; it returns the age label only.
### Edge cases
- **Sorting**: not changing. The badge surfaces age; the operator
still sorts by `created` if they want.
- **Rep filtering**: the recent commit
`feat(invoicing): filter rows to the logged-in rep's own orders` is
orthogonal. Age badge fires regardless of who the rep is.
- **Long lists**: each card gets one badge. No batched summary view
changes.
- **Existing `formatDate` call**: `formatDate(order.created)` moves
from the JSX into the badge's `title=` tooltip so the absolute
date is still accessible on hover.
### Acceptance
- An order created today shows a green `today` badge in the
right-aligned slot where the date used to live.
- An order 5 days old shows an amber `5d old` badge.
- An order 20 days old shows a red `20d old` badge.
- Hovering the badge reveals the full `Created YYYY-MM-DD` tooltip.
- The order card layout is otherwise unchanged — the badge slot has
the same `ml-auto hidden sm:inline` styling hooks.
---
## What's NOT in scope
- **No new server endpoints.** Both features use existing data.
- **No PB schema changes.** Purely UI.
- **No item count / total value on DN rows.** Rejected during
brainstorm (would need a server-side aggregate).
- **No sorting changes on /orders.** Badge only.
- **No changes to MCP tools.**
- **No changes to /api/v1 routes.**
- **No changes to other delivery-note pages** (`/delivery-notes`,
`/delivery-notes/[id]`, `/delivery-note-print`). Only
`/deliveries`'s row component.
## Testing
- `src/lib/order-age.test.ts` — 6+ unit tests covering all bucket
thresholds + future-date clamp + `bucketToBadgeClass` stability.
- Manual smoke: open `/orders`, verify badges appear with correct
colours for orders of various ages; open `/deliveries`, verify the
DN row subline shows customer/company/order/paperclip as expected.
- Existing page tests (if any) for `/orders` and `/deliveries`
should continue to pass — no layout regressions.
## Risk assessment
**Low.** Both features are additive rendering changes against
existing, well-typed payloads. The age util is a pure function and
the only file with logic worth testing. The DN row change touches
one component and doesn't change any user-facing interaction
(no new buttons, no new fields).