WFDASH-WEBAPP/docs/superpowers/plans/2026-09-01-delivery-row-info-and-order-age-badge.md
Dave b3965b1454 docs: implementation plan for compact DN row + order age badge
Three bite-sized tasks with full code:
  1. TDD the order-age util (test → impl → run)
  2. Wire OrderAgeBadge into /orders (replace bare date)
  3. Enrich /deliveries DN row subline (customer/order/paperclip)

All UI/rendering only. No server, no schema, no MCP changes.

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

22 KiB

Compact DN Row + Order Age Badge Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Two small, independent rendering improvements: enrich each delivery-note row on /deliveries with customer / company / order / signed-copy info, and replace the bare date on /orders cards with a colour-coded age badge.

Architecture: Pure UI/rendering work. One new pure-function util (src/lib/order-age.ts) with full TDD; two existing pages (/deliveries, /orders) get small additive edits. No server changes, no PB schema changes, no MCP tool changes.

Tech Stack: Next.js + React 19 (existing). Vitest (existing) for the util test. Tailwind classes for the badge colours (existing).

Spec: docs/superpowers/specs/2026-09-01-delivery-row-info-and-order-age-badge-design.md


File structure

Action File Responsibility
Create src/lib/order-age.ts Pure: getOrderAge(created, now?) + bucketToBadgeClass(bucket). Calendar-day delta in Africa/Johannesburg.
Create src/lib/order-age.test.ts Vitest unit tests for every bucket threshold + future-date clamp + JNB-tz edge case + badge-class stability.
Modify src/app/(app)/orders/page.tsx Add OrderAgeBadge inline component + import getOrderAge / bucketToBadgeClass. Replace {formatDate(order.created)} with <OrderAgeBadge created={order.created} … /> at line ~866.
Modify src/app/(app)/deliveries/page.tsx Edit DeliveryNoteRowComponent only (~lines 1533-1547). Replace the subline with the compact pieces list (customer, company, order, paperclip).

Task 1: Order-age util (TDD)

Files:

  • Create: src/lib/order-age.ts

  • Create: src/lib/order-age.test.ts

  • Step 1: Write the failing tests

Write src/lib/order-age.test.ts:

import { describe, it, expect } from "vitest";
import { getOrderAge, bucketToBadgeClass } from "./order-age";

