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

11 KiB
Raw Blame History

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

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:

<span className="ml-auto text-xs text-muted-foreground hidden sm:inline">
  {formatDate(order.created)}
</span>

Replace with:

<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):

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).