From 3fc9c576bfbf20b92b37d058e669676b0bc6395c Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 1 Sep 2026 01:56:28 +0200 Subject: [PATCH] docs: spec for compact DN row + order age badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...ery-row-info-and-order-age-badge-design.md | 305 ++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-01-delivery-row-info-and-order-age-badge-design.md diff --git a/docs/superpowers/specs/2026-09-01-delivery-row-info-and-order-age-badge-design.md b/docs/superpowers/specs/2026-09-01-delivery-row-info-and-order-age-badge-design.md new file mode 100644 index 0000000..c2a131a --- /dev/null +++ b/docs/superpowers/specs/2026-09-01-delivery-row-info-and-order-age-badge-design.md @@ -0,0 +1,305 @@ +# 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 `

` 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** — `` 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 + + {formatDate(order.created)} + +``` + +Replace with: + +```tsx + +``` + +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 ( + + {age.label} + + ); +} +``` + +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). \ No newline at end of file