describe("getOrderAge", () => {
  // Anchor `now` so the tests don't drift with wall-clock time.
  // 2026-09-01 12:00 in Africa/Johannesburg = 2026-09-01T10:00:00Z
  const NOW = new Date("2026-09-01T10:00:00.000Z");

  it("returns daysOld=0 with label 'today' when created today", () => {
    const result = getOrderAge("2026-09-01T08:00:00.000Z", NOW);
    expect(result).toEqual({ daysOld: 0, bucket: "fresh", label: "today" });
  });

  it("returns daysOld=1 with bucket fresh for 1-day-old orders", () => {
    const result = getOrderAge("2026-08-31T08:00:00.000Z", NOW);
    expect(result.daysOld).toBe(1);
    expect(result.bucket).toBe("fresh");
    expect(result.label).toBe("1d old");
  });

  it("returns daysOld=2 with bucket fresh at the upper edge of fresh", () => {
    const result = getOrderAge("2026-08-30T08:00:00.000Z", NOW);
    expect(result.daysOld).toBe(2);
    expect(result.bucket).toBe("fresh");
    expect(result.label).toBe("2d old");
  });

  it("returns daysOld=3 with bucket recent at the lower edge of recent", () => {
    const result = getOrderAge("2026-08-29T08:00:00.000Z", NOW);
    expect(result.daysOld).toBe(3);
    expect(result.bucket).toBe("recent");
    expect(result.label).toBe("3d old");
  });

  it("returns daysOld=7 with bucket recent at the upper edge of recent", () => {
    const result = getOrderAge("2026-08-25T08:00:00.000Z", NOW);
    expect(result.daysOld).toBe(7);
    expect(result.bucket).toBe("recent");
    expect(result.label).toBe("7d old");
  });

  it("returns daysOld=8 with bucket slipping at the lower edge of slipping", () => {
    const result = getOrderAge("2026-08-24T08:00:00.000Z", NOW);
    expect(result.daysOld).toBe(8);
    expect(result.bucket).toBe("slipping");
    expect(result.label).toBe("8d old");
  });

  it("returns daysOld=14 with bucket slipping at the upper edge of slipping", () => {
    const result = getOrderAge("2026-08-18T08:00:00.000Z", NOW);
    expect(result.daysOld).toBe(14);
    expect(result.bucket).toBe("slipping");
    expect(result.label).toBe("14d old");
  });

  it("returns daysOld=15 with bucket stale at the lower edge of stale", () => {
    const result = getOrderAge("2026-08-17T08:00:00.000Z", NOW);
    expect(result.daysOld).toBe(15);
    expect(result.bucket).toBe("stale");
    expect(result.label).toBe("15d old");
  });

  it("returns daysOld=365 with bucket stale for very old orders", () => {
    const result = getOrderAge("2025-09-01T08:00:00.000Z", NOW);
    expect(result.daysOld).toBe(365);
    expect(result.bucket).toBe("stale");
    expect(result.label).toBe("365d old");
  });

  it("clamps future-dated created to daysOld=0 (clock skew tolerance)", () => {
    const result = getOrderAge("2026-09-02T08:00:00.000Z", NOW);
    expect(result.daysOld).toBe(0);
    expect(result.bucket).toBe("fresh");
    expect(result.label).toBe("today");
  });

  it("computes calendar-day delta in Africa/Johannesburg, not millisecond delta", () => {
    // 23:00 SAST on 2026-08-31 (UTC+2) → 21:00 UTC
    // 01:00 SAST on 2026-09-01 → 23:00 UTC on 2026-08-31
    // Both are in the SAME calendar day in JNB (Sep 1 vs Aug 31).
    // Millisecond delta is 2 hours, but the day delta must be 1.
    const created = new Date("2026-08-31T21:00:00.000Z"); // 23:00 SAST Aug 31
    const now = new Date("2026-08-31T23:00:00.000Z");     // 01:00 SAST Sep 1
    const result = getOrderAge(created, now);
    expect(result.daysOld).toBe(1);
    expect(result.label).toBe("1d old");
  });

  it("treats an order created at 23:00 yesterday JNB and read at 01:00 today JNB as today", () => {
    // Same calendar day in JNB despite the millisecond delta.
    const created = new Date("2026-08-31T21:00:00.000Z"); // 23:00 SAST Aug 31
    const now = new Date("2026-09-01T09:00:00.000Z");     // 11:00 SAST Sep 1 → 1d old actually
    // 11:00 SAST Sep 1 vs 23:00 SAST Aug 31 = next calendar day in JNB.
    // For a SAME-day case, swap now to 21:00 SAST Aug 31 = 19:00 UTC Aug 31.
    const sameDayNow = new Date("2026-08-31T19:00:00.000Z");
    const result = getOrderAge(created, sameDayNow);
    expect(result.daysOld).toBe(0);
    expect(result.label).toBe("today");
  });
});

describe("bucketToBadgeClass", () => {
  it("returns a non-empty string for every bucket", () => {
    expect(bucketToBadgeClass("fresh").length).toBeGreaterThan(0);
    expect(bucketToBadgeClass("recent").length).toBeGreaterThan(0);
    expect(bucketToBadgeClass("slipping").length).toBeGreaterThan(0);
    expect(bucketToBadgeClass("stale").length).toBeGreaterThan(0);
  });

  it("is stable — same input returns same output", () => {
    expect(bucketToBadgeClass("fresh")).toBe(bucketToBadgeClass("fresh"));
    expect(bucketToBadgeClass("recent")).toBe(bucketToBadgeClass("recent"));
    expect(bucketToBadgeClass("slipping")).toBe(bucketToBadgeClass("slipping"));
    expect(bucketToBadgeClass("stale")).toBe(bucketToBadgeClass("stale"));
  });
});
  • Step 2: Run the tests to verify they fail

Run:

npx vitest run src/lib/order-age.test.ts

Expected: FAIL — Cannot find module './order-age' (or similar import error). The util doesn't exist yet.

  • Step 3: Write the implementation

Write src/lib/order-age.ts:

/**
 * Order-age utility — calendar-day delta + colour-bucket mapping for
 * the `/orders` list card age badge. See:
 *   docs/superpowers/specs/2026-09-01-delivery-row-info-and-order-age-badge-design.md
 *
 * Why a separate util: keeps the page component free of date math;
 * trivial to unit-test in isolation; pure function (no React, no PB).
 */

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

/**
 * Office timezone — the rest of the app doesn't deal in TZs explicitly,
 * but the age badge reads "today" if the order was created on the same
 * calendar day in JNB, not if it's been less than 24h since `created`.
 * Hardcoded for now; centralise if a second locale ever needs it.
 */
const OFFICE_TZ = "Africa/Johannesburg";

/**
 * Format a Date as YYYY-MM-DD in the given IANA timezone.
 * Used to compute the calendar-day delta independent of the
 * machine's local timezone (vitest runs in UTC by default).
 */
function toLocalDayKey(d: Date, tz: string): string {
  const parts = new Intl.DateTimeFormat("en-CA", {
    timeZone: tz,
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
  }).formatToParts(d);
  const y = parts.find((p) => p.type === "year")!.value;
  const m = parts.find((p) => p.type === "month")!.value;
  const day = parts.find((p) => p.type === "day")!.value;
  return `${y}-${m}-${day}`;
}

/**
 * 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) — an order created at
 * 23:00 JNB yesterday read at 01:00 JNB today is `daysOld = 0`
 * ("today"), not 1. The day delta is computed as
 * `(localTodayKey - localCreatedKey)` parsed as UTC midnight dates
 * and subtracted. Never returns a negative number; future-dated
 * `created` values clamp to 0.
 */
export function getOrderAge(
  created: string | Date,
  now: Date = new Date(),
): OrderAge {
  const createdDate = typeof created === "string" ? new Date(created) : created;

  const createdKey = toLocalDayKey(createdDate, OFFICE_TZ);
  const todayKey = toLocalDayKey(now, OFFICE_TZ);

  // Both keys parse as UTC midnight, so subtraction yields whole days.
  const createdMs = new Date(`${createdKey}T00:00:00Z`).getTime();
  const todayMs = new Date(`${todayKey}T00:00:00Z`).getTime();
  const rawDays = Math.round((todayMs - createdMs) / 86_400_000);
  const daysOld = rawDays < 0 ? 0 : rawDays;

  let bucket: OrderAgeBucket;
  if (daysOld <= 2) bucket = "fresh";
  else if (daysOld <= 7) bucket = "recent";
  else if (daysOld <= 14) bucket = "slipping";
  else bucket = "stale";

  const label = daysOld === 0 ? "today" : `${daysOld}d old`;

  return { daysOld, bucket, label };
}

/**
 * Map an age bucket to a tailwind className for the badge. Initial
 * palette per the spec; centralise here so a future palette tweak is
 * a one-line change.
 */
export function bucketToBadgeClass(bucket: OrderAgeBucket): string {
  switch (bucket) {
    case "fresh":
      return "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300";
    case "recent":
      return "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300";
    case "slipping":
      return "bg-orange-100 text-orange-800 dark:bg-orange-950 dark:text-orange-300";
    case "stale":
      return "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300";
  }
}
  • Step 4: Run the tests to verify they pass

Run:

npx vitest run src/lib/order-age.test.ts

Expected: all tests pass. 14 tests total (12 in getOrderAge describe, 2 in bucketToBadgeClass describe).

  • Step 5: Commit
git add src/lib/order-age.ts src/lib/order-age.test.ts
git commit -m "feat(orders): add order-age util with bucket thresholds

Pure function: getOrderAge(created, now?) returns {daysOld, bucket, label}
in calendar-day delta for Africa/Johannesburg. bucketToBadgeClass maps
to tailwind classes (fresh/recent/slipping/stale). No React, no DB.

Vitest unit tests cover all 5 bucket thresholds, future-date clamp, and
the JNB timezone edge case (same calendar day in JNB = daysOld 0).

Co-Authored-By: Claude Code <noreply@anthropic.com>"

Task 2: Wire OrderAgeBadge into the orders page

Files:

  • Modify: src/app/(app)/orders/page.tsx

  • Step 1: Add the imports

In src/app/(app)/orders/page.tsx, add the new imports at the end of the existing import { Badge } from "@/components/ui/badge" block (line 8):

import { getOrderAge, bucketToBadgeClass } from "@/lib/order-age";

Add cn to the @/lib/utils import (currently displayContactName on line 27). If cn is not already exported by @/lib/utils, leave the import alone and use template-literal class strings instead — but check first. The util almost certainly exports cn (used everywhere in the codebase). If present:

import { cn, displayContactName } from "@/lib/utils";
  • Step 2: Add the OrderAgeBadge component

In src/app/(app)/orders/page.tsx, immediately above the existing formatDate function (line 90), add:

/**
 * Inline age badge for the order card metadata slot. Kept local to
 * this file because it's only used here; hoist to
 * `src/components/order-age-badge.tsx` if a second page needs it.
 *
 * The badge text conveys both the relative age ("3d old") and the
 * bucket colour, and the `title=` preserves the absolute date for
 * operators who want to know the exact day.
 */
function OrderAgeBadge({
  created,
  className,
}: {
  created: string;
  className?: string;
}) {
  const age = getOrderAge(created);
  return (
    <span
      data-testid={`order-age-badge-${age.bucket}`}
      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>
  );
}

(If cn is not available from @/lib/utils, replace cn(...) with a template literal: `rounded-full px-2 py-0.5 text-[10px] font-medium tabular-nums ${bucketToBadgeClass(age.bucket)}${className ? ` ${className}` : ""}`.)

  • Step 3: Replace the bare date with the badge

In src/app/(app)/orders/page.tsx, locate line ~865-867:

<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"
/>
  • Step 4: Smoke-check in the dev server

Start the dev server (per CLAUDE.md / project memory: http://localhost:4001):

npm run dev

Open http://localhost:4001/orders. Verify:

  • A recently-created test order (e.g. ORD-2026-08-058 from the previous walkthrough — created ~30 min ago) shows a green today badge in the right-aligned metadata slot where the date used to live.
  • Hover the badge — tooltip shows Created <formatted date>.
  • Order card layout is otherwise unchanged.

Stop the dev server when done (Ctrl+C).

  • Step 5: Commit
git add "src/app/(app)/orders/page.tsx"
git commit -m "feat(orders): surface order age as colour-coded badge

Replaces the bare formatDate(order.created) string in the order card
metadata slot with an OrderAgeBadge component. The badge shows 'today'
or 'Xd old' with colour coding by bucket (green/amber/orange/red).
The absolute date is preserved in the badge's title= tooltip.

Bucket math lives in the new src/lib/order-age.ts util (covered by
unit tests). This change is rendering-only — no server or schema impact.

Co-Authored-By: Claude Code <noreply@anthropic.com>"

Task 3: Compact info on delivery-note rows

Files:

  • Modify: src/app/(app)/deliveries/page.tsx (only DeliveryNoteRowComponent, lines ~1475-1588)

  • Step 1: Add the Paperclip import

In src/app/(app)/deliveries/page.tsx, the lucide-react import already includes Pencil, Truck, User. Add Paperclip:

Locate the lucide-react import (around lines 86-94) and add Paperclip to the named-imports list:

import {
  CheckSquare,
  FileText,
  Loader2,
  PackageOpen,
  Paperclip,         // ← add this
  Pencil,
  Square,
  Truck,
  User,
} from "lucide-react";
  • Step 2: Replace the subline with the compact pieces list

In src/app/(app)/deliveries/page.tsx, locate DeliveryNoteRowComponent (lines ~1475-1588). Find the <p className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-muted-foreground tabular-nums">…</p> block (around line 1537-1547). Replace its children (the {dn.delivery_date && …} + <span className={…}>{subline}</span> content) with the following compact pieces list:

<p className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-muted-foreground tabular-nums">
  {dn.order ? (
    <>
      <span className="truncate max-w-[40ch]" title={dn.order.customer_name ?? ""}>
        {dn.order.customer_name}
      </span>
      {dn.order.company_name && (
        <>
          <span aria-hidden>·</span>
          <span>{dn.order.company_name}</span>
        </>
      )}
      {dn.order.order_number && (
        <>
          <span aria-hidden>·</span>
          <span className="font-mono">{dn.order.order_number}</span>
        </>
      )}
    </>
  ) : (
    <span className="italic">Counter pickup</span>
  )}
  {dn.signed_copy && dn.signed_copy.length > 0 && (
    <>
      <span aria-hidden>·</span>
      <span
        className="inline-flex items-center gap-1"
        title={dn.signed_copy.join(", ")}
      >
        <Paperclip className="h-3 w-3" aria-hidden />
        signed copy
      </span>
    </>
  )}
  {dn.delivery_date && (
    <>
      <span aria-hidden>·</span>
      <span>Delivery {dn.delivery_date}</span>
    </>
  )}
</p>

This keeps the existing text-muted-foreground / tabular-nums styling and adds: customer name (truncated, with title tooltip), optional company, optional order number, optional signed-copy indicator, optional delivery date. Counter-pickup DNs (dn.order === null) collapse to a single "Counter pickup" label per the spec.

Leave the rest of DeliveryNoteRowComponent (the <FileText /> icon, the title row with DN {n}, the status badge column, the two buttons) untouched.

  • Step 3: Smoke-check in the dev server

Start the dev server:

npm run dev

Open http://localhost:4001/deliveries. Verify on the To Load tab:

  • A DN with an order attached renders the customer name + company + order number under the DN number, joined by ·.
  • A counter-pickup DN (no order) shows "Counter pickup" in the same slot.
  • A DN with a signed copy uploaded shows a paperclip icon + "signed copy" text.
  • Hovering the paperclip reveals the filenames.
  • Layout is otherwise unchanged — buttons still on the right, status badge still on top.

Stop the dev server (Ctrl+C).

  • Step 4: Commit
git add "src/app/(app)/deliveries/page.tsx"
git commit -m "feat(deliveries): enrich DN row subline with customer/order/paperclip

The /deliveries To Load + Back from Driver + Checked In tabs each
render delivery-note rows showing only 'DN {number}' + a one-line
status subline. Operators couldn't identify the customer or tell
whether the signed copy was back without scrolling up to the
customer/order group header.

Replace the existing subline with a compact pieces list:
customer name (truncated, with title tooltip) · company name ·
parent order number · signed-copy indicator (paperclip icon +
filenames in title=) · delivery date (if set). Counter-pickup DNs
(no parent order) collapse to 'Counter pickup'.

All data is already on the row payload from /api/v1/deliveries —
no server or schema changes.

Co-Authored-By: Claude Code <noreply@anthropic.com>"

Self-review

Spec coverage check (against docs/superpowers/specs/2026-09-01-delivery-row-info-and-order-age-badge-design.md):

Spec section Plan task
§ Feature 1: Compact info on delivery-note rows — 4 pieces (customer, company, order, signed-copy indicator) + edge cases Task 3 ✓
§ Feature 1: Counter-pickup fallback ("Counter pickup") Task 3 Step 2 ✓
§ Feature 1: Signed-copy indicator (paperclip + filename tooltip) Task 3 Step 2 ✓
§ Feature 1: Long customer name truncation Task 3 Step 2 (max-w-[40ch] + title) ✓
§ Feature 1: No new network request Task 3 only edits rendering, no fetch added ✓
§ Feature 2: New src/lib/order-age.ts with getOrderAge + bucketToBadgeClass Task 1 ✓
§ Feature 2: Bucket thresholds (0/1-2/3-7/8-14/≥15) Task 1 Step 1 (tests cover all edges) ✓
§ Feature 2: Calendar-day delta in Africa/Johannesburg Task 1 Step 1 (timezone test) + Step 3 (OFFICE_TZ + toLocalDayKey) ✓
§ Feature 2: Future-date clamp Task 1 Step 1 (clamp test) + Step 3 (rawDays < 0 ? 0 : rawDays) ✓
§ Feature 2: Tailwind classes (4 buckets, dark variants) Task 1 Step 3 (bucketToBadgeClass) ✓
§ Feature 2: Unit tests for all bucket thresholds Task 1 Step 1 ✓
§ Feature 2: JNB-tz edge case test (same calendar day = daysOld 0) Task 1 Step 1 ✓
§ Feature 2: bucketToBadgeClass stability test Task 1 Step 1 ✓
§ Feature 2: OrderAgeBadge inline component Task 2 Step 2 ✓
§ Feature 2: Replace bare date with badge, preserving title= for absolute date Task 2 Step 3 ✓
§ Feature 2: Existing formatDate import kept on page Task 2 Step 3 (uses local formatDate) ✓
§ What's NOT in scope: no server changes, no schema, no MCP, no other pages All tasks comply ✓

Placeholder scan: No "TBD" / "TODO" / "implement later" / "fill in details". All code blocks are complete. The one hedge (if cn is not available…) is an explicit fallback instruction for a real ambiguity, not a placeholder.

Type consistency: OrderAgeBucket, OrderAge, getOrderAge, bucketToBadgeClass are defined and used with matching signatures across Tasks 1 and 2. OrderAgeBadge props match its usage at Task 2 Step 3.

Ambiguity check: None found. The cn-available vs. not-available branch is the only conditional, and the plan gives an explicit fallback.

All spec requirements are addressed; no gaps.