diff --git a/docs/driver-app-data-source.md b/docs/driver-app-data-source.md new file mode 100644 index 0000000..260818c --- /dev/null +++ b/docs/driver-app-data-source.md @@ -0,0 +1,191 @@ +# Driver App — Data Source & Integration Notes + +> **Audience:** the agent building the driver-facing app. +> **Author context:** WFDash is the operator app (Next.js + PocketBase). This doc describes how the driver app should read the same data so the operator's "Assign driver" action on `/collection-map` shows up on the driver's phone in real time. + +--- + +## TL;DR + +A driver's stop list = **every `order_items` row where `drv_id == currentDriver.id` and `item_status == "assigned"`**, regardless of the `ready_for_collection` flag. Show ready rows one colour, not-yet-ready rows another colour. Do **not** filter `ready_for_collection` server-side — that flag controls whether the *operator* may assign the row, not whether the *driver* should see it once assigned. + +### Worked example + +"Bright Packaging" supplier, assigned to Driver A. The supplier has 6 items on the same PO: + +| id | item_status | drv_id | ready_for_collection | what driver sees | +|-----|-------------|--------|----------------------|------------------| +| 1 | assigned | A | `true` | blue pill — "Ready" | +| 2 | assigned | A | `true` | blue pill — "Ready" | +| 3 | assigned | A | `true` | blue pill — "Ready" | +| 4 | assigned | A | `false` | amber pill — "Waiting — ask supplier" | +| 5 | assigned | A | `false` | amber pill — "Waiting — ask supplier" | +| 6 | assigned | A | `false` | amber pill — "Waiting — ask supplier" | + +The driver app should show all 6 under one supplier card. The driver picks up the 3 ready items and asks the supplier about the other 3 — that's the whole point of surfacing both states. + +--- + +## 1. Topology + +``` +┌──────────────┐ HTTPS ┌─────────────────────┐ +│ Driver App │ ──────────► │ WFDash Next.js API │ (recommended) +│ (mobile) │ │ /api/v1/... │ +└──────────────┘ └──────────┬──────────┘ + │ admin client + ▼ + ┌─────────────────────┐ + │ PocketBase │ + │ - order_items │ + │ - suppliers │ + │ - users (drivers) │ + └─────────────────────┘ +``` + +Two reasonable shapes: + +1. **Through the WFDash API** — easier to evolve, single auth surface, but WFDash already does its own UI-side loading; no dedicated driver endpoint exists yet. You'll need to either add one or call an existing route. +2. **Direct PocketBase** — driver app talks to PB with its own auth. Faster, but you re-implement filters, expansions, and auth yourself. + +**Recommendation:** start direct-to-PocketBase for the read path. The schema is small and the filters are simple. Add a WFDash proxy only if you need server-side validation (e.g., before letting the driver mark something collected). + +--- + +## 2. Source collection: `order_items` + +This is the canonical row. The operator's "Assign driver" modal on `/collection-map` flips these fields: + +| field | type | meaning | +|-----------------------|---------|-------------------------------------------------------------------------| +| `item_status` | select | One of `pending`, `processed`, `po_generated`, `collection_pool`, `assigned`, `collected`, `stock_verified`, `picking`, `ready_delivery`, `delivered`, `cancelled`. For driver app purposes, you care about **`assigned`** (in your list) and **`collected`** (you've picked it up — drop from list or move to history). | +| `drv_id` | relation → `users` | The driver this row is assigned to. **Filter on this.** | +| `drv_status` | select | Operator-side mirror of `item_status` for drivers — `unassigned`, `assigned`, `en_route`, `collected`, `failed`. You can ignore this; `item_status` is the truth. | +| `drv_assigned_at` | date | When the operator hit "Assign". Sort by this if you want a "newest first" list. | +| `ready_for_collection`| bool | **Operator-side gate.** Set to `true` by `/collection-pool` "Mark ready". Do **not** filter on this — surface it as a colour. | +| `notes` | text | Operator note (often set alongside `ready_for_collection=true`, e.g. "Boxes at back loading dock"). Show this prominently when set. | +| `supplier` | relation → `suppliers` | Expand to get `suppliername`, `email`, `coordinates` (semicolon-separated `"lat;lng"` string in WFDash), phone, etc. | +| `description` | text | Item description. | +| `quantity` | number | Quantity ordered for this line. | +| `po_description` | text | PO-level description if different from the item line. | +| `order` | relation → `orders` | Expand to get `order_number`. | +| `purchase_order` | relation → `purchase_orders` | Expand to get `po_number`. | + +### Other fields you can ignore (for now) + +`cost_price`, `selling_price`, `markup`, `markup_percent`, `qty_*`, `problem_flag`, `problem_type`, `problem_notes`, `legacy_order_item_id`, `in_stock`, `driver_status`, `delivery_status`, `invoice_status`, `delivered_at`, `drv_collected_at`. + +--- + +## 3. The driver-stop query + +### PocketBase filter syntax + +``` +collection: order_items +filter: drv_id = "" && (item_status = "assigned" || item_status = "collected") +sort: -drv_assigned_at +expand: supplier, purchase_order, order +``` + +- **`drv_id`** is a relation, so in PocketBase filter syntax you compare against the relation's record id. +- **`item_status`** — include `assigned` (active work) and optionally `collected` (just-picked-up, before the operator flips it forward). Do **not** include `collection_pool` (operator hasn't picked a driver), `delivered`/`cancelled` (terminal), or anything else. +- **Expand** keeps you off N+1 lookups. PB will inline supplier / PO / order objects. + +### Important: do NOT add `ready_for_collection=true` to the filter + +The operator-side gate that prevents assignment does not mean the driver should be blind to unready items. The whole point is for the driver to nudge the supplier ("by the way, I see you have a few more items on the PO — are they ready too?"). Server-side filtering on `ready_for_collection` defeats the purpose. + +--- + +## 4. UI — colour and grouping + +Match WFDash's existing palette so when an operator looks over the driver's shoulder it lines up: + +| State | Pill label | Colour (Tailwind) | +|-------------------------------------------------|----------------------|--------------------------------------------| +| `item_status == "collected"` | "Collected" | emerald — `bg-emerald-100 text-emerald-900 border-emerald-300` | +| `ready_for_collection == true` (not collected) | "Ready" | blue — `bg-blue-100 text-blue-900 border-blue-300` | +| `ready_for_collection == false` (not collected) | "Waiting — ask supplier" | amber — `bg-amber-100 text-amber-900 border-amber-300` | + +### Counts strip per driver (header) + +``` +6 stops · 🟢 0 collected · 🔵 3 ready · 🟠 3 waiting +``` + +Hide segments that are zero (omit "0 collected" if nothing's collected yet). This mirrors `/collection-map`'s right-sidebar driver cards exactly. + +### Grouping + +Group rows by `supplier.id`. Within a supplier, sort however you like. The supplier card header should show the supplier name + the counts strip for that supplier's items: + +``` +┌─ Bright Packaging ─────────────────────────┐ +│ 6 items · 🔵 3 ready · 🟠 3 waiting │ +│ • 100× Cardboard boxes [Ready] │ +│ • 50× Bubble wrap rolls [Ready] │ +│ • 20× Tape dispensers [Ready] │ +│ • 10× Pallet wrap [Waiting] │ +│ • 5× Strapping rolls [Waiting] │ +│ • 2× Stretch film [Waiting] │ +│ [Open in maps]│ +└────────────────────────────────────────────┘ +``` + +### Notes + +If `notes` is set on a row, surface it inline (the operator often writes "back loading dock", "ask for John", "ring bell"). WFDash renders these as a hover tooltip; in a phone UI a chevron expand or bottom-sheet works fine. + +--- + +## 5. "What should the driver be able to *do*?" + +Out of scope for this doc, but: the only writes the operator expects from the driver app today are: + +- **Mark collected** — flip `item_status` from `assigned` → `collected` and set `drv_status = "collected"`, `drv_collected_at = `. The operator's collection-confirm page then handles the rest. Use the existing `/api/v1/order-items//status` route (Bearer `AGENT_API_KEY`) if going through WFDash, or hit PB directly. +- **Report a problem** — set `problem_flag = true`, `problem_type = "supplier"`, `problem_notes = ""`. The operator surfaces these on `/collection-confirm`. + +Do not let the driver flip `ready_for_collection` or reassign themselves — those are operator-only actions. + +--- + +## 6. Auth + +Drivers are PB `users` records. They log in with email/password (PB's built-in `users` collection, `_pb_users_auth_`). The driver app stores the PB auth token after login and passes it as `Authorization: ` on every API call. PB enforces row-level `listRule` / `viewRule` (`@request.auth.id != ''`) — drivers will see only what they're allowed to read, which today is all `order_items`. You'll likely want to tighten that to `drv_id = @request.auth.id` via PB rule, but that's a schema change — confirm with the WFDash maintainer first. + +--- + +## 7. What changes you'll need from WFDash + +If you go the **direct-PB** route, you can ship without any WFDash changes — just consume the existing schema and filter. + +If you go the **WFDash-API** route, you'll need one of: + +- A new endpoint `GET /api/v1/drivers/me/stops` that runs the filter above through the admin PB client and returns shaped JSON, **or** +- A small wrapper around the existing `/api/v1/order-items` list route (if you want to reuse pagination/auth shape). + +Either way, the filter logic is identical to section 3. + +--- + +## 8. Quick reference — the canonical filter + +```js +// PocketBase JS SDK +const stops = await pb.collection("order_items").getList(1, 200, { + filter: 'drv_id = "' + driverId + '" && (item_status = "assigned" || item_status = "collected")', + sort: "-drv_assigned_at", + expand: "supplier,purchase_order,order", +}); +``` + +Each row in `stops.items` carries everything section 2 lists. Use `expand.supplier.suppliername`, `expand.supplier.coordinates`, etc. for display. + +--- + +## 9. Open questions for the operator (WFDash maintainer) + +- Should `drv_status` ever diverge from `item_status` for the driver? (Today they mirror — pick `item_status` as truth.) +- Should the driver see **historical** collected items from today, or only active `assigned`? (`collected` rows have already moved past the driver's hand — usually safe to drop from the live list, but the operator may want a "today's history" pane.) +- Are PO numbers (`po_number`) surfaced on the supplier card, or only on drill-in? Operator's map shows `po_number`; safe default = show on card. diff --git a/docs/superpowers/plans/2026-07-10-collection-pool-supplier-groups-and-notes.md b/docs/superpowers/plans/2026-07-10-collection-pool-supplier-groups-and-notes.md new file mode 100644 index 0000000..0d896b9 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-collection-pool-supplier-groups-and-notes.md @@ -0,0 +1,1254 @@ +# Collection Pool: supplier groups, bulk assign, primary time-in-list, note preview — 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:** Reorganise `/collection-pool` into collapsible supplier sections with bulk-assign (whole-section or selective), make the time-in-list dominant in `text-primary`, and replace the `title`-attribute Notes chip with a proper hover/focus tooltip. + +**Architecture:** Helper `buildSupplierGroups` lives in `view-model.ts`; the sort flips to `(supplierName, assignmentState, updated)` so the group builder can slice the sorted array. New `SupplierGroup` component (kept in `page.tsx`) renders the header strip (dot · supplier · count · PO eyebrow · chevron · Assign-all button) plus collapsible body. A second `AssignDriverDialog` instance handles bulk with `bulkCount`. The existing `PoolRow` gets two new props (`selected`, `onToggleSelected`) and a per-row checkbox. + +**Tech Stack:** Next.js 16 App Router (read `node_modules/next/dist/docs/` before any Next-specific code), React 18, TypeScript, Tailwind, shadcn/ui, PocketBase admin client, lucide-react, sonner, Vitest. + +**Spec:** [docs/superpowers/specs/2026-07-10-collection-pool-supplier-groups-and-notes-design.md](../specs/2026-07-10-collection-pool-supplier-groups-and-notes-design.md) + +**Pre-flight:** + +- [ ] **Read the spec end-to-end** so the helper signatures, sort tiebreakers, and bulk-merge semantics are locked in your head. +- [ ] **Skim `node_modules/next/dist/docs/`** for anything that affects Client Component state patterns (this codebase is "NOT the Next.js you know" per `AGENTS.md`). +- [ ] **Re-read `page.tsx`** (663 lines) — `PoolRow` props, the colour-stripe helper, the existing single-row `AssignDriverDialog` mount, and the `load()` + `updateItemStatus` patterns. + +--- + +## File Structure + +| File | Responsibility | Touched? | +| --- | --- | --- | +| `src/app/(app)/collection-pool/view-model.ts` | Add `CollectionPoolGroup` type + `buildSupplierGroups` helper; flip `buildVisibleRows` sort to lead with `supplierName`. | Modify | +| `src/app/(app)/collection-pool/view-model.test.ts` | Pin the new sort tiebreaker order; cover `buildSupplierGroups` (empty, fold, eyebrow, marker token). | Modify | +| `src/app/(app)/collection-pool/page.tsx` | Add `selectedIds`, `collapsedSuppliers`, `bulkAssignDialog` state; add `SupplierGroup` + `PoolRow` updates (time-in-list, notes tooltip, checkbox); wire bulk dialog and sequential PATCH handler; replace flat `
    ` with grouped `
    `s. | Modify | +| `src/app/(app)/collection-pool/page.test.tsx` | Cover selection toggling, section select-all, Assign-all opens bulk dialog with `bulkCount=N`, notes tooltip visibility, time-in-list colour class. | Modify | +| `src/app/(app)/collection-pool/components.tsx` (NEW, conditional) | If `page.tsx` exceeds 30 KB after Task 11, split `PoolRow` + `SupplierGroup` here. | Create (conditional) | + +--- + +## Task 1: Add `buildSupplierGroups` helper (TDD) + +**Files:** +- Modify: `src/app/(app)/collection-pool/view-model.test.ts` +- Modify: `src/app/(app)/collection-pool/view-model.ts` + +- [ ] **Step 1: Append failing tests** + +Append to `src/app/(app)/collection-pool/view-model.test.ts` (the existing `makeRow` helper above is reused): + +```ts +import { buildSupplierGroups, type CollectionPoolGroup } from "./view-model"; + +describe("buildSupplierGroups", () => { + it("returns an empty array for empty input", () => { + expect(buildSupplierGroups([], new Map())).toEqual([]); + }); + + it("folds rows by supplierId in input order", () => { + const rows = [ + makeRow({ id: "a", supplierId: "sup-1", supplierName: "Acme", poNumber: "PO-1" }), + makeRow({ id: "b", supplierId: "sup-2", supplierName: "Beta", poNumber: "PO-2" }), + makeRow({ id: "c", supplierId: "sup-1", supplierName: "Acme", poNumber: "PO-3" }), + ]; + const groups = buildSupplierGroups(rows, new Map()); + expect(groups.map((g) => g.supplierId)).toEqual(["sup-1", "sup-2"]); + expect(groups[0].rows.map((r) => r.id)).toEqual(["a", "c"]); + expect(groups[1].rows.map((r) => r.id)).toEqual(["b"]); + }); + + it("captures the top two PO numbers and reports the remaining count", () => { + const rows = [ + makeRow({ supplierId: "sup-1", poNumber: "PO-1" }), + makeRow({ supplierId: "sup-1", poNumber: "PO-2" }), + makeRow({ supplierId: "sup-1", poNumber: "PO-3" }), + makeRow({ supplierId: "sup-1", poNumber: "PO-4" }), + makeRow({ supplierId: "sup-1", poNumber: "PO-4" }), // duplicate PO + ]; + const [group] = buildSupplierGroups(rows, new Map()); + expect(group.topPoNumbers).toEqual(["PO-1", "PO-2"]); + expect(group.remainingPoCount).toBe(2); + }); + + it("uses marker token from supplierMarkerMap", () => { + const map = new Map([["sup-1", "marker-blue"]]); + const [group] = buildSupplierGroups( + [makeRow({ supplierId: "sup-1" })], + map, + ); + expect(group.markerToken).toBe("marker-blue"); + }); + + it("returns empty eyebrow when no rows have a PO number", () => { + const [group] = buildSupplierGroups( + [makeRow({ supplierId: "sup-1", poNumber: "" })], + new Map(), + ); + expect(group.topPoNumbers).toEqual([]); + expect(group.remainingPoCount).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run tests — expect FAIL** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/view-model.test' +``` + +Expected: 5 new tests fail with "buildSupplierGroups is not a function". + +- [ ] **Step 3: Implement `buildSupplierGroups`** + +Append to `src/app/(app)/collection-pool/view-model.ts`: + +```ts +export type CollectionPoolGroup = { + supplierId: string; + supplierName: string; + /** Cluster colour token from the proximity map (may be undefined). */ + markerToken?: string; + /** First two distinct PO numbers for the header eyebrow. */ + topPoNumbers: string[]; + /** Number of distinct POs beyond the top two. */ + remainingPoCount: number; + /** Rows belonging to this group, in render order. */ + rows: CollectionPoolViewRow[]; +}; + +/** + * Folds already-sorted rows into per-supplier groups, preserving input + * order. The caller is responsible for sorting rows by `supplierName` + * (use `buildVisibleRows` first); this helper does not re-sort. + */ +export function buildSupplierGroups( + rows: CollectionPoolViewRow[], + supplierMarkerMap: Map, +): CollectionPoolGroup[] { + const groups: CollectionPoolGroup[] = []; + const bySupplier = new Map(); + + for (const row of rows) { + let group = bySupplier.get(row.supplierId); + if (!group) { + group = { + supplierId: row.supplierId, + supplierName: row.supplierName ?? "", + markerToken: supplierMarkerMap.get(row.supplierId), + topPoNumbers: [], + remainingPoCount: 0, + rows: [], + }; + bySupplier.set(row.supplierId, group); + groups.push(group); + } + group.rows.push(row); + } + + for (const group of groups) { + const uniquePos = Array.from( + new Set(group.rows.map((r) => r.poNumber ?? "").filter(Boolean)), + ); + group.topPoNumbers = uniquePos.slice(0, 2); + group.remainingPoCount = Math.max(0, uniquePos.length - 2); + } + + return groups; +} +``` + +- [ ] **Step 4: Run tests — expect PASS** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/view-model.test' +``` + +Expected: all tests green. + +- [ ] **Step 5: Commit** + +```bash +git add 'src/app/(app)/collection-pool/view-model.ts' 'src/app/(app)/collection-pool/view-model.test.ts' +git commit -m "feat(collection-pool): add buildSupplierGroups helper" +``` + +--- + +## Task 2: Flip sort tiebreaker to lead with `supplierName` + +**Files:** +- Modify: `src/app/(app)/collection-pool/view-model.test.ts` +- Modify: `src/app/(app)/collection-pool/view-model.ts` + +- [ ] **Step 1: Append failing tests** + +Append to `src/app/(app)/collection-pool/view-model.test.ts`: + +```ts +describe("buildVisibleRows — supplier-first sort (operational mode)", () => { + const todayYmd = "2026-07-10"; + + it("sorts by supplierName first when sortMode is operational", () => { + const rows = buildVisibleRows( + [ + makeRow({ id: "beta", supplierName: "Beta" }), + makeRow({ id: "acme", supplierName: "Acme" }), + makeRow({ id: "alpha", supplierName: "Alpha" }), + ], + { todayYmd, searchText: "", readyFilter: "all", sortMode: "operational" }, + ); + expect(rows.map((r) => r.id)).toEqual(["acme", "alpha", "beta"]); + }); + + it("falls through to assignmentState within the same supplier", () => { + const rows = buildVisibleRows( + [ + makeRow({ + id: "acme-unassigned", + supplierName: "Acme", + item_status: "collection_pool", + }), + makeRow({ + id: "acme-today", + supplierName: "Acme", + item_status: "assigned", + drv_id: "drv-1", + assigned_at: `${todayYmd}T00:00:00.000Z`, + }), + ], + { todayYmd, searchText: "", readyFilter: "all", sortMode: "operational" }, + ); + expect(rows.map((r) => r.id)).toEqual(["acme-today", "acme-unassigned"]); + }); + + it("nearby mode still uses cluster prefix before supplier", () => { + const rows = buildVisibleRows( + [ + makeRow({ + id: "beta-cluster-a", + supplierName: "Beta", + clusterSortKey: "cluster-a", + }), + makeRow({ + id: "acme-cluster-b", + supplierName: "Acme", + clusterSortKey: "cluster-b", + }), + makeRow({ + id: "acme-cluster-a", + supplierName: "Acme", + clusterSortKey: "cluster-a", + }), + ], + { todayYmd, searchText: "", readyFilter: "all", sortMode: "nearby" }, + ); + expect(rows.map((r) => r.id)).toEqual([ + "acme-cluster-a", + "beta-cluster-a", + "acme-cluster-b", + ]); + }); +}); +``` + +- [ ] **Step 2: Run tests — expect FAIL** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/view-model.test' +``` + +Expected: 3 new tests fail. The two existing `buildVisibleRows sort modes` tests should still pass (they don't depend on supplier ordering); the original `"sorts assigned today first"` test may start to fail because it expects today-first but the new sort is supplier-first. **Note that failure and proceed — it's expected and will be reconciled when the production change lands in Step 3.** + +- [ ] **Step 3: Update `buildVisibleRows` sort** + +In `src/app/(app)/collection-pool/view-model.ts`, replace the existing `.sort((a, b) => { ... })` callback inside `buildVisibleRows` with: + +```ts +.sort((a, b) => { + if (sortMode === "nearby") { + const clusterDiff = (a.clusterSortKey ?? "zzz").localeCompare(b.clusterSortKey ?? "zzz"); + if (clusterDiff !== 0) return clusterDiff; + } + + // Supplier name is the primary group key. Rows in the same + // supplier are then ordered by assignment urgency (today → + // unassigned → future), then by updated, then by PO + description. + const supplierDiff = (a.supplierName ?? "").localeCompare(b.supplierName ?? ""); + if (supplierDiff !== 0) return supplierDiff; + + const stateDiff = + ASSIGNMENT_SORT_ORDER[getAssignmentState(a, options.todayYmd)] - + ASSIGNMENT_SORT_ORDER[getAssignmentState(b, options.todayYmd)]; + if (stateDiff !== 0) return stateDiff; + + const updatedA = a.updated ?? ""; + const updatedB = b.updated ?? ""; + if (updatedA !== updatedB) return updatedA.localeCompare(updatedB); + + return `${a.poNumber ?? ""}|${a.description ?? ""}`.localeCompare( + `${b.poNumber ?? ""}|${b.description ?? ""}`, + ); +}); +``` + +- [ ] **Step 4: Run tests — expect PASS** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/view-model.test' +``` + +Expected: all tests green. The pre-existing `"sorts assigned today first, then unassigned, then future"` test will now fail because supplier is the primary key; the expected order across multiple suppliers is supplier-alphabetical regardless of assignment state. **If that test was already updated by a previous commit, leave it alone; otherwise update its expectation to include supplier-distinct fixtures so the assertion still holds.** The simplest fix: + +```ts +// Old assertion was: ["today", "unassigned", "future"] with one supplier. +// Update fixture so all three rows share a supplier OR split into +// distinct suppliers and assert alphabetical. Easiest: keep one +// supplier and assert the assignment order. +makeRow({ id: "future", supplierName: "Same", ... }), +makeRow({ id: "unassigned", supplierName: "Same", ... }), +makeRow({ id: "today", supplierName: "Same", ... }), +// Expect ["today", "unassigned", "future"] +``` + +- [ ] **Step 5: Commit** + +```bash +git add 'src/app/(app)/collection-pool/view-model.ts' 'src/app/(app)/collection-pool/view-model.test.ts' +git commit -m "feat(collection-pool): sort by supplier name first" +``` + +--- + +## Task 3: Time-in-list primary colour + "In pool since" sub-label + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx:607` + +- [ ] **Step 1: Update the `PoolRow` order-detail cell** + +In `src/app/(app)/collection-pool/page.tsx`, replace the order-detail cell body that currently contains the time-in-list label (around line 607): + +```tsx +
    + {getPoolAgeLabel(row.updated)} +
    +{row.updated && ( +
    + In pool since {new Date(row.updated).toLocaleDateString(undefined, { + day: "numeric", + month: "short", + })} +
    +)} +``` + +Keep the surrounding markup (`
    `, the Order/PO labels above, the `Scheduled {date}` line below) untouched. + +- [ ] **Step 2: Run the existing page suite** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/page' +``` + +Expected: existing tests still pass (this change adds markup but doesn't alter data flow). + +- [ ] **Step 3: Commit** + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "style(collection-pool): promote time-in-list to text-primary" +``` + +--- + +## Task 4: Notes chip — proper hover/focus tooltip + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx:625-632` + +- [ ] **Step 1: Replace the native `title` chip with a `group/notes` tooltip** + +In `src/app/(app)/collection-pool/page.tsx`, replace the existing notes chip block: + +```tsx +{row.notes && row.notes.trim().length > 0 && ( + + Notes + + {row.notes.length > 280 ? `${row.notes.slice(0, 280)}…` : row.notes} + + +)} +``` + +If `bg-popover` / `text-popover-foreground` are not defined in the project's `globals.css`, fall back to `bg-background text-foreground` (these always resolve). + +- [ ] **Step 2: Run the existing page suite** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/page' +``` + +Expected: existing tests still pass. If any test pinned the old `title` attribute, update the selector to match the new tooltip (e.g. `findByRole("tooltip")`). + +- [ ] **Step 3: Commit** + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): replace notes title with hover tooltip" +``` + +--- + +## Task 5: Selection state + per-row checkbox on `PoolRow` + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx` (state, props, JSX, render call) + +- [ ] **Step 1: Add `selectedIds` state + handlers** + +In `CollectionPoolPage`, alongside the existing `useState` calls (around line 106–116), add: + +```ts +const [selectedIds, setSelectedIds] = useState>(new Set()); + +const toggleRowSelected = useCallback((rowId: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(rowId)) next.delete(rowId); + else next.add(rowId); + return next; + }); +}, []); + +const toggleSectionSelected = useCallback((rowIds: string[]) => { + setSelectedIds((prev) => { + const allSelected = rowIds.every((id) => prev.has(id)); + const next = new Set(prev); + if (allSelected) { + for (const id of rowIds) next.delete(id); + } else { + for (const id of rowIds) next.add(id); + } + return next; + }); +}, []); +``` + +- [ ] **Step 2: Extend `PoolRow` props and signature** + +Update the `PoolRow` props type and signature in the same file (around line 551): + +```tsx +function PoolRow({ + row, + busy, + markerToken, + todayYmd, + selected, + onToggleSelected, + onOpenAssign, + onToggleReady, +}: { + row: CollectionPoolVisibleRow; + busy: boolean; + markerToken?: string; + todayYmd: string; + selected: boolean; + onToggleSelected: (rowId: string) => void; + onOpenAssign: (row: CollectionPoolVisibleRow) => void; + onToggleReady: (row: CollectionPoolVisibleRow, ready: boolean) => void; +}) { +``` + +- [ ] **Step 3: Add the checkbox and row tint to `PoolRow`'s JSX** + +In `PoolRow`'s `
  • ` element (around line 579), apply the selection tint and add a checkbox at the start of the content grid. Replace the opening markup: + +```tsx +
  • + + +
    +
    + onToggleSelected(row.id)} + disabled={busy} + aria-label={`Select ${row.description ?? "item"} at ${row.supplierName ?? "unknown supplier"}`} + className="mt-1 size-4 shrink-0 cursor-pointer accent-primary disabled:cursor-not-allowed" + /> +
    +

    {row.supplierName}

    +

    {row.description || "(no description)"}

    +
    + {row.customerName || "No customer"} + / + {row.companyName || "No company"} +
    + {!row.purchase_order && ( +
    + + This row has no purchase order link. The PO may have been deleted. +
    + )} +
    +
    + + {/* ...rest of the columns unchanged... */} +
    +
  • +``` + +(The supplier/description/customer/company/warning block was previously the direct first child of the inner `
    `. It is now nested inside a new wrapper `
    ` that hosts the checkbox.) + +- [ ] **Step 4: Update the call site (still the flat `
      ` for now)** + +In the rendering block around line 488–500, update the `PoolRow` invocation: + +```tsx +
        + {visibleRows.map((row) => ( + + ))} +
      +``` + +- [ ] **Step 5: Run page suite + commit** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/page' +``` + +Expected: existing tests still pass (PoolRow is exercised via the page). + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): per-row checkbox + selection state" +``` + +--- + +## Task 6: `SupplierGroup` component (renders header + collapsible body) + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx` (new `SupplierGroup` function) + +- [ ] **Step 1: Add the `SupplierGroup` component** + +Append a new `SupplierGroup` function inside `page.tsx`, after `PoolRow`. It renders the section header strip and the collapsible row body: + +```tsx +function SupplierGroup({ + group, + busy, + todayYmd, + selectedIds, + collapsed, + onToggleRowSelected, + onToggleSectionSelected, + onToggleCollapsed, + onOpenAssign, + onOpenBulkAssign, + onToggleReady, +}: { + group: CollectionPoolGroup; + busy: boolean; + todayYmd: string; + selectedIds: Set; + collapsed: boolean; + onToggleRowSelected: (rowId: string) => void; + onToggleSectionSelected: (rowIds: string[]) => void; + onToggleCollapsed: (supplierId: string) => void; + onOpenAssign: (row: CollectionPoolVisibleRow) => void; + onOpenBulkAssign: (supplierId: string, supplierName: string, rowIds: string[]) => void; + onToggleReady: (row: CollectionPoolVisibleRow, ready: boolean) => void; +}) { + const rowIds = group.rows.map((r) => r.id); + const selectedCount = rowIds.filter((id) => selectedIds.has(id)).length; + const allSelected = selectedCount === rowIds.length && rowIds.length > 0; + const someSelected = selectedCount > 0 && !allSelected; + const allAssigned = group.rows.every( + (r) => getAssignmentState(r, todayYmd) !== "unassigned", + ); + + const eyebrowText = (() => { + if (group.topPoNumbers.length === 0) return null; + const head = group.topPoNumbers.map((p) => `PO ${p}`).join(", "); + return group.remainingPoCount > 0 + ? `${head} (+${group.remainingPoCount} more)` + : head; + })(); + + const assignLabel = allSelected + ? `Assign ${rowIds.length} stops` + : selectedCount > 0 + ? `Assign ${selectedCount} selected` + : `Assign all ${rowIds.length}`; + + const onAssignClick = () => { + const ids = allSelected || selectedCount === 0 ? rowIds : rowIds.filter((id) => selectedIds.has(id)); + onOpenBulkAssign(group.supplierId, group.supplierName, ids); + }; + + return ( +
      +
      + +

      + {group.supplierName || "Unknown supplier"} +

      + + · {group.rows.length} {group.rows.length === 1 ? "stop" : "stops"} + {eyebrowText ? <> · {eyebrowText} : null} + +
      + + + +
      +
      +
      +
        + {group.rows.map((row) => ( + + ))} +
      +
      +
      + ); +} +``` + +- [ ] **Step 2: Add the missing imports** + +At the top of `page.tsx`, extend the lucide-react import: + +```tsx +import { AlertTriangle, ChevronDown, Loader2, Search, Truck } from "lucide-react"; +``` + +And update the `./view-model` imports so `buildSupplierGroups`, `CollectionPoolGroup`, and `getAssignmentBadge` are all present: + +```tsx +import { + buildProximityMarkerMap, + buildSupplierGroups, + buildVisibleRows, + type CollectionPoolGroup, + type CollectionPoolSortMode, + getAssignmentBadge, + getAssignmentState, + getPoolAgeLabel, +} from "./view-model"; +``` + +(`getAssignmentBadge` is still consumed by `PoolRow`'s status pill — keep it.) + +- [ ] **Step 3: Run typecheck** + +```bash +npx tsc --noEmit +``` + +Expected: clean. If `Type 'Set' is not assignable to ...` errors fire on `selectedIds`, the `useState>(new Set())` should already be inferring — double-check the import. + +- [ ] **Step 4: Commit (component only — not wired yet)** + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): add SupplierGroup component" +``` + +--- + +## Task 7: Replace flat `
        ` with grouped `
        `s + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx` (rendering block + state) + +- [ ] **Step 1: Add `collapsedSuppliers` state** + +In `CollectionPoolPage`, next to `selectedIds`: + +```ts +const [collapsedSuppliers, setCollapsedSuppliers] = useState>(new Set()); + +const toggleCollapsed = useCallback((supplierId: string) => { + setCollapsedSuppliers((prev) => { + const next = new Set(prev); + if (next.has(supplierId)) next.delete(supplierId); + else next.add(supplierId); + return next; + }); +}, []); +``` + +- [ ] **Step 2: Add `openBulkAssign` handler stub** + +Below `openAssignForRow`, add a stub that just opens state — the actual submit handler comes in Task 9. (The stub is intentional so Task 8 can wire the button without Task 9 needing to be done first.) + +```ts +const [bulkAssignDialog, setBulkAssignDialog] = useState<{ + supplierId: string; + supplierName: string; + rowIds: string[]; +} | null>(null); + +const openBulkAssign = useCallback( + (supplierId: string, supplierName: string, rowIds: string[]) => { + if (rowIds.length === 0) return; + setBulkAssignDialog({ supplierId, supplierName, rowIds }); + }, + [], +); + +const closeBulkAssign = useCallback(() => setBulkAssignDialog(null), []); +``` + +- [ ] **Step 3: Compute `groups` in the page** + +Below `visibleRows`, add: + +```tsx +const groups = useMemo(() => { + if (!visibleRows) return null; + return buildSupplierGroups(visibleRows, supplierMarkerMap); +}, [visibleRows, supplierMarkerMap]); +``` + +- [ ] **Step 4: Replace the flat-list rendering** + +Replace the `
          ` block (around line 488–500 in the current `page.tsx`) with: + +```tsx +
          +
          + Collection Item + Order Detail + Status + Actions +
          + {groups === null ? ( +
          + {Array.from({ length: 4 }).map((_, i) => ( +
          + ))} +
          + ) : groups.length === 0 ? ( + + ) : ( + groups.map((group) => ( + + )) + )} +
          +``` + +Note: the empty-states at the top level (`rows === null` skeleton, `rows.length === 0` empty, `visibleRows.length === 0` empty) now collapse into the new conditional inside the container — delete the previous three separate early-return blocks (around lines 460–478) to avoid double-rendering. + +- [ ] **Step 5: Run typecheck + page suite** + +```bash +npx tsc --noEmit +npm run test:run -- 'src/app/(app)/collection-pool/page' +``` + +Expected: typecheck clean; page tests still pass. If a test pinned the old `
            `, update the selector to find rows by data-od-id on the section or fall back to a more stable test query. + +- [ ] **Step 6: Commit** + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): render supplier-grouped sections" +``` + +--- + +## Task 8: Wire bulk-assign dialog mount + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx` (second `AssignDriverDialog` mount) + +- [ ] **Step 1: Stub the submit handler + bulkBusy state** + +Just below `handleAssignDialogSubmit`, add: + +```ts +const [bulkBusy, setBulkBusy] = useState(false); + +const handleBulkAssignSubmit = useCallback( + async (_payload: AssignDriverSubmitPayload) => { + // Full implementation in Task 9. + }, + [], +); +``` + +- [ ] **Step 2: Add the bulk-assign dialog JSX** + +Just below the existing single-row `` (around line 504), add: + +```tsx + { + if (!open) closeBulkAssign(); + }} + onSubmit={handleBulkAssignSubmit} + bulkCount={bulkAssignDialog?.rowIds.length ?? 1} + eyebrow={ + bulkAssignDialog ? ( + + + {bulkAssignDialog.supplierName || "Unknown supplier"} + · + {bulkAssignDialog.rowIds.length} stops + + ) : undefined + } + busy={bulkBusy} +/> +``` + +- [ ] **Step 3: Run typecheck + commit** + +```bash +npx tsc --noEmit +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): wire bulk-assign dialog mount" +``` + +--- + +## Task 9: Sequential bulk PATCH handler + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx` (`handleBulkAssignSubmit` body) + +- [ ] **Step 1: Replace the stub with the real implementation** + +In `page.tsx`, replace the empty `handleBulkAssignSubmit` with: + +```ts +const handleBulkAssignSubmit = useCallback( + async (payload: AssignDriverSubmitPayload) => { + if (!bulkAssignDialog) return; + const { driverId, date } = payload; + const { rowIds, supplierName } = bulkAssignDialog; + + setBulkBusy(true); + try { + const pb = getPocketBase(); + let successCount = 0; + let firstError: string | null = null; + + // Sequential, not parallel: any failure leaves earlier + // successes committed (matches the single-row PATCH semantics). + for (const id of rowIds) { + try { + await pb.collection("order_items").update(id, { + item_status: "assigned", + drv_id: driverId, + drv_status: "assigned", + assigned_at: `${date}T00:00:00.000Z`, + } as Partial); + successCount++; + } catch (err) { + if (!firstError) { + firstError = err instanceof Error ? err.message : String(err); + } + } + } + + // Clear selections for this supplier on success (partial or full). + setSelectedIds((prev) => { + const next = new Set(prev); + for (const id of rowIds) next.delete(id); + return next; + }); + closeBulkAssign(); + + if (firstError) { + toast.error( + `Assigned ${successCount} of ${rowIds.length} stops at ${supplierName || "supplier"}. First error: ${firstError}`, + ); + } else { + toast.success(`Assigned ${successCount} stops at ${supplierName || "supplier"}.`); + } + + await load(); + } finally { + setBulkBusy(false); + } + }, + [bulkAssignDialog, closeBulkAssign, load], +); +``` + +- [ ] **Step 2: Run typecheck + page suite** + +```bash +npx tsc --noEmit +npm run test:run -- 'src/app/(app)/collection-pool/page' +``` + +Expected: typecheck clean; existing page tests pass. Mock the PocketBase `update` call in tests as needed — see Task 10 for the test wiring. + +- [ ] **Step 3: Commit** + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): sequential bulk-assign PATCH" +``` + +--- + +## Task 10: Page-level tests + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.test.tsx` + +- [ ] **Step 1: Add a test that time-in-list uses `text-primary`** + +Find the existing render fixture in `page.test.tsx` (search for `getPoolAgeLabel` or a row containing `text-foreground`). Append: + +```tsx +import { screen, within } from "@testing-library/react"; + +it("renders time-in-list with text-primary class", async () => { + renderPage(); + const ageLabels = await screen.findAllByText(/^\d+[hd]$/); + expect(ageLabels.length).toBeGreaterThan(0); + for (const label of ageLabels) { + expect(label.className).toMatch(/text-primary/); + } +}); +``` + +(The selector `/^\d+[hd]$/` matches the `Nh` / `Nd` format produced by `getPoolAgeLabel`. Adjust if existing tests use a different fixture pattern — search the file for any pre-existing age assertions first.) + +- [ ] **Step 2: Add a test that the Notes tooltip appears on hover** + +```tsx +it("surfaces the Notes tooltip on row hover", async () => { + // Fixture row must include `notes: "Spoke to David — confirmed pickup tomorrow"`. + renderPage({ rowOverrides: { notes: "Spoke to David — confirmed pickup tomorrow" } }); + const tooltip = await screen.findByRole("tooltip"); + expect(tooltip).toHaveTextContent(/Spoke to David/); + expect(tooltip).toHaveClass("hidden"); // default state +}); +``` + +If the project's `renderPage` helper doesn't accept row overrides, add a parameter that forwards to the fixture builder — it's a one-line change. + +- [ ] **Step 3: Add a test that selecting a row toggles the selection tint** + +```tsx +it("applies a primary-tint background to selected rows", async () => { + const { container } = renderPage(); + const firstCheckbox = await screen.findByRole("checkbox", { name: /Select .* at .*/i }); + fireEvent.click(firstCheckbox); + const row = firstCheckbox.closest("li"); + expect(row).not.toBeNull(); + expect(row!.className).toMatch(/bg-primary\/5/); +}); +``` + +- [ ] **Step 4: Add a test that the section "Select all" toggles every row in the section** + +```tsx +it("section Select all toggles every row in the supplier group", async () => { + renderPage(); + const selectAll = await screen.findByRole("checkbox", { name: /Select all \d+ stops at/i }); + fireEvent.click(selectAll); + const rowCheckboxes = await screen.findAllByRole("checkbox", { name: /^Select .* at/i }); + expect(rowCheckboxes.length).toBeGreaterThan(0); + for (const cb of rowCheckboxes) { + expect(cb).toBeChecked(); + } +}); +``` + +- [ ] **Step 5: Add a test that the Assign-all button opens the bulk dialog with `bulkCount=N`** + +Mock `pb.collection("order_items").update` so the test doesn't actually hit PocketBase. Then: + +```tsx +it("opens bulk-assign dialog with bulkCount equal to selected rows", async () => { + const { container } = renderPage(); + const selectAll = await screen.findByRole("checkbox", { name: /Select all \d+ stops at/i }); + fireEvent.click(selectAll); + const assignAll = screen.getByRole("button", { name: /Assign \d+ stops$/ }); + fireEvent.click(assignAll); + // Dialog title reads "Assign N items to driver" when bulkCount > 1. + const dialogTitle = await screen.findByText(/Assign \d+ items to driver/i); + expect(dialogTitle).toBeInTheDocument(); +}); +``` + +If the `getPocketBase()` mock in the existing suite doesn't intercept the second dialog's submit handler, extend the mock to also intercept the bulk-assign PATCH chain. + +- [ ] **Step 6: Add a test that collapsing a section hides its rows** + +```tsx +it("collapses a supplier section on chevron click", async () => { + renderPage(); + const chevron = await screen.findByRole("button", { name: "" }); // chevron has no accessible name; query by aria-controls + const section = document.querySelector('[aria-labelledby^="supplier-"]'); + const id = section!.querySelector("header")!.id; + const body = document.getElementById(`${id}-body`) || section!.querySelector('[id$="-body"]'); + expect(body!.className).toMatch(/grid-rows-\[1fr\]/); + fireEvent.click(section!.querySelector("button[aria-expanded]")!); + expect(body!.className).toMatch(/grid-rows-\[0fr\]/); +}); +``` + +- [ ] **Step 7: Run the full collection-pool suite** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool' +``` + +Expected: green. Pre-existing failures (documented across the repo, e.g. `page.test.tsx:175`) are acceptable. + +- [ ] **Step 8: Commit** + +```bash +git add 'src/app/(app)/collection-pool/page.test.tsx' +git commit -m "test(collection-pool): cover groups, bulk assign, notes tooltip" +``` + +--- + +## Task 11: Typecheck + size budget + conditional split + +**Files:** +- Modify or create: `src/app/(app)/collection-pool/page.tsx` (or split into `components.tsx`) + +- [ ] **Step 1: Full typecheck** + +```bash +npx tsc --noEmit +``` + +Expected: clean. + +- [ ] **Step 2: Measure `page.tsx` size** + +```bash +wc -c 'src/app/(app)/collection-pool/page.tsx' +``` + +If the result is **≤ 30 KB**, stop here — the file is within budget. Skip to Step 5. + +If the result is **> 30 KB**, continue with the split in Steps 3–4. + +- [ ] **Step 3: Create `components.tsx` with `PoolRow` + `SupplierGroup`** + +Create `src/app/(app)/collection-pool/components.tsx`: + +```tsx +"use client"; + +import { ChevronDown, Truck, AlertTriangle } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import type { CollectionPoolViewRow } from "./view-model"; +import { + type CollectionPoolGroup, + getAssignmentState, + getPoolAgeLabel, +} from "./view-model"; +import { cn } from "@/lib/utils"; + +// Copy `CollectionPoolVisibleRow` type and `markerTokenToClassName` +// helper from page.tsx. They're needed by both PoolRow and SupplierGroup. + +export type CollectionPoolVisibleRow = CollectionPoolViewRow & { + // mirror the type from page.tsx +}; + +function markerTokenToClassName(token?: string): string { + switch (token) { + case "marker-blue": + return "bg-blue-500"; + case "marker-emerald": + return "bg-emerald-500"; + case "marker-violet": + return "bg-violet-500"; + case "marker-amber": + return "bg-amber-500"; + default: + return "bg-slate-300"; + } +} + +// Then paste the FULL `PoolRow` and `SupplierGroup` function bodies +// from page.tsx, unchanged. +``` + +Cut both functions from `page.tsx` and update imports so `page.tsx` imports them from `./components` instead. + +- [ ] **Step 4: Re-measure + retest** + +```bash +wc -c 'src/app/(app)/collection-pool/page.tsx' 'src/app/(app)/collection-pool/components.tsx' +npx tsc --noEmit +npm run test:run -- 'src/app/(app)/collection-pool' +``` + +Expected: each file ≤ 30 KB; typecheck clean; tests green. + +- [ ] **Step 5: Commit (split or no-op)** + +If you split: +```bash +git add 'src/app/(app)/collection-pool/components.tsx' 'src/app/(app)/collection-pool/page.tsx' +git commit -m "refactor(collection-pool): split PoolRow + SupplierGroup into components.tsx" +``` + +If you didn't split, no commit is needed. + +--- + +## Task 12: Manual smoke + full suite + +- [ ] **Step 1: Full test suite** + +```bash +npm run test:run +``` + +Expected: same passing set as before plus the new tests in `view-model.test.ts` and `page.test.tsx`. Pre-existing failures (the 6 we documented across the repo) are acceptable. + +- [ ] **Step 2: Manual smoke on `/collection-pool`** + +1. Load the page. Rows render in collapsible supplier sections, alphabetical. +2. Click the chevron on a section → body collapses with a smooth transition; chevron rotates. +3. Hover the time-in-list label — confirm it's in `text-primary` and bold. +4. Hover the Notes chip on a row with notes — confirm the tooltip appears with the note text (truncated at 280 chars + ellipsis if longer). +5. Tick the section "Select all" — every row checkbox in the section ticks; row backgrounds tint. +6. Click `Assign N stops`. Bulk dialog opens with eyebrow `Supplier · N stops` and title `Assign N items to driver`. +7. Pick a driver + tomorrow's date, submit. Toast confirms. Reload — rows show "Assigned future" badge and the section's selections clear. +8. Mark a row Ready with a long note (>280 chars). Hover the Notes chip — the preview is truncated with `…`. +9. Switch sort to "Nearby suppliers" — order changes; switch back to "Operational" — supplier-grouped order restored. + +- [ ] **Step 3: Commit any test-only follow-ups** + +```bash +git add -A 'src/app/(app)/collection-pool/' +git commit -m "test(collection-pool): post-smoke follow-ups" --allow-empty +``` + +--- + +## Out-of-scope reminders + +- **Bulk mark-ready.** The dialog requires a free-text note per item; a multi-note UI is a separate feature. Don't add it here. +- **Per-section "Mark section ready".** Same reason. +- **Route rename / nav label change.** Not in scope. +- **Mobile-only layout changes.** The existing grid already collapses correctly; no special breakpoint work is needed for the new structure. +- **Transactional batch PATCH endpoint.** Sequential single-row PATCHes are intentional; rollback on partial failure is out of scope. + +--- + +## Risk notes + +- **Page render budget.** Current 23 KB. Estimated growth ~7–10 KB from the new component + state + handlers. If it crosses 30 KB, Task 11 Step 3 splits `PoolRow` + `SupplierGroup` into `components.tsx`. If it still crosses after the split, consider further splitting the dialog wiring into a hook (`useBulkAssign`). +- **Sort change is observable.** Users who relied on the old "by urgency" default will see suppliers grouped together. The Sort radio's "Operational" label now matches this new behaviour; "Nearby" remains for cluster-priority workflows. +- **Bulk PATCH is not transactional.** A failure on row 4 of 5 leaves rows 1–3 committed. The toast reports partial success; the operator can re-open the dialog and retry. \ No newline at end of file diff --git a/docs/superpowers/plans/2026-07-11-collection-map-simplify-pins.md b/docs/superpowers/plans/2026-07-11-collection-map-simplify-pins.md new file mode 100644 index 0000000..4b80831 --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-collection-map-simplify-pins.md @@ -0,0 +1,992 @@ +# Collection Map: drop the filter console + collapse pin taxonomy — 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:** Reorganise `/collection-map` to show every stop on the picked date, drop the status / readiness / driver multi-select / search filter console, and replace the per-(driver × status × failed-flag) pin explosion with a clean two-shape model: blue circle + driver letter for assigned, amber circle + ✓/✕ for unassigned. + +**Architecture:** Two new pure helpers in `src/lib/geo.ts` (`driverLabelFor`, `unassignedGlyphFor`) drive the new pin factories in `src/components/leaflet-markers.ts` (`driverLetterPin`, `unassignedPoolPin`). The page deletes four filter state objects, the Drivers sidebar card, and the `buildRoutedBuckets` function, then re-emits one pin per distinct driver + one pin per unassigned bucket at every supplier coordinate. Clusters and popups keep their existing shapes. No backend or schema changes. + +**Tech Stack:** Next.js 16 App Router (read `node_modules/next/dist/docs/` before any Next-specific code), React 18, TypeScript, Tailwind, shadcn/ui, PocketBase admin client, lucide-react, sonner, Leaflet via `react-leaflet`, Vitest + jsdom. + +**Spec:** [docs/superpowers/specs/2026-07-11-collection-map-simplify-pins-design.md](../specs/2026-07-11-collection-map-simplify-pins-design.md) + +**Pre-flight:** + +- [ ] **Read the spec end-to-end** so the pin model, helper signatures, and refresh-button placement (locked as option A — stay in toolbar) are settled. +- [ ] **Skim `node_modules/next/dist/docs/`** for anything that affects Client Component state patterns (this codebase is "NOT the Next.js you know" per `AGENTS.md`). +- [ ] **Re-read `src/app/(app)/collection-map/page.tsx`** (1,331 lines, 53 KB) — `buildRoutedBuckets`, the filter strip JSX, the Drivers sidebar card, the `MapWatcher` + `StopClusterMarker` mount, the `assignDialog` flow. You only modify the affected regions; the bottom list, KPI strip, popup body, traffic overlay, and clusters stay. +- [ ] **Re-read `src/components/leaflet-markers.ts`** — `pinMarker`, `pinMarkerKind`, `driverMarker`, `clusterBubble`. You add two new factories but keep every existing export unchanged so other pages still compile. + +--- + +## File Structure + +| File | Responsibility | Touched? | +| --- | --- | --- | +| `src/lib/geo.ts` | Add `driverLabelFor` + `unassignedGlyphFor` helpers (pure functions, no React / Leaflet). | Modify | +| `src/lib/geo.test.ts` (NEW) | Pin letter assignment (A–Z then 1–9, "?" for unknown, stable across calls) + readiness glyph logic. | Create | +| `src/components/leaflet-markers.ts` | Add `driverLetterPin(letter, count, color?)` and `unassignedPoolPin(glyph, count)` factories. Keep all existing exports. | Modify | +| `src/components/leaflet-markers.test.ts` (NEW) | Snapshot `L.DivIcon` size / html for both new factories + glyph encoding (✓ / ✕). | Create | +| `src/app/(app)/collection-map/page.tsx` | Delete `statusFilter`, `readyFilter`, `search`, `enabledDriverIds` state and their JSX; delete the Drivers sidebar card; delete `buildRoutedBuckets`; replace the per-stop pin render with the two-pin model; import + use the new helpers and pin factories. Refresh button stays in toolbar. | Modify | +| `src/app/(app)/collection-map/page.test.tsx` (NEW) | Cover toolbar shape (date + quick chips only, no filter chips, no Drivers card), per-supplier pin rendering (one per driver + one unassigned), readiness glyph logic. | Create | + +--- + +## Task 1: Add `driverLabelFor` + `unassignedGlyphFor` helpers (TDD) + +**Files:** +- Create: `src/lib/geo.test.ts` +- Modify: `src/lib/geo.ts` + +- [ ] **Step 1: Create the test file with failing tests** + +Create `src/lib/geo.test.ts`: + +```ts +import { driverLabelFor, unassignedGlyphFor } from "./geo"; + +describe("driverLabelFor", () => { + it("returns A for the first driver alphabetically", () => { + const drivers = [{ id: "u-1" }, { id: "u-2" }].sort((a, b) => + a.id.localeCompare(b.id) + ); + // u-1 < u-2 lexically, so driver at index 0 is "u-1" + expect(driverLabelFor(drivers, drivers[0].id)).toBe("A"); + expect(driverLabelFor(drivers, drivers[1].id)).toBe("B"); + }); + + it("returns sequential letters A through Z for the first 26 drivers", () => { + const drivers = Array.from({ length: 26 }, (_, i) => ({ + id: `driver-${String(i).padStart(2, "0")}`, + })); + // After sorting lexically: driver-00 ... driver-25 + const sorted = [...drivers].sort((a, b) => a.id.localeCompare(b.id)); + for (let i = 0; i < 26; i++) { + expect(driverLabelFor(sorted, sorted[i].id)).toBe( + String.fromCharCode("A".charCodeAt(0) + i) + ); + } + }); + + it("falls back to digits 1..9 starting at position 27", () => { + const drivers = Array.from({ length: 30 }, (_, i) => ({ + id: `driver-${String(i).padStart(2, "0")}`, + })); + const sorted = [...drivers].sort((a, b) => a.id.localeCompare(b.id)); + // Sorted: driver-00 (A) ... driver-25 (Z), then driver-26 (1) ... driver-29 (4) + expect(driverLabelFor(sorted, sorted[26].id)).toBe("1"); + expect(driverLabelFor(sorted, sorted[27].id)).toBe("2"); + expect(driverLabelFor(sorted, sorted[28].id)).toBe("3"); + expect(driverLabelFor(sorted, sorted[29].id)).toBe("4"); + }); + + it("returns '?' for an unknown driver id", () => { + const drivers = [{ id: "u-1" }]; + expect(driverLabelFor(drivers, "u-missing")).toBe("?"); + }); + + it("is stable — same input returns the same label on repeated calls", () => { + const drivers = [{ id: "u-1" }, { id: "u-2" }, { id: "u-3" }]; + const sorted = [...drivers].sort((a, b) => a.id.localeCompare(b.id)); + const first = driverLabelFor(sorted, "u-2"); + const second = driverLabelFor(sorted, "u-2"); + const third = driverLabelFor(sorted, "u-2"); + expect(first).toBe(second); + expect(second).toBe(third); + }); + + it("is stable when given names instead of ids (alphabetical by name)", () => { + // The production callers pass objects with an `id` field; the helper + // sorts by id. This test documents that contract. + const drivers = [ + { id: "alice" }, + { id: "bob" }, + { id: "carol" }, + ]; + expect(driverLabelFor(drivers, "alice")).toBe("A"); + expect(driverLabelFor(drivers, "bob")).toBe("B"); + expect(driverLabelFor(drivers, "carol")).toBe("C"); + }); +}); + +describe("unassignedGlyphFor", () => { + it("returns 'ready' when every item is ready_for_collection=true", () => { + expect( + unassignedGlyphFor([ + { ready_for_collection: true }, + { ready_for_collection: true }, + ]) + ).toBe("ready"); + }); + + it("returns 'unready' when at least one item is not ready", () => { + expect( + unassignedGlyphFor([ + { ready_for_collection: true }, + { ready_for_collection: false }, + ]) + ).toBe("unready"); + }); + + it("returns 'unready' when at least one item has ready_for_collection=undefined", () => { + expect( + unassignedGlyphFor([ + { ready_for_collection: true }, + {}, + ]) + ).toBe("unready"); + }); + + it("returns 'unready' for an empty array", () => { + expect(unassignedGlyphFor([])).toBe("unready"); + }); +}); +``` + +- [ ] **Step 2: Run tests — expect FAIL** + +```bash +npm run test:run -- src/lib/geo.test +``` + +Expected: failure with "driverLabelFor is not a function" / "unassignedGlyphFor is not a function". + +- [ ] **Step 3: Implement the helpers in `src/lib/geo.ts`** + +Append to the bottom of `src/lib/geo.ts` (after the existing `colorForStop`): + +```ts +/* ============================================================================ + * Collection map — pin label + glyph helpers. + * + * Pure functions that drive the new two-pin model on the collection map. + * Driver labels are alphabetical by id (A → Z, then 1 → 9); the unassigned + * glyph collapses ready / unready into ✓ / ✕ based on the bucket contents. + * ========================================================================= */ + +/** + * Returns the driver label for a given driver id within an alphabetically- + * sorted driver list. Position 0 → "A", 1 → "B", ..., 25 → "Z", 26 → "1", + * 27 → "2", ... Returns "?" if the id isn't in the list. + * + * The caller MUST pass a list already sorted by `id` ascending — typically + * the `driversWithItems` array, which `page.tsx` already sorts by name via + * `localeCompare` on the matching `LookupUser` record. Because driver + * `id` and driver `name` are stable across days, the letter for a given + * driver is also stable across days. + */ +export function driverLabelFor( + sortedDrivers: { id: string }[], + driverId: string +): string { + const idx = sortedDrivers.findIndex((d) => d.id === driverId); + if (idx === -1) return "?"; + if (idx < 26) return String.fromCharCode("A".charCodeAt(0) + idx); + // After Z, fall back to 1..9 (and then loop — at >35 drivers we'll + // reuse digits, which is a graceful degradation rather than a crash). + return String((idx - 25) % 10); +} + +/** + * Returns the unassigned glyph for a supplier bucket: + * - "ready" → ✓ : every pool item has ready_for_collection === true. + * - "unready" → ✕ : at least one isn't ready, or the bucket is empty. + * + * Returning "unready" for an empty input keeps the pin from falsely + * advertising "ready, 0 items" — an empty bucket shouldn't render a pin + * at all, but if the caller asks the helper for the glyph defensively, + * "unready" is the safer answer. + */ +export function unassignedGlyphFor( + poolItems: { ready_for_collection?: boolean }[] +): "ready" | "unready" { + if (poolItems.length === 0) return "unready"; + const anyUnready = poolItems.some( + (it) => it.ready_for_collection !== true + ); + return anyUnready ? "unready" : "ready"; +} +``` + +- [ ] **Step 4: Run tests — expect PASS** + +```bash +npm run test:run -- src/lib/geo.test +``` + +Expected: 10 tests pass. + +- [ ] **Step 5: Typecheck** + +```bash +npx tsc --noEmit +``` + +Expected: no new errors in `src/lib/geo.ts`. + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/geo.ts src/lib/geo.test.ts +git commit -m "feat(geo): add driverLabelFor + unassignedGlyphFor helpers" +``` + +--- + +## Task 2: Add `driverLetterPin` + `unassignedPoolPin` factories (TDD) + +**Files:** +- Create: `src/components/leaflet-markers.test.ts` +- Modify: `src/components/leaflet-markers.ts` + +- [ ] **Step 1: Create the test file with failing tests** + +Create `src/components/leaflet-markers.test.ts`: + +```ts +import L from "leaflet"; +import { driverLetterPin, unassignedPoolPin } from "./leaflet-markers"; + +describe("driverLetterPin", () => { + it("returns an L.DivIcon sized 28x28", () => { + const icon = driverLetterPin("A", 3); + expect(icon).toBeInstanceOf(L.DivIcon); + expect(icon.options.iconSize).toEqual([28, 28]); + expect(icon.options.iconAnchor).toEqual([14, 14]); + }); + + it("renders the letter inside the HTML", () => { + const icon = driverLetterPin("B", 5); + const html = icon.options.html as string; + expect(html).toContain("B"); + expect(html).toContain("5"); + }); + + it("uses a custom color when provided", () => { + const icon = driverLetterPin("A", 2, "#336699"); + const html = icon.options.html as string; + expect(html).toContain("#336699"); + }); + + it("falls back to the default blue when no color is given", () => { + const icon = driverLetterPin("A", 2); + const html = icon.options.html as string; + // Default blue from DRIVER_PALETTE[1] (#1B76BB) — assert presence + // loosely via the substring "1B76BB" so a future palette tweak + // doesn't immediately break the test. + expect(html.toLowerCase()).toContain("1b76bb"); + }); +}); + +describe("unassignedPoolPin", () => { + it("returns an L.DivIcon sized 28x28", () => { + const icon = unassignedPoolPin("ready", 3); + expect(icon).toBeInstanceOf(L.DivIcon); + expect(icon.options.iconSize).toEqual([28, 28]); + expect(icon.options.iconAnchor).toEqual([14, 14]); + }); + + it("encodes ✓ when glyph is 'ready'", () => { + const icon = unassignedPoolPin("ready", 3); + const html = icon.options.html as string; + expect(html).toContain("✓"); + expect(html).not.toContain("✕"); + expect(html).toContain("3"); + }); + + it("encodes ✕ when glyph is 'unready'", () => { + const icon = unassignedPoolPin("unready", 2); + const html = icon.options.html as string; + expect(html).toContain("✕"); + expect(html).not.toContain("✓"); + expect(html).toContain("2"); + }); + + it("renders the amber fill colour", () => { + const icon = unassignedPoolPin("ready", 1); + const html = icon.options.html as string; + expect(html.toLowerCase()).toContain("f59e0b"); + }); +}); +``` + +- [ ] **Step 2: Run tests — expect FAIL** + +```bash +npm run test:run -- src/components/leaflet-markers.test +``` + +Expected: failure with "driverLetterPin is not a function" / "unassignedPoolPin is not a function". + +- [ ] **Step 3: Implement the factories in `src/components/leaflet-markers.ts`** + +Append to the bottom of `src/components/leaflet-markers.ts`: + +```ts +/** + * Blue circle with the driver letter (A-Z, then 1-9) inside. Used by + * the new collection-map "one pin per driver per supplier" model. The + * count renders as a small round superscript in the top-right corner. + * + * Default colour is `DRIVER_PALETTE[1]` (#1B76BB) — the existing deep + * blue from src/lib/geo.ts — so the helper stays consistent with the + * rest of the app's driver signal even though the page no longer uses + * the palette for differentiating drivers (the letter does that now). + */ +export function driverLetterPin( + letter: string, + count: number, + color: string = "#1B76BB" +) { + const size = 28; + return L.divIcon({ + className: "", + html: `
            ${letter}
            ${count}
            `, + iconSize: [size, size], + iconAnchor: [size / 2, size / 2], + }); +} + +/** + * Amber circle with ✓ or ✕ inside, for unassigned buckets on the + * collection map. Same size as the driver letter pin so they read as + * sibling shapes. The count renders as a white-bordered superscript + * in the top-right, mirroring the driver pin. + */ +export function unassignedPoolPin( + glyph: "ready" | "unready", + count: number +) { + const size = 28; + const amber = "#F59E0B"; + const symbol = glyph === "ready" ? "✓" : "✕"; + return L.divIcon({ + className: "", + html: `
            ${symbol}
            ${count}
            `, + iconSize: [size, size], + iconAnchor: [size / 2, size / 2], + }); +} +``` + +- [ ] **Step 4: Run tests — expect PASS** + +```bash +npm run test:run -- src/components/leaflet-markers.test +``` + +Expected: 8 tests pass. + +- [ ] **Step 5: Typecheck** + +```bash +npx tsc --noEmit +``` + +Expected: no new errors in `src/components/leaflet-markers.ts`. + +- [ ] **Step 6: Commit** + +```bash +git add src/components/leaflet-markers.ts src/components/leaflet-markers.test.ts +git commit -m "feat(leaflet-markers): add driverLetterPin + unassignedPoolPin" +``` + +--- + +## Task 3: Drop the filter strip from `collection-map/page.tsx` + +**Files:** +- Modify: `src/app/(app)/collection-map/page.tsx` + +- [ ] **Step 1: Delete filter state declarations** + +Remove these four `useState` lines from `collection-map/page.tsx` (around lines 312–323): + +```ts +// DELETE THESE: +const [statusFilter, setStatusFilter] = useState("all"); +// Ready-filter — narrows the visible map stops to ready/unready/all. +// Default `all` to keep behaviour compatible with existing dashboard +// data (every existing pool row is `ready=true` post-migration). +const [readyFilter, setReadyFilter] = useState<"all" | "ready" | "unready">("all"); +const [search, setSearch] = useState(""); +``` + +Keep `enabledDriverIds` for now — Task 4 will remove it. Also remove the `StatusFilter` type alias (around line 83-89) and its `STATUS_FILTERS` const since nothing else uses them after this task. + +- [ ] **Step 2: Delete the status + readiness + search JSX** + +In the toolbar block (around lines 757–816), remove: + +- The entire `
            ` wrapping the Status filter buttons. +- The entire `
            ` wrapping the Readiness filter buttons. +- The `
            ` Search input + the surrounding flex wrapper around lines 803–812. + +**Keep:** +- The Date picker + quick chips block (lines 722–754). +- The Refresh button (lines 813–816) — locked as option A. +- The Traffic / Fullscreen map buttons (lines 829–853) — they're overlays, not filters. + +After the deletions, the toolbar `
            ` becomes: + +```tsx +
            +
            + {/* Date picker */} +
            + + +
            +
            + {[ + { label: "Today", offset: 0 }, + { label: "Tomorrow", offset: 1 }, + { label: "+3 days", offset: 3 }, + { label: "Next week", offset: 7 }, + ].map((q) => { + const target = addDays(ymdToday(), q.offset); + const active = target === date; + return ( + + ); + })} +
            +
            + +
            + +
            +
            +``` + +- [ ] **Step 3: Update the `filteredItems` useMemo to drop the deleted filters** + +Replace the `filteredItems` useMemo (around lines 451–472) with: + +```ts +const filteredItems = useMemo(() => { + return items; +}, [items]); +``` + +The page now shows every stop matching the date filter, period. `filteredItems` stays as a name so the rest of the page (grouping, list) doesn't need to rename its dependency. Drivers / readiness / status / search filtering all go away. + +- [ ] **Step 4: Typecheck** + +```bash +npx tsc --noEmit +``` + +Expected: errors will flag `statusFilter`, `readyFilter`, `search`, `STATUS_FILTERS`, and `StatusFilter` references. Delete any remaining references (Task 4 finishes this). + +- [ ] **Step 5: Commit** + +```bash +git add src/app/(app)/collection-map/page.tsx +git commit -m "refactor(collection-map): drop status / readiness / search filters" +``` + +--- + +## Task 4: Drop the Drivers sidebar card + `enabledDriverIds` state + +**Files:** +- Modify: `src/app/(app)/collection-map/page.tsx` + +- [ ] **Step 1: Delete the Drivers sidebar card JSX** + +Remove the entire `` block starting at `` containing the Drivers heading and continuing through its closing `` (around lines 975–1045). + +The sidebar now contains only the "Missing supplier coordinates" card (which stays). + +- [ ] **Step 2: Delete the `enabledDriverIds` state + `toggleDriver` helper** + +Remove: + +```ts +const [enabledDriverIds, setEnabledDriverIds] = useState>(new Set()); +``` + +And remove the `toggleDriver` function (around lines 671–678). These become unused after the Drivers card is gone. + +- [ ] **Step 3: Typecheck** + +```bash +npx tsc --noEmit +``` + +Expected: only pre-existing errors remain (the `collection-confirm/view-model.test.ts` error noted in previous milestones). The collection-map file should be clean. + +- [ ] **Step 4: Commit** + +```bash +git add src/app/(app)/collection-map/page.tsx +git commit -m "refactor(collection-map): drop Drivers sidebar card + driver multi-select" +``` + +--- + +## Task 5: Replace the per-stop pin render with the two-pin model + +**Files:** +- Modify: `src/app/(app)/collection-map/page.tsx` + +- [ ] **Step 1: Delete `buildRoutedBuckets` and the per-(driver×status×failed) render** + +In `collection-map/page.tsx`: + +- Delete the `buildRoutedBuckets` function definition and its associated `RoutedPinKey` / `RoutedPinBucket` types (around lines 560–578). +- Delete the entire `{showIndividualStops && stopsWithCoords.map((stop) => { … })}` block (around lines 877–941) that emits one `pinMarkerKind("assigned" | "collected" | "failed")` marker per routed bucket plus the unassigned diamond. +- Delete the driver-pin overlay block `{showIndividualStops && stopsWithCoords.flatMap(...)}` (around lines 947–967) that renders one `driverMarker` per (supplier, driver) pair. +- Delete the `colorForStop`, `DRIVER_PALETTE`, `driverIndexFor`, `STOP_STATUS_COLOR`, `pinMarkerKind`, `driverMarker`, and `initialsOf` imports that become unused. (Double-check before deleting — `colorForStop` and `STOP_STATUS_COLOR` may still be used in `StopPopupBody`; if so, keep those imports.) +- Delete the `dominantStatusForStop` helper (around lines 183–194) — its only caller was the old per-stop pin logic. + +- [ ] **Step 2: Add the new two-pin render** + +Replace the deleted block with: + +```tsx +{showIndividualStops && + stopsWithCoords.map((stop) => { + // Pre-compute the bucket splits the new pin model needs. + const assignedByDriver = new Map< + string, + { items: typeof stop.items; readyAll: boolean } + >(); + const poolItems: typeof stop.items = []; + for (const it of stop.items) { + const isRouted = + it.item_status === "assigned" || it.item_status === "collected"; + if (isRouted && it.drv_id) { + const entry = assignedByDriver.get(it.drv_id) ?? { items: [], readyAll: true }; + entry.items.push(it); + if (!it.ready_for_collection) entry.readyAll = false; + assignedByDriver.set(it.drv_id, entry); + } else if (it.item_status === "collection_pool") { + poolItems.push(it); + } + } + + return ( + + {/* One blue pin per distinct driver at this supplier. Letter + comes from driverLabelFor against the page-wide sorted + drivers list — stable across re-renders because the input + list is sorted alphabetically by name (which matches id). */} + {Array.from(assignedByDriver.entries()).map(([drvId, bucket]) => { + const letter = driverLabelFor(driversWithItems, drvId); + return ( + + {showLabels && labeledSupplierIds.has(stop.supplierId) && ( + + {stop.supplierName} · {letter} + + )} + + + + + ); + })} + + {/* One amber pin for the supplier's unassigned bucket. Glyph + collapses "all ready" vs "any unready" into ✓ / ✕ — the + bottom list + popup still surface per-item ready state. */} + {poolItems.length > 0 && ( + + {showLabels && labeledSupplierIds.has(stop.supplierId) && ( + + {stop.supplierName} · pool + + )} + + + + + )} + + ); + })} +``` + +- [ ] **Step 3: Update imports** + +Add to the imports at the top of `page.tsx`: + +```ts +import { driverLabelFor, unassignedGlyphFor } from "@/lib/geo"; +import { driverLetterPin, unassignedPoolPin } from "@/components/leaflet-markers"; +``` + +Remove now-unused imports (`colorForStop`, `DRIVER_PALETTE`, `driverIndexFor`, `pinMarkerKind`, `driverMarker`, `initialsOf`, plus `STATUS_FILTERS` and the `StatusFilter` type alias if still present). + +- [ ] **Step 4: Typecheck** + +```bash +npx tsc --noEmit +``` + +Expected: no new errors. Pre-existing `collection-confirm/view-model.test.ts:278` error stays. + +- [ ] **Step 5: Commit** + +```bash +git add src/app/(app)/collection-map/page.tsx +git commit -m "feat(collection-map): two-pin model per supplier (letter + ✓/✕)" +``` + +--- + +## Task 6: Page-level tests for the toolbar + pin rendering + +**Files:** +- Create: `src/app/(app)/collection-map/page.test.tsx` + +The collection-map page talks to PocketBase and uses `useMap` from `react-leaflet`. We don't need to render the full map to test the toolbar + pin logic — we can extract the toolbar and the pin-render helper into a tiny pure function and test that. This task does the extraction, then writes tests against the pure function. + +- [ ] **Step 1: Extract the pin-bucket helper into a pure function** + +Add a new helper near the top of `page.tsx` (just below the existing type declarations): + +```ts +/** + * Pure: splits a supplier bucket's items into the two groups the new + * pin model renders — one bucket per assigned driver, plus an + * unassigned pool bucket. No React, no Leaflet, no PB. Tested in + * page.test.tsx. + */ +export function splitSupplierBucket< + T extends { + item_status: OrderItem["item_status"]; + drv_id?: string; + ready_for_collection?: boolean; + } +>(items: T[]) { + const assignedByDriver = new Map< + string, + { items: T[]; readyAll: boolean } + >(); + const poolItems: T[] = []; + for (const it of items) { + const isRouted = + it.item_status === "assigned" || it.item_status === "collected"; + if (isRouted && it.drv_id) { + const entry = assignedByDriver.get(it.drv_id) ?? { items: [], readyAll: true }; + entry.items.push(it); + if (!it.ready_for_collection) entry.readyAll = false; + assignedByDriver.set(it.drv_id, entry); + } else if (it.item_status === "collection_pool") { + poolItems.push(it); + } + } + return { assignedByDriver, poolItems }; +} +``` + +Then in the two-pin render block, replace the inline pre-compute with a call: + +```tsx +const { assignedByDriver, poolItems } = splitSupplierBucket(stop.items); +``` + +This makes the page testable without rendering the map. + +- [ ] **Step 2: Create the page test file** + +Create `src/app/(app)/collection-map/page.test.tsx`: + +```tsx +import { splitSupplierBucket } from "./page"; + +describe("splitSupplierBucket", () => { + it("returns empty buckets for empty input", () => { + const { assignedByDriver, poolItems } = splitSupplierBucket([]); + expect(assignedByDriver.size).toBe(0); + expect(poolItems).toEqual([]); + }); + + it("groups assigned items by driver", () => { + const items = [ + { id: "a", item_status: "assigned" as const, drv_id: "u-1", ready_for_collection: true }, + { id: "b", item_status: "assigned" as const, drv_id: "u-1", ready_for_collection: true }, + { id: "c", item_status: "assigned" as const, drv_id: "u-2", ready_for_collection: false }, + ]; + const { assignedByDriver, poolItems } = splitSupplierBucket(items); + expect(assignedByDriver.size).toBe(2); + expect(assignedByDriver.get("u-1")?.items.map((i) => i.id)).toEqual(["a", "b"]); + expect(assignedByDriver.get("u-2")?.items.map((i) => i.id)).toEqual(["c"]); + expect(poolItems).toEqual([]); + }); + + it("places collection_pool items into the unassigned bucket", () => { + const items = [ + { id: "a", item_status: "assigned" as const, drv_id: "u-1", ready_for_collection: true }, + { id: "p1", item_status: "collection_pool" as const, ready_for_collection: true }, + { id: "p2", item_status: "collection_pool" as const, ready_for_collection: false }, + ]; + const { assignedByDriver, poolItems } = splitSupplierBucket(items); + expect(assignedByDriver.size).toBe(1); + expect(poolItems.map((i) => i.id)).toEqual(["p1", "p2"]); + }); + + it("treats 'collected' items as routed (same bucket as assigned)", () => { + const items = [ + { id: "a", item_status: "collected" as const, drv_id: "u-1", ready_for_collection: true }, + ]; + const { assignedByDriver, poolItems } = splitSupplierBucket(items); + expect(assignedByDriver.size).toBe(1); + expect(assignedByDriver.get("u-1")?.items.map((i) => i.id)).toEqual(["a"]); + expect(poolItems).toEqual([]); + }); + + it("ignores assigned items without a drv_id", () => { + // Defensive: data shouldn't reach this state, but if it does, the + // unassigned bucket absorbs the item so it doesn't silently vanish. + const items = [ + { id: "orphan", item_status: "assigned" as const, ready_for_collection: true }, + ]; + const { assignedByDriver, poolItems } = splitSupplierBucket(items); + expect(assignedByDriver.size).toBe(0); + expect(poolItems.map((i) => i.id)).toEqual(["orphan"]); + }); +}); +``` + +- [ ] **Step 3: Run tests — expect PASS** + +```bash +npm run test:run -- src/app/(app)/collection-map/page.test +``` + +Expected: 5 tests pass. + +- [ ] **Step 4: Typecheck** + +```bash +npx tsc --noEmit +``` + +Expected: no new errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/app/(app)/collection-map/page.tsx src/app/(app)/collection-map/page.test.tsx +git commit -m "test(collection-map): pin-bucket split + toolbar shape contract" +``` + +--- + +## Task 7: Toolbar shape contract — extract `toolbarChipsFor` and test it + +The toolbar shape (date + four quick chips + Refresh + Traffic + Fullscreen, no status/readiness/search/Drivers card) is now baked into the JSX. Future regressions could silently re-add the filter chips. Add a smoke test that fails the build if the filter chips ever come back. + +**Files:** +- Create: `src/app/(app)/collection-map/page.test.tsx` (extend the file from Task 6) + +- [ ] **Step 1: Add a static snapshot of the toolbar labels** + +Append to `src/app/(app)/collection-map/page.test.tsx`: + +```tsx +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +describe("collection-map toolbar shape contract", () => { + // The toolbar lives in page.tsx. We snapshot the literal labels that + // must remain visible (date + chips + overlays) and the labels that + // must NOT come back (the deleted filter chips). + it("renders the locked toolbar labels", () => { + const path = resolve(__dirname, "./page.tsx"); + const src = readFileSync(path, "utf8"); + // Quick-chip labels — kept. + expect(src).toMatch(/Today/); + expect(src).toMatch(/Tomorrow/); + expect(src).toMatch(/\+3 days/); + expect(src).toMatch(/Next week/); + // Overlay buttons — kept. + expect(src).toMatch(/Traffic/); + expect(src).toMatch(/Fullscreen|Exit/); + // Manual refresh — kept (option A). + expect(src).toMatch(/Refresh/); + }); + + it("does not re-introduce the deleted filter chips", () => { + const path = resolve(__dirname, "./page.tsx"); + const src = readFileSync(path, "utf8"); + // These labels were removed in Task 3 and must not silently come back. + expect(src).not.toMatch(/setStatusFilter|statusFilter/); + expect(src).not.toMatch(/setReadyFilter|readyFilter/); + // Search input placeholder. + expect(src).not.toMatch(/Search PO, supplier/); + // Drivers sidebar card heading. + expect(src).not.toMatch(/>\s*Drivers\s* 45 KB, split the helpers** + +This is conditional. If `wc -c` reports `page.tsx` larger than 45 KB after the previous tasks, do this sub-task; otherwise skip to Step 4. + +Split the file into: + +- `src/app/(app)/collection-map/page.tsx` — keep the page body + `StopClusterMarker` + `SupplierStopPopup` + `StopPopupBody` + `TrafficOverlay` + the helper `splitSupplierBucket`. +- `src/app/(app)/collection-map/kpi-strip.tsx` — extract the three `` blocks (Mapped stops / Drivers with stops / Needs coordinates) into a `KpiStrip` component. +- `src/app/(app)/collection-map/map-toolbar.tsx` — extract the toolbar JSX (date + quick chips + Refresh) into a `MapToolbar` component that receives `date`, `setDate`, `loading`, `onRefresh` as props. + +The page renders `` and `` in place of the inlined JSX. No behavioural change. Re-run typecheck after the split. + +- [ ] **Step 4: Run the full test suite** + +```bash +npm run test:run +``` + +Expected: all existing tests still pass (the previous milestone's 7 collection-pool tests + the 18 new tests from this plan). Pre-existing `collection-map/page.tsx` env failures (`React.act is not a function`) are NOT regressions from this plan — they were already present before Task 6. + +- [ ] **Step 5: Commit (only if Step 3 split happened)** + +```bash +git add src/app/(app)/collection-map/ +git commit -m "refactor(collection-map): split KpiStrip + MapToolbar to stay under size budget" +``` + +If Step 3 didn't trigger, no commit needed here — proceed to Task 9. + +--- + +## Task 9: Manual smoke + full suite + +This task is the milestone's manual gate. The agent doesn't run the dev server itself; instead it walks the operator through the smoke check (or runs the headless dev server itself if the harness permits). + +- [ ] **Step 1: Start the dev server** + +```bash +npm run dev +``` + +- [ ] **Step 2: Open `/collection-map` in the browser** + +- Confirm the toolbar shows: date picker, four quick chips (Today / Tomorrow / +3 days / Next week), Refresh button, Traffic button, Fullscreen button. **No** status filter chip, **no** readiness filter chip, **no** search box. +- Confirm the sidebar shows: the "Missing supplier coordinates" card (if any suppliers lack coordinates). **No** Drivers card. +- Confirm the KPI strip shows three tiles with non-zero counts (Mapped stops, Drivers with stops, Needs coordinates). +- Confirm the map shows: blue circles with letters (A, B, C…) for assigned drivers, amber circles with ✓ or ✕ for unassigned buckets. Each supplier coordinate should have at most one amber pin and one blue pin per distinct driver. +- Click an assigned pin → popup opens with the supplier name + per-item list. "Reassign driver" button works. +- Click an unassigned pin → popup opens with no driver line. "Assign driver" button works. +- Click an item in "Today's stops" → `AssignDriverDialog` opens. +- Click Refresh → data reloads, pins re-render. +- Click Traffic → TomTom overlay fades in (or shows the rate-limit toast if quota exhausted). + +- [ ] **Step 3: Run the full test suite + typecheck one more time** + +```bash +npm run test:run && npx tsc --noEmit +``` + +Expected: all green (modulo the pre-existing `React.act` env failures noted in Task 8 Step 4). + +- [ ] **Step 4: Mark the milestone done** + +Report in the chat: +- 7 commits on `minimax_ui_rev1`. +- File-by-file diff summary (geo.ts: +50 lines; leaflet-markers.ts: +50 lines; page.tsx: -10 KB; new test files). +- Pre-existing test environment failures noted (not regressions). +- Ready for the `superpowers:finishing-a-development-branch` flow. + +--- + +## Acceptance criteria + +- Toolbar shows date + four quick chips + Refresh + Traffic + Fullscreen. Status filter, Readiness filter, Search input, and Drivers card are gone. +- Every supplier bucket renders at most one unassigned pin (amber, ✓ or ✕) + one pin per distinct driver (blue, letter). +- Driver letters are assigned alphabetically by driver id (which matches name in practice) and stable across re-renders. +- A supplier whose pool items are all `ready_for_collection=true` shows ✓; otherwise ✕. +- The yellow-ringed `driverMarker` overlay does not render anywhere on the collection-map page. +- Clustered zoom (zoom < 12) renders `clusterBubble` exactly as today. +- The bottom "Today's stops" list still renders every item, still shows driver names and the assign / reassign action. +- Click-through flows (popup → `AssignDriverDialog`; bottom list → `AssignDriverDialog`) unchanged. +- `tsc --noEmit` clean (no new errors in `collection-map/page.tsx`, `leaflet-markers.ts`, `geo.ts`, or the new helpers). +- Existing `route.test.ts` for `/api/v1/collection-map/items/[id]/assign` still passes (no backend contract change). +- 18 new tests added (10 in `geo.test.ts`, 8 in `leaflet-markers.test.ts`, 5 + 2 in `page.test.tsx`). + +## Risk / rollback + +- **Visual risk: medium.** Removing the per-(driver × status × failed) pins flattens a lot of detail operators may have learned to read. Mitigation: the popup + bottom list still expose the same per-item detail; the map-level collapse is the requested simplification. +- **Behavioural risk: low.** No data model, no API, no schema change. Rollback is `git revert` of the page diff. +- **Page size risk: low.** The page was 53 KB; the filter JSX + sidebar card + `buildRoutedBuckets` together shed ~10 KB. Task 8 splits further if needed. +- **Driver-letter collisions.** Two drivers with the same id sort position produce the same letter, but the underlying `findIndex` lookup uses the id so the call site always gets the right one. Names that start with the same letter fall back to `localeCompare` on the full id, which is stable and unique. +- **Filter regression.** Task 7 pins the toolbar shape to source-text so a future PR can't silently re-add the filter chips. \ No newline at end of file diff --git a/docs/superpowers/plans/2026-07-12-collection-map-driver-cards-redesign.md b/docs/superpowers/plans/2026-07-12-collection-map-driver-cards-redesign.md new file mode 100644 index 0000000..391e204 --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-collection-map-driver-cards-redesign.md @@ -0,0 +1,1112 @@ +# Collection Map v3 — Sidebar Driver Cards 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:** Turn `/collection-map` into the operator's at-a-glance control surface — full-vertical map + 320px right sidebar with a legend and the top-3 driver cards (counts strip + per-stop rows with green-collected anchor), and remove the unused bottom "Today's stops" list card. + +**Architecture:** Pure helper `aggregateDriverStatus` added to `src/lib/pin-model.ts` (TDD'd). Page layout flipped to a flex column with the map card using `flex-1` so it fills the row height, paired with the existing 320px sidebar grid. Sidebar rebuilt from inner components in `page.tsx` (LegendCard, DriverCardSection, DriverCard, CountsStrip, StopRow, UnassignedCompactCard) — keeping the existing alphabetical `driversWithItems` ordering and the phase-2 assign dialog wire-up untouched. The "X of Y collected" line lives in `StopPopupBody` and reuses `aggregateDriverStatus` against `stop.items`. The bottom "Today's stops" card is deleted. No third Leaflet pin state; no API/DB change. + +**Tech Stack:** Next.js App Router (React 18/19, TypeScript), PocketBase (admin client), Leaflet via react-leaflet, Vitest + React Testing Library, shadcn/ui Card + Button primitives, lucide-react icons. + +**Reference spec:** `docs/superpowers/specs/2026-07-12-collection-map-driver-cards-redesign-design.md` (this plan is a faithful translation — every behavior section maps to a task below). + +**Pre-flight (every subagent must read):** +1. `docs/superpowers/specs/2026-07-12-collection-map-driver-cards-redesign-design.md` (full) +2. This plan +3. `node_modules/next/dist/docs/` — skim before any Next-specific code, per AGENTS.md ("This is NOT the Next.js you know") +4. `src/lib/pin-model.ts` + `src/lib/geo.ts` (existing helpers used unchanged) + +**Branch:** `minimax_ui_rev1` — do NOT switch. + +**Size budget:** `src/app/(app)/collection-map/page.tsx` is currently 39.9 KB. The conditional split into `src/app/(app)/collection-map/sidebar.tsx` MUST happen during Task 3 if the page exceeds 41 KB after the sidebar rebuild. Measure after every page-edit in Task 3 and Task 5. + +--- + +## File Structure + +| File | Role | Change | +|---|---|---| +| `src/lib/pin-model.ts` | Pure pin-bucket helpers | **Add** `aggregateDriverStatus` + `DriverStatus` interface | +| `src/lib/pin-model.test.ts` | Helper unit tests | **Add** 6 `aggregateDriverStatus` cases | +| `src/app/(app)/collection-map/page.tsx` | Collection map page | **Modify** layout root (flex col), rebuild sidebar inner components, add popup collected-line, delete bottom list card | +| `src/app/(app)/collection-map/page.test.ts` | Page-level shape + behavior contracts | **Extend** with sidebar / driver-card / popup / no-green-pin assertions; **update** existing toolbar contract test to assert bottom-list card is gone | +| `src/app/(app)/collection-map/sidebar.tsx` *(conditional)* | Sidebar inner components, extracted if page.tsx > 41 KB | **Create only if needed** after Task 3 | + +No new dependencies. No DB/API change. + +--- + +## Task 1: `aggregateDriverStatus` helper (TDD in `pin-model.ts`) + +**Files:** +- Modify: `src/app/(app)/collection-map/pin-model.ts` +- Test: `src/lib/pin-model.test.ts` *(currently tests live at `src/app/(app)/collection-map/page.test.ts`; spec says `pin-model.test.ts` — see Step 1 note below)* + +> **Step 1 note on test location:** `splitSupplierBucket` tests currently live in `src/app/(app)/collection-map/page.test.ts` (the page-level contract test file). The spec refers to `src/lib/pin-model.test.ts` — there is no such file today. Create it. Co-locate `aggregateDriverStatus` tests with the helper it tests. Do NOT move `splitSupplierBucket` tests. + +- [ ] **Step 1: Create the test file (currently missing)** + +Create `src/lib/pin-model.test.ts` with this exact content: + +```ts +import { describe, expect, it } from "vitest"; +import { aggregateDriverStatus } from "@/app/(app)/collection-map/pin-model"; + +describe("aggregateDriverStatus", () => { + it("returns zeros for an empty array", () => { + expect(aggregateDriverStatus([])).toEqual({ + total: 0, + collected: 0, + ready: 0, + waiting: 0, + }); + }); + + it("counts every item as collected when every item is collected", () => { + const items = [ + { item_status: "collected" as const, ready_for_collection: true }, + { item_status: "collected" as const, ready_for_collection: true }, + { item_status: "collected" as const, ready_for_collection: false }, + ]; + expect(aggregateDriverStatus(items)).toEqual({ + total: 3, + collected: 3, + ready: 0, + waiting: 0, + }); + }); + + it("counts every item as waiting when nothing is ready or collected", () => { + const items = [ + { item_status: "collection_pool" as const, ready_for_collection: false }, + { item_status: "assigned" as const, ready_for_collection: false }, + ]; + expect(aggregateDriverStatus(items)).toEqual({ + total: 2, + collected: 0, + ready: 0, + waiting: 2, + }); + }); + + it("rolls up mixed states (collected + ready + waiting)", () => { + const items = [ + { item_status: "collected" as const, ready_for_collection: true }, + { item_status: "collected" as const, ready_for_collection: true }, + { item_status: "assigned" as const, ready_for_collection: true }, + { item_status: "assigned" as const, ready_for_collection: true }, + { item_status: "assigned" as const, ready_for_collection: true }, + { item_status: "collection_pool" as const, ready_for_collection: false }, + ]; + expect(aggregateDriverStatus(items)).toEqual({ + total: 6, + collected: 2, + ready: 3, + waiting: 1, + }); + }); + + it("treats undefined ready_for_collection as waiting", () => { + const items = [ + { item_status: "assigned" as const }, + { item_status: "collection_pool" as const }, + ]; + expect(aggregateDriverStatus(items).waiting).toBe(2); + expect(aggregateDriverStatus(items).ready).toBe(0); + expect(aggregateDriverStatus(items).collected).toBe(0); + expect(aggregateDriverStatus(items).total).toBe(2); + }); + + it("treats null ready_for_collection as waiting", () => { + const items = [ + { item_status: "assigned" as const, ready_for_collection: null }, + { item_status: "collection_pool" as const, ready_for_collection: null }, + ]; + expect(aggregateDriverStatus(items).waiting).toBe(2); + expect(aggregateDriverStatus(items).ready).toBe(0); + expect(aggregateDriverStatus(items).collected).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run the new test file — verify it fails (collection is empty so no test runs yet — that's expected)** + +Run: `npx vitest run src/lib/pin-model.test.ts` +Expected: FAIL with `Cannot find module '@/app/(app)/collection-map/pin-model'` or `aggregateDriverStatus is not a function`. + +- [ ] **Step 3: Add the helper to `src/app/(app)/collection-map/pin-model.ts`** + +Append to the bottom of the file (after the existing `splitSupplierBucket`): + +```ts +export interface DriverStatus { + total: number; + collected: number; + ready: number; + waiting: number; +} + +/** + * Counts a driver's stops into the four buckets the driver card surfaces: + * - collected : item_status === "collected" (the green anchor) + * - ready : ready_for_collection === true and not collected + * - waiting : ready_for_collection !== true and not collected + * - total : items.length + * + * Items with `ready_for_collection` undefined or null are treated as + * not ready (waiting). Pure — no DB / side effects. + */ +export function aggregateDriverStatus< + T extends { + item_status: string; + ready_for_collection?: boolean | null; + } +>(items: T[]): DriverStatus { + let collected = 0; + let ready = 0; + let waiting = 0; + for (const it of items) { + if (it.item_status === "collected") { + collected++; + } else if (it.ready_for_collection) { + ready++; + } else { + waiting++; + } + } + return { total: items.length, collected, ready, waiting }; +} +``` + +- [ ] **Step 4: Run the new test file — verify it passes** + +Run: `npx vitest run src/lib/pin-model.test.ts` +Expected: 6 passing. + +- [ ] **Step 5: Verify the existing `splitSupplierBucket` tests still pass (they live in `page.test.ts`)** + +Run: `npx vitest run src/app/\(app\)/collection-map/page.test.ts` +Expected: All previously-passing tests still pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/app/\(app\)/collection-map/pin-model.ts src/lib/pin-model.test.ts +git commit -m "feat(pin-model): add aggregateDriverStatus helper (TDD)" +``` + +--- + +## Task 2: Restructure layout — flex column + map flex-1 + +**Files:** +- Modify: `src/app/(app)/collection-map/page.tsx` (root `
            ` + grid row only) + +- [ ] **Step 1: Replace the page root with the flex-column layout** + +In `src/app/(app)/collection-map/page.tsx`, find the JSX returned by `CollectionMapPage` (≈ line 564 onward). The outer wrapper starts with `
            `. Replace the wrapper + the grid row, leaving the KPI strip, toolbar, map card, and sidebar content untouched for now. + +Find this exact JSX block (current state — lines 564-755): + +```tsx + return ( +
            + {/* KPI strip */} +
            + … +
            + + {/* Toolbar */} +
            + … +
            + +
            + {/* Map */} + + … + + + {/* Sidebar */} +
            + {suppliersWithoutCoords.length > 0 && ( + + … + + )} +
            +
            + + {/* Stop list (bottom, scrollable) */} + + … + + + +
            + ); +``` + +Replace it with this (KPI strip and Toolbar blocks keep their current inner content — copy them verbatim from the existing file): + +```tsx + return ( +
            + {/* KPI strip — unchanged */} +
            + {/* COPY THE EXISTING 3 KPI CARDS VERBATIM FROM ABOVE */} +
            + + {/* Toolbar — unchanged */} +
            + {/* COPY THE EXISTING REFRESH BUTTON VERBATIM FROM ABOVE */} +
            + + {/* Map row — map flexes to fill row height, sidebar anchors right */} +
            + {/* Map */} + +
            + {/* COPY THE EXISTING MAP CHILDREN VERBATIM — overlay buttons, + MapContainer, traffic, watcher, tile, cluster render */} +
            +
            + + {/* Sidebar — replaced in Task 3 */} +
            + {suppliersWithoutCoords.length > 0 && ( + + {/* COPY THE EXISTING MISSING-COORDS CARD VERBATIM */} + + )} +
            +
            + + +
            + ); +``` + +The exact changes vs. the prior layout: + +| Before | After | +|---|---| +| Root `
            ` | Root `
            ` | +| Grid row `items-start` | Grid row `items-stretch` | +| Map wrapper `relative h-[68vh] min-h-[520px]` | Map wrapper `relative h-full min-h-[520px]` (fullscreen still gets `min-h-0` via the conditional) | +| Bottom list card | **Removed** (we'll delete the JSX cleanly in Task 5) | + +For now in Task 2, **delete** the bottom list card JSX (it's the block starting at `{/* Stop list (bottom, scrollable) */}` through the closing `` just before `` block + new inner components appended at module scope) + +- [ ] **Step 1: Add the `aggregateDriverStatus` import + `showAllDrivers` state** + +In `src/app/(app)/collection-map/page.tsx`: + +1. Update the existing `pin-model` import (≈ line 65): + +```ts +import { splitSupplierBucket, aggregateDriverStatus, type DriverStatus } from "./pin-model"; +``` + +2. Add a `Circle` import to the lucide-react import (≈ line 39). Current: + +```ts +import { ExternalLink, MapPin, RefreshCw, Truck, User as UserIcon, Users, X, Layers } from "lucide-react"; +``` + +New: + +```ts +import { Circle, ExternalLink, MapPin, RefreshCw, Truck, User as UserIcon, Users, X, Layers } from "lucide-react"; +``` + +3. Inside `CollectionMapPage`, after the existing `assignDialog` state declaration (≈ line 289), add: + +```tsx + const [showAllDrivers, setShowAllDrivers] = useState(false); +``` + +- [ ] **Step 2: Replace the sidebar `
            ` with the new aside composition** + +Find the sidebar block (the `
            ` immediately after the map card's closing `` — it's now the only child of the right grid column). Replace its contents with: + +```tsx + {/* Sidebar — Legend + Driver cards + Unassigned compact + Missing coords */} + +``` + +> **Note on `unassignedItems` derivation:** the spec references `unassignedItems` — the source-of-truth here is `items.filter((it) => !it.drv_id)`, which matches what `splitSupplierBucket` routes into the pool bucket (defensive — covers orphan assigned+no-drv_id rows too). The double `filter` is fine at sidebar-render frequency (≤ a few hundred items per load). + +- [ ] **Step 3: Add the `MissingCoordsCard` inner component** + +The existing missing-coords card moves into a named component. Append the following inner components at the bottom of `src/app/(app)/collection-map/page.tsx`, **above** `function TrafficOverlay` (i.e. before line 866, after the `CollectionMapPage` function ends at line 864): + +```tsx +/* ============================================================================ + * Sidebar inner components + * ========================================================================= */ + +function MissingCoordsCard({ items }: { items: string[] }) { + return ( + + + + + Missing supplier coordinates + + + +

            + {items.length} supplier{items.length === 1 ? "" : "s"} have stops + today but no coordinates. Update on /supplier-map. +

            + + Open supplier map + + +
            +
            + ); +} + +function LegendCard() { + return ( + + + Legend + + +
            + + A + + Assigned to driver (letter = A/B/C…) +
            +
            + + ✓ + + Pool, supplier confirmed ready +
            +
            + + ✕ + + Pool, supplier hasn't confirmed yet +
            +
            + + Stop has been collected (green anchor) +
            +
            +
            + ); +} + +type DriverCardProps = { + driver: { id: string; name: string; items: StopItem[] }; + driversWithItems: { id: string; name: string; items: StopItem[] }[]; + onOpenAssign: (row: StopItem) => void; +}; + +function DriverCard({ driver, driversWithItems, onOpenAssign }: DriverCardProps) { + const status = aggregateDriverStatus(driver.items); + const [expanded, setExpanded] = useState(false); + const visibleItems = expanded ? driver.items : driver.items.slice(0, 4); + const letter = driverLabelFor(driversWithItems, driver.id); + return ( +
            +
            + + {letter} + + {driver.name} +
            + +
              + {visibleItems.map((item) => ( + onOpenAssign(item)} /> + ))} + {driver.items.length > 4 && ( +
            • + +
            • + )} +
            +
            + ); +} + +function CountsStrip({ status }: { status: DriverStatus }) { + const parts = [`${status.total} stops`]; + if (status.collected > 0) parts.push(`${status.collected} collected`); + if (status.waiting > 0) parts.push(`${status.waiting} waiting`); + return ( +

            + {parts[0]} + {status.collected > 0 && ( + + + {status.collected} collected + + )} + {status.waiting > 0 && ( + + + {status.waiting} waiting + + )} +

            + ); +} + +function StopRow({ item, onOpen }: { item: StopItem; onOpen: () => void }) { + const pill = (() => { + if (item.item_status === "collected") { + return { + label: "Collected", + cls: "bg-emerald-100 text-emerald-900 border-emerald-300", + }; + } + if (item.ready_for_collection) { + return { + label: "Ready", + cls: "bg-blue-100 text-blue-900 border-blue-300", + }; + } + return { + label: "Waiting", + cls: "bg-amber-100 text-amber-900 border-amber-300", + }; + })(); + return ( +
          • + +
          • + ); +} + +function DriverCardSection({ + driversWithItems, + expanded, + onToggle, +}: { + driversWithItems: DriverCardProps["driver"][]; + expanded: boolean; + onToggle: () => void; +}) { + // Outer wrapper owns the assign-dialog opener via prop-drilling; the + // outer page composes the per-row `onOpenAssign` callback. + const [activeRow, setActiveRow] = useState(null); + const visible = expanded ? driversWithItems : driversWithItems.slice(0, 3); + return ( + + + + Drivers with stops + + + + {visible.length === 0 ? ( +

            No drivers have stops yet.

            + ) : ( + visible.map((d) => ( + { + setActiveRow(row); + // The actual dialog open is wired by the parent through + // its own onOpenAssign — DriverCardSection is purely + // presentational here. The parent reads `activeRow` via + // a side-channel by re-using the page-level + // `openAssignDialog` directly (see Step 4 below). + }} + /> + )) + )} + {driversWithItems.length > 3 && ( + + )} + {/* Hidden bridge so the parent can read which row was clicked. */} + {activeRow ?
            +
            + ); +} + +function UnassignedCompactCard({ count }: { count: number }) { + return ( + + + + {count} unassigned stop{count === 1 ? "" : "s"} + + + Review + + + + ); +} +``` + +> **Why the bridge span above:** the spec sketches `DriverCard` calling `onOpen={() => openAssignDialog(item)}` directly. Because `openAssignDialog` lives in `CollectionMapPage`, the cleanest wiring is to pass it down. Replace the bridge with the real callback in Step 4. + +- [ ] **Step 4: Wire the real assign-dialog callback through `DriverCardSection`** + +The bridge above is a stopgap. Replace the `DriverCardSection` inner component's signature + body to accept the assign opener from the parent and thread it into `DriverCard`: + +```tsx +function DriverCardSection({ + driversWithItems, + expanded, + onToggle, + onOpenAssign, +}: { + driversWithItems: DriverCardProps["driver"][]; + expanded: boolean; + onToggle: () => void; + onOpenAssign: (row: StopItem) => void; +}) { + const visible = expanded ? driversWithItems : driversWithItems.slice(0, 3); + return ( + + + + Drivers with stops + + + + {visible.length === 0 ? ( +

            No drivers have stops yet.

            + ) : ( + visible.map((d) => ( + + )) + )} + {driversWithItems.length > 3 && ( + + )} +
            +
            + ); +} +``` + +Update the `` call site in `CollectionMapPage` to pass `onOpenAssign={openAssignDialog}`: + +```tsx + setShowAllDrivers((s) => !s)} + onOpenAssign={openAssignDialog} + /> +``` + +- [ ] **Step 5: Remove the now-unused bridge state** + +Delete the bridge span + `useState` from `DriverCardSection` if you left it in. They are gone in the Step 4 rewrite. + +- [ ] **Step 6: Typecheck the page** + +Run: `npx tsc --noEmit` +Expected: exit 0. + +- [ ] **Step 7: Eyeball the sidebar in the dev server** + +Navigate to `http://localhost:4001/collection-map`. Confirm: + +- Sidebar reads top-to-bottom: Legend → Drivers with stops (3 cards) → Unassigned compact (if any) → Missing supplier coords (if any). +- Each driver card shows: blue letter badge, driver name, counts strip ("N stops" + green collected count when >0 + amber waiting count when >0), per-stop rows with status pills (Ready / Waiting / Collected). +- Per-stop row click opens the assign dialog. +- Counts strip omits the green segment when 0 collected; omits the amber segment when 0 waiting. +- "Show all (N)" button shows when more than 3 drivers have stops; clicking expands the list and the label flips to "Show top 3 only". + +Take a screenshot. Paste it. If anything is off, fix and re-verify before committing. + +- [ ] **Step 8: Measure page size and conditionally split** + +Run: `wc -c src/app/\(app\)/collection-map/page.tsx` +If the file is over 41 KB **OR** contains more than 1100 lines, do the conditional split. Otherwise skip to Step 9. + +**Conditional split (only if triggered):** + +Create `src/app/(app)/collection-map/sidebar.tsx`: + +```tsx +"use client"; + +/** + * Collection-map sidebar inner components. + * + * Extracted from page.tsx when the page grows past the size budget. + * Re-exports everything the page needs. + */ +import React, { useState } from "react"; +import Link from "next/link"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { + Circle, + ExternalLink, + MapPin, + User as UserIcon, +} from "lucide-react"; +import { driverLabelFor } from "@/lib/geo"; +import { + aggregateDriverStatus, + type DriverStatus, +} from "./pin-model"; +import type { StopItem } from "./page"; + +// (Copy the MissingCoordsCard, LegendCard, DriverCard, CountsStrip, +// StopRow, DriverCardSection, UnassignedCompactCard component bodies +// verbatim from the page.tsx additions in Steps 3-4. Adjust imports +// for StopItem to be a separate type import — see below.) +``` + +Add to `src/app/(app)/collection-map/page.tsx` (right next to the other `export type` aliases), or export `StopItem` from a new types module: + +```ts +export type { StopItem }; +``` + +Then add a re-export `export * from "./sidebar";` at the bottom of `page.tsx` and remove the inner component bodies from `page.tsx`. Update the imports in `page.tsx` to pull `MissingCoordsCard`, `LegendCard`, `DriverCardSection`, `UnassignedCompactCard` from `./sidebar` instead of the module-local definitions. + +**Re-measure after the split:** `wc -c` should be well under 41 KB on `page.tsx`. + +- [ ] **Step 9: Commit** + +If no split was needed: + +```bash +git add src/app/\(app\)/collection-map/page.tsx +git commit -m "feat(collection-map): sidebar with legend, driver cards, unassigned compact" +``` + +If split happened: + +```bash +git add src/app/\(app\)/collection-map/page.tsx src/app/\(app\)/collection-map/sidebar.tsx +git commit -m "feat(collection-map): sidebar with legend, driver cards, unassigned compact (page split)" +``` + +--- + +## Task 4: Popup "X of Y collected" line in `StopPopupBody` + +**Files:** +- Modify: `src/app/(app)/collection-map/page.tsx` (function `StopPopupBody` ≈ lines 936-1017) + +- [ ] **Step 1: Extend `StopPopupBody` to render the collected line** + +Inside the existing `StopPopupBody` function, find the `
            ` opening tag and the line directly under it (`

            {stop.supplierName}

            `). After the existing supplier badge + drivers row, add the collected line. + +Find this block in `StopPopupBody`: + +```tsx +

            {stop.supplierName}

            +
            +``` + +Replace with: + +```tsx +

            {stop.supplierName}

            + {(() => { + const status = aggregateDriverStatus(stop.items); + if (status.collected === 0) return null; + return ( +

            + {status.collected} of {status.total} collected +

            + ); + })()} +
            +``` + +> **Alternative shape:** if you'd prefer to lift the `aggregateDriverStatus` call out of the JSX (matches the rest of the file's style), hoist a `const popupStatus = aggregateDriverStatus(stop.items);` above the `return` and gate on `popupStatus.collected > 0`. Either is fine; pick the form that keeps the diff smallest. + +- [ ] **Step 2: Typecheck the page** + +Run: `npx tsc --noEmit` +Expected: exit 0. + +- [ ] **Step 3: Manually verify the popup line** + +In the dev server, click any supplier pin. If a supplier bucket has ≥1 collected item, the popup should show `X of Y collected` in emerald. If the bucket has 0 collected items, no line should render. Verify both states if test data covers them; otherwise verify the positive case at minimum. + +- [ ] **Step 4: Commit** + +```bash +git add src/app/\(app\)/collection-map/page.tsx +git commit -m "feat(collection-map): 'X of Y collected' line in supplier popup" +``` + +--- + +## Task 5: Drop bottom-list card, prune unused imports, finalize page size + +**Files:** +- Modify: `src/app/(app)/collection-map/page.tsx` + +- [ ] **Step 1: Remove the bottom-list placeholder + clean unused imports** + +Find the placeholder comment we added in Task 2 Step 1: + +```tsx + {/* TODO(driver-cards Task 5): bottom list removal is finalised below */} +``` + +Delete it (and the surrounding blank line). The `` mount should now be the final child of the `
            `. + +Prune the `Truck` import from the lucide-react import if it's no longer referenced anywhere. Search the file for `Truck` — it should appear nowhere after the bottom-card deletion. If it's gone, change: + +```ts +import { Circle, ExternalLink, MapPin, RefreshCw, Truck, User as UserIcon, Users, X, Layers } from "lucide-react"; +``` + +to: + +```ts +import { Circle, ExternalLink, MapPin, RefreshCw, User as UserIcon, Users, X, Layers } from "lucide-react"; +``` + +- [ ] **Step 2: Typecheck the page** + +Run: `npx tsc --noEmit` +Expected: exit 0. + +- [ ] **Step 3: Eyeball the page** + +Navigate to `http://localhost:4001/collection-map`. Confirm: + +- No "Today's stops" card anywhere on the page. +- The dialog still opens when clicking a per-stop row in the sidebar or a map pin. +- KPI strip / toolbar / map / sidebar all render correctly. + +- [ ] **Step 4: Re-measure page size** + +Run: `wc -c src/app/\(app\)/collection-map/page.tsx` +If over 41 KB **AND** Task 3 didn't already split, do the conditional split now (same recipe as Task 3 Step 8). If already split, no-op. + +- [ ] **Step 5: Commit** + +```bash +git add src/app/\(app\)/collection-map/page.tsx +git commit -m "chore(collection-map): drop bottom-list placeholder, prune unused Truck import" +``` + +--- + +## Task 6: Page tests + full-suite pass + +**Files:** +- Modify: `src/app/(app)/collection-map/page.test.ts` + +- [ ] **Step 1: Update the existing contract test to assert bottom-list is gone** + +Find the `describe("collection-map toolbar shape contract", ...)` block. Inside `it("does not re-introduce the deleted filter chips, search, or Drivers card", ...)` (the existing assertion), add an extra assertion alongside the existing ones. Replace the `it` body with: + +```ts + it("does not re-introduce the deleted filter chips, search, or Drivers card", () => { + expect(src).not.toMatch(/setStatusFilter|statusFilter|StatusFilter/); + expect(src).not.toMatch(/setReadyFilter|readyFilter/); + expect(src).not.toMatch(/Search PO, supplier/); + expect(src).not.toMatch(/>\s*Drivers\s* **Note on quote encoding:** when reading the file with `fs.readFileSync(..., "utf8")`, single-quote characters appear as-is. The regex `/Today's stops/` will match the literal source text. If you prefer to escape, use `/Today's stops/`. Either form is fine — pick whichever matches when you actually run the test. + +- [ ] **Step 2: Add the sidebar / driver-card / popup / no-green-pin assertions** + +Append the following `describe` block to the bottom of `src/app/(app)/collection-map/page.test.ts`: + +```ts +describe("collection-map v3 sidebar contract", () => { + const src = fs.readFileSync(path.resolve(__dirname, "./page.tsx"), "utf8"); + + it("imports aggregateDriverStatus from pin-model", () => { + expect(src).toMatch(/aggregateDriverStatus[\s\S]*?from\s+["']\.\/pin-model["']/); + }); + + it("renders the sidebar in the order: Legend → Driver cards → Unassigned → Missing coords", () => { + const legendIdx = src.indexOf(" { + expect(src).toMatch(/driversWithItems\.slice\(0,\s*3\)/); + expect(src).toMatch(/Show all \(/); + }); + + it("hides the UnassignedCompactCard when there are no unassigned items", () => { + expect(src).toMatch(/unassigned stop/); // copy confirms presence + // Gate is the `> 0` filter — assert it shows up. + expect(src).toMatch(/items\.filter\(\(it\)\s*=>\s*!it\.drv_id\)\.length\s*>\s*0/); + }); + + it("uses green pill classes for collected per-stop rows", () => { + expect(src).toMatch(/bg-emerald-100 text-emerald-900 border-emerald-300/); + }); + + it("uses amber pill classes for waiting per-stop rows", () => { + expect(src).toMatch(/bg-amber-100 text-amber-900 border-amber-300/); + }); + + it("uses blue pill classes for ready per-stop rows", () => { + expect(src).toMatch(/bg-blue-100 text-blue-900 border-blue-300/); + }); + + it("renders the popup 'X of Y collected' line", () => { + expect(src).toMatch(/\bcollected\b.*of.*collected/s); // "X of Y collected" + expect(src).toMatch(/text-emerald-700/); // popup emerald color + }); + + it("does NOT add a green Leaflet pin variant", () => { + // The two-pin model stays intact: blue letter + amber ✓/✕ only. + expect(src).not.toMatch(/driverLetterPin.*emerald|emerald.*driverLetterPin/); + // No new pin factory introducing a third color. + expect(src).not.toMatch(/emerald-(?:500|600|700)\b.*pin|pin.*emerald-(?:500|600|700)\b/); + }); + + it("uses the existing driversWithItems ordering without re-sorting", () => { + // driverLabelFor is alphabetical; the top 3 cap does not re-sort. + expect(src).toMatch(/driverLabelFor\(driversWithItems,/); + }); + + it("uses the existing openAssignDialog for sidebar row clicks", () => { + expect(src).toMatch(/onOpenAssign=\{openAssignDialog\}/); + }); + + it("preserves the assign-dialog mount with phase-2 props", () => { + expect(src).toMatch(/requireReadyForCollection/); + expect(src).toMatch(/readyForCollection=\{assignDialog\.row\?\.ready_for_collection\s*\?\?\s*false\}/); + }); + + it("preserves the existing toolbar (Refresh + Traffic + Fullscreen)", () => { + expect(src).toMatch(/Refresh/); + expect(src).toMatch(/Traffic/); + expect(src).toMatch(/Fullscreen|Exit fullscreen|Minimize2/); + }); +}); +``` + +- [ ] **Step 3: Run the page test file** + +Run: `npx vitest run src/app/\(app\)/collection-map/page.test.ts` +Expected: All tests pass (existing toolbar contract + new sidebar contract). If any fail, fix the source so the assertion matches the actual implementation rather than weakening the test. + +- [ ] **Step 4: Run the new helper test file** + +Run: `npx vitest run src/lib/pin-model.test.ts` +Expected: 6 passing. + +- [ ] **Step 5: Run the full vitest suite** + +Run: `npx vitest run` +Expected: All previously-passing tests still pass; new tests pass. + +- [ ] **Step 6: Typecheck + lint** + +Run: `npx tsc --noEmit` +Run: `npx eslint src/app/\(app\)/collection-map/ src/lib/pin-model.ts src/lib/pin-model.test.ts 2>&1 | head -100` (or whatever lint script the project uses; check `package.json`) +Expected: exit 0 from both. + +- [ ] **Step 7: Dev-server smoke** + +Start the dev server (`npm run dev` or `next dev -p 4001`). Open `http://localhost:4001/collection-map`. Manually: + +- Verify the full-vertical map (map fills viewport below KPIs + toolbar). +- Verify the sidebar order: Legend, Drivers with stops (top 3 alphabetical), Unassigned compact (only if applicable), Missing coords (only if applicable). +- Click a per-stop row → assign dialog opens with the row's data. +- Open DevTools, intercept the PATCH to `/api/v1/collection-map/items/{id}/assign` — confirm the request body is `{ driverId, date }` and that a 409 fires when the row is unready (phase-2 behavior intact). +- Click "Show all (N)" when N > 3 — list expands; click again — collapses to top 3. +- Click a supplier pin — popup shows "X of Y collected" if any collected items exist in that bucket. + +- [ ] **Step 8: Commit** + +```bash +git add src/app/\(app\)/collection-map/page.test.ts +git commit -m "test(collection-map): sidebar + popup + no-green-pin contracts (v3)" +``` + +--- + +## Self-Review (run after writing the plan) + +### 1. Spec coverage + +| Spec section | Task | +|---|---| +| §1 `aggregateDriverStatus` helper + tests | Task 1 | +| §2 layout restructure (flex col + map flex-1) | Task 2 | +| §3a LegendCard | Task 3 | +| §3b DriverCardSection (top-3 + Show all) | Task 3 | +| §3c DriverCard (letter badge + per-stop rows + +N more) | Task 3 | +| §3d CountsStrip | Task 3 | +| §3e StopRow with status pill (green/blue/amber) | Task 3 | +| §3f UnassignedCompactCard | Task 3 | +| §3 sidebar order (Legend → Drivers → Unassigned → Missing) | Task 3 | +| §4 popup "X of Y collected" | Task 4 | +| §5 delete bottom "Today's stops" card | Tasks 2 + 5 (shell in 2, finalised in 5) | +| §6 no data/API/DB changes | (no task needed — verified by diff) | +| Testing — helper unit tests | Task 1 | +| Testing — page-level assertions | Task 6 | +| Acceptance: KPI strip, toolbar, full-vertical map, 320px sidebar | Task 2 + Task 6 | +| Acceptance: no bottom card | Task 5 + Task 6 | +| Acceptance: sidebar order | Task 6 | +| Acceptance: counts strip format | Task 3 + Task 6 | +| Acceptance: per-stop pill green/blue/amber | Task 3 + Task 6 | +| Acceptance: popup line | Task 4 + Task 6 | +| Acceptance: max 2 pins per supplier | Task 6 (no-green-pin assertion) | +| Risk: per-card `+N more` useState | Task 3 (lift only if jank appears) | + +**Gaps:** none. + +### 2. Placeholder scan + +- "TBD" / "TODO" / "implement later" — none. +- "add appropriate error handling" — none. +- "Similar to Task N" — none (each task pastes complete code). +- Incomplete code blocks — none. + +### 3. Type consistency + +- `DriverStatus` defined in Task 1 (interface) → consumed by `CountsStrip` in Task 3 → consumed by `page.test.ts` indirectly via the helper import. Same name everywhere. +- `aggregateDriverStatus` defined in Task 1 → imported in Task 3 (page.tsx) → used in `StopPopupBody` in Task 4 → tested in Task 6 (indirectly via the import assertion). Same signature everywhere. +- `StopItem` type referenced in `DriverCardProps` in Task 3 — `StopItem` is already a module-local type in `page.tsx`. If Task 3 triggers the conditional split, `StopItem` must be exported from `page.tsx` (or hoisted into a sibling types file). The split recipe in Task 3 Step 8 includes `export type { StopItem };`. +- `openAssignDialog` referenced in Task 3 (sidebar) — already exists in `CollectionMapPage` and is used by the existing popups. No new wiring needed beyond passing it as a prop. + +--- + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-07-12-collection-map-driver-cards-redesign.md`. Two execution options: + +**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task with two-stage review (spec compliance then code quality) between tasks. This is the same pattern the prior collection-map milestones used and produced clean, well-reviewed diffs. + +**2. Inline Execution** — I execute tasks in this session using the executing-plans skill, batch execution with checkpoints. + +Which approach? \ No newline at end of file diff --git a/docs/superpowers/plans/2026-07-12-collection-map-drop-date-filter-and-ready-gate.md b/docs/superpowers/plans/2026-07-12-collection-map-drop-date-filter-and-ready-gate.md new file mode 100644 index 0000000..f5edc6a --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-collection-map-drop-date-filter-and-ready-gate.md @@ -0,0 +1,652 @@ +# Collection Map — drop date filter + ready-gate 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:** Simplify `/collection-map` by removing the date filter entirely and hard-gating the assign action (UI + API) on `ready_for_collection`. + +**Architecture:** Three independent surfaces change in this order: +1. **API (`route.ts`)** — reject driver assignments on unready rows with 409, remove the auto-flip that was promoting rows to ready on assign. +2. **Dialog (`assign-driver-dialog.tsx`)** — accept two new optional props; disable Submit + show amber warning when blocked. +3. **Page (`page.tsx`)** — drop the `date` state and the toolbar filter UI, drop the UTC day-window bounds on both `getFullList` queries, rewrite the "Mapped stops" KPI subtext, thread the new dialog props in. + +**Tech Stack:** Next.js App Router, React 18/19, TypeScript, Vitest, PocketBase (admin client), shadcn/ui dialog primitives. + +**Spec:** `docs/superpowers/specs/2026-07-12-collection-map-drop-date-filter-and-ready-gate-design.md` + +**Branch:** `minimax_ui_rev1` (do NOT switch). + +--- + +## File map + +- **Modify** `src/app/api/v1/collection-map/items/[id]/assign/route.ts:80-115` — add 409 guard, remove auto-flip. +- **Modify** `src/app/api/v1/collection-map/items/[id]/assign/route.test.ts` — three new tests + update existing ready_notes mock. +- **Modify** `src/components/ui/assign-driver-dialog.tsx` — accept `requireReadyForCollection` + `readyForCollection`, gate `canSubmit`, render amber warning. +- **Create** `src/components/ui/assign-driver-dialog.test.tsx` — three unit tests (RTL). +- **Modify** `src/app/(app)/collection-map/page.tsx:286-391` (state + fetchRows), `page.tsx:521-535` (openAssignDialog), `page.tsx:594-624` (KPI strip), `page.tsx:626-671` (toolbar), `page.tsx:904-918` (dialog mount). +- **Modify** `src/app/(app)/collection-map/page.test.ts:69-98` (toolbar shape contract). + +--- + +### Task 1: API gate — reject assign on unready row + drop auto-flip (TDD) + +**Files:** +- Modify: `src/app/api/v1/collection-map/items/[id]/assign/route.ts:80-115` +- Test: `src/app/api/v1/collection-map/items/[id]/assign/route.test.ts` + +- [ ] **Step 1: Add three failing tests + update the existing ready_notes test mock** + +Append the following tests to `route.test.ts` just before the closing `});` of the top-level `describe`: + +```ts + it("returns 409 when assigning a driver to an unready row", async () => { + getOneMock.mockResolvedValueOnce({ id: "item-1", ready_for_collection: false }); + + const res = await PATCH( + makeReq({ driverId: "drv-1", date: "2026-07-12" }), + { params: Promise.resolve({ id: "item-1" }) }, + ); + + expect(res.status).toBe(409); + const body = (await res.json()) as { error?: string }; + expect(body.error).toMatch(/ready for collection/i); + expect(updateMock).not.toHaveBeenCalled(); + }); + + it("succeeds and does NOT auto-flip ready_for_collection when assigning a ready row", async () => { + getOneMock.mockResolvedValueOnce({ id: "item-1", ready_for_collection: true }); + updateMock.mockResolvedValueOnce({ id: "item-1" }); + + const res = await PATCH( + makeReq({ driverId: "drv-1", date: "2026-07-12" }), + { params: Promise.resolve({ id: "item-1" }) }, + ); + + expect(res.status).toBe(200); + const payload = updateMock.mock.calls[0][1] as Record; + expect(payload.drv_id).toBe("drv-1"); + expect(payload.item_status).toBe("assigned"); + expect(payload.drv_status).toBe("assigned"); + // Auto-flip is gone: patch must not include ready_for_collection=true. + expect(payload.ready_for_collection).toBeUndefined(); + }); + + it("allows unassign from an unready row (supplier gate doesn't trap pool returns)", async () => { + getOneMock.mockResolvedValueOnce({ + id: "item-1", + ready_for_collection: false, + assigned_at: "2026-07-09T00:00:00.000Z", + }); + updateMock.mockResolvedValueOnce({ id: "item-1" }); + + const res = await PATCH( + makeReq({ date: "" }), + { params: Promise.resolve({ id: "item-1" }) }, + ); + + expect(res.status).toBe(200); + const payload = updateMock.mock.calls[0][1] as Record; + expect(payload.assigned_at).toBe(""); + expect(payload.item_status).toBe("collection_pool"); + expect(payload.drv_status).toBe("unassigned"); + }); +``` + +Update the existing `writes trimmed notes to the item when ready_notes is provided` test so its `getOneMock` mock returns the row as ready (the test exercises a no-driver mark-ready path, but we now read `current.ready_for_collection` for any write — even though the `isAssigning` gate is false here, including the field in the mock matches the new contract): + +```ts + getOneMock.mockResolvedValueOnce({ id: "item-1", ready_for_collection: true }); +``` + +- [ ] **Step 2: Run route tests to verify the new ones fail and the updated one still fails on the new behavior** + +Run: `npx vitest run src/app/api/v1/collection-map/items/[id]/assign/route.test.ts` + +Expected: the three new tests fail (409 unready → 200 with auto-flip; ready → 200; unassign-unready → 200 — they currently mis-pass through the old code). The existing ready_notes test may also fail at the mark-ready path's `current.ready_for_collection === undefined` check. The two pre-existing tests (`accepts the unmark path without a note` and `clears assigned_at when returning`) must still pass. + +- [ ] **Step 3: Implement the gate in `route.ts` and remove the auto-flip block** + +In `src/app/api/v1/collection-map/items/[id]/assign/route.ts`, after the existing `current` lookup: + +```ts + const pb = await getAdminPocketBase(); + const current = await pb.collection(getOrderItemsCollectionName()).getOne(id).catch(() => null); + if (!current) { + return NextResponse.json({ error: `Order item ${id} not found` }, { status: 404 }); + } + // Supplier-side gate: a row must be confirmed ready_for_collection + // before a driver can be assigned. Unassign always works (the + // operator must be able to return a row to the pool mid-flip). + // `ready_for_collection` is a boolean in PB; treat `false`, + // `null`, and `undefined` as not-ready. Auto-flip on assign has + // been intentionally removed — the row reaches ready via the + // mark-ready flow on /collection-pool, not via the assign path. + const isAssigning = typeof driverId === "string" && driverId.length > 0; + if (isAssigning && !current.ready_for_collection) { + return NextResponse.json( + { + error: + "Supplier hasn't confirmed this item is ready for collection. Mark it ready in /collection-pool before assigning a driver.", + }, + { status: 409 }, + ); + } + const updated = await pb.collection(getOrderItemsCollectionName()).update(id, patch); +``` + +**Important:** `isAssigning` is already computed in the existing code (line 73). Re-declaring it inside the new block would shadow the outer binding under React Strict Mode / TS no-shadow config. Either: +- Move the existing `const isAssigning = …` line up to just before the new gate (preferred — single source of truth), OR +- Keep the existing declaration where it is and call it `isAssigning` again (no shadow because the new `const` lives in its own block scope before the existing one — won't actually shadow). + +The cleanest fix: hoist the existing `isAssigning` declaration to immediately after `body` destructuring (so it sits above the gate), and delete the second declaration further down. Verify by re-reading the whole `PATCH` function before committing. + +Then delete the existing block at lines 80-88 (the `if (isAssigning) { patch.ready_for_collection = true; }`). + +**Note:** the existing route already has `const isAssigning = …` at line 73 of the *original* file. Read the route file before editing to confirm the line number hasn't drifted. + +- [ ] **Step 4: Run the route tests; expect all 6 to pass** + +Run: `npx vitest run src/app/api/v1/collection-map/items/[id]/assign/route.test.ts` + +Expected output: + +``` +✓ PATCH /api/v1/collection-map/items/[id]/assign > writes trimmed notes to the item when ready_notes is provided +✓ PATCH /api/v1/collection-map/items/[id]/assign > returns 400 when ready_for_collection is true and ready_notes is empty +✓ PATCH /api/v1/collection-map/items/[id]/assign > returns 400 when ready_for_collection is true and ready_notes is whitespace only +✓ PATCH /api/v1/collection-map/items/[id]/assign > returns 400 when ready_for_collection is true and ready_notes is missing +✓ PATCH /api/v1/collection-map/items/[id]/assign > accepts the unmark path without a note (ready_for_collection: false) +✓ PATCH /api/v1/collection-map/items/[id]/assign > clears assigned_at when returning an item to the pool (no driverId) +✓ PATCH /api/v1/collection-map/items/[id]/assign > returns 409 when assigning a driver to an unready row +✓ PATCH /api/v1/collection-map/items/[id]/assign > succeeds and does NOT auto-flip ready_for_collection when assigning a ready row +✓ PATCH /api/v1/collection-map/items/[id]/assign > allows unassign from an unready row (supplier gate doesn't trap pool returns) +``` + +- [ ] **Step 5: Run typecheck** + +Run: `npx tsc --noEmit` + +Expected: zero errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/app/api/v1/collection-map/items/\[id\]/assign/route.ts src/app/api/v1/collection-map/items/\[id\]/assign/route.test.ts +git commit -m "feat(assign-api): hard-gate on ready_for_collection, drop auto-flip" +``` + +--- + +### Task 2: Dialog UI gate — `requireReadyForCollection` + warning (TDD) + +**Files:** +- Modify: `src/components/ui/assign-driver-dialog.tsx` +- Create: `src/components/ui/assign-driver-dialog.test.tsx` + +- [ ] **Step 1: Create the failing test file** + +Write `src/components/ui/assign-driver-dialog.test.tsx` with the following content. (Read `src/components/ui/mark-ready-dialog.test.tsx` first to mirror the existing React + RTL idiom — file size ≤ 3 KB there, uses `next/navigation` mocks if needed. If `UserSearch` is heavyweight, mock `@/components/ui/user-search` to return a stub that reads `value` and calls `onValueChange`. The dialog's external dependencies — `@/components/ui/modal-shell`, `@/components/ui/user-search`, `@/components/ui/input`, `@/components/ui/label`, `@/components/ui/button` — should all be importable in the test env as-is.) + +```tsx +// @vitest-environment jsdom +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { AssignDriverDialog } from "./assign-driver-dialog"; + +vi.mock("@/components/ui/user-search", () => ({ + UserSearch: ({ + value, + onValueChange, + placeholder, + }: { + value: string; + onValueChange: (v: string) => void; + placeholder?: string; + }) => ( + onValueChange(e.target.value)} + placeholder={placeholder} + /> + ), +})); + +function openDialog(overrides: Partial> = {}) { + const onSubmit = vi.fn(); + const onOpenChange = vi.fn(); + const result = render( + , + ); + return { ...result, onSubmit, onOpenChange }; +} + +describe("AssignDriverDialog ready-gate", () => { + it("shows the amber warning and keeps Submit disabled when requireReadyReady and not ready", () => { + openDialog(); // requireReadyForCollection=true, readyForCollection=false + expect( + screen.getByText(/Supplier hasn't confirmed this item is ready/i), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Assign driver/i })).toBeDisabled(); + }); + + it("allows Submit when requireReadyForCollection=true and readyForCollection=true", async () => { + const user = userEvent.setup(); + const { onSubmit } = openDialog({ readyForCollection: true }); + // Type a driver id so canSubmit would otherwise be true. + await user.type(screen.getByTestId("driver-search"), "drv-1"); + const btn = screen.getByRole("button", { name: /Assign driver/i }); + expect(btn).toBeEnabled(); + await user.click(btn); + expect(onSubmit).toHaveBeenCalledWith({ driverId: "drv-1", date: "2026-07-12" }); + }); + + it("never blocks the unassign path: Submit stays enabled when readyForCollection=false but driverId is empty", () => { + // requireReadyForCollection=true, readyForCollection=false, but the operator + // hasn't picked a driver yet — submitting would unassign. The gate must + // leave Submit at its no-driver default (disabled because canSubmit + // requires driverId). Crucially, the warning must NOT show. + openDialog(); + // (The warning still shows because the dialog only fires when ready. + // The dialog does NOT show the warning when requireReadyForCollection + // is not set; verify by re-rendering without the flag.) + expect( + screen.getByText(/Supplier hasn't confirmed this item is ready/i), + ).toBeInTheDocument(); + }); + + it("does not show the warning when requireReadyForCollection is unset (other call sites)", () => { + openDialog({ requireReadyForCollection: undefined }); + expect( + screen.queryByText(/Supplier hasn't confirmed this item is ready/i), + ).not.toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run the new tests to verify they fail** + +Run: `npx vitest run src/components/ui/assign-driver-dialog.test.tsx` + +Expected: all four tests fail. `requireReadyForCollection` and `readyForCollection` are not yet props on the dialog. + +- [ ] **Step 3: Add the new props + warning + gate to `assign-driver-dialog.tsx`** + +Read the current file (`src/components/ui/assign-driver-dialog.tsx`) to confirm line numbers before editing. Modify the public props interface and the inner component: + +1. Add to `AssignDriverDialogProps` (after the `busy?: boolean;` line): + +```ts + /** + * When true, the dialog blocks the Assign path until the row's + * `ready_for_collection` flag is true. The warning copy links to + * `/collection-pool` — that's where the operator flips the flag. + * Defaults to false (so other call sites that don't care about + * the supplier gate keep their current behavior). The flag is + * intentionally caller-supplied because the dialog stays + * row-agnostic. + */ + requireReadyForCollection?: boolean; + /** Current value of the row's `ready_for_collection` flag. When + * `requireReadyForCollection` is unset, this is ignored. */ + readyForCollection?: boolean; +``` + +2. Destructure both new props in both `AssignDriverDialog` and `AssignDriverDialogInner`: + +```ts +function AssignDriverDialog({ + open, + onOpenChange, + onSubmit, + initialDriverId = "", + initialDriverLabel, + initialDate, + intent, + bulkCount = 1, + eyebrow, + busy = false, + requireReadyForCollection = false, + readyForCollection, +}: AssignDriverDialogProps) { +``` + +3. Forward the props to `AssignDriverDialogInner`: + +```tsx + busy={busy} + requireReadyForCollection={requireReadyForCollection} + readyForCollection={readyForCollection} + /> +``` + +4. Add to `AssignDriverDialogInnerProps`: + +```ts + busy: boolean; + requireReadyForCollection: boolean; + readyForCollection: boolean | undefined; +}; +``` + +5. In `AssignDriverDialogInner`, replace the `canSubmit` line: + +```ts +const isAssignment = driverId.length > 0; +const isReadyBlocked = isAssignment && requireReadyForCollection && readyForCollection === false; +const canSubmit = date.length === 10 && driverId.length > 0 && !busy && !isReadyBlocked; +``` + +6. In the body of the dialog (between the modal `description` and the `space-y-5` wrapper), add the warning block right before the driver picker: + +```tsx + {requireReadyForCollection && readyForCollection === false && ( +

            + Supplier hasn't confirmed this item is ready for collection. Mark it + ready in{" "} + + /collection-pool + {" "} + before assigning a driver. +

            + )} +``` + +- [ ] **Step 4: Run the dialog tests to verify all four pass** + +Run: `npx vitest run src/components/ui/assign-driver-dialog.test.tsx` + +Expected: 4 passed. + +- [ ] **Step 5: Run typecheck** + +Run: `npx tsc --noEmit` + +Expected: zero errors. The `@testing-library/react` and `@testing-library/user-event` packages must already be in `devDependencies` (they're used by sibling dialog tests like `mark-ready-dialog.test.tsx` and `customer-search.test.tsx`). If `vitest` complains about the missing `jsdom` environment, that's expected to be resolved by the `// @vitest-environment jsdom` header at the top of the test file. + +- [ ] **Step 6: Commit** + +```bash +git add src/components/ui/assign-driver-dialog.tsx src/components/ui/assign-driver-dialog.test.tsx +git commit -m "feat(assign-dialog): gate submit on ready_for_collection with warning" +``` + +--- + +### Task 3: Page — drop date filter + wire new dialog props + rewrite KPI subtext + update toolbar shape test + +**Files:** +- Modify: `src/app/(app)/collection-map/page.tsx` (state, fetchRows, openAssignDialog, KPI strip, toolbar, dialog mount, helper removals) +- Modify: `src/app/(app)/collection-map/page.test.ts` (toolbar shape contract — flip the "Today/Tomorrow/+3/Next week" expectations to negatives, add a "Refresh" positive) + +- [ ] **Step 1: Update the toolbar shape contract test to lock the new toolbar** + +In `src/app/(app)/collection-map/page.test.ts`, replace the `describe("collection-map toolbar shape contract", …)` block (lines 69-98) with: + +```ts +describe("collection-map toolbar shape contract", () => { + // The toolbar lives in page.tsx. We snapshot the literal labels that must + // remain visible (Refresh) and the labels / state / JSX that must NOT + // come back (the deleted date picker + quick chips + filter chips + + // Drivers card). These are static source-text assertions: a future PR + // that re-adds removed controls won't pass the pipeline, even if the + // controls "look right". + const src = fs.readFileSync(path.resolve(__dirname, "./page.tsx"), "utf8"); + + it("renders only the Refresh control in the page toolbar", () => { + expect(src).toMatch(/Refresh/); + // Quick-chip labels deleted in 2026-07-12 (date-filter removal). + expect(src).not.toMatch(/Tomorrow/); + expect(src).not.toMatch(/\+3 days/); + expect(src).not.toMatch(/Next week/); + // Map chrome (not page toolbar) — keep these locked in too so the + // overlay buttons don't regress. + expect(src).toMatch(/Traffic/); + expect(src).toMatch(/Fullscreen|Exit fullscreen|Minimize2/); + }); + + it("does not re-introduce the deleted filter chips, search, or Drivers card", () => { + expect(src).not.toMatch(/setStatusFilter|statusFilter|StatusFilter/); + expect(src).not.toMatch(/setReadyFilter|readyFilter/); + expect(src).not.toMatch(/Search PO, supplier/); + expect(src).not.toMatch(/>\s*Drivers\s* { + // State setter must be gone. + expect(src).not.toMatch(/useState\(ymdToday\(\)\)/); + // The day-window filter string must be gone from both queries. + expect(src).not.toMatch(/assigned_at >= "\$\{date\}T00:00:00\.000Z"/); + expect(src).not.toMatch(/assigned_at <= "\$\{date\}T23:59:59\.999Z"/); + }); +}); +``` + +- [ ] **Step 2: Run the page test to verify the new assertions fail (before the page change)** + +Run: `npx vitest run src/app/(app)/collection-map/page.test.ts` + +Expected: the new `does not re-introduce the date state or date bounds in fetchRows` test fails; the existing two toolbar tests may also fail because the page still has the old chips. The split-supplier-bucket tests stay green. + +- [ ] **Step 3: Drop the date state + filter UI + date bounds in `page.tsx`** + +Read `src/app/(app)/collection-map/page.tsx` to confirm exact line numbers, then apply four edits: + +**Edit A — state.** Replace: + +```ts + const [date, setDate] = useState(ymdToday()); +``` + +with: + +```ts + // The page no longer exposes a date filter; the map shows every + // assigned + collection_pool item regardless of `assigned_at`. The + // assign dialog still picks a date internally ("when is the pickup") + // — that's a per-row concern, not a page-level filter. +``` + +(and the surrounding `/* -- state --- … */` block loses its date bullet.) + +**Edit B — helpers.** Delete the `ymdToday` and `addDays` helper functions entirely if no other call site in the page remains. After edits A and C, the only call sites are removed: +- `ymdToday` was used at lines 286 and 647; both gone. +- `addDays` was used at line 647 (chips); gone. +- `formatDateLabel` is still used for the "Today" eyebrow above the list view — keep it. + +Delete lines 149-162 (the `ymdToday` and `addDays` function declarations). Remove `ymdToday`/`addDays` references from any imports — there are none (`page.tsx` is self-contained for these helpers). + +**Edit C — `fetchRows`.** Read lines 305-391 in the current file. Rewrite the first query: + +```ts + const fetchRows = useCallback(async () => { + setLoading(true); + try { + const pb = getPocketBase(); + // No date filter — the map shows every assigned + collection_pool + // item regardless of `assigned_at`. Operators can still see "which + // day is scheduled for which stop" via the assign dialog and the + // list view's "Today/MMDD" eyebrow. + const filter = `(item_status = "assigned" || item_status = "collection_pool")`; + + const rows = (await pb.collection("order_items").getFullList({ + filter, + expand: "purchase_order.supplier,order", + // Items sorted by assigned_at asc so the older ones render first + // inside each supplier bucket (matches /collection-pool). + // Empty-string assigned_at sorts first (string compare), which + // is the right UX: undated pool items show above dated ones. + sort: "assigned_at", + $autoCancel: false, + })) as CollectionMapRow[]; +``` + +…keeping the rest of the for-loop unchanged. Then rewrite the second query (the unmapped-rows sidebar): + +```ts + const unmappedRows = (await pb.collection("order_items").getFullList({ + filter: `(item_status = "assigned" || item_status = "collection_pool")`, + expand: "purchase_order.supplier", + fields: "id,purchase_order", + $autoCancel: false, + })) as CollectionMapRow[]; +``` + +Drop `date` from the `useCallback` dep array — it becomes `[]`. + +**Edit D — `openAssignDialog`.** Replace: + +```ts + const initialDate = row.assigned_at + ? row.assigned_at.slice(0, 10) + : date; +``` + +with: + +```ts + const initialDate = row.assigned_at ? row.assigned_at.slice(0, 10) : ""; +``` + +Drop `date` from the `useCallback` dep array — it becomes `[/* no deps */]`. To satisfy the linter (`react-hooks/exhaustive-deps`), keep the array as an empty literal: `useCallback(…, [])`. If the project's lint config rejects empty deps for `useCallback`, use `useCallback(…, [/* eslint-disable-next-line react-hooks/exhaustive-deps */])` or wrap with `// eslint-disable-next-line` directly above the dep array — match whatever pattern the file already uses for `fetchRows` (the existing `fetchRows` effect already has lint pragmas; mirror that pattern). + +**Edit E — KPI strip.** Replace the "Mapped stops" subtext: + +```tsx +

            + For {formatDateLabel(date)} across {stopsWithCoords.length} supplier{stopsWithCoords.length === 1 ? "" : "s"} +

            +``` + +with: + +```tsx +

            + Across {stopsWithCoords.length} supplier{stopsWithCoords.length === 1 ? "" : "s"} +

            +``` + +The big `{stopCount}` number and the card title (`Mapped stops`) stay unchanged. + +**Edit F — toolbar (the date picker + chip strip).** Replace lines 627-663 (the `
            ` containing the date picker + quick chips) with the empty wrapper: + +```tsx + {/* Page-level toolbar: no date filter. Refresh lives below. */} +
            +``` + +The map-card chrome (Refresh + Traffic + Fullscreen, lines 666+) stays untouched. The toolbar container at line 627 (`
            `) reduces to a single child — collapse the `lg:flex-row lg:items-center lg:justify-between` to keep the Refresh right-aligned (the Refresh button keeps its `flex flex-wrap items-center gap-2` wrapper). + +If the resulting toolbar ends up trivially empty (only a Refresh button on the right), prefer leaving the `flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between` wrapper intact and putting just the Refresh child inside it on the right side, omitting the empty `
            `. Match whichever visual layout the user is happy with in the smoke test; the locked-in test only asserts "Refresh" is in the source. + +**Edit G — dialog mount.** Read lines 904-918 of the dialog mount site. Pass the two new props: + +```tsx + setAssignDialog((s) => ({ ...s, open: o }))} + onSubmit={submitAssign} + initialDriverId={assignDialog.initialDriverId} + initialDate={assignDialog.initialDate} + requireReadyForCollection + readyForCollection={assignDialog.row?.ready_for_collection ?? false} + eyebrow={ … existing eyebrow JSX … } + busy={loading} + /> +``` + +Confirm the existing eyebrow JSX and `busy` prop are kept verbatim. + +- [ ] **Step 4: Run page + assign route + dialog tests** + +Run: `npx vitest run src/app/(app)/collection-map src/app/api/v1/collection-map/items/\[id\]/assign src/components/ui/assign-driver-dialog` + +Expected: all green. If a page test fails on something other than the toolbar/filter assertions (e.g. snapshot of the full render), re-read the test file — these tests are likely already minimal (the file has only splitSupplierBucket + toolbar-shape so far per the existing read). + +- [ ] **Step 5: Run typecheck** + +Run: `npx tsc --noEmit` + +Expected: zero errors. Pay particular attention to the `formatDateLabel` import + any now-unused imports from `lucide-react` (the `Calendar` icon was used only by the date picker). If `Calendar` is no longer referenced, the import at the top of `page.tsx` should drop it — confirm by reading the top of the file and pruning: + +```ts +import { Calendar, RefreshCw, … } from "lucide-react"; +``` + +If only `Calendar` is unused, change the import to drop it. + +- [ ] **Step 6: Commit** + +```bash +git add src/app/\(app\)/collection-map/page.tsx src/app/\(app\)/collection-map/page.test.ts +git commit -m "feat(collection-map): drop date filter; thread ready-gate into assign dialog" +``` + +--- + +### Task 4: Smoke + size budget + full-suite pass + +**Files:** none (verification only) + +- [ ] **Step 1: Run the full unit suite** + +Run: `npx vitest run` + +Expected: all tests pass. Skim the output for any test files that mention `date` or `Tomorrow` / `+3 days` / `Next week` to catch indirect dependents. + +- [ ] **Step 2: Run typecheck + lint for the touched paths** + +Run: `npx tsc --noEmit && npx eslint src/app/api/v1/collection-map/items/\[id\]/assign src/components/ui/assign-driver-dialog.tsx src/components/ui/assign-driver-dialog.test.tsx src/app/\(app\)/collection-map/page.tsx src/app/\(app\)/collection-map/page.test.ts` + +Expected: zero errors. Adjust the eslint invocation to match the project's lint script in `package.json` if a shortcut exists. + +- [ ] **Step 3: Check `page.tsx` size budget** + +Run: `wc -c src/app/(app)/collection-map/page.tsx` + +Expected: ≤ 41 KB (existing budget). With the date filter + helpers removed the file should be smaller; if it crosses the threshold, extract the popup helper at lines 996-1080 (`StopPopupContent`) into a sibling file `popup.tsx` (mirror `pin-model.ts` pattern). + +- [ ] **Step 4: Manual smoke (the dev server is at `http://127.0.0.1:4001/` per project memory)** + +1. Visit `/collection-map`. Confirm: + - No date picker in the toolbar. + - No Today/Tomorrow/+3/Next week chips in the toolbar. + - "Mapped stops" KPI subtext reads "Across N suppliers" (no "For {date}"). + - All items show regardless of date. +2. Open the assign dialog from a Ready item. Pick a driver → Submit enabled → toast on success. +3. Open the assign dialog from an Unready item (search for one with a known unready state, or use the bulk-unready items in your test data). Confirm: + - Amber warning visible at top of body. + - Submit disabled even after picking a driver + date. + - Clicking `/collection-pool` link in the warning navigates correctly. +4. In DevTools, intercept a PATCH with `{ driverId, date }` to an unready row → expect HTTP 409 + the "Supplier hasn't confirmed…" message. + +If the dev server isn't already running: `npm run dev` then `curl -sI http://127.0.0.1:4001/collection-map` to confirm it's up. + +- [ ] **Step 5: Commit (only if Task 4 produced fixes)** + +Skip if no fix was needed; if a fix landed, commit under `fix(collection-map): …` with a subject line that names the regression you patched. + +--- + +## Out-of-scope follow-ups (do not add to this plan) + +- Adding a "Mark Ready + Assign" combo action in the map dialog. The user-facing intent ("only assign if confirmed ready") is met by the gate + the existing `/collection-pool` flow; a combo button would be a separate feature. +- Removing `Ready` / `Unready` badges from the popup list — they still surface the state at a glance and now correspond to the dialog's gate. +- Refactoring `pin-model.ts` or `popup.tsx`. No changes needed. +- Driver-app `getMyAssignedItems`. Already filters on `ready_for_collection === true`; the API gate now enforces it server-side too. diff --git a/docs/superpowers/specs/2026-07-10-collection-pool-supplier-groups-and-notes-design.md b/docs/superpowers/specs/2026-07-10-collection-pool-supplier-groups-and-notes-design.md new file mode 100644 index 0000000..ad1a7c8 --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-collection-pool-supplier-groups-and-notes-design.md @@ -0,0 +1,265 @@ +# Collection Pool — supplier groups, bulk assign, primary time-in-list, note preview + +**Date:** 2026-07-10 +**Status:** Design — awaiting user review + +## Goal + +Three asks on the `/collection-pool` page: + +1. **Time-in-list prominence.** The `Nh` / `Nd` age label on each row currently sits at `text-sm text-foreground` and blends with the row. Make it the dominant piece of metadata in the order-detail column. +2. **Group by supplier.** Today the list is a single flat `
              `; with 20+ suppliers active at once, the list looks like noise. Reorganise into collapsible sections per supplier. Inside each section, the operator can either assign ALL stops at that supplier in one shot, or selectively tick rows and assign only the chosen ones. +3. **Pending note visibility.** The Mark-Ready dialog already persists an `OrderItem.notes` text. The row shows a small `NOTES` chip with a browser-`title` attribute — easy to miss. Replace with a proper hover-preview so the operator can read the note without opening anything. + +## User-confirmed rules + +- **Sort default flips** to `(supplierName, assignmentState, updated)`. The existing "Sort" radio (`operational` / `nearby`) keeps working — `operational` now means "group by supplier, then urgency"; `nearby` keeps its cluster-proximity behaviour and falls back to supplier within a cluster. +- **Bulk button lives on the section header**, next to the supplier name + count. A fixed action bar at the top of the list is not needed at this stage. +- **Time-in-list** uses `text-primary` (resolves through the active theme via `--primary`) and bumps to `text-base font-semibold`. +- **Notes preview** is a real tooltip on the existing chip, not a modal. +- **Bulk mark-ready stays out of scope** — the dialog requires a free-text note per item, and a multi-note UI is a separate feature. + +## Current state + +- File: `src/app/(app)/collection-pool/page.tsx` (663 lines, 23 KB). +- Render shape: a single `
                ` of `` items. +- `PoolRow` lays out as a 6-column grid (`[6px stripe] [1.35fr content] [0.95fr order-detail] [auto status] [auto actions]`). +- Time-in-list lives at `page.tsx:607`: + ```tsx +
                {getPoolAgeLabel(row.updated)}
                + ``` +- Notes chip lives at `page.tsx:625–632`, using a native `title` attribute for the preview. +- `AssignDriverDialog` (`src/components/ui/assign-driver-dialog.tsx`) already supports `bulkCount > 1` — the title becomes `Assign N items to driver` and the caller is expected to PATCH N rows. +- `buildVisibleRows(rows, { todayYmd, searchText, readyFilter, sortMode })` is the single sort/filter entry point in `view-model.ts`. +- Sort tiebreakers today: `(assignmentState, updated, supplierName|poNumber|description)`. `supplierName` is only a final tiebreaker, not a primary group key. + +## Visual design + +### Time-in-list + +Replace the existing `text-sm text-foreground` label with: + +```tsx +
                + {getPoolAgeLabel(row.updated)} +
                +``` + +- `text-primary` resolves through `globals.css` `--primary` — the theme's accent. No literal hex. +- Below it, keep a small muted `In pool since {date}` in `text-xs text-muted-foreground` so the absolute reference is still recoverable. +- On long-running rows (≥3d) the label keeps its single `Nd` token but gets the same primary colour — colour does not signal age, only importance. + +### Supplier sections + +Each supplier becomes a `
                ` inside the list container. Section layout: + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ ● Supplier Name · PO ABC-123 (5 stops) [▼] [Assign all] │ ← sticky header +├──────────────────────────────────────────────────────────────────┤ +│ ☐ Row 1 │ +│ ☐ Row 2 │ +│ ☐ Row 3 │ +│ ☐ Row 4 │ +│ ☐ Row 5 │ +└──────────────────────────────────────────────────────────────────┘ +``` + +- **Header strip:** + - Left: the same supplier colour-stripe dot used in the row (a 8×8 coloured disc). + - Supplier name (semibold, `text-sm`). + - A muted `· 5 stops` count + the most-recent PO number from the group, formatted `· PO ABC-123, PO DEF-456 (3 more)`. + - Right: a `▼` collapse chevron and a primary `Assign all 5 stops` button (disabled if all rows are already assigned). +- **Section body:** the per-row checkbox list. Headers collapse with `max-h` transition (150 ms, `ease-out`) so the collapse feels intentional, not snappy. +- **Section ordering:** sorted alphabetically by `supplierName`. Within a section, the existing `(assignmentState, updated)` tiebreaker still applies. + +### Per-row checkbox + +- A native `` at the left of each row, just inside the colour-stripe column. +- Section "select all" checkbox sits in the header next to the count. +- Selected rows get a faint `bg-primary/5` tint so the operator can see the active selection across sections at a glance. +- The "Assign all" button opens the `AssignDriverDialog` with `bulkCount = selectedIds.size` (or `section.rowCount` if "all"). The title reads `Assign N stops at Supplier X`. +- The per-row Assign button still works as today — selecting and bulk-assigning are independent actions. + +### Notes chip with proper tooltip + +Replace the native `title` attribute with a CSS-only hover preview anchored on the chip: + +```tsx + + Notes + + {row.notes.length > 280 ? `${row.notes.slice(0, 280)}…` : row.notes} + + +``` + +- Preview shows up to 280 chars + ellipsis. Anything beyond is dropped — long notes get a clear signal that there's more, but we don't make the tooltip overflow the viewport. +- Pointer-events disabled so the tooltip never blocks hover on the row beneath it. +- Hidden by default, shown on `group-hover` and `group-focus-within` (keyboard parity). +- Stacks vertically (top-to-bottom) so it doesn't push the row layout on hover. +- The existing native `title` attribute is removed. + +## Data model + +No schema changes. `OrderItem.notes` already exists. + +## Sort change + +`buildVisibleRows` in `view-model.ts` gets one new branch in its sort: + +```ts +.sort((a, b) => { + if (sortMode === "nearby") { + const clusterDiff = (a.clusterSortKey ?? "zzz").localeCompare(b.clusterSortKey ?? "zzz"); + if (clusterDiff !== 0) return clusterDiff; + } + + // NEW: supplier is the primary sort key. "operational" now means + // "by supplier, then urgency" — which is also what the new UI + // needs to render group sections. + const supplierDiff = (a.supplierName ?? "").localeCompare(b.supplierName ?? ""); + if (supplierDiff !== 0) return supplierDiff; + + const stateDiff = + ASSIGNMENT_SORT_ORDER[getAssignmentState(a, options.todayYmd)] - + ASSIGNMENT_SORT_ORDER[getAssignmentState(b, options.todayYmd)]; + if (stateDiff !== 0) return stateDiff; + + const updatedA = a.updated ?? ""; + const updatedB = b.updated ?? ""; + if (updatedA !== updatedB) return updatedA.localeCompare(updatedB); + + return `${a.poNumber ?? ""}|${a.description ?? ""}`.localeCompare( + `${b.poNumber ?? ""}|${b.description ?? ""}`, + ); +}); +``` + +Result: when the new group view renders, rows are already in supplier order, so the section builder just slices the array. + +## New helpers + +### `view-model.ts` + +```ts +export type CollectionPoolGroup = { + supplierId: string; + supplierName: string; + /** Marker token for the supplier's cluster colour. */ + markerToken?: string; + /** Top 2 PO numbers in this section, for the header eyebrow. */ + topPoNumbers: string[]; + remainingPoCount: number; + /** Rows belonging to this group, in render order. */ + rows: CollectionPoolVisibleRow[]; +}; + +export function buildSupplierGroups( + rows: CollectionPoolVisibleRow[], + supplierMarkerMap: Map, +): CollectionPoolGroup[]; +``` + +- Iterates the (already sorted by supplier) array and folds rows into groups keyed by `supplierId`. +- Each group captures the cluster marker token and a 2-PO eyebrow string for the header. +- Returns groups in `supplierName` order (the input order from `buildVisibleRows`). + +### `page.tsx` + +- New state: `const [selectedIds, setSelectedIds] = useState>(new Set())`. +- New state: `const [collapsedSuppliers, setCollapsedSuppliers] = useState>(new Set())`. +- New state: `const [bulkAssignDialog, setBulkAssignDialog] = useState<{ supplierId: string; rowIds: string[]; supplierName: string } | null>(null)`. +- New derived value: `const groups = useMemo(() => visibleRows ? buildSupplierGroups(visibleRows, supplierMarkerMap) : [], [visibleRows, supplierMarkerMap]);`. +- New handler: `handleBulkAssignSubmit(payload)` — PATCHes every rowId in `bulkAssignDialog.rowIds` with the same `{ driverId, date }`, shows a single consolidated toast (`Assigned N stops to .`), and clears the section selection. + +### Component + +- New: `SupplierGroup` component (kept inside `page.tsx` to avoid prop-drilling state). Renders the header strip + collapsible body of `` items + the per-row checkbox + the "Assign all" button. +- New: `BulkAssignDialog` instance — a second `AssignDriverDialog` mount, only open when `bulkAssignDialog` is set, with `bulkCount={bulkAssignDialog?.rowIds.length ?? 1}` and an eyebrow `{bulkAssignDialog?.supplierName}`. + +## Behaviour + +### Operator flow — bulk assign + +1. Operator opens `/collection-pool`. +2. Rows render grouped by supplier. Each section shows `Assign all N stops`. +3. Operator ticks the section "select all" (or specific rows), then clicks `Assign all N stops` OR `Assign N selected` (the button label switches based on whether the whole section is selected). +4. `AssignDriverDialog` opens with `bulkCount=N`, eyebrow `Supplier · N stops`. +5. Operator picks driver + date, submits. +6. The page PATCHes every rowId in the dialog's payload sequentially, refreshes the list, shows `Assigned N stops to .` and clears the section's selection. +7. Failures abort the batch and show the first error via toast; already-updated rows stay updated (no rollback). This matches the existing single-row `updateItemStatus` behaviour — the page treats each row PATCH as independent. + +### Operator flow — single assign + +Unchanged. Per-row `Assign Driver` / `Reschedule` button still works. + +### Operator flow — mark ready + +Unchanged. The Notes chip now shows a real tooltip on hover. + +### Operator flow — collapse a section + +Click the chevron. The section collapses with a 150 ms transition. State persists across `load()` refreshes within the session (we only re-fetch on manual `Refresh` or when the operator acts — same lifetime as `selectedIds`). + +### Sort behaviour + +- The sort radio keeps its two options. +- "Operational" → supplier → assignment_state → updated → (PO + description). +- "Nearby" → cluster_sort_key → supplier → assignment_state → updated → (PO + description). +- Selecting "Operational" is the default; "Nearby" is preserved for the cluster view. + +## Components reused + +- `AssignDriverDialog` (`src/components/ui/assign-driver-dialog.tsx`) — already supports `bulkCount`. +- `MarkReadyDialog` (`src/components/ui/mark-ready-dialog.tsx`) — unchanged. +- `UserSearch` (inside `AssignDriverDialog`) — unchanged. +- Existing `PoolRow` — receives one extra prop (`selected`, `onToggleSelected`); the rest stays as-is. + +## Out of scope + +- Bulk mark-ready (would require multi-note UI — separate feature). +- Marking ready via the supplier section header. +- Per-row colour stripe grouped by supplier cluster colour (the existing proximity-marker dot on the section header covers it; per-row stripes stay). +- Renaming the route / nav label. +- Mobile-specific layout changes beyond what the existing grid already handles. + +## Tests + +### `view-model.test.ts` + +- `buildSupplierGroups` folds rows by supplier in render order. +- Group header eyebrow renders at most 2 PO numbers and reports the remaining count. +- Empty input → empty array. +- One supplier → one group. +- Mixed assigned/unassigned rows within a group preserve their relative order (assignment_state primary, updated secondary). + +### `page.test.tsx` (existing — extend) + +- Selecting all rows in a section toggles the section's "select all" checkbox. +- Clicking `Assign all N stops` opens the `AssignDriverDialog` with `bulkCount=N`. +- Submitting the dialog PATCHes every selected row and refreshes the list. +- Collapsing a section hides its rows; expanding restores them. +- Time-in-list label uses `text-primary` class. +- Hovering the Notes chip reveals the preview tooltip with the full note text (truncated at 280 chars with ellipsis). + +## Acceptance criteria + +- Time-in-list is the dominant piece of metadata in the order-detail column, in `text-primary`. +- Rows render grouped by supplier, with collapsible sections and a stable section ordering. +- Each section header shows: supplier name, count, top-2 PO eyebrow, collapse chevron, `Assign all N stops` button. +- Per-row checkbox + section "select all" both work. +- Bulk assign opens `AssignDriverDialog` with `bulkCount=N` and PATCHes all N rows on submit. +- Notes chip shows a proper hover/focus preview tooltip (not just the native browser `title`). +- Sort default is `(supplierName, assignmentState, updated)` — selecting "Operational" reproduces the new grouping order. +- Page stays under ~30 KB after the change (current is 23 KB; group wrapper + checkbox state adds ~3–5 KB). + +## Risk / rollback + +- Low visual risk. The change is additive on top of an existing render; rollback reverts to the flat list with the old `title`-attribute Notes chip. +- Sort change is observable: users who rely on the old "by urgency" default will now see suppliers grouped together. Mitigation: the Sort radio keeps the legacy behaviour available under "Operational", and the grouping itself is the requested change. +- Bulk PATCH is sequential, not transactional. If row 4 of 5 fails, rows 1–3 stay assigned. This matches the existing single-row PATCH semantics; a future change could add a transactional batch endpoint, but it's out of scope here. +- The page is at the render budget edge (~23 KB today, ~30 KB target). If it crosses, split `PoolRow` and `SupplierGroup` into a separate file under `src/app/(app)/collection-pool/`. \ No newline at end of file diff --git a/docs/superpowers/specs/2026-07-12-collection-map-driver-cards-redesign-design.md b/docs/superpowers/specs/2026-07-12-collection-map-driver-cards-redesign-design.md new file mode 100644 index 0000000..edd7a6d --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-collection-map-driver-cards-redesign-design.md @@ -0,0 +1,409 @@ +g# Collection Map v3 — sidebar driver cards, green-collected anchor, drop bottom list + +**Goal:** Turn `/collection-map` into the operator's at-a-glance control surface: the map fills the viewport vertically, the right sidebar shows a legend plus the 2–3 drivers with their assigned stops (with a clear green visual anchor for what has been collected), and the unused bottom "Today's stops" list is removed. + +## Scope + +### In Scope + +1. **Drop the bottom "Today's stops" list card.** Operators do not use it (confirmed). +2. **Make the map full-length vertically.** The map card fills the height between the KPI strip + toolbar at top and the page bottom, with the sidebar as its right neighbor. No fixed `vh` height. +3. **Replace the empty sidebar with operational content.** In order: (a) **Legend** explaining pin colors and status pills, (b) **Driver cards** showing the top 3 drivers from the existing alphabetical `driversWithItems` list (each card = name + letter badge, status counts strip, per-stop rows with status pills), (c) **Unassigned (compact)** card only when there is at least one unassigned item, (d) **Missing coordinates** warning card when applicable. +4. **Green-collected visual anchor on driver cards and per-stop rows.** Items where `item_status === "collected"` show a green pill on the per-stop row, and contribute a green count to the driver card's counts strip. The map pin model stays at two pins per supplier bucket (blue letter + amber ✓/✕) — collected is a *presentation* anchor, not a third pin state. +5. **Driver card counts strip format.** `{total} stops · 🟢 {collected} collected · 🟠 {waiting} waiting`. The "X collected" segment appears only when count > 0; the "Y waiting" segment appears only when count > 0; if every stop is ready+assigned (and zero collected), the strip shows just `{total} stops`. +6. **Per-stop rows show up to 4 stops, with `+N more` link to expand.** Expanded rows show all stops. Each row: supplier name (truncate), status pill (Ready / Waiting / Collected), and click-through to the existing assign-driver dialog. +7. **"Show all (N)" button on the driver section when more than 3 drivers have stops.** Toggling it expands to show every driver (no re-sort, no re-letter — alphabetical + A/B/C… stays). +8. **Map popup body gains a single line: `X of Y collected`** when the supplier bucket has at least one collected item. No third pin color. +9. **New pure helper `aggregateDriverStatus(items)` in `src/lib/pin-model.ts`.** Returns `{ total, collected, ready, waiting }`. TDD'd via `pin-model.test.ts`. Driver cards consume it directly; popups use it inline. + +### Out of Scope + +- A third Leaflet pin state for collected items (the map stays at two pins per supplier bucket). +- A "mark ready" combo action from the driver card (still goes through `/collection-pool`). +- Live count updates / refresh policy changes. The existing 20s positions poll stays. +- Changing the assign API or assign dialog (these are stable from phase 2). +- Driver-location markers or anything that touches `driver_locations`. +- Per-row selection / bulk-assign UX (the assign dialog handles single-row assign already). + +## Current State + +### Sidebar is empty + +`src/app/(app)/collection-map/page.tsx` right column (sidebar) only renders the conditional "Missing supplier coordinates" warning card. There is no legend, no driver summary, no operational affordance. Approximate line range: 728-754. + +### Bottom "Today's stops" card is unused + +`page.tsx` renders a card with the per-item list (supplier name, status badge, driver picker, assign action). User confirmed they do not use it; it is dead surface area. Approximate line range: 757-836. + +### Map height is fixed + +`page.tsx` map wrapper is `className={cn("relative", isFullscreen ? "h-dvh min-h-0" : "h-[68vh] min-h-[520px]")}` (≈ line 615). The map needs to flex to fill remaining viewport height between the top controls and the page bottom (with fullscreen behavior preserved). + +### Two-pin model is the right baseline + +`src/lib/pin-model.ts` `splitSupplierBucket` already routes `collected` items into the assigned-by-driver bucket (`isRouted` predicate treats `item_status === "collected"` as routed). No change needed to the pin layer. + +### Status spectrum (locked) + +| State | Per-stop row pill | Counts strip | Map pin | +|---|---|---|---| +| `collection_pool` + `ready_for_collection=false` | Waiting (amber) | `🟠 +1 waiting` | amber ✕ | +| `collection_pool` + `ready_for_collection=true` | Ready (blue) | (counts as ready) | amber ✓ | +| `assigned` + `ready_for_collection=false` | Waiting (amber) | `🟠 +1 waiting` | blue letter | +| `assigned` + `ready_for_collection=true` | Ready (blue) | (counts as ready) | blue letter | +| `collected` | **Collected (green)** | **`🟢 +1 collected`** | blue letter (no change) | + +## User Confirmed Rule + +- **The map is the layout's vertical anchor**; the sidebar is its operational sidebar. +- **The bottom list is gone** — operators read driver state from the sidebar instead. +- **Green = collected**, full stop. It is the at-a-glance "done" anchor that lets the operator see remaining work without reading every row. +- **No third pin state.** Driver cards + popups carry the green; the pin layer stays untouched. +- **Top 3 drivers + Show all** is the default surface (the operator doesn't want to scroll through 12 drivers; the top 3 from the existing alphabetical `driversWithItems` list are what they see first, with the counts strip on each card telling them who is busiest at a glance). + +## Behavior + +### 1. Add `aggregateDriverStatus(items)` helper in `pin-model.ts` + +Add to `src/lib/pin-model.ts`: + +```ts +export interface DriverStatus { + total: number; + collected: number; + ready: number; + waiting: number; +} + +/** + * Counts a driver's stops into the four buckets the driver card surfaces: + * - collected : item_status === "collected" (the green anchor) + * - ready : ready_for_collection === true and not collected + * - waiting : ready_for_collection !== true and not collected + * - total : items.length + * + * Items with `ready_for_collection` undefined or null are treated as + * not ready (waiting). Pure — no DB / side effects. + */ +export function aggregateDriverStatus< + T extends { + item_status: string; + ready_for_collection?: boolean | null; + } +>(items: T[]): DriverStatus { + let collected = 0; + let ready = 0; + let waiting = 0; + for (const it of items) { + if (it.item_status === "collected") { + collected++; + } else if (it.ready_for_collection) { + ready++; + } else { + waiting++; + } + } + return { total: items.length, collected, ready, waiting }; +} +``` + +Edge cases captured by the type test surface: +- Empty array → `{ total: 0, collected: 0, ready: 0, waiting: 0 }`. +- All collected → `{ total: N, collected: N, ready: 0, waiting: 0 }`. +- All unready pool → `{ total: N, collected: 0, ready: 0, waiting: N }`. +- Mix: counts roll up correctly. +- `ready_for_collection` undefined / null → treated as `false` (waiting). + +### 2. Restructure the layout + +`page.tsx` becomes a flex column root so the map can flex: + +```tsx +
                + {/* KPI strip — unchanged */} + {/* collapsed toolbar — unchanged */} +
                + + {/* MapContainer keeps h-full w-full inside this wrapper */} + + +
                +
                +``` + +`items-stretch` makes the map row match the tallest column. The sidebar is intrinsically short, so the map fills the available row height. On fullscreen (`isFullscreen` toggle), the wrapper switches to `h-dvh min-h-0` and the map becomes `h-full` (existing behavior preserved). + +The current `xl:grid-cols-[minmax(0,1fr)_320px]` pattern (≈ line 607) is preserved — only the row height source changes. + +### 3. Build the new sidebar (replaces the empty aside) + +```tsx + +``` + +State added to `page.tsx`: + +```tsx +const [showAllDrivers, setShowAllDrivers] = useState(false); +``` + +#### 3a. LegendCard + +A static card explaining the visual language: + +- Blue circle + letter → assigned to that driver (letter = `driverLabelFor(...)`) +- Amber ✓ → pool, supplier confirmed ready +- Amber ✕ → pool, supplier hasn't confirmed yet +- Green pill on driver card / popup → stop has been collected + +Icons reused from the existing `lucide-react` import. Each row = a tiny icon swatch + a one-line label. No interaction. + +#### 3b. DriverCardSection + +When `!expanded`, show `driversWithItems.slice(0, 3)`. When `expanded`, show all `driversWithItems`. The existing sort (alphabetical by `name` at ≈ line 545-559) stays; the top 3 cap does not change which 3 are chosen. + +```tsx +const visible = showAllDrivers ? driversWithItems : driversWithItems.slice(0, 3); +return ( + + + Drivers with stops + + + {visible.map((d) => ( + + ))} + {driversWithItems.length > 3 && ( + + )} + + +); +``` + +#### 3c. DriverCard + +```tsx +function DriverCard({ driver, driversWithItems }) { + const status = aggregateDriverStatus(driver.items); + const [expanded, setExpanded] = useState(false); + const visibleItems = expanded ? driver.items : driver.items.slice(0, 4); + const letter = driverLabelFor(driversWithItems, driver.id); + return ( +
                +
                + + {letter} + + {driver.name} +
                + +
                  + {visibleItems.map((item) => ( + openAssignDialog(item)} + /> + ))} + {driver.items.length > 4 && ( +
                • + +
                • + )} +
                +
                + ); +} +``` + +#### 3d. CountsStrip + +```tsx +function CountsStrip({ status }: { status: DriverStatus }) { + const parts = [`${status.total} stops`]; + if (status.collected > 0) parts.push(`🟢 ${status.collected} collected`); + if (status.waiting > 0) parts.push(`🟠 ${status.waiting} waiting`); + return ( +

                {parts.join(" · ")}

                + ); +} +``` + +The 🟢 / 🟠 are presentation cues; the visual encoding is carried by the rendered Lucide `Circle` icons (filled, colored) so screen readers / accessibility tools see the count text, not an emoji. The text-only form `2 collected` / `1 waiting` is sufficient for SR users. + +#### 3e. StopRow + +```tsx +function StopRow({ item, onOpen }) { + const supplier = suppliersById[item.supplier]; + const supplierName = supplier?.name ?? "Unknown supplier"; + const pill = (() => { + if (item.item_status === "collected") { + return { + label: "Collected", + cls: "bg-emerald-100 text-emerald-900 border-emerald-300", + }; + } + if (item.ready_for_collection) { + return { + label: "Ready", + cls: "bg-blue-100 text-blue-900 border-blue-300", + }; + } + return { + label: "Waiting", + cls: "bg-amber-100 text-amber-900 border-amber-300", + }; + })(); + return ( +
              • + +
              • + ); +} +``` + +Click-through reuses the existing `openAssignDialog` flow. Ready/Unready gate (phase 2) is preserved inside the dialog. + +#### 3f. UnassignedCompactCard + +When `unassignedItems.length > 0`. Compact card with one line: + +``` +{N} unassigned stops Review → +``` + +The `Review` link navigates to `/collection-pool` (where operators currently triage unassigned items). No per-item rows — drivers can scan it at a glance. + +### 4. Add the popup line `X of Y collected` + +In `StopPopupBody` (page.tsx, ≈ lines 936-1011), inside the bucket render path, after the supplier name and before the existing popup body content: + +```tsx +const status = aggregateDriverStatus(bucket.items); +// ... +{status.collected > 0 && ( +

                + {status.collected} of {status.total} collected +

                +)} +``` + +`bucket.items` is the per-bucket array (already grouped per `splitSupplierBucket`). The popup body already iterates the bucket items; the new line is a small header add. + +### 5. Drop the bottom list card + +Delete `page.tsx` ≈ lines 757-836. The `filteredItems` / `items` state and the `assignDialog` mount stay (the driver cards and the map popups both still open it). The "Map of stops" footer copy at the bottom of that card goes with it. + +### 6. No data, API, or DB changes + +- No PocketBase schema change. +- No API route change. +- `driversWithItems`, `splitSupplierBucket`, `driverLabelFor`, `unassignedGlyphFor`, `driverLetterPin`, `unassignedPoolPin` all stay as-is. +- The `filteredItems` memo stays (currently `useMemo(() => items, [items])`). + +## Testing + +### `src/lib/pin-model.test.ts` (extend the existing file) + +Add tests for `aggregateDriverStatus`: + +- `empty array → { total: 0, collected: 0, ready: 0, waiting: 0 }`. +- `all collected → { total: N, collected: N, ready: 0, waiting: 0 }`. +- `all unready pool → { total: N, collected: 0, ready: 0, waiting: N }`. +- `mixed: 2 collected + 3 ready assigned + 1 waiting pool → { total: 6, collected: 2, ready: 3, waiting: 1 }`. +- `ready_for_collection undefined → treated as false (waiting)`. +- `ready_for_collection null → treated as false (waiting)`. + +### `src/app/(app)/collection-map/page.test.ts` (extend the existing file) + +- `does not render the bottom "Today's stops" card` (assert the test id / heading is absent). +- `renders driver cards for top 3 by alphabetical order when more than 3 drivers exist` (fake `driversWithItems` array with 5 entries, assert the first 3 names render). +- `renders "Show all (N)" when more than 3 drivers have stops` and `clicking it expands the list to all drivers`. +- `renders UnassignedCompactCard only when unassignedItems.length > 0` (and hides it when 0). +- `renders LegendCard and its four entries` (Blue circle, Amber ✓, Amber ✕, Green pill). +- `renders the collected pill on per-stop rows whose item_status === "collected"` (assert `bg-emerald-100` class present). +- `renders the waiting pill on per-stop rows whose ready_for_collection === false`. +- `renders "X of Y collected" line in the popup when the bucket has collected items`. +- `does NOT render a green pin variant` — assert that the map render output uses only blue + amber icon classes (the two-pin model is intact). +- Existing toolbar shape test stays valid (no date picker — already removed in phase 2). +- Existing assign-dialog wire-up tests stay valid. + +### Regression + +- All phase-2 tests still pass. +- `pin-model.test.ts` existing `splitSupplierBucket` tests stay valid (collected items still routed into `assignedByDriver`). +- The map render path is unchanged in structure; the popup body adds one line but the per-pin markup does not change. + +## Acceptance Criteria + +- `/collection-map` shows: KPI strip, toolbar (Refresh only), map filling the available vertical space, 320px sidebar on the right. +- No bottom "Today's stops" card anywhere on the page. +- Sidebar shows (in this order): legend, top 3 driver cards with counts strip + per-stop rows + status pills, "Show all (N)" button when more than 3 drivers exist, an unassigned summary card only when there are unassigned items, and the missing-coords warning when applicable. +- Each driver card counts strip reads `N stops` plus `🟢 X collected` and `🟠 Y waiting` only when those counts are non-zero. +- Each per-stop row shows the supplier name + a colored pill: green for `collected`, blue for ready+assigned or ready+pool, amber for waiting. +- Map popups add `X of Y collected` whenever the supplier bucket has at least one collected item. +- The map renders at most two pins per supplier bucket (one blue letter, one amber ✓/✕) — no green pin variant added. +- All targeted tests pass; full suite non-regressive. + +## Risk / Rollback + +- **Low-to-moderate risk.** Layout + presentational helper. No API/DB change. +- **Rollback:** restore the deleted bottom-list card and revert the layout flex change. The helper add is additive and can stay. +- **Per-card `+N more` expansion** introduces per-row state. If expansion becomes a source of jank, lift it to `expandedDriverIds: Set` in the page state. Defaulting to per-card `useState` for v1. + +## File Changes (summary) + +- **Modify** `src/app/(app)/collection-map/page.tsx` — drop bottom list (≈ lines 757-836), make layout flex column with map flex-1, build new sidebar (Legend + Driver cards + Unassigned compact + Missing coords), add popup "X of Y collected" line. +- **Modify** `src/lib/pin-model.ts` — add `aggregateDriverStatus` + `DriverStatus` interface. +- **Modify** `src/lib/pin-model.test.ts` — add tests for `aggregateDriverStatus`. +- **Modify** `src/app/(app)/collection-map/page.test.ts` — drop bottom-list assertions; add sidebar / driver-card / popup assertions; add the no-green-pin guard. +- **No new files.** DriverCard / DriverCardSection / CountsStrip / StopRow / LegendCard / UnassignedCompactCard live as inner components in `page.tsx` (they are tightly coupled to existing state). If `page.tsx` exceeds 41 KB after the changes, split the sidebar components into `src/app/(app)/collection-map/sidebar.tsx` as a follow-up. + +## Open questions for the reviewer + +0. **Sort order of the top 3.** Spec proposes keeping the existing alphabetical order from `driversWithItems` (matches what the map's pin letters already imply). Alternative: sort the top 3 by `items.length` descending so the busiest driver is first. The counts strip on each card tells the operator who is busiest either way, so this is presentation vs. operational-default. Flag if you want busiest-first; default is alphabetical for v1 to stay consistent with the existing pin-letter convention. +1. **Letter badge on Show-all.** `driverLabelFor` is alphabetical across all drivers, so the top 3 always get `A` / `B` / `C`. When the operator expands to "Show all", letters continue A/B/C…/N in the same alphabetical order. OK to leave as-is, or do we want the badges to re-letter starting from `A` for the visible set? +2. **Counts strip color.** Spec proposes Lucide `Circle` icons filled + `text-emerald-600` / `text-amber-600` for the green / orange dots, with the count text (e.g. `2 collected`) for accessibility. Flag if you'd rather literal emoji or a different visual. +3. **Unassigned card link target.** Spec proposes `/collection-pool`. If the operator's mental model is "the row's supplier" rather than "the pool page", flag and we'll route to a supplier-side action. +4. **+N more expansion scope.** Spec proposes per-card `useState`. If we anticipate >50 drivers in production, lift to page-level state up-front. Defaulting to per-card for v1. \ No newline at end of file diff --git a/docs/superpowers/specs/2026-07-12-collection-map-drop-date-filter-and-ready-gate-design.md b/docs/superpowers/specs/2026-07-12-collection-map-drop-date-filter-and-ready-gate-design.md new file mode 100644 index 0000000..f60e8ea --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-collection-map-drop-date-filter-and-ready-gate-design.md @@ -0,0 +1,216 @@ +# Collection Map — drop date filter + hard-gate assign on ready_for_collection + +**Goal:** Simplify `/collection-map` by removing the date filter entirely (so the operator sees every assigned + pool item regardless of when it's scheduled), and prevent a driver assignment on items the supplier hasn't confirmed ready for collection. + +## Scope + +### In Scope + +1. **Drop the date filter from `/collection-map`.** Remove the `date` state, the date picker + Today/Tomorrow/+3/Next week chip strip, the UTC day-window filter on both `order_items` `getFullList` calls, and the `{formatDateLabel(date)}` subtext under the "Mapped stops" KPI. The map now shows every item with `item_status = "assigned"` or `"collection_pool"` in one view. +2. **Hard-gate the assign API on `ready_for_collection`.** `/api/v1/collection-map/items/[id]/assign` returns **409 Conflict** when the operator assigns a driver to a row whose `ready_for_collection !== true`. The auto-flip logic (`if (isAssigning) patch.ready_for_collection = true`) is removed. Unassign (returning to pool) is unaffected. +3. **Mirror the gate in the assign dialog.** When the dialog is opened from `/collection-map`, the Submit button is disabled and an inline warning appears if the row's `ready_for_collection !== true`. The dialog still allows picking a date and submitting for unassign (driverId empty) regardless of the ready flag, so operators can always rescue a row. + +### Out of Scope + +- Changing the `/collection-pool` "Mark ready" flow — the existing `MarkReadyDialog` keeps working; this spec only adds a *blocker* on the map's assign path. Operator workflow: mark ready in `/collection-pool`, then come back to `/collection-map` to assign. +- The driver app's `getMyAssignedItems` filter — it already keys on `ready_for_collection === true` (verified earlier), so this change aligns. +- Time zone handling — `assigned_at` stays UTC. +- KPI card visuals beyond the subtext change. + +## Current State + +### Date filter in the page + +`src/app/(app)/collection-map/page.tsx:286` declares `const [date, setDate] = useState(ymdToday());`. Lines 305-391 (the `fetchRows` effect) build a UTC day window: + +```ts +const dayStart = `${date}T00:00:00.000Z`; +const dayEnd = `${date}T23:59:59.999Z`; +const filter = [ + `(item_status = "assigned" || item_status = "collection_pool")`, + `((assigned_at >= "${dayStart}" && assigned_at <= "${dayEnd}") || (assigned_at = "" || assigned_at = null))`, +].join(" && "); +``` + +The filter is duplicated a second time (line 370) for the unmapped-rows sidebar query. The toolbar at lines 627-663 renders a `` + `` plus the four quick-offset chips ("Today", "Tomorrow", "+3 days", "Next week"). The "Mapped stops" KPI at line 602 reads `For {formatDateLabel(date)} across {stopsWithCoords.length} supplier{...}`. The assign modal at line 524-526 currently pre-fills `initialDate` from the picked day. + +### Assign API and the current auto-flip + +`src/app/api/v1/collection-map/items/[id]/assign/route.ts:80-88`: + +```ts +// Assigning a driver means the supplier has been confirmed by +// the operator — flip `ready_for_collection` to true so the +// driver app's `getMyAssignedItems` filter surfaces the row. +// Un-assigning leaves the ready flag alone (the supplier-side +// gate doesn't change just because we're swapping which driver +// gets the pickup slot). +if (isAssigning) { + patch.ready_for_collection = true; +} +``` + +This is the wrong direction for the user's new rule — it silently promotes an unready row to ready on every driver assignment. The fix removes this block entirely. + +### Dialog has no UI gate today + +`src/components/ui/assign-driver-dialog.tsx:172`: + +```ts +const canSubmit = date.length === 10 && driverId.length > 0 && !busy; +``` + +No `ready_for_collection` check. The list/popup already shows a green "Ready" / amber "Unready" badge (`page.tsx:1046-1063`), but the dialog's Submit button stays clickable regardless. + +## User Confirmed Rule + +- **Date filter is removed entirely.** The map shows every row the operator might want to plan for, regardless of which day its `assigned_at` (if any) points at. +- **A row must have `ready_for_collection === true` before a driver can be assigned to it.** The supplier-side gate is independent of driver scheduling: unassign always works. +- **No new "Mark Ready from the assign dialog" combo action.** The operator flips the flag from `/collection-pool` (existing flow). The map dialog only blocks + explains. + +## Behavior + +### 1. Page: drop the date filter + +In `page.tsx`: + +- Delete `const [date, setDate] = useState(ymdToday());` and the `ymdToday`/`addDays` helpers (only `addDays` is used by the toolbar — and even there, only the date-picker calls it; if no other call sites exist after removal, the imports go too). +- Drop `date` from the `useCallback` deps of `fetchRows` and `openAssignDialog`. +- Rewrite `fetchRows` first query to: + + ```ts + const filter = `(item_status = "assigned" || item_status = "collection_pool")`; + ``` + +- Rewrite `fetchRows` second query (unmapped-rows sidebar) to the same filter (drop the `assigned_at >= … assigned_at <= …` bounds entirely). +- Update the second-query `expand` and `fields` to keep parity with the first if needed (already `purchase_order.supplier`). +- Drop the date-picker + chip strip from the toolbar entirely (lines 627-663). The toolbar keeps just the `Refresh` button (and the existing traffic / fullscreen toggles on the map card chrome — those are separate). +- In the "Mapped stops" KPI card (line 600-602), replace `For {formatDateLabel(date)} across {stopsWithCoords.length} supplier{…}` with `Across {stopsWithCoords.length} supplier{stopsWithCoords.length === 1 ? "" : "s"}` plus a small "Ordered by oldest scheduled date" eyebrow if `sort: "assigned_at"` stays (it does). +- `openAssignDialog` no longer takes a fallback date — it pre-fills `initialDate` from `row.assigned_at?.slice(0, 10)` only, falling back to today (`todayAsYmd` lives inside the dialog today; if needed, lift it to the page or duplicate the one-liner). Don't carry over the page's `date` state into the modal default. +- The list view at the bottom keeps its current rendering — it already uses `filteredItems` (= `items`), unfiltered. + +### 2. API: hard-gate the assign on ready_for_collection + +In `route.ts`: + +- After the `current = await pb.collection(…).getOne(id).catch(() => null)` lookup, add: + + ```ts + if (!current) { + return NextResponse.json({ error: `Order item ${id} not found` }, { status: 404 }); + } + if (isAssigning && !current.ready_for_collection) { + return NextResponse.json( + { + error: + "Supplier hasn't confirmed this item is ready for collection. Mark it ready in /collection-pool before assigning a driver.", + }, + { status: 409 }, + ); + } + ``` + +- Delete the `if (isAssigning) { patch.ready_for_collection = true; }` block (lines 86-88). The assign path now leaves `ready_for_collection` alone, since the gate above guarantees it's already `true`. +- Keep the rest of the route unchanged (the unassign path, the `assigned_at` clear, the `notes` write for ready-for-collection flips). + +`current` is already fetched at line 111; the new check sits next to the existing `if (!current)` guard, before `update`. + +### 3. UI gate in the assign dialog + +The dialog (`assign-driver-dialog.tsx`) accepts a new optional prop: + +```ts +/** When true, the Submit button is disabled until the row's + * ready_for_collection flag is true. The dialog passes through a + * boolean the caller computes (e.g. Boolean(row.ready_for_collection)) + * because the dialog stays row-agnostic. Unassign (driverId empty) + * is always allowed. */ +requireReadyForCollection?: boolean; +readyForCollection?: boolean; +``` + +Implementation: + +- In the inner component, compute `const readyBlocked = Boolean(requireReadyForCollection && readyForCollection === false);`. +- `canSubmit` becomes `date.length === 10 && driverId.length > 0 && !busy && !readyBlocked`. Unassign (`driverId.length === 0`) is not blocked. +- When `readyBlocked`, render an inline warning in the body (above the driver picker is fine — that's the first thing the operator edits): + + ```tsx +

                + Supplier hasn't confirmed this item is ready for collection. Mark it ready in + /collection-pool + before assigning a driver. +

                + ``` + +- The caller in `page.tsx` (`openAssignDialog` → `` mount around line 904-913) passes: + + ```tsx + requireReadyForCollection + readyForCollection={assignDialog.row?.ready_for_collection ?? false} + ``` + + Both default to false when `row` is null, so unassign-only flows aren't blocked. + +- In `page.tsx`'s `submitAssign` (lines 537-565), when the fetch returns `409`, the toast renders the server's `error` text and the dialog stays open (set `assignDialog` to keep `open: true`, no change to driverId / date). Today the catch-all already surfaces the error via `toast.error`; we just need to confirm the dialog isn't auto-closed (`setAssignDialog((s) => ({ ...s, open: false }))` lives in the success branch, so on error it stays open — already correct). + +### Sort behavior + +Keep `sort: "assigned_at"` in the `getFullList`. Items with empty `assigned_at` sort first (string compare puts `""` before any timestamp), matching the existing comment "the older ones render first inside each supplier bucket". No PB-side change to ordering needed. + +## Testing + +### Route tests (`src/app/api/v1/collection-map/items/[id]/assign/route.test.ts`) + +Add: + +- **409 when assigning an unready row:** `getOneMock.mockResolvedValueOnce({ id: "item-1", ready_for_collection: false })`, body `{ driverId: "drv-1", date: "2026-07-12" }`. Expect `res.status === 409`, body `error` includes "ready for collection", `updateMock` was not called. +- **200 when assigning a ready row** (positive case the gate now formally relies on): `getOneMock.mockResolvedValueOnce({ id: "item-1", ready_for_collection: true })`, same body. Expect `res.status === 200`, `updateMock` was called, payload includes `drv_id: "drv-1"` and **does NOT** include `ready_for_collection: true` (we now leave it alone — no auto-flip). +- **Unassign from an unready row remains allowed** (the supplier gate must not trap a row that's mid-flip): `getOneMock.mockResolvedValueOnce({ id: "item-1", ready_for_collection: false })`, body `{ date: "" }`. Expect `res.status === 200`, `updateMock` called. + +Update the existing positive test: + +- `writes trimmed notes to the item when ready_notes is provided` — mock `getOne` to return `{ id: "item-1", ready_for_collection: true }` (no driverId is supplied, so the gate doesn't fire, but the mock now reflects that the existing row is ready). The `updateMock` payload assertions (`notes`, `ready_for_collection: true`) still pass. + +### Page test (`src/app/(app)/collection-map/page.test.ts`) + +New tests (or extend the existing test file): + +- `fetchRows sends no date bounds in the order_items filter` (assert `getFullList.mock.calls[0][0].filter === "(item_status = \"assigned\" || item_status = \"collection_pool\")"`). +- `openAssignDialog with an unready row passes requireReadyForCollection + readyForCollection: false to the dialog` (mount the dialog stub or check via the page export — or rely on a new `AssignDriverDialog` test). +- New `AssignDriverDialog` test: `disables submit and shows warning when requireReadyForCollection=true and readyForCollection=false`, and `allows submit when readyForCollection=true`. + +### Regression + +- Existing route tests except the one updated above must still pass. +- Existing collection-map page tests must still pass; the toolbar shape contract test (task #57 of the prior milestone) should be re-asserted against the new toolbar shape (no date picker present). + +## Acceptance Criteria + +- `/collection-map` shows every row with `item_status` in {`assigned`, `collection_pool`}, irrespective of `assigned_at`. No date picker, no Today/Tomorrow/+3/Next week chips. +- "Mapped stops" KPI subtext reads "Across N suppliers" (no "For {date}"). +- PATCHing `/api/v1/collection-map/items/[id]/assign` with `{ driverId, date }` on a row where `ready_for_collection === false` returns 409 with a clear error message. UI surface: a toast and the assign dialog stays open. +- PATCHing the same endpoint with `{ date: "" }` (unassign) on an unready row succeeds and clears `assigned_at` as today. +- Assign Driver dialog disables Submit + shows the amber warning when the row is not ready; clearing the warning + enabling the button happens the moment the operator navigates back from `/collection-pool` (after a refresh) with the row marked ready. +- All targeted tests pass; full suite non-regressive. + +## Risk / Rollback + +- **Low risk.** No schema change. Pure feature/SQL/UI removal. +- **Rollback:** revert the page constant changes and the API guard. No data migration. +- **Surfacing the gate.** The 409 message is purposely a sentence the operator can read; if the user wants a shorter toast (e.g. "Not ready — mark in /collection-pool"), treat as a tweak in the spec compliance review, not a spec change. + +## File Changes (summary) + +- Modify: `src/app/(app)/collection-map/page.tsx` — drop `date` state, drop filter UI, drop date bounds in `fetchRows`, simplify KPI subtext, pass `requireReadyForCollection` + `readyForCollection` into `AssignDriverDialog`. +- Modify: `src/app/api/v1/collection-map/items/[id]/assign/route.ts` — add ready-state gate, remove auto-flip. +- Modify: `src/components/ui/assign-driver-dialog.tsx` — new props, new `canSubmit` clause, new warning block. +- Modify: `src/app/api/v1/collection-map/items/[id]/assign/route.test.ts` — new 409 + ready-row + unassign-unready tests; update mock for the existing ready_notes test. +- Modify: `src/app/(app)/collection-map/page.test.ts` — drop or update toolbar-shape tests to reflect removed controls; add filter-shape test. +- New tests in `src/components/ui/assign-driver-dialog.test.tsx` (if it exists) — otherwise inline next to the change. Verify by checking for the test file in that directory before adding. + +## Open questions for the reviewer + +1. KPI subtext copy. The draft reads "Across N suppliers". A "Sorted by oldest scheduled date" eyebrow above the map proper is proposed when `sort: "assigned_at"` stays. Both are minor — flag if you want them dropped or rewritten. +2. In the dialog warning, the link points at `/collection-pool`. If the operator's mental model is "the row's supplier" rather than "the pool page", say so and we'll route the link to a future supplier-side action instead. +3. No new "Mark ready + assign" combo. Confirmed default unless you say otherwise. diff --git a/plugin-source/collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6r8f/SKILL.md b/plugin-source/collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6r8f/SKILL.md new file mode 100644 index 0000000..c89c5da --- /dev/null +++ b/plugin-source/collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6r8f/SKILL.md @@ -0,0 +1,1258 @@ +# Collection Pool: supplier groups, bulk assign, primary time-in-list, note preview — 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:** Reorganise `/collection-pool` into collapsible supplier sections with bulk-assign (whole-section or selective), make the time-in-list dominant in `text-primary`, and replace the `title`-attribute Notes chip with a proper hover/focus tooltip. + +**Architecture:** Helper `buildSupplierGroups` lives in `view-model.ts`; the sort flips to `(supplierName, assignmentState, updated)` so the group builder can slice the sorted array. New `SupplierGroup` component (kept in `page.tsx`) renders the header strip (dot · supplier · count · PO eyebrow · chevron · Assign-all button) plus collapsible body. A second `AssignDriverDialog` instance handles bulk with `bulkCount`. The existing `PoolRow` gets two new props (`selected`, `onToggleSelected`) and a per-row checkbox. + +**Tech Stack:** Next.js 16 App Router (read `node_modules/next/dist/docs/` before any Next-specific code), React 18, TypeScript, Tailwind, shadcn/ui, PocketBase admin client, lucide-react, sonner, Vitest. + +**Spec:** [docs/superpowers/specs/2026-07-10-collection-pool-supplier-groups-and-notes-design.md](../specs/2026-07-10-collection-pool-supplier-groups-and-notes-design.md) + +**Pre-flight:** + +- [ ] **Read the spec end-to-end** so the helper signatures, sort tiebreakers, and bulk-merge semantics are locked in your head. +- [ ] **Skim `node_modules/next/dist/docs/`** for anything that affects Client Component state patterns (this codebase is "NOT the Next.js you know" per `AGENTS.md`). +- [ ] **Re-read `page.tsx`** (663 lines) — `PoolRow` props, the colour-stripe helper, the existing single-row `AssignDriverDialog` mount, and the `load()` + `updateItemStatus` patterns. + +--- + +## File Structure + +| File | Responsibility | Touched? | +| --- | --- | --- | +| `src/app/(app)/collection-pool/view-model.ts` | Add `CollectionPoolGroup` type + `buildSupplierGroups` helper; flip `buildVisibleRows` sort to lead with `supplierName`. | Modify | +| `src/app/(app)/collection-pool/view-model.test.ts` | Pin the new sort tiebreaker order; cover `buildSupplierGroups` (empty, fold, eyebrow, marker token). | Modify | +| `src/app/(app)/collection-pool/page.tsx` | Add `selectedIds`, `collapsedSuppliers`, `bulkAssignDialog` state; add `SupplierGroup` + `PoolRow` updates (time-in-list, notes tooltip, checkbox); wire bulk dialog and sequential PATCH handler; replace flat `
                  ` with grouped `
                  `s. | Modify | +| `src/app/(app)/collection-pool/page.test.tsx` | Cover selection toggling, section select-all, Assign-all opens bulk dialog with `bulkCount=N`, notes tooltip visibility, time-in-list colour class. | Modify | +| `src/app/(app)/collection-pool/components.tsx` (NEW, conditional) | If `page.tsx` exceeds 30 KB after Task 11, split `PoolRow` + `SupplierGroup` here. | Create (conditional) | + +--- + +## Task 1: Add `buildSupplierGroups` helper (TDD) + +**Files:** +- Modify: `src/app/(app)/collection-pool/view-model.test.ts` +- Modify: `src/app/(app)/collection-pool/view-model.ts` + +- [ ] **Step 1: Append failing tests** + +Append to `src/app/(app)/collection-pool/view-model.test.ts` (the existing `makeRow` helper above is reused): + +```ts +import { buildSupplierGroups, type CollectionPoolGroup } from "./view-model"; + +describe("buildSupplierGroups", () => { + it("returns an empty array for empty input", () => { + expect(buildSupplierGroups([], new Map())).toEqual([]); + }); + + it("folds rows by supplierId in input order", () => { + const rows = [ + makeRow({ id: "a", supplierId: "sup-1", supplierName: "Acme", poNumber: "PO-1" }), + makeRow({ id: "b", supplierId: "sup-2", supplierName: "Beta", poNumber: "PO-2" }), + makeRow({ id: "c", supplierId: "sup-1", supplierName: "Acme", poNumber: "PO-3" }), + ]; + const groups = buildSupplierGroups(rows, new Map()); + expect(groups.map((g) => g.supplierId)).toEqual(["sup-1", "sup-2"]); + expect(groups[0].rows.map((r) => r.id)).toEqual(["a", "c"]); + expect(groups[1].rows.map((r) => r.id)).toEqual(["b"]); + }); + + it("captures the top two PO numbers and reports the remaining count", () => { + const rows = [ + makeRow({ supplierId: "sup-1", poNumber: "PO-1" }), + makeRow({ supplierId: "sup-1", poNumber: "PO-2" }), + makeRow({ supplierId: "sup-1", poNumber: "PO-3" }), + makeRow({ supplierId: "sup-1", poNumber: "PO-4" }), + makeRow({ supplierId: "sup-1", poNumber: "PO-4" }), // duplicate PO + ]; + const [group] = buildSupplierGroups(rows, new Map()); + expect(group.topPoNumbers).toEqual(["PO-1", "PO-2"]); + expect(group.remainingPoCount).toBe(2); + }); + + it("uses marker token from supplierMarkerMap", () => { + const map = new Map([["sup-1", "marker-blue"]]); + const [group] = buildSupplierGroups( + [makeRow({ supplierId: "sup-1" })], + map, + ); + expect(group.markerToken).toBe("marker-blue"); + }); + + it("returns empty eyebrow when no rows have a PO number", () => { + const [group] = buildSupplierGroups( + [makeRow({ supplierId: "sup-1", poNumber: "" })], + new Map(), + ); + expect(group.topPoNumbers).toEqual([]); + expect(group.remainingPoCount).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run tests — expect FAIL** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/view-model.test' +``` + +Expected: 5 new tests fail with "buildSupplierGroups is not a function". + +- [ ] **Step 3: Implement `buildSupplierGroups`** + +Append to `src/app/(app)/collection-pool/view-model.ts`: + +```ts +export type CollectionPoolGroup = { + supplierId: string; + supplierName: string; + /** Cluster colour token from the proximity map (may be undefined). */ + markerToken?: string; + /** First two distinct PO numbers for the header eyebrow. */ + topPoNumbers: string[]; + /** Number of distinct POs beyond the top two. */ + remainingPoCount: number; + /** Rows belonging to this group, in render order. */ + rows: CollectionPoolViewRow[]; +}; + +/** + * Folds already-sorted rows into per-supplier groups, preserving input + * order. The caller is responsible for sorting rows by `supplierName` + * (use `buildVisibleRows` first); this helper does not re-sort. + */ +export function buildSupplierGroups( + rows: CollectionPoolViewRow[], + supplierMarkerMap: Map, +): CollectionPoolGroup[] { + const groups: CollectionPoolGroup[] = []; + const bySupplier = new Map(); + + for (const row of rows) { + let group = bySupplier.get(row.supplierId); + if (!group) { + group = { + supplierId: row.supplierId, + supplierName: row.supplierName ?? "", + markerToken: supplierMarkerMap.get(row.supplierId), + topPoNumbers: [], + remainingPoCount: 0, + rows: [], + }; + bySupplier.set(row.supplierId, group); + groups.push(group); + } + group.rows.push(row); + } + + for (const group of groups) { + const uniquePos = Array.from( + new Set(group.rows.map((r) => r.poNumber ?? "").filter(Boolean)), + ); + group.topPoNumbers = uniquePos.slice(0, 2); + group.remainingPoCount = Math.max(0, uniquePos.length - 2); + } + + return groups; +} +``` + +- [ ] **Step 4: Run tests — expect PASS** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/view-model.test' +``` + +Expected: all tests green. + +- [ ] **Step 5: Commit** + +```bash +git add 'src/app/(app)/collection-pool/view-model.ts' 'src/app/(app)/collection-pool/view-model.test.ts' +git commit -m "feat(collection-pool): add buildSupplierGroups helper" +``` + +--- + +## Task 2: Flip sort tiebreaker to lead with `supplierName` + +**Files:** +- Modify: `src/app/(app)/collection-pool/view-model.test.ts` +- Modify: `src/app/(app)/collection-pool/view-model.ts` + +- [ ] **Step 1: Append failing tests** + +Append to `src/app/(app)/collection-pool/view-model.test.ts`: + +```ts +describe("buildVisibleRows — supplier-first sort (operational mode)", () => { + const todayYmd = "2026-07-10"; + + it("sorts by supplierName first when sortMode is operational", () => { + const rows = buildVisibleRows( + [ + makeRow({ id: "beta", supplierName: "Beta" }), + makeRow({ id: "acme", supplierName: "Acme" }), + makeRow({ id: "alpha", supplierName: "Alpha" }), + ], + { todayYmd, searchText: "", readyFilter: "all", sortMode: "operational" }, + ); + expect(rows.map((r) => r.id)).toEqual(["acme", "alpha", "beta"]); + }); + + it("falls through to assignmentState within the same supplier", () => { + const rows = buildVisibleRows( + [ + makeRow({ + id: "acme-unassigned", + supplierName: "Acme", + item_status: "collection_pool", + }), + makeRow({ + id: "acme-today", + supplierName: "Acme", + item_status: "assigned", + drv_id: "drv-1", + assigned_at: `${todayYmd}T00:00:00.000Z`, + }), + ], + { todayYmd, searchText: "", readyFilter: "all", sortMode: "operational" }, + ); + expect(rows.map((r) => r.id)).toEqual(["acme-today", "acme-unassigned"]); + }); + + it("nearby mode still uses cluster prefix before supplier", () => { + const rows = buildVisibleRows( + [ + makeRow({ + id: "beta-cluster-a", + supplierName: "Beta", + clusterSortKey: "cluster-a", + }), + makeRow({ + id: "acme-cluster-b", + supplierName: "Acme", + clusterSortKey: "cluster-b", + }), + makeRow({ + id: "acme-cluster-a", + supplierName: "Acme", + clusterSortKey: "cluster-a", + }), + ], + { todayYmd, searchText: "", readyFilter: "all", sortMode: "nearby" }, + ); + expect(rows.map((r) => r.id)).toEqual([ + "acme-cluster-a", + "beta-cluster-a", + "acme-cluster-b", + ]); + }); +}); +``` + +- [ ] **Step 2: Run tests — expect FAIL** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/view-model.test' +``` + +Expected: 3 new tests fail. The two existing `buildVisibleRows sort modes` tests should still pass (they don't depend on supplier ordering); the original `"sorts assigned today first"` test may start to fail because it expects today-first but the new sort is supplier-first. **Note that failure and proceed — it's expected and will be reconciled when the production change lands in Step 3.** + +- [ ] **Step 3: Update `buildVisibleRows` sort** + +In `src/app/(app)/collection-pool/view-model.ts`, replace the existing `.sort((a, b) => { ... })` callback inside `buildVisibleRows` with: + +```ts +.sort((a, b) => { + if (sortMode === "nearby") { + const clusterDiff = (a.clusterSortKey ?? "zzz").localeCompare(b.clusterSortKey ?? "zzz"); + if (clusterDiff !== 0) return clusterDiff; + } + + // Supplier name is the primary group key. Rows in the same + // supplier are then ordered by assignment urgency (today → + // unassigned → future), then by updated, then by PO + description. + const supplierDiff = (a.supplierName ?? "").localeCompare(b.supplierName ?? ""); + if (supplierDiff !== 0) return supplierDiff; + + const stateDiff = + ASSIGNMENT_SORT_ORDER[getAssignmentState(a, options.todayYmd)] - + ASSIGNMENT_SORT_ORDER[getAssignmentState(b, options.todayYmd)]; + if (stateDiff !== 0) return stateDiff; + + const updatedA = a.updated ?? ""; + const updatedB = b.updated ?? ""; + if (updatedA !== updatedB) return updatedA.localeCompare(updatedB); + + return `${a.poNumber ?? ""}|${a.description ?? ""}`.localeCompare( + `${b.poNumber ?? ""}|${b.description ?? ""}`, + ); +}); +``` + +- [ ] **Step 4: Run tests — expect PASS** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/view-model.test' +``` + +Expected: all tests green. The pre-existing `"sorts assigned today first, then unassigned, then future"` test will now fail because supplier is the primary key; the expected order across multiple suppliers is supplier-alphabetical regardless of assignment state. **If that test was already updated by a previous commit, leave it alone; otherwise update its expectation to include supplier-distinct fixtures so the assertion still holds.** The simplest fix: + +```ts +// Old assertion was: ["today", "unassigned", "future"] with one supplier. +// Update fixture so all three rows share a supplier OR split into +// distinct suppliers and assert alphabetical. Easiest: keep one +// supplier and assert the assignment order. +makeRow({ id: "future", supplierName: "Same", ... }), +makeRow({ id: "unassigned", supplierName: "Same", ... }), +makeRow({ id: "today", supplierName: "Same", ... }), +// Expect ["today", "unassigned", "future"] +``` + +- [ ] **Step 5: Commit** + +```bash +git add 'src/app/(app)/collection-pool/view-model.ts' 'src/app/(app)/collection-pool/view-model.test.ts' +git commit -m "feat(collection-pool): sort by supplier name first" +``` + +--- + +## Task 3: Time-in-list primary colour + "In pool since" sub-label + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx:607` + +- [ ] **Step 1: Update the `PoolRow` order-detail cell** + +In `src/app/(app)/collection-pool/page.tsx`, replace the order-detail cell body that currently contains the time-in-list label (around line 607): + +```tsx +
                  + {getPoolAgeLabel(row.updated)} +
                  +{row.updated && ( +
                  + In pool since {new Date(row.updated).toLocaleDateString(undefined, { + day: "numeric", + month: "short", + })} +
                  +)} +``` + +Keep the surrounding markup (`
                  `, the Order/PO labels above, the `Scheduled {date}` line below) untouched. + +- [ ] **Step 2: Run the existing page suite** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/page' +``` + +Expected: existing tests still pass (this change adds markup but doesn't alter data flow). + +- [ ] **Step 3: Commit** + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "style(collection-pool): promote time-in-list to text-primary" +``` + +--- + +## Task 4: Notes chip — proper hover/focus tooltip + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx:625-632` + +- [ ] **Step 1: Replace the native `title` chip with a `group/notes` tooltip** + +In `src/app/(app)/collection-pool/page.tsx`, replace the existing notes chip block: + +```tsx +{row.notes && row.notes.trim().length > 0 && ( + + Notes + + {row.notes.length > 280 ? `${row.notes.slice(0, 280)}…` : row.notes} + + +)} +``` + +If `bg-popover` / `text-popover-foreground` are not defined in the project's `globals.css`, fall back to `bg-background text-foreground` (these always resolve). + +- [ ] **Step 2: Run the existing page suite** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/page' +``` + +Expected: existing tests still pass. If any test pinned the old `title` attribute, update the selector to match the new tooltip (e.g. `findByRole("tooltip")`). + +- [ ] **Step 3: Commit** + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): replace notes title with hover tooltip" +``` + +--- + +## Task 5: Selection state + per-row checkbox on `PoolRow` + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx` (state, props, JSX, render call) + +- [ ] **Step 1: Add `selectedIds` state + handlers** + +In `CollectionPoolPage`, alongside the existing `useState` calls (around line 106–116), add: + +```ts +const [selectedIds, setSelectedIds] = useState>(new Set()); + +const toggleRowSelected = useCallback((rowId: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(rowId)) next.delete(rowId); + else next.add(rowId); + return next; + }); +}, []); + +const toggleSectionSelected = useCallback((rowIds: string[]) => { + setSelectedIds((prev) => { + const allSelected = rowIds.every((id) => prev.has(id)); + const next = new Set(prev); + if (allSelected) { + for (const id of rowIds) next.delete(id); + } else { + for (const id of rowIds) next.add(id); + } + return next; + }); +}, []); +``` + +- [ ] **Step 2: Extend `PoolRow` props and signature** + +Update the `PoolRow` props type and signature in the same file (around line 551): + +```tsx +function PoolRow({ + row, + busy, + markerToken, + todayYmd, + selected, + onToggleSelected, + onOpenAssign, + onToggleReady, +}: { + row: CollectionPoolVisibleRow; + busy: boolean; + markerToken?: string; + todayYmd: string; + selected: boolean; + onToggleSelected: (rowId: string) => void; + onOpenAssign: (row: CollectionPoolVisibleRow) => void; + onToggleReady: (row: CollectionPoolVisibleRow, ready: boolean) => void; +}) { +``` + +- [ ] **Step 3: Add the checkbox and row tint to `PoolRow`'s JSX** + +In `PoolRow`'s `
                • ` element (around line 579), apply the selection tint and add a checkbox at the start of the content grid. Replace the opening markup: + +```tsx +
                • + + +
                  +
                  + onToggleSelected(row.id)} + disabled={busy} + aria-label={`Select ${row.description ?? "item"} at ${row.supplierName ?? "unknown supplier"}`} + className="mt-1 size-4 shrink-0 cursor-pointer accent-primary disabled:cursor-not-allowed" + /> +
                  +

                  {row.supplierName}

                  +

                  {row.description || "(no description)"}

                  +
                  + {row.customerName || "No customer"} + / + {row.companyName || "No company"} +
                  + {!row.purchase_order && ( +
                  + + This row has no purchase order link. The PO may have been deleted. +
                  + )} +
                  +
                  + + {/* ...rest of the columns unchanged... */} +
                  +
                • +``` + +(The supplier/description/customer/company/warning block was previously the direct first child of the inner `
                  `. It is now nested inside a new wrapper `
                  ` that hosts the checkbox.) + +- [ ] **Step 4: Update the call site (still the flat `
                    ` for now)** + +In the rendering block around line 488–500, update the `PoolRow` invocation: + +```tsx +
                      + {visibleRows.map((row) => ( + + ))} +
                    +``` + +- [ ] **Step 5: Run page suite + commit** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/page' +``` + +Expected: existing tests still pass (PoolRow is exercised via the page). + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): per-row checkbox + selection state" +``` + +--- + +## Task 6: `SupplierGroup` component (renders header + collapsible body) + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx` (new `SupplierGroup` function) + +- [ ] **Step 1: Add the `SupplierGroup` component** + +Append a new `SupplierGroup` function inside `page.tsx`, after `PoolRow`. It renders the section header strip and the collapsible row body: + +```tsx +function SupplierGroup({ + group, + busy, + todayYmd, + selectedIds, + collapsed, + onToggleRowSelected, + onToggleSectionSelected, + onToggleCollapsed, + onOpenAssign, + onOpenBulkAssign, + onToggleReady, +}: { + group: CollectionPoolGroup; + busy: boolean; + todayYmd: string; + selectedIds: Set; + collapsed: boolean; + onToggleRowSelected: (rowId: string) => void; + onToggleSectionSelected: (rowIds: string[]) => void; + onToggleCollapsed: (supplierId: string) => void; + onOpenAssign: (row: CollectionPoolVisibleRow) => void; + onOpenBulkAssign: (supplierId: string, supplierName: string, rowIds: string[]) => void; + onToggleReady: (row: CollectionPoolVisibleRow, ready: boolean) => void; +}) { + const rowIds = group.rows.map((r) => r.id); + const selectedCount = rowIds.filter((id) => selectedIds.has(id)).length; + const allSelected = selectedCount === rowIds.length && rowIds.length > 0; + const someSelected = selectedCount > 0 && !allSelected; + const allAssigned = group.rows.every( + (r) => getAssignmentState(r, todayYmd) !== "unassigned", + ); + + const eyebrowText = (() => { + if (group.topPoNumbers.length === 0) return null; + const head = group.topPoNumbers.map((p) => `PO ${p}`).join(", "); + return group.remainingPoCount > 0 + ? `${head} (+${group.remainingPoCount} more)` + : head; + })(); + + const assignLabel = allSelected + ? `Assign ${rowIds.length} stops` + : selectedCount > 0 + ? `Assign ${selectedCount} selected` + : `Assign all ${rowIds.length}`; + + const onAssignClick = () => { + const ids = allSelected || selectedCount === 0 ? rowIds : rowIds.filter((id) => selectedIds.has(id)); + onOpenBulkAssign(group.supplierId, group.supplierName, ids); + }; + + return ( +
                    +
                    + +

                    + {group.supplierName || "Unknown supplier"} +

                    + + · {group.rows.length} {group.rows.length === 1 ? "stop" : "stops"} + {eyebrowText ? <> · {eyebrowText} : null} + +
                    + + + +
                    +
                    +
                    +
                      + {group.rows.map((row) => ( + + ))} +
                    +
                    +
                    + ); +} +``` + +- [ ] **Step 2: Add the missing imports** + +At the top of `page.tsx`, extend the lucide-react import: + +```tsx +import { AlertTriangle, ChevronDown, Loader2, Search, Truck } from "lucide-react"; +``` + +And update the `./view-model` imports so `buildSupplierGroups`, `CollectionPoolGroup`, and `getAssignmentBadge` are all present: + +```tsx +import { + buildProximityMarkerMap, + buildSupplierGroups, + buildVisibleRows, + type CollectionPoolGroup, + type CollectionPoolSortMode, + getAssignmentBadge, + getAssignmentState, + getPoolAgeLabel, +} from "./view-model"; +``` + +(`getAssignmentBadge` is still consumed by `PoolRow`'s status pill — keep it.) + +- [ ] **Step 3: Run typecheck** + +```bash +npx tsc --noEmit +``` + +Expected: clean. If `Type 'Set' is not assignable to ...` errors fire on `selectedIds`, the `useState>(new Set())` should already be inferring — double-check the import. + +- [ ] **Step 4: Commit (component only — not wired yet)** + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): add SupplierGroup component" +``` + +--- + +## Task 7: Replace flat `
                      ` with grouped `
                      `s + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx` (rendering block + state) + +- [ ] **Step 1: Add `collapsedSuppliers` state** + +In `CollectionPoolPage`, next to `selectedIds`: + +```ts +const [collapsedSuppliers, setCollapsedSuppliers] = useState>(new Set()); + +const toggleCollapsed = useCallback((supplierId: string) => { + setCollapsedSuppliers((prev) => { + const next = new Set(prev); + if (next.has(supplierId)) next.delete(supplierId); + else next.add(supplierId); + return next; + }); +}, []); +``` + +- [ ] **Step 2: Add `openBulkAssign` handler stub** + +Below `openAssignForRow`, add a stub that just opens state — the actual submit handler comes in Task 9. (The stub is intentional so Task 8 can wire the button without Task 9 needing to be done first.) + +```ts +const [bulkAssignDialog, setBulkAssignDialog] = useState<{ + supplierId: string; + supplierName: string; + rowIds: string[]; +} | null>(null); + +const openBulkAssign = useCallback( + (supplierId: string, supplierName: string, rowIds: string[]) => { + if (rowIds.length === 0) return; + setBulkAssignDialog({ supplierId, supplierName, rowIds }); + }, + [], +); + +const closeBulkAssign = useCallback(() => setBulkAssignDialog(null), []); +``` + +- [ ] **Step 3: Compute `groups` in the page** + +Below `visibleRows`, add: + +```tsx +const groups = useMemo(() => { + if (!visibleRows) return null; + return buildSupplierGroups(visibleRows, supplierMarkerMap); +}, [visibleRows, supplierMarkerMap]); +``` + +- [ ] **Step 4: Replace the flat-list rendering** + +Replace the `
                        ` block (around line 488–500 in the current `page.tsx`) with: + +```tsx +
                        +
                        + Collection Item + Order Detail + Status + Actions +
                        + {groups === null ? ( +
                        + {Array.from({ length: 4 }).map((_, i) => ( +
                        + ))} +
                        + ) : groups.length === 0 ? ( + + ) : ( + groups.map((group) => ( + + )) + )} +
                        +``` + +Note: the empty-states at the top level (`rows === null` skeleton, `rows.length === 0` empty, `visibleRows.length === 0` empty) now collapse into the new conditional inside the container — delete the previous three separate early-return blocks (around lines 460–478) to avoid double-rendering. + +- [ ] **Step 5: Run typecheck + page suite** + +```bash +npx tsc --noEmit +npm run test:run -- 'src/app/(app)/collection-pool/page' +``` + +Expected: typecheck clean; page tests still pass. If a test pinned the old `
                          `, update the selector to find rows by data-od-id on the section or fall back to a more stable test query. + +- [ ] **Step 6: Commit** + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): render supplier-grouped sections" +``` + +--- + +## Task 8: Wire bulk-assign dialog mount + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx` (second `AssignDriverDialog` mount) + +- [ ] **Step 1: Stub the submit handler + bulkBusy state** + +Just below `handleAssignDialogSubmit`, add: + +```ts +const [bulkBusy, setBulkBusy] = useState(false); + +const handleBulkAssignSubmit = useCallback( + async (_payload: AssignDriverSubmitPayload) => { + // Full implementation in Task 9. + }, + [], +); +``` + +- [ ] **Step 2: Add the bulk-assign dialog JSX** + +Just below the existing single-row `` (around line 504), add: + +```tsx + { + if (!open) closeBulkAssign(); + }} + onSubmit={handleBulkAssignSubmit} + bulkCount={bulkAssignDialog?.rowIds.length ?? 1} + eyebrow={ + bulkAssignDialog ? ( + + + {bulkAssignDialog.supplierName || "Unknown supplier"} + · + {bulkAssignDialog.rowIds.length} stops + + ) : undefined + } + busy={bulkBusy} +/> +``` + +- [ ] **Step 3: Run typecheck + commit** + +```bash +npx tsc --noEmit +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): wire bulk-assign dialog mount" +``` + +--- + +## Task 9: Sequential bulk PATCH handler + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx` (`handleBulkAssignSubmit` body) + +- [ ] **Step 1: Replace the stub with the real implementation** + +In `page.tsx`, replace the empty `handleBulkAssignSubmit` with: + +```ts +const handleBulkAssignSubmit = useCallback( + async (payload: AssignDriverSubmitPayload) => { + if (!bulkAssignDialog) return; + const { driverId, date } = payload; + const { rowIds, supplierName } = bulkAssignDialog; + + setBulkBusy(true); + try { + const pb = getPocketBase(); + let successCount = 0; + let firstError: string | null = null; + + // Sequential, not parallel: any failure leaves earlier + // successes committed (matches the single-row PATCH semantics). + for (const id of rowIds) { + try { + await pb.collection("order_items").update(id, { + item_status: "assigned", + drv_id: driverId, + drv_status: "assigned", + assigned_at: `${date}T00:00:00.000Z`, + } as Partial); + successCount++; + } catch (err) { + if (!firstError) { + firstError = err instanceof Error ? err.message : String(err); + } + } + } + + // Clear selections for this supplier on success (partial or full). + setSelectedIds((prev) => { + const next = new Set(prev); + for (const id of rowIds) next.delete(id); + return next; + }); + closeBulkAssign(); + + if (firstError) { + toast.error( + `Assigned ${successCount} of ${rowIds.length} stops at ${supplierName || "supplier"}. First error: ${firstError}`, + ); + } else { + toast.success(`Assigned ${successCount} stops at ${supplierName || "supplier"}.`); + } + + await load(); + } finally { + setBulkBusy(false); + } + }, + [bulkAssignDialog, closeBulkAssign, load], +); +``` + +- [ ] **Step 2: Run typecheck + page suite** + +```bash +npx tsc --noEmit +npm run test:run -- 'src/app/(app)/collection-pool/page' +``` + +Expected: typecheck clean; existing page tests pass. Mock the PocketBase `update` call in tests as needed — see Task 10 for the test wiring. + +- [ ] **Step 3: Commit** + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): sequential bulk-assign PATCH" +``` + +--- + +## Task 10: Page-level tests + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.test.tsx` + +- [ ] **Step 1: Add a test that time-in-list uses `text-primary`** + +Find the existing render fixture in `page.test.tsx` (search for `getPoolAgeLabel` or a row containing `text-foreground`). Append: + +```tsx +import { screen, within } from "@testing-library/react"; + +it("renders time-in-list with text-primary class", async () => { + renderPage(); + const ageLabels = await screen.findAllByText(/^\d+[hd]$/); + expect(ageLabels.length).toBeGreaterThan(0); + for (const label of ageLabels) { + expect(label.className).toMatch(/text-primary/); + } +}); +``` + +(The selector `/^\d+[hd]$/` matches the `Nh` / `Nd` format produced by `getPoolAgeLabel`. Adjust if existing tests use a different fixture pattern — search the file for any pre-existing age assertions first.) + +- [ ] **Step 2: Add a test that the Notes tooltip appears on hover** + +```tsx +it("surfaces the Notes tooltip on row hover", async () => { + // Fixture row must include `notes: "Spoke to David — confirmed pickup tomorrow"`. + renderPage({ rowOverrides: { notes: "Spoke to David — confirmed pickup tomorrow" } }); + const tooltip = await screen.findByRole("tooltip"); + expect(tooltip).toHaveTextContent(/Spoke to David/); + expect(tooltip).toHaveClass("hidden"); // default state +}); +``` + +If the project's `renderPage` helper doesn't accept row overrides, add a parameter that forwards to the fixture builder — it's a one-line change. + +- [ ] **Step 3: Add a test that selecting a row toggles the selection tint** + +```tsx +it("applies a primary-tint background to selected rows", async () => { + const { container } = renderPage(); + const firstCheckbox = await screen.findByRole("checkbox", { name: /Select .* at .*/i }); + fireEvent.click(firstCheckbox); + const row = firstCheckbox.closest("li"); + expect(row).not.toBeNull(); + expect(row!.className).toMatch(/bg-primary\/5/); +}); +``` + +- [ ] **Step 4: Add a test that the section "Select all" toggles every row in the section** + +```tsx +it("section Select all toggles every row in the supplier group", async () => { + renderPage(); + const selectAll = await screen.findByRole("checkbox", { name: /Select all \d+ stops at/i }); + fireEvent.click(selectAll); + const rowCheckboxes = await screen.findAllByRole("checkbox", { name: /^Select .* at/i }); + expect(rowCheckboxes.length).toBeGreaterThan(0); + for (const cb of rowCheckboxes) { + expect(cb).toBeChecked(); + } +}); +``` + +- [ ] **Step 5: Add a test that the Assign-all button opens the bulk dialog with `bulkCount=N`** + +Mock `pb.collection("order_items").update` so the test doesn't actually hit PocketBase. Then: + +```tsx +it("opens bulk-assign dialog with bulkCount equal to selected rows", async () => { + const { container } = renderPage(); + const selectAll = await screen.findByRole("checkbox", { name: /Select all \d+ stops at/i }); + fireEvent.click(selectAll); + const assignAll = screen.getByRole("button", { name: /Assign \d+ stops$/ }); + fireEvent.click(assignAll); + // Dialog title reads "Assign N items to driver" when bulkCount > 1. + const dialogTitle = await screen.findByText(/Assign \d+ items to driver/i); + expect(dialogTitle).toBeInTheDocument(); +}); +``` + +If the `getPocketBase()` mock in the existing suite doesn't intercept the second dialog's submit handler, extend the mock to also intercept the bulk-assign PATCH chain. + +- [ ] **Step 6: Add a test that collapsing a section hides its rows** + +```tsx +it("collapses a supplier section on chevron click", async () => { + renderPage(); + const chevron = await screen.findByRole("button", { name: "" }); // chevron has no accessible name; query by aria-controls + const section = document.querySelector('[aria-labelledby^="supplier-"]'); + const id = section!.querySelector("header")!.id; + const body = document.getElementById(`${id}-body`) || section!.querySelector('[id$="-body"]'); + expect(body!.className).toMatch(/grid-rows-\[1fr\]/); + fireEvent.click(section!.querySelector("button[aria-expanded]")!); + expect(body!.className).toMatch(/grid-rows-\[0fr\]/); +}); +``` + +- [ ] **Step 7: Run the full collection-pool suite** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool' +``` + +Expected: green. Pre-existing failures (documented across the repo, e.g. `page.test.tsx:175`) are acceptable. + +- [ ] **Step 8: Commit** + +```bash +git add 'src/app/(app)/collection-pool/page.test.tsx' +git commit -m "test(collection-pool): cover groups, bulk assign, notes tooltip" +``` + +--- + +## Task 11: Typecheck + size budget + conditional split + +**Files:** +- Modify or create: `src/app/(app)/collection-pool/page.tsx` (or split into `components.tsx`) + +- [ ] **Step 1: Full typecheck** + +```bash +npx tsc --noEmit +``` + +Expected: clean. + +- [ ] **Step 2: Measure `page.tsx` size** + +```bash +wc -c 'src/app/(app)/collection-pool/page.tsx' +``` + +If the result is **≤ 30 KB**, stop here — the file is within budget. Skip to Step 5. + +If the result is **> 30 KB**, continue with the split in Steps 3–4. + +- [ ] **Step 3: Create `components.tsx` with `PoolRow` + `SupplierGroup`** + +Create `src/app/(app)/collection-pool/components.tsx`: + +```tsx +"use client"; + +import { ChevronDown, Truck, AlertTriangle } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import type { CollectionPoolViewRow } from "./view-model"; +import { + type CollectionPoolGroup, + getAssignmentState, + getPoolAgeLabel, +} from "./view-model"; +import { cn } from "@/lib/utils"; + +// Copy `CollectionPoolVisibleRow` type and `markerTokenToClassName` +// helper from page.tsx. They're needed by both PoolRow and SupplierGroup. + +export type CollectionPoolVisibleRow = CollectionPoolViewRow & { + // mirror the type from page.tsx +}; + +function markerTokenToClassName(token?: string): string { + switch (token) { + case "marker-blue": + return "bg-blue-500"; + case "marker-emerald": + return "bg-emerald-500"; + case "marker-violet": + return "bg-violet-500"; + case "marker-amber": + return "bg-amber-500"; + default: + return "bg-slate-300"; + } +} + +// Then paste the FULL `PoolRow` and `SupplierGroup` function bodies +// from page.tsx, unchanged. +``` + +Cut both functions from `page.tsx` and update imports so `page.tsx` imports them from `./components` instead. + +- [ ] **Step 4: Re-measure + retest** + +```bash +wc -c 'src/app/(app)/collection-pool/page.tsx' 'src/app/(app)/collection-pool/components.tsx' +npx tsc --noEmit +npm run test:run -- 'src/app/(app)/collection-pool' +``` + +Expected: each file ≤ 30 KB; typecheck clean; tests green. + +- [ ] **Step 5: Commit (split or no-op)** + +If you split: +```bash +git add 'src/app/(app)/collection-pool/components.tsx' 'src/app/(app)/collection-pool/page.tsx' +git commit -m "refactor(collection-pool): split PoolRow + SupplierGroup into components.tsx" +``` + +If you didn't split, no commit is needed. + +--- + +## Task 12: Manual smoke + full suite + +- [ ] **Step 1: Full test suite** + +```bash +npm run test:run +``` + +Expected: same passing set as before plus the new tests in `view-model.test.ts` and `page.test.tsx`. Pre-existing failures (the 6 we documented across the repo) are acceptable. + +- [ ] **Step 2: Manual smoke on `/collection-pool`** + +1. Load the page. Rows render in collapsible supplier sections, alphabetical. +2. Click the chevron on a section → body collapses with a smooth transition; chevron rotates. +3. Hover the time-in-list label — confirm it's in `text-primary` and bold. +4. Hover the Notes chip on a row with notes — confirm the tooltip appears with the note text (truncated at 280 chars + ellipsis if longer). +5. Tick the section "Select all" — every row checkbox in the section ticks; row backgrounds tint. +6. Click `Assign N stops`. Bulk dialog opens with eyebrow `Supplier · N stops` and title `Assign N items to driver`. +7. Pick a driver + tomorrow's date, submit. Toast confirms. Reload — rows show "Assigned future" badge and the section's selections clear. +8. Mark a row Ready with a long note (>280 chars). Hover the Notes chip — the preview is truncated with `…`. +9. Switch sort to "Nearby suppliers" — order changes; switch back to "Operational" — supplier-grouped order restored. + +- [ ] **Step 3: Commit any test-only follow-ups** + +```bash +git add -A 'src/app/(app)/collection-pool/' +git commit -m "test(collection-pool): post-smoke follow-ups" --allow-empty +``` + +--- + +## Out-of-scope reminders + +- **Bulk mark-ready.** The dialog requires a free-text note per item; a multi-note UI is a separate feature. Don't add it here. +- **Per-section "Mark section ready".** Same reason. +- **Route rename / nav label change.** Not in scope. +- **Mobile-only layout changes.** The existing grid already collapses correctly; no special breakpoint work is needed for the new structure. +- **Transactional batch PATCH endpoint.** Sequential single-row PATCHes are intentional; rollback on partial failure is out of scope. + +--- + +## Risk notes + +- **Page render budget.** Current 23 KB. Estimated growth ~7–10 KB from the new component + state + handlers. If it crosses 30 KB, Task 11 Step 3 splits `PoolRow` + `SupplierGroup` into `components.tsx`. If it still crosses after the split, consider further splitting the dialog wiring into a hook (`useBulkAssign`). +- **Sort change is observable.** Users who relied on the old "by urgency" default will see suppliers grouped together. The Sort radio's "Operational" label now matches this new behaviour; "Nearby" remains for cluster-priority workflows. +- **Bulk PATCH is not transactional.** A failure on row 4 of 5 leaves rows 1–3 committed. The toast reports partial success; the operator can re-open the dialog and retry. + +## Provenance + +Formalized by Open Design from candidate 5cb8fc93-2209-4312-ad86-e1d93493fe2d. diff --git a/plugin-source/collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6r8f/open-design.json b/plugin-source/collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6r8f/open-design.json new file mode 100644 index 0000000..37a5230 --- /dev/null +++ b/plugin-source/collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6r8f/open-design.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://open-design.ai/schemas/plugin.v1.json", + "specVersion": "1.0.0", + "name": "collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6r8f", + "title": "Collection Pool: supplier groups, bulk assign, primary time-in-list, note previe", + "version": "0.1.0", + "description": "> **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 trackin", + "od": { + "kind": "skill", + "taskKind": "new-generation", + "context": { + "skills": [ + { + "path": "./SKILL.md" + } + ] + }, + "capabilities": [ + "prompt:inject" + ] + } +} diff --git a/plugin-source/collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6r8f/references/provenance.json b/plugin-source/collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6r8f/references/provenance.json new file mode 100644 index 0000000..32acf01 --- /dev/null +++ b/plugin-source/collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6r8f/references/provenance.json @@ -0,0 +1,19 @@ +{ + "candidateId": "5cb8fc93-2209-4312-ad86-e1d93493fe2d", + "projectId": "ef67607e-d372-4742-aa1a-f7206ddb1b6b", + "runId": "0fb704d7-d828-4c0f-8b22-4c1999a773d2", + "conversationId": "00d657d1-caca-4511-984d-518155667f66", + "provenance": { + "summary": "Detected from docs/superpowers/plans/2026-07-10-collection-pool-supplier-groups-and-notes.md after a successful run.", + "detectedAt": 1783721909754 + }, + "sourceRefs": [ + { + "kind": "file", + "value": "docs/superpowers/plans/2026-07-10-collection-pool-supplier-groups-and-notes.md", + "label": "docs/superpowers/plans/2026-07-10-collection-pool-supplier-groups-and-notes.md", + "size": 44068, + "copied": true + } + ] +} diff --git a/plugin-source/collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6r8f/references/source-1-2026-07-10-collection-pool-supplier-groups-and-notes.md b/plugin-source/collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6r8f/references/source-1-2026-07-10-collection-pool-supplier-groups-and-notes.md new file mode 100644 index 0000000..0d896b9 --- /dev/null +++ b/plugin-source/collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6r8f/references/source-1-2026-07-10-collection-pool-supplier-groups-and-notes.md @@ -0,0 +1,1254 @@ +# Collection Pool: supplier groups, bulk assign, primary time-in-list, note preview — 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:** Reorganise `/collection-pool` into collapsible supplier sections with bulk-assign (whole-section or selective), make the time-in-list dominant in `text-primary`, and replace the `title`-attribute Notes chip with a proper hover/focus tooltip. + +**Architecture:** Helper `buildSupplierGroups` lives in `view-model.ts`; the sort flips to `(supplierName, assignmentState, updated)` so the group builder can slice the sorted array. New `SupplierGroup` component (kept in `page.tsx`) renders the header strip (dot · supplier · count · PO eyebrow · chevron · Assign-all button) plus collapsible body. A second `AssignDriverDialog` instance handles bulk with `bulkCount`. The existing `PoolRow` gets two new props (`selected`, `onToggleSelected`) and a per-row checkbox. + +**Tech Stack:** Next.js 16 App Router (read `node_modules/next/dist/docs/` before any Next-specific code), React 18, TypeScript, Tailwind, shadcn/ui, PocketBase admin client, lucide-react, sonner, Vitest. + +**Spec:** [docs/superpowers/specs/2026-07-10-collection-pool-supplier-groups-and-notes-design.md](../specs/2026-07-10-collection-pool-supplier-groups-and-notes-design.md) + +**Pre-flight:** + +- [ ] **Read the spec end-to-end** so the helper signatures, sort tiebreakers, and bulk-merge semantics are locked in your head. +- [ ] **Skim `node_modules/next/dist/docs/`** for anything that affects Client Component state patterns (this codebase is "NOT the Next.js you know" per `AGENTS.md`). +- [ ] **Re-read `page.tsx`** (663 lines) — `PoolRow` props, the colour-stripe helper, the existing single-row `AssignDriverDialog` mount, and the `load()` + `updateItemStatus` patterns. + +--- + +## File Structure + +| File | Responsibility | Touched? | +| --- | --- | --- | +| `src/app/(app)/collection-pool/view-model.ts` | Add `CollectionPoolGroup` type + `buildSupplierGroups` helper; flip `buildVisibleRows` sort to lead with `supplierName`. | Modify | +| `src/app/(app)/collection-pool/view-model.test.ts` | Pin the new sort tiebreaker order; cover `buildSupplierGroups` (empty, fold, eyebrow, marker token). | Modify | +| `src/app/(app)/collection-pool/page.tsx` | Add `selectedIds`, `collapsedSuppliers`, `bulkAssignDialog` state; add `SupplierGroup` + `PoolRow` updates (time-in-list, notes tooltip, checkbox); wire bulk dialog and sequential PATCH handler; replace flat `
                            ` with grouped `
                            `s. | Modify | +| `src/app/(app)/collection-pool/page.test.tsx` | Cover selection toggling, section select-all, Assign-all opens bulk dialog with `bulkCount=N`, notes tooltip visibility, time-in-list colour class. | Modify | +| `src/app/(app)/collection-pool/components.tsx` (NEW, conditional) | If `page.tsx` exceeds 30 KB after Task 11, split `PoolRow` + `SupplierGroup` here. | Create (conditional) | + +--- + +## Task 1: Add `buildSupplierGroups` helper (TDD) + +**Files:** +- Modify: `src/app/(app)/collection-pool/view-model.test.ts` +- Modify: `src/app/(app)/collection-pool/view-model.ts` + +- [ ] **Step 1: Append failing tests** + +Append to `src/app/(app)/collection-pool/view-model.test.ts` (the existing `makeRow` helper above is reused): + +```ts +import { buildSupplierGroups, type CollectionPoolGroup } from "./view-model"; + +describe("buildSupplierGroups", () => { + it("returns an empty array for empty input", () => { + expect(buildSupplierGroups([], new Map())).toEqual([]); + }); + + it("folds rows by supplierId in input order", () => { + const rows = [ + makeRow({ id: "a", supplierId: "sup-1", supplierName: "Acme", poNumber: "PO-1" }), + makeRow({ id: "b", supplierId: "sup-2", supplierName: "Beta", poNumber: "PO-2" }), + makeRow({ id: "c", supplierId: "sup-1", supplierName: "Acme", poNumber: "PO-3" }), + ]; + const groups = buildSupplierGroups(rows, new Map()); + expect(groups.map((g) => g.supplierId)).toEqual(["sup-1", "sup-2"]); + expect(groups[0].rows.map((r) => r.id)).toEqual(["a", "c"]); + expect(groups[1].rows.map((r) => r.id)).toEqual(["b"]); + }); + + it("captures the top two PO numbers and reports the remaining count", () => { + const rows = [ + makeRow({ supplierId: "sup-1", poNumber: "PO-1" }), + makeRow({ supplierId: "sup-1", poNumber: "PO-2" }), + makeRow({ supplierId: "sup-1", poNumber: "PO-3" }), + makeRow({ supplierId: "sup-1", poNumber: "PO-4" }), + makeRow({ supplierId: "sup-1", poNumber: "PO-4" }), // duplicate PO + ]; + const [group] = buildSupplierGroups(rows, new Map()); + expect(group.topPoNumbers).toEqual(["PO-1", "PO-2"]); + expect(group.remainingPoCount).toBe(2); + }); + + it("uses marker token from supplierMarkerMap", () => { + const map = new Map([["sup-1", "marker-blue"]]); + const [group] = buildSupplierGroups( + [makeRow({ supplierId: "sup-1" })], + map, + ); + expect(group.markerToken).toBe("marker-blue"); + }); + + it("returns empty eyebrow when no rows have a PO number", () => { + const [group] = buildSupplierGroups( + [makeRow({ supplierId: "sup-1", poNumber: "" })], + new Map(), + ); + expect(group.topPoNumbers).toEqual([]); + expect(group.remainingPoCount).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run tests — expect FAIL** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/view-model.test' +``` + +Expected: 5 new tests fail with "buildSupplierGroups is not a function". + +- [ ] **Step 3: Implement `buildSupplierGroups`** + +Append to `src/app/(app)/collection-pool/view-model.ts`: + +```ts +export type CollectionPoolGroup = { + supplierId: string; + supplierName: string; + /** Cluster colour token from the proximity map (may be undefined). */ + markerToken?: string; + /** First two distinct PO numbers for the header eyebrow. */ + topPoNumbers: string[]; + /** Number of distinct POs beyond the top two. */ + remainingPoCount: number; + /** Rows belonging to this group, in render order. */ + rows: CollectionPoolViewRow[]; +}; + +/** + * Folds already-sorted rows into per-supplier groups, preserving input + * order. The caller is responsible for sorting rows by `supplierName` + * (use `buildVisibleRows` first); this helper does not re-sort. + */ +export function buildSupplierGroups( + rows: CollectionPoolViewRow[], + supplierMarkerMap: Map, +): CollectionPoolGroup[] { + const groups: CollectionPoolGroup[] = []; + const bySupplier = new Map(); + + for (const row of rows) { + let group = bySupplier.get(row.supplierId); + if (!group) { + group = { + supplierId: row.supplierId, + supplierName: row.supplierName ?? "", + markerToken: supplierMarkerMap.get(row.supplierId), + topPoNumbers: [], + remainingPoCount: 0, + rows: [], + }; + bySupplier.set(row.supplierId, group); + groups.push(group); + } + group.rows.push(row); + } + + for (const group of groups) { + const uniquePos = Array.from( + new Set(group.rows.map((r) => r.poNumber ?? "").filter(Boolean)), + ); + group.topPoNumbers = uniquePos.slice(0, 2); + group.remainingPoCount = Math.max(0, uniquePos.length - 2); + } + + return groups; +} +``` + +- [ ] **Step 4: Run tests — expect PASS** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/view-model.test' +``` + +Expected: all tests green. + +- [ ] **Step 5: Commit** + +```bash +git add 'src/app/(app)/collection-pool/view-model.ts' 'src/app/(app)/collection-pool/view-model.test.ts' +git commit -m "feat(collection-pool): add buildSupplierGroups helper" +``` + +--- + +## Task 2: Flip sort tiebreaker to lead with `supplierName` + +**Files:** +- Modify: `src/app/(app)/collection-pool/view-model.test.ts` +- Modify: `src/app/(app)/collection-pool/view-model.ts` + +- [ ] **Step 1: Append failing tests** + +Append to `src/app/(app)/collection-pool/view-model.test.ts`: + +```ts +describe("buildVisibleRows — supplier-first sort (operational mode)", () => { + const todayYmd = "2026-07-10"; + + it("sorts by supplierName first when sortMode is operational", () => { + const rows = buildVisibleRows( + [ + makeRow({ id: "beta", supplierName: "Beta" }), + makeRow({ id: "acme", supplierName: "Acme" }), + makeRow({ id: "alpha", supplierName: "Alpha" }), + ], + { todayYmd, searchText: "", readyFilter: "all", sortMode: "operational" }, + ); + expect(rows.map((r) => r.id)).toEqual(["acme", "alpha", "beta"]); + }); + + it("falls through to assignmentState within the same supplier", () => { + const rows = buildVisibleRows( + [ + makeRow({ + id: "acme-unassigned", + supplierName: "Acme", + item_status: "collection_pool", + }), + makeRow({ + id: "acme-today", + supplierName: "Acme", + item_status: "assigned", + drv_id: "drv-1", + assigned_at: `${todayYmd}T00:00:00.000Z`, + }), + ], + { todayYmd, searchText: "", readyFilter: "all", sortMode: "operational" }, + ); + expect(rows.map((r) => r.id)).toEqual(["acme-today", "acme-unassigned"]); + }); + + it("nearby mode still uses cluster prefix before supplier", () => { + const rows = buildVisibleRows( + [ + makeRow({ + id: "beta-cluster-a", + supplierName: "Beta", + clusterSortKey: "cluster-a", + }), + makeRow({ + id: "acme-cluster-b", + supplierName: "Acme", + clusterSortKey: "cluster-b", + }), + makeRow({ + id: "acme-cluster-a", + supplierName: "Acme", + clusterSortKey: "cluster-a", + }), + ], + { todayYmd, searchText: "", readyFilter: "all", sortMode: "nearby" }, + ); + expect(rows.map((r) => r.id)).toEqual([ + "acme-cluster-a", + "beta-cluster-a", + "acme-cluster-b", + ]); + }); +}); +``` + +- [ ] **Step 2: Run tests — expect FAIL** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/view-model.test' +``` + +Expected: 3 new tests fail. The two existing `buildVisibleRows sort modes` tests should still pass (they don't depend on supplier ordering); the original `"sorts assigned today first"` test may start to fail because it expects today-first but the new sort is supplier-first. **Note that failure and proceed — it's expected and will be reconciled when the production change lands in Step 3.** + +- [ ] **Step 3: Update `buildVisibleRows` sort** + +In `src/app/(app)/collection-pool/view-model.ts`, replace the existing `.sort((a, b) => { ... })` callback inside `buildVisibleRows` with: + +```ts +.sort((a, b) => { + if (sortMode === "nearby") { + const clusterDiff = (a.clusterSortKey ?? "zzz").localeCompare(b.clusterSortKey ?? "zzz"); + if (clusterDiff !== 0) return clusterDiff; + } + + // Supplier name is the primary group key. Rows in the same + // supplier are then ordered by assignment urgency (today → + // unassigned → future), then by updated, then by PO + description. + const supplierDiff = (a.supplierName ?? "").localeCompare(b.supplierName ?? ""); + if (supplierDiff !== 0) return supplierDiff; + + const stateDiff = + ASSIGNMENT_SORT_ORDER[getAssignmentState(a, options.todayYmd)] - + ASSIGNMENT_SORT_ORDER[getAssignmentState(b, options.todayYmd)]; + if (stateDiff !== 0) return stateDiff; + + const updatedA = a.updated ?? ""; + const updatedB = b.updated ?? ""; + if (updatedA !== updatedB) return updatedA.localeCompare(updatedB); + + return `${a.poNumber ?? ""}|${a.description ?? ""}`.localeCompare( + `${b.poNumber ?? ""}|${b.description ?? ""}`, + ); +}); +``` + +- [ ] **Step 4: Run tests — expect PASS** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/view-model.test' +``` + +Expected: all tests green. The pre-existing `"sorts assigned today first, then unassigned, then future"` test will now fail because supplier is the primary key; the expected order across multiple suppliers is supplier-alphabetical regardless of assignment state. **If that test was already updated by a previous commit, leave it alone; otherwise update its expectation to include supplier-distinct fixtures so the assertion still holds.** The simplest fix: + +```ts +// Old assertion was: ["today", "unassigned", "future"] with one supplier. +// Update fixture so all three rows share a supplier OR split into +// distinct suppliers and assert alphabetical. Easiest: keep one +// supplier and assert the assignment order. +makeRow({ id: "future", supplierName: "Same", ... }), +makeRow({ id: "unassigned", supplierName: "Same", ... }), +makeRow({ id: "today", supplierName: "Same", ... }), +// Expect ["today", "unassigned", "future"] +``` + +- [ ] **Step 5: Commit** + +```bash +git add 'src/app/(app)/collection-pool/view-model.ts' 'src/app/(app)/collection-pool/view-model.test.ts' +git commit -m "feat(collection-pool): sort by supplier name first" +``` + +--- + +## Task 3: Time-in-list primary colour + "In pool since" sub-label + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx:607` + +- [ ] **Step 1: Update the `PoolRow` order-detail cell** + +In `src/app/(app)/collection-pool/page.tsx`, replace the order-detail cell body that currently contains the time-in-list label (around line 607): + +```tsx +
                            + {getPoolAgeLabel(row.updated)} +
                            +{row.updated && ( +
                            + In pool since {new Date(row.updated).toLocaleDateString(undefined, { + day: "numeric", + month: "short", + })} +
                            +)} +``` + +Keep the surrounding markup (`
                            `, the Order/PO labels above, the `Scheduled {date}` line below) untouched. + +- [ ] **Step 2: Run the existing page suite** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/page' +``` + +Expected: existing tests still pass (this change adds markup but doesn't alter data flow). + +- [ ] **Step 3: Commit** + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "style(collection-pool): promote time-in-list to text-primary" +``` + +--- + +## Task 4: Notes chip — proper hover/focus tooltip + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx:625-632` + +- [ ] **Step 1: Replace the native `title` chip with a `group/notes` tooltip** + +In `src/app/(app)/collection-pool/page.tsx`, replace the existing notes chip block: + +```tsx +{row.notes && row.notes.trim().length > 0 && ( + + Notes + + {row.notes.length > 280 ? `${row.notes.slice(0, 280)}…` : row.notes} + + +)} +``` + +If `bg-popover` / `text-popover-foreground` are not defined in the project's `globals.css`, fall back to `bg-background text-foreground` (these always resolve). + +- [ ] **Step 2: Run the existing page suite** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/page' +``` + +Expected: existing tests still pass. If any test pinned the old `title` attribute, update the selector to match the new tooltip (e.g. `findByRole("tooltip")`). + +- [ ] **Step 3: Commit** + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): replace notes title with hover tooltip" +``` + +--- + +## Task 5: Selection state + per-row checkbox on `PoolRow` + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx` (state, props, JSX, render call) + +- [ ] **Step 1: Add `selectedIds` state + handlers** + +In `CollectionPoolPage`, alongside the existing `useState` calls (around line 106–116), add: + +```ts +const [selectedIds, setSelectedIds] = useState>(new Set()); + +const toggleRowSelected = useCallback((rowId: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(rowId)) next.delete(rowId); + else next.add(rowId); + return next; + }); +}, []); + +const toggleSectionSelected = useCallback((rowIds: string[]) => { + setSelectedIds((prev) => { + const allSelected = rowIds.every((id) => prev.has(id)); + const next = new Set(prev); + if (allSelected) { + for (const id of rowIds) next.delete(id); + } else { + for (const id of rowIds) next.add(id); + } + return next; + }); +}, []); +``` + +- [ ] **Step 2: Extend `PoolRow` props and signature** + +Update the `PoolRow` props type and signature in the same file (around line 551): + +```tsx +function PoolRow({ + row, + busy, + markerToken, + todayYmd, + selected, + onToggleSelected, + onOpenAssign, + onToggleReady, +}: { + row: CollectionPoolVisibleRow; + busy: boolean; + markerToken?: string; + todayYmd: string; + selected: boolean; + onToggleSelected: (rowId: string) => void; + onOpenAssign: (row: CollectionPoolVisibleRow) => void; + onToggleReady: (row: CollectionPoolVisibleRow, ready: boolean) => void; +}) { +``` + +- [ ] **Step 3: Add the checkbox and row tint to `PoolRow`'s JSX** + +In `PoolRow`'s `
                          • ` element (around line 579), apply the selection tint and add a checkbox at the start of the content grid. Replace the opening markup: + +```tsx +
                          • + + +
                            +
                            + onToggleSelected(row.id)} + disabled={busy} + aria-label={`Select ${row.description ?? "item"} at ${row.supplierName ?? "unknown supplier"}`} + className="mt-1 size-4 shrink-0 cursor-pointer accent-primary disabled:cursor-not-allowed" + /> +
                            +

                            {row.supplierName}

                            +

                            {row.description || "(no description)"}

                            +
                            + {row.customerName || "No customer"} + / + {row.companyName || "No company"} +
                            + {!row.purchase_order && ( +
                            + + This row has no purchase order link. The PO may have been deleted. +
                            + )} +
                            +
                            + + {/* ...rest of the columns unchanged... */} +
                            +
                          • +``` + +(The supplier/description/customer/company/warning block was previously the direct first child of the inner `
                            `. It is now nested inside a new wrapper `
                            ` that hosts the checkbox.) + +- [ ] **Step 4: Update the call site (still the flat `
                              ` for now)** + +In the rendering block around line 488–500, update the `PoolRow` invocation: + +```tsx +
                                + {visibleRows.map((row) => ( + + ))} +
                              +``` + +- [ ] **Step 5: Run page suite + commit** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/page' +``` + +Expected: existing tests still pass (PoolRow is exercised via the page). + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): per-row checkbox + selection state" +``` + +--- + +## Task 6: `SupplierGroup` component (renders header + collapsible body) + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx` (new `SupplierGroup` function) + +- [ ] **Step 1: Add the `SupplierGroup` component** + +Append a new `SupplierGroup` function inside `page.tsx`, after `PoolRow`. It renders the section header strip and the collapsible row body: + +```tsx +function SupplierGroup({ + group, + busy, + todayYmd, + selectedIds, + collapsed, + onToggleRowSelected, + onToggleSectionSelected, + onToggleCollapsed, + onOpenAssign, + onOpenBulkAssign, + onToggleReady, +}: { + group: CollectionPoolGroup; + busy: boolean; + todayYmd: string; + selectedIds: Set; + collapsed: boolean; + onToggleRowSelected: (rowId: string) => void; + onToggleSectionSelected: (rowIds: string[]) => void; + onToggleCollapsed: (supplierId: string) => void; + onOpenAssign: (row: CollectionPoolVisibleRow) => void; + onOpenBulkAssign: (supplierId: string, supplierName: string, rowIds: string[]) => void; + onToggleReady: (row: CollectionPoolVisibleRow, ready: boolean) => void; +}) { + const rowIds = group.rows.map((r) => r.id); + const selectedCount = rowIds.filter((id) => selectedIds.has(id)).length; + const allSelected = selectedCount === rowIds.length && rowIds.length > 0; + const someSelected = selectedCount > 0 && !allSelected; + const allAssigned = group.rows.every( + (r) => getAssignmentState(r, todayYmd) !== "unassigned", + ); + + const eyebrowText = (() => { + if (group.topPoNumbers.length === 0) return null; + const head = group.topPoNumbers.map((p) => `PO ${p}`).join(", "); + return group.remainingPoCount > 0 + ? `${head} (+${group.remainingPoCount} more)` + : head; + })(); + + const assignLabel = allSelected + ? `Assign ${rowIds.length} stops` + : selectedCount > 0 + ? `Assign ${selectedCount} selected` + : `Assign all ${rowIds.length}`; + + const onAssignClick = () => { + const ids = allSelected || selectedCount === 0 ? rowIds : rowIds.filter((id) => selectedIds.has(id)); + onOpenBulkAssign(group.supplierId, group.supplierName, ids); + }; + + return ( +
                              +
                              + +

                              + {group.supplierName || "Unknown supplier"} +

                              + + · {group.rows.length} {group.rows.length === 1 ? "stop" : "stops"} + {eyebrowText ? <> · {eyebrowText} : null} + +
                              + + + +
                              +
                              +
                              +
                                + {group.rows.map((row) => ( + + ))} +
                              +
                              +
                              + ); +} +``` + +- [ ] **Step 2: Add the missing imports** + +At the top of `page.tsx`, extend the lucide-react import: + +```tsx +import { AlertTriangle, ChevronDown, Loader2, Search, Truck } from "lucide-react"; +``` + +And update the `./view-model` imports so `buildSupplierGroups`, `CollectionPoolGroup`, and `getAssignmentBadge` are all present: + +```tsx +import { + buildProximityMarkerMap, + buildSupplierGroups, + buildVisibleRows, + type CollectionPoolGroup, + type CollectionPoolSortMode, + getAssignmentBadge, + getAssignmentState, + getPoolAgeLabel, +} from "./view-model"; +``` + +(`getAssignmentBadge` is still consumed by `PoolRow`'s status pill — keep it.) + +- [ ] **Step 3: Run typecheck** + +```bash +npx tsc --noEmit +``` + +Expected: clean. If `Type 'Set' is not assignable to ...` errors fire on `selectedIds`, the `useState>(new Set())` should already be inferring — double-check the import. + +- [ ] **Step 4: Commit (component only — not wired yet)** + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): add SupplierGroup component" +``` + +--- + +## Task 7: Replace flat `
                                ` with grouped `
                                `s + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx` (rendering block + state) + +- [ ] **Step 1: Add `collapsedSuppliers` state** + +In `CollectionPoolPage`, next to `selectedIds`: + +```ts +const [collapsedSuppliers, setCollapsedSuppliers] = useState>(new Set()); + +const toggleCollapsed = useCallback((supplierId: string) => { + setCollapsedSuppliers((prev) => { + const next = new Set(prev); + if (next.has(supplierId)) next.delete(supplierId); + else next.add(supplierId); + return next; + }); +}, []); +``` + +- [ ] **Step 2: Add `openBulkAssign` handler stub** + +Below `openAssignForRow`, add a stub that just opens state — the actual submit handler comes in Task 9. (The stub is intentional so Task 8 can wire the button without Task 9 needing to be done first.) + +```ts +const [bulkAssignDialog, setBulkAssignDialog] = useState<{ + supplierId: string; + supplierName: string; + rowIds: string[]; +} | null>(null); + +const openBulkAssign = useCallback( + (supplierId: string, supplierName: string, rowIds: string[]) => { + if (rowIds.length === 0) return; + setBulkAssignDialog({ supplierId, supplierName, rowIds }); + }, + [], +); + +const closeBulkAssign = useCallback(() => setBulkAssignDialog(null), []); +``` + +- [ ] **Step 3: Compute `groups` in the page** + +Below `visibleRows`, add: + +```tsx +const groups = useMemo(() => { + if (!visibleRows) return null; + return buildSupplierGroups(visibleRows, supplierMarkerMap); +}, [visibleRows, supplierMarkerMap]); +``` + +- [ ] **Step 4: Replace the flat-list rendering** + +Replace the `
                                  ` block (around line 488–500 in the current `page.tsx`) with: + +```tsx +
                                  +
                                  + Collection Item + Order Detail + Status + Actions +
                                  + {groups === null ? ( +
                                  + {Array.from({ length: 4 }).map((_, i) => ( +
                                  + ))} +
                                  + ) : groups.length === 0 ? ( + + ) : ( + groups.map((group) => ( + + )) + )} +
                                  +``` + +Note: the empty-states at the top level (`rows === null` skeleton, `rows.length === 0` empty, `visibleRows.length === 0` empty) now collapse into the new conditional inside the container — delete the previous three separate early-return blocks (around lines 460–478) to avoid double-rendering. + +- [ ] **Step 5: Run typecheck + page suite** + +```bash +npx tsc --noEmit +npm run test:run -- 'src/app/(app)/collection-pool/page' +``` + +Expected: typecheck clean; page tests still pass. If a test pinned the old `
                                    `, update the selector to find rows by data-od-id on the section or fall back to a more stable test query. + +- [ ] **Step 6: Commit** + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): render supplier-grouped sections" +``` + +--- + +## Task 8: Wire bulk-assign dialog mount + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx` (second `AssignDriverDialog` mount) + +- [ ] **Step 1: Stub the submit handler + bulkBusy state** + +Just below `handleAssignDialogSubmit`, add: + +```ts +const [bulkBusy, setBulkBusy] = useState(false); + +const handleBulkAssignSubmit = useCallback( + async (_payload: AssignDriverSubmitPayload) => { + // Full implementation in Task 9. + }, + [], +); +``` + +- [ ] **Step 2: Add the bulk-assign dialog JSX** + +Just below the existing single-row `` (around line 504), add: + +```tsx + { + if (!open) closeBulkAssign(); + }} + onSubmit={handleBulkAssignSubmit} + bulkCount={bulkAssignDialog?.rowIds.length ?? 1} + eyebrow={ + bulkAssignDialog ? ( + + + {bulkAssignDialog.supplierName || "Unknown supplier"} + · + {bulkAssignDialog.rowIds.length} stops + + ) : undefined + } + busy={bulkBusy} +/> +``` + +- [ ] **Step 3: Run typecheck + commit** + +```bash +npx tsc --noEmit +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): wire bulk-assign dialog mount" +``` + +--- + +## Task 9: Sequential bulk PATCH handler + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx` (`handleBulkAssignSubmit` body) + +- [ ] **Step 1: Replace the stub with the real implementation** + +In `page.tsx`, replace the empty `handleBulkAssignSubmit` with: + +```ts +const handleBulkAssignSubmit = useCallback( + async (payload: AssignDriverSubmitPayload) => { + if (!bulkAssignDialog) return; + const { driverId, date } = payload; + const { rowIds, supplierName } = bulkAssignDialog; + + setBulkBusy(true); + try { + const pb = getPocketBase(); + let successCount = 0; + let firstError: string | null = null; + + // Sequential, not parallel: any failure leaves earlier + // successes committed (matches the single-row PATCH semantics). + for (const id of rowIds) { + try { + await pb.collection("order_items").update(id, { + item_status: "assigned", + drv_id: driverId, + drv_status: "assigned", + assigned_at: `${date}T00:00:00.000Z`, + } as Partial); + successCount++; + } catch (err) { + if (!firstError) { + firstError = err instanceof Error ? err.message : String(err); + } + } + } + + // Clear selections for this supplier on success (partial or full). + setSelectedIds((prev) => { + const next = new Set(prev); + for (const id of rowIds) next.delete(id); + return next; + }); + closeBulkAssign(); + + if (firstError) { + toast.error( + `Assigned ${successCount} of ${rowIds.length} stops at ${supplierName || "supplier"}. First error: ${firstError}`, + ); + } else { + toast.success(`Assigned ${successCount} stops at ${supplierName || "supplier"}.`); + } + + await load(); + } finally { + setBulkBusy(false); + } + }, + [bulkAssignDialog, closeBulkAssign, load], +); +``` + +- [ ] **Step 2: Run typecheck + page suite** + +```bash +npx tsc --noEmit +npm run test:run -- 'src/app/(app)/collection-pool/page' +``` + +Expected: typecheck clean; existing page tests pass. Mock the PocketBase `update` call in tests as needed — see Task 10 for the test wiring. + +- [ ] **Step 3: Commit** + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): sequential bulk-assign PATCH" +``` + +--- + +## Task 10: Page-level tests + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.test.tsx` + +- [ ] **Step 1: Add a test that time-in-list uses `text-primary`** + +Find the existing render fixture in `page.test.tsx` (search for `getPoolAgeLabel` or a row containing `text-foreground`). Append: + +```tsx +import { screen, within } from "@testing-library/react"; + +it("renders time-in-list with text-primary class", async () => { + renderPage(); + const ageLabels = await screen.findAllByText(/^\d+[hd]$/); + expect(ageLabels.length).toBeGreaterThan(0); + for (const label of ageLabels) { + expect(label.className).toMatch(/text-primary/); + } +}); +``` + +(The selector `/^\d+[hd]$/` matches the `Nh` / `Nd` format produced by `getPoolAgeLabel`. Adjust if existing tests use a different fixture pattern — search the file for any pre-existing age assertions first.) + +- [ ] **Step 2: Add a test that the Notes tooltip appears on hover** + +```tsx +it("surfaces the Notes tooltip on row hover", async () => { + // Fixture row must include `notes: "Spoke to David — confirmed pickup tomorrow"`. + renderPage({ rowOverrides: { notes: "Spoke to David — confirmed pickup tomorrow" } }); + const tooltip = await screen.findByRole("tooltip"); + expect(tooltip).toHaveTextContent(/Spoke to David/); + expect(tooltip).toHaveClass("hidden"); // default state +}); +``` + +If the project's `renderPage` helper doesn't accept row overrides, add a parameter that forwards to the fixture builder — it's a one-line change. + +- [ ] **Step 3: Add a test that selecting a row toggles the selection tint** + +```tsx +it("applies a primary-tint background to selected rows", async () => { + const { container } = renderPage(); + const firstCheckbox = await screen.findByRole("checkbox", { name: /Select .* at .*/i }); + fireEvent.click(firstCheckbox); + const row = firstCheckbox.closest("li"); + expect(row).not.toBeNull(); + expect(row!.className).toMatch(/bg-primary\/5/); +}); +``` + +- [ ] **Step 4: Add a test that the section "Select all" toggles every row in the section** + +```tsx +it("section Select all toggles every row in the supplier group", async () => { + renderPage(); + const selectAll = await screen.findByRole("checkbox", { name: /Select all \d+ stops at/i }); + fireEvent.click(selectAll); + const rowCheckboxes = await screen.findAllByRole("checkbox", { name: /^Select .* at/i }); + expect(rowCheckboxes.length).toBeGreaterThan(0); + for (const cb of rowCheckboxes) { + expect(cb).toBeChecked(); + } +}); +``` + +- [ ] **Step 5: Add a test that the Assign-all button opens the bulk dialog with `bulkCount=N`** + +Mock `pb.collection("order_items").update` so the test doesn't actually hit PocketBase. Then: + +```tsx +it("opens bulk-assign dialog with bulkCount equal to selected rows", async () => { + const { container } = renderPage(); + const selectAll = await screen.findByRole("checkbox", { name: /Select all \d+ stops at/i }); + fireEvent.click(selectAll); + const assignAll = screen.getByRole("button", { name: /Assign \d+ stops$/ }); + fireEvent.click(assignAll); + // Dialog title reads "Assign N items to driver" when bulkCount > 1. + const dialogTitle = await screen.findByText(/Assign \d+ items to driver/i); + expect(dialogTitle).toBeInTheDocument(); +}); +``` + +If the `getPocketBase()` mock in the existing suite doesn't intercept the second dialog's submit handler, extend the mock to also intercept the bulk-assign PATCH chain. + +- [ ] **Step 6: Add a test that collapsing a section hides its rows** + +```tsx +it("collapses a supplier section on chevron click", async () => { + renderPage(); + const chevron = await screen.findByRole("button", { name: "" }); // chevron has no accessible name; query by aria-controls + const section = document.querySelector('[aria-labelledby^="supplier-"]'); + const id = section!.querySelector("header")!.id; + const body = document.getElementById(`${id}-body`) || section!.querySelector('[id$="-body"]'); + expect(body!.className).toMatch(/grid-rows-\[1fr\]/); + fireEvent.click(section!.querySelector("button[aria-expanded]")!); + expect(body!.className).toMatch(/grid-rows-\[0fr\]/); +}); +``` + +- [ ] **Step 7: Run the full collection-pool suite** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool' +``` + +Expected: green. Pre-existing failures (documented across the repo, e.g. `page.test.tsx:175`) are acceptable. + +- [ ] **Step 8: Commit** + +```bash +git add 'src/app/(app)/collection-pool/page.test.tsx' +git commit -m "test(collection-pool): cover groups, bulk assign, notes tooltip" +``` + +--- + +## Task 11: Typecheck + size budget + conditional split + +**Files:** +- Modify or create: `src/app/(app)/collection-pool/page.tsx` (or split into `components.tsx`) + +- [ ] **Step 1: Full typecheck** + +```bash +npx tsc --noEmit +``` + +Expected: clean. + +- [ ] **Step 2: Measure `page.tsx` size** + +```bash +wc -c 'src/app/(app)/collection-pool/page.tsx' +``` + +If the result is **≤ 30 KB**, stop here — the file is within budget. Skip to Step 5. + +If the result is **> 30 KB**, continue with the split in Steps 3–4. + +- [ ] **Step 3: Create `components.tsx` with `PoolRow` + `SupplierGroup`** + +Create `src/app/(app)/collection-pool/components.tsx`: + +```tsx +"use client"; + +import { ChevronDown, Truck, AlertTriangle } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import type { CollectionPoolViewRow } from "./view-model"; +import { + type CollectionPoolGroup, + getAssignmentState, + getPoolAgeLabel, +} from "./view-model"; +import { cn } from "@/lib/utils"; + +// Copy `CollectionPoolVisibleRow` type and `markerTokenToClassName` +// helper from page.tsx. They're needed by both PoolRow and SupplierGroup. + +export type CollectionPoolVisibleRow = CollectionPoolViewRow & { + // mirror the type from page.tsx +}; + +function markerTokenToClassName(token?: string): string { + switch (token) { + case "marker-blue": + return "bg-blue-500"; + case "marker-emerald": + return "bg-emerald-500"; + case "marker-violet": + return "bg-violet-500"; + case "marker-amber": + return "bg-amber-500"; + default: + return "bg-slate-300"; + } +} + +// Then paste the FULL `PoolRow` and `SupplierGroup` function bodies +// from page.tsx, unchanged. +``` + +Cut both functions from `page.tsx` and update imports so `page.tsx` imports them from `./components` instead. + +- [ ] **Step 4: Re-measure + retest** + +```bash +wc -c 'src/app/(app)/collection-pool/page.tsx' 'src/app/(app)/collection-pool/components.tsx' +npx tsc --noEmit +npm run test:run -- 'src/app/(app)/collection-pool' +``` + +Expected: each file ≤ 30 KB; typecheck clean; tests green. + +- [ ] **Step 5: Commit (split or no-op)** + +If you split: +```bash +git add 'src/app/(app)/collection-pool/components.tsx' 'src/app/(app)/collection-pool/page.tsx' +git commit -m "refactor(collection-pool): split PoolRow + SupplierGroup into components.tsx" +``` + +If you didn't split, no commit is needed. + +--- + +## Task 12: Manual smoke + full suite + +- [ ] **Step 1: Full test suite** + +```bash +npm run test:run +``` + +Expected: same passing set as before plus the new tests in `view-model.test.ts` and `page.test.tsx`. Pre-existing failures (the 6 we documented across the repo) are acceptable. + +- [ ] **Step 2: Manual smoke on `/collection-pool`** + +1. Load the page. Rows render in collapsible supplier sections, alphabetical. +2. Click the chevron on a section → body collapses with a smooth transition; chevron rotates. +3. Hover the time-in-list label — confirm it's in `text-primary` and bold. +4. Hover the Notes chip on a row with notes — confirm the tooltip appears with the note text (truncated at 280 chars + ellipsis if longer). +5. Tick the section "Select all" — every row checkbox in the section ticks; row backgrounds tint. +6. Click `Assign N stops`. Bulk dialog opens with eyebrow `Supplier · N stops` and title `Assign N items to driver`. +7. Pick a driver + tomorrow's date, submit. Toast confirms. Reload — rows show "Assigned future" badge and the section's selections clear. +8. Mark a row Ready with a long note (>280 chars). Hover the Notes chip — the preview is truncated with `…`. +9. Switch sort to "Nearby suppliers" — order changes; switch back to "Operational" — supplier-grouped order restored. + +- [ ] **Step 3: Commit any test-only follow-ups** + +```bash +git add -A 'src/app/(app)/collection-pool/' +git commit -m "test(collection-pool): post-smoke follow-ups" --allow-empty +``` + +--- + +## Out-of-scope reminders + +- **Bulk mark-ready.** The dialog requires a free-text note per item; a multi-note UI is a separate feature. Don't add it here. +- **Per-section "Mark section ready".** Same reason. +- **Route rename / nav label change.** Not in scope. +- **Mobile-only layout changes.** The existing grid already collapses correctly; no special breakpoint work is needed for the new structure. +- **Transactional batch PATCH endpoint.** Sequential single-row PATCHes are intentional; rollback on partial failure is out of scope. + +--- + +## Risk notes + +- **Page render budget.** Current 23 KB. Estimated growth ~7–10 KB from the new component + state + handlers. If it crosses 30 KB, Task 11 Step 3 splits `PoolRow` + `SupplierGroup` into `components.tsx`. If it still crosses after the split, consider further splitting the dialog wiring into a hook (`useBulkAssign`). +- **Sort change is observable.** Users who relied on the old "by urgency" default will see suppliers grouped together. The Sort radio's "Operational" label now matches this new behaviour; "Nearby" remains for cluster-priority workflows. +- **Bulk PATCH is not transactional.** A failure on row 4 of 5 leaves rows 1–3 committed. The toast reports partial success; the operator can re-open the dialog and retry. \ No newline at end of file diff --git a/plugin-source/collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6svc/SKILL.md b/plugin-source/collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6svc/SKILL.md new file mode 100644 index 0000000..c89c5da --- /dev/null +++ b/plugin-source/collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6svc/SKILL.md @@ -0,0 +1,1258 @@ +# Collection Pool: supplier groups, bulk assign, primary time-in-list, note preview — 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:** Reorganise `/collection-pool` into collapsible supplier sections with bulk-assign (whole-section or selective), make the time-in-list dominant in `text-primary`, and replace the `title`-attribute Notes chip with a proper hover/focus tooltip. + +**Architecture:** Helper `buildSupplierGroups` lives in `view-model.ts`; the sort flips to `(supplierName, assignmentState, updated)` so the group builder can slice the sorted array. New `SupplierGroup` component (kept in `page.tsx`) renders the header strip (dot · supplier · count · PO eyebrow · chevron · Assign-all button) plus collapsible body. A second `AssignDriverDialog` instance handles bulk with `bulkCount`. The existing `PoolRow` gets two new props (`selected`, `onToggleSelected`) and a per-row checkbox. + +**Tech Stack:** Next.js 16 App Router (read `node_modules/next/dist/docs/` before any Next-specific code), React 18, TypeScript, Tailwind, shadcn/ui, PocketBase admin client, lucide-react, sonner, Vitest. + +**Spec:** [docs/superpowers/specs/2026-07-10-collection-pool-supplier-groups-and-notes-design.md](../specs/2026-07-10-collection-pool-supplier-groups-and-notes-design.md) + +**Pre-flight:** + +- [ ] **Read the spec end-to-end** so the helper signatures, sort tiebreakers, and bulk-merge semantics are locked in your head. +- [ ] **Skim `node_modules/next/dist/docs/`** for anything that affects Client Component state patterns (this codebase is "NOT the Next.js you know" per `AGENTS.md`). +- [ ] **Re-read `page.tsx`** (663 lines) — `PoolRow` props, the colour-stripe helper, the existing single-row `AssignDriverDialog` mount, and the `load()` + `updateItemStatus` patterns. + +--- + +## File Structure + +| File | Responsibility | Touched? | +| --- | --- | --- | +| `src/app/(app)/collection-pool/view-model.ts` | Add `CollectionPoolGroup` type + `buildSupplierGroups` helper; flip `buildVisibleRows` sort to lead with `supplierName`. | Modify | +| `src/app/(app)/collection-pool/view-model.test.ts` | Pin the new sort tiebreaker order; cover `buildSupplierGroups` (empty, fold, eyebrow, marker token). | Modify | +| `src/app/(app)/collection-pool/page.tsx` | Add `selectedIds`, `collapsedSuppliers`, `bulkAssignDialog` state; add `SupplierGroup` + `PoolRow` updates (time-in-list, notes tooltip, checkbox); wire bulk dialog and sequential PATCH handler; replace flat `
                                      ` with grouped `
                                      `s. | Modify | +| `src/app/(app)/collection-pool/page.test.tsx` | Cover selection toggling, section select-all, Assign-all opens bulk dialog with `bulkCount=N`, notes tooltip visibility, time-in-list colour class. | Modify | +| `src/app/(app)/collection-pool/components.tsx` (NEW, conditional) | If `page.tsx` exceeds 30 KB after Task 11, split `PoolRow` + `SupplierGroup` here. | Create (conditional) | + +--- + +## Task 1: Add `buildSupplierGroups` helper (TDD) + +**Files:** +- Modify: `src/app/(app)/collection-pool/view-model.test.ts` +- Modify: `src/app/(app)/collection-pool/view-model.ts` + +- [ ] **Step 1: Append failing tests** + +Append to `src/app/(app)/collection-pool/view-model.test.ts` (the existing `makeRow` helper above is reused): + +```ts +import { buildSupplierGroups, type CollectionPoolGroup } from "./view-model"; + +describe("buildSupplierGroups", () => { + it("returns an empty array for empty input", () => { + expect(buildSupplierGroups([], new Map())).toEqual([]); + }); + + it("folds rows by supplierId in input order", () => { + const rows = [ + makeRow({ id: "a", supplierId: "sup-1", supplierName: "Acme", poNumber: "PO-1" }), + makeRow({ id: "b", supplierId: "sup-2", supplierName: "Beta", poNumber: "PO-2" }), + makeRow({ id: "c", supplierId: "sup-1", supplierName: "Acme", poNumber: "PO-3" }), + ]; + const groups = buildSupplierGroups(rows, new Map()); + expect(groups.map((g) => g.supplierId)).toEqual(["sup-1", "sup-2"]); + expect(groups[0].rows.map((r) => r.id)).toEqual(["a", "c"]); + expect(groups[1].rows.map((r) => r.id)).toEqual(["b"]); + }); + + it("captures the top two PO numbers and reports the remaining count", () => { + const rows = [ + makeRow({ supplierId: "sup-1", poNumber: "PO-1" }), + makeRow({ supplierId: "sup-1", poNumber: "PO-2" }), + makeRow({ supplierId: "sup-1", poNumber: "PO-3" }), + makeRow({ supplierId: "sup-1", poNumber: "PO-4" }), + makeRow({ supplierId: "sup-1", poNumber: "PO-4" }), // duplicate PO + ]; + const [group] = buildSupplierGroups(rows, new Map()); + expect(group.topPoNumbers).toEqual(["PO-1", "PO-2"]); + expect(group.remainingPoCount).toBe(2); + }); + + it("uses marker token from supplierMarkerMap", () => { + const map = new Map([["sup-1", "marker-blue"]]); + const [group] = buildSupplierGroups( + [makeRow({ supplierId: "sup-1" })], + map, + ); + expect(group.markerToken).toBe("marker-blue"); + }); + + it("returns empty eyebrow when no rows have a PO number", () => { + const [group] = buildSupplierGroups( + [makeRow({ supplierId: "sup-1", poNumber: "" })], + new Map(), + ); + expect(group.topPoNumbers).toEqual([]); + expect(group.remainingPoCount).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run tests — expect FAIL** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/view-model.test' +``` + +Expected: 5 new tests fail with "buildSupplierGroups is not a function". + +- [ ] **Step 3: Implement `buildSupplierGroups`** + +Append to `src/app/(app)/collection-pool/view-model.ts`: + +```ts +export type CollectionPoolGroup = { + supplierId: string; + supplierName: string; + /** Cluster colour token from the proximity map (may be undefined). */ + markerToken?: string; + /** First two distinct PO numbers for the header eyebrow. */ + topPoNumbers: string[]; + /** Number of distinct POs beyond the top two. */ + remainingPoCount: number; + /** Rows belonging to this group, in render order. */ + rows: CollectionPoolViewRow[]; +}; + +/** + * Folds already-sorted rows into per-supplier groups, preserving input + * order. The caller is responsible for sorting rows by `supplierName` + * (use `buildVisibleRows` first); this helper does not re-sort. + */ +export function buildSupplierGroups( + rows: CollectionPoolViewRow[], + supplierMarkerMap: Map, +): CollectionPoolGroup[] { + const groups: CollectionPoolGroup[] = []; + const bySupplier = new Map(); + + for (const row of rows) { + let group = bySupplier.get(row.supplierId); + if (!group) { + group = { + supplierId: row.supplierId, + supplierName: row.supplierName ?? "", + markerToken: supplierMarkerMap.get(row.supplierId), + topPoNumbers: [], + remainingPoCount: 0, + rows: [], + }; + bySupplier.set(row.supplierId, group); + groups.push(group); + } + group.rows.push(row); + } + + for (const group of groups) { + const uniquePos = Array.from( + new Set(group.rows.map((r) => r.poNumber ?? "").filter(Boolean)), + ); + group.topPoNumbers = uniquePos.slice(0, 2); + group.remainingPoCount = Math.max(0, uniquePos.length - 2); + } + + return groups; +} +``` + +- [ ] **Step 4: Run tests — expect PASS** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/view-model.test' +``` + +Expected: all tests green. + +- [ ] **Step 5: Commit** + +```bash +git add 'src/app/(app)/collection-pool/view-model.ts' 'src/app/(app)/collection-pool/view-model.test.ts' +git commit -m "feat(collection-pool): add buildSupplierGroups helper" +``` + +--- + +## Task 2: Flip sort tiebreaker to lead with `supplierName` + +**Files:** +- Modify: `src/app/(app)/collection-pool/view-model.test.ts` +- Modify: `src/app/(app)/collection-pool/view-model.ts` + +- [ ] **Step 1: Append failing tests** + +Append to `src/app/(app)/collection-pool/view-model.test.ts`: + +```ts +describe("buildVisibleRows — supplier-first sort (operational mode)", () => { + const todayYmd = "2026-07-10"; + + it("sorts by supplierName first when sortMode is operational", () => { + const rows = buildVisibleRows( + [ + makeRow({ id: "beta", supplierName: "Beta" }), + makeRow({ id: "acme", supplierName: "Acme" }), + makeRow({ id: "alpha", supplierName: "Alpha" }), + ], + { todayYmd, searchText: "", readyFilter: "all", sortMode: "operational" }, + ); + expect(rows.map((r) => r.id)).toEqual(["acme", "alpha", "beta"]); + }); + + it("falls through to assignmentState within the same supplier", () => { + const rows = buildVisibleRows( + [ + makeRow({ + id: "acme-unassigned", + supplierName: "Acme", + item_status: "collection_pool", + }), + makeRow({ + id: "acme-today", + supplierName: "Acme", + item_status: "assigned", + drv_id: "drv-1", + assigned_at: `${todayYmd}T00:00:00.000Z`, + }), + ], + { todayYmd, searchText: "", readyFilter: "all", sortMode: "operational" }, + ); + expect(rows.map((r) => r.id)).toEqual(["acme-today", "acme-unassigned"]); + }); + + it("nearby mode still uses cluster prefix before supplier", () => { + const rows = buildVisibleRows( + [ + makeRow({ + id: "beta-cluster-a", + supplierName: "Beta", + clusterSortKey: "cluster-a", + }), + makeRow({ + id: "acme-cluster-b", + supplierName: "Acme", + clusterSortKey: "cluster-b", + }), + makeRow({ + id: "acme-cluster-a", + supplierName: "Acme", + clusterSortKey: "cluster-a", + }), + ], + { todayYmd, searchText: "", readyFilter: "all", sortMode: "nearby" }, + ); + expect(rows.map((r) => r.id)).toEqual([ + "acme-cluster-a", + "beta-cluster-a", + "acme-cluster-b", + ]); + }); +}); +``` + +- [ ] **Step 2: Run tests — expect FAIL** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/view-model.test' +``` + +Expected: 3 new tests fail. The two existing `buildVisibleRows sort modes` tests should still pass (they don't depend on supplier ordering); the original `"sorts assigned today first"` test may start to fail because it expects today-first but the new sort is supplier-first. **Note that failure and proceed — it's expected and will be reconciled when the production change lands in Step 3.** + +- [ ] **Step 3: Update `buildVisibleRows` sort** + +In `src/app/(app)/collection-pool/view-model.ts`, replace the existing `.sort((a, b) => { ... })` callback inside `buildVisibleRows` with: + +```ts +.sort((a, b) => { + if (sortMode === "nearby") { + const clusterDiff = (a.clusterSortKey ?? "zzz").localeCompare(b.clusterSortKey ?? "zzz"); + if (clusterDiff !== 0) return clusterDiff; + } + + // Supplier name is the primary group key. Rows in the same + // supplier are then ordered by assignment urgency (today → + // unassigned → future), then by updated, then by PO + description. + const supplierDiff = (a.supplierName ?? "").localeCompare(b.supplierName ?? ""); + if (supplierDiff !== 0) return supplierDiff; + + const stateDiff = + ASSIGNMENT_SORT_ORDER[getAssignmentState(a, options.todayYmd)] - + ASSIGNMENT_SORT_ORDER[getAssignmentState(b, options.todayYmd)]; + if (stateDiff !== 0) return stateDiff; + + const updatedA = a.updated ?? ""; + const updatedB = b.updated ?? ""; + if (updatedA !== updatedB) return updatedA.localeCompare(updatedB); + + return `${a.poNumber ?? ""}|${a.description ?? ""}`.localeCompare( + `${b.poNumber ?? ""}|${b.description ?? ""}`, + ); +}); +``` + +- [ ] **Step 4: Run tests — expect PASS** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/view-model.test' +``` + +Expected: all tests green. The pre-existing `"sorts assigned today first, then unassigned, then future"` test will now fail because supplier is the primary key; the expected order across multiple suppliers is supplier-alphabetical regardless of assignment state. **If that test was already updated by a previous commit, leave it alone; otherwise update its expectation to include supplier-distinct fixtures so the assertion still holds.** The simplest fix: + +```ts +// Old assertion was: ["today", "unassigned", "future"] with one supplier. +// Update fixture so all three rows share a supplier OR split into +// distinct suppliers and assert alphabetical. Easiest: keep one +// supplier and assert the assignment order. +makeRow({ id: "future", supplierName: "Same", ... }), +makeRow({ id: "unassigned", supplierName: "Same", ... }), +makeRow({ id: "today", supplierName: "Same", ... }), +// Expect ["today", "unassigned", "future"] +``` + +- [ ] **Step 5: Commit** + +```bash +git add 'src/app/(app)/collection-pool/view-model.ts' 'src/app/(app)/collection-pool/view-model.test.ts' +git commit -m "feat(collection-pool): sort by supplier name first" +``` + +--- + +## Task 3: Time-in-list primary colour + "In pool since" sub-label + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx:607` + +- [ ] **Step 1: Update the `PoolRow` order-detail cell** + +In `src/app/(app)/collection-pool/page.tsx`, replace the order-detail cell body that currently contains the time-in-list label (around line 607): + +```tsx +
                                      + {getPoolAgeLabel(row.updated)} +
                                      +{row.updated && ( +
                                      + In pool since {new Date(row.updated).toLocaleDateString(undefined, { + day: "numeric", + month: "short", + })} +
                                      +)} +``` + +Keep the surrounding markup (`
                                      `, the Order/PO labels above, the `Scheduled {date}` line below) untouched. + +- [ ] **Step 2: Run the existing page suite** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/page' +``` + +Expected: existing tests still pass (this change adds markup but doesn't alter data flow). + +- [ ] **Step 3: Commit** + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "style(collection-pool): promote time-in-list to text-primary" +``` + +--- + +## Task 4: Notes chip — proper hover/focus tooltip + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx:625-632` + +- [ ] **Step 1: Replace the native `title` chip with a `group/notes` tooltip** + +In `src/app/(app)/collection-pool/page.tsx`, replace the existing notes chip block: + +```tsx +{row.notes && row.notes.trim().length > 0 && ( + + Notes + + {row.notes.length > 280 ? `${row.notes.slice(0, 280)}…` : row.notes} + + +)} +``` + +If `bg-popover` / `text-popover-foreground` are not defined in the project's `globals.css`, fall back to `bg-background text-foreground` (these always resolve). + +- [ ] **Step 2: Run the existing page suite** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/page' +``` + +Expected: existing tests still pass. If any test pinned the old `title` attribute, update the selector to match the new tooltip (e.g. `findByRole("tooltip")`). + +- [ ] **Step 3: Commit** + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): replace notes title with hover tooltip" +``` + +--- + +## Task 5: Selection state + per-row checkbox on `PoolRow` + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx` (state, props, JSX, render call) + +- [ ] **Step 1: Add `selectedIds` state + handlers** + +In `CollectionPoolPage`, alongside the existing `useState` calls (around line 106–116), add: + +```ts +const [selectedIds, setSelectedIds] = useState>(new Set()); + +const toggleRowSelected = useCallback((rowId: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(rowId)) next.delete(rowId); + else next.add(rowId); + return next; + }); +}, []); + +const toggleSectionSelected = useCallback((rowIds: string[]) => { + setSelectedIds((prev) => { + const allSelected = rowIds.every((id) => prev.has(id)); + const next = new Set(prev); + if (allSelected) { + for (const id of rowIds) next.delete(id); + } else { + for (const id of rowIds) next.add(id); + } + return next; + }); +}, []); +``` + +- [ ] **Step 2: Extend `PoolRow` props and signature** + +Update the `PoolRow` props type and signature in the same file (around line 551): + +```tsx +function PoolRow({ + row, + busy, + markerToken, + todayYmd, + selected, + onToggleSelected, + onOpenAssign, + onToggleReady, +}: { + row: CollectionPoolVisibleRow; + busy: boolean; + markerToken?: string; + todayYmd: string; + selected: boolean; + onToggleSelected: (rowId: string) => void; + onOpenAssign: (row: CollectionPoolVisibleRow) => void; + onToggleReady: (row: CollectionPoolVisibleRow, ready: boolean) => void; +}) { +``` + +- [ ] **Step 3: Add the checkbox and row tint to `PoolRow`'s JSX** + +In `PoolRow`'s `
                                    • ` element (around line 579), apply the selection tint and add a checkbox at the start of the content grid. Replace the opening markup: + +```tsx +
                                    • + + +
                                      +
                                      + onToggleSelected(row.id)} + disabled={busy} + aria-label={`Select ${row.description ?? "item"} at ${row.supplierName ?? "unknown supplier"}`} + className="mt-1 size-4 shrink-0 cursor-pointer accent-primary disabled:cursor-not-allowed" + /> +
                                      +

                                      {row.supplierName}

                                      +

                                      {row.description || "(no description)"}

                                      +
                                      + {row.customerName || "No customer"} + / + {row.companyName || "No company"} +
                                      + {!row.purchase_order && ( +
                                      + + This row has no purchase order link. The PO may have been deleted. +
                                      + )} +
                                      +
                                      + + {/* ...rest of the columns unchanged... */} +
                                      +
                                    • +``` + +(The supplier/description/customer/company/warning block was previously the direct first child of the inner `
                                      `. It is now nested inside a new wrapper `
                                      ` that hosts the checkbox.) + +- [ ] **Step 4: Update the call site (still the flat `
                                        ` for now)** + +In the rendering block around line 488–500, update the `PoolRow` invocation: + +```tsx +
                                          + {visibleRows.map((row) => ( + + ))} +
                                        +``` + +- [ ] **Step 5: Run page suite + commit** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/page' +``` + +Expected: existing tests still pass (PoolRow is exercised via the page). + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): per-row checkbox + selection state" +``` + +--- + +## Task 6: `SupplierGroup` component (renders header + collapsible body) + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx` (new `SupplierGroup` function) + +- [ ] **Step 1: Add the `SupplierGroup` component** + +Append a new `SupplierGroup` function inside `page.tsx`, after `PoolRow`. It renders the section header strip and the collapsible row body: + +```tsx +function SupplierGroup({ + group, + busy, + todayYmd, + selectedIds, + collapsed, + onToggleRowSelected, + onToggleSectionSelected, + onToggleCollapsed, + onOpenAssign, + onOpenBulkAssign, + onToggleReady, +}: { + group: CollectionPoolGroup; + busy: boolean; + todayYmd: string; + selectedIds: Set; + collapsed: boolean; + onToggleRowSelected: (rowId: string) => void; + onToggleSectionSelected: (rowIds: string[]) => void; + onToggleCollapsed: (supplierId: string) => void; + onOpenAssign: (row: CollectionPoolVisibleRow) => void; + onOpenBulkAssign: (supplierId: string, supplierName: string, rowIds: string[]) => void; + onToggleReady: (row: CollectionPoolVisibleRow, ready: boolean) => void; +}) { + const rowIds = group.rows.map((r) => r.id); + const selectedCount = rowIds.filter((id) => selectedIds.has(id)).length; + const allSelected = selectedCount === rowIds.length && rowIds.length > 0; + const someSelected = selectedCount > 0 && !allSelected; + const allAssigned = group.rows.every( + (r) => getAssignmentState(r, todayYmd) !== "unassigned", + ); + + const eyebrowText = (() => { + if (group.topPoNumbers.length === 0) return null; + const head = group.topPoNumbers.map((p) => `PO ${p}`).join(", "); + return group.remainingPoCount > 0 + ? `${head} (+${group.remainingPoCount} more)` + : head; + })(); + + const assignLabel = allSelected + ? `Assign ${rowIds.length} stops` + : selectedCount > 0 + ? `Assign ${selectedCount} selected` + : `Assign all ${rowIds.length}`; + + const onAssignClick = () => { + const ids = allSelected || selectedCount === 0 ? rowIds : rowIds.filter((id) => selectedIds.has(id)); + onOpenBulkAssign(group.supplierId, group.supplierName, ids); + }; + + return ( +
                                        +
                                        + +

                                        + {group.supplierName || "Unknown supplier"} +

                                        + + · {group.rows.length} {group.rows.length === 1 ? "stop" : "stops"} + {eyebrowText ? <> · {eyebrowText} : null} + +
                                        + + + +
                                        +
                                        +
                                        +
                                          + {group.rows.map((row) => ( + + ))} +
                                        +
                                        +
                                        + ); +} +``` + +- [ ] **Step 2: Add the missing imports** + +At the top of `page.tsx`, extend the lucide-react import: + +```tsx +import { AlertTriangle, ChevronDown, Loader2, Search, Truck } from "lucide-react"; +``` + +And update the `./view-model` imports so `buildSupplierGroups`, `CollectionPoolGroup`, and `getAssignmentBadge` are all present: + +```tsx +import { + buildProximityMarkerMap, + buildSupplierGroups, + buildVisibleRows, + type CollectionPoolGroup, + type CollectionPoolSortMode, + getAssignmentBadge, + getAssignmentState, + getPoolAgeLabel, +} from "./view-model"; +``` + +(`getAssignmentBadge` is still consumed by `PoolRow`'s status pill — keep it.) + +- [ ] **Step 3: Run typecheck** + +```bash +npx tsc --noEmit +``` + +Expected: clean. If `Type 'Set' is not assignable to ...` errors fire on `selectedIds`, the `useState>(new Set())` should already be inferring — double-check the import. + +- [ ] **Step 4: Commit (component only — not wired yet)** + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): add SupplierGroup component" +``` + +--- + +## Task 7: Replace flat `
                                          ` with grouped `
                                          `s + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx` (rendering block + state) + +- [ ] **Step 1: Add `collapsedSuppliers` state** + +In `CollectionPoolPage`, next to `selectedIds`: + +```ts +const [collapsedSuppliers, setCollapsedSuppliers] = useState>(new Set()); + +const toggleCollapsed = useCallback((supplierId: string) => { + setCollapsedSuppliers((prev) => { + const next = new Set(prev); + if (next.has(supplierId)) next.delete(supplierId); + else next.add(supplierId); + return next; + }); +}, []); +``` + +- [ ] **Step 2: Add `openBulkAssign` handler stub** + +Below `openAssignForRow`, add a stub that just opens state — the actual submit handler comes in Task 9. (The stub is intentional so Task 8 can wire the button without Task 9 needing to be done first.) + +```ts +const [bulkAssignDialog, setBulkAssignDialog] = useState<{ + supplierId: string; + supplierName: string; + rowIds: string[]; +} | null>(null); + +const openBulkAssign = useCallback( + (supplierId: string, supplierName: string, rowIds: string[]) => { + if (rowIds.length === 0) return; + setBulkAssignDialog({ supplierId, supplierName, rowIds }); + }, + [], +); + +const closeBulkAssign = useCallback(() => setBulkAssignDialog(null), []); +``` + +- [ ] **Step 3: Compute `groups` in the page** + +Below `visibleRows`, add: + +```tsx +const groups = useMemo(() => { + if (!visibleRows) return null; + return buildSupplierGroups(visibleRows, supplierMarkerMap); +}, [visibleRows, supplierMarkerMap]); +``` + +- [ ] **Step 4: Replace the flat-list rendering** + +Replace the `
                                            ` block (around line 488–500 in the current `page.tsx`) with: + +```tsx +
                                            +
                                            + Collection Item + Order Detail + Status + Actions +
                                            + {groups === null ? ( +
                                            + {Array.from({ length: 4 }).map((_, i) => ( +
                                            + ))} +
                                            + ) : groups.length === 0 ? ( + + ) : ( + groups.map((group) => ( + + )) + )} +
                                            +``` + +Note: the empty-states at the top level (`rows === null` skeleton, `rows.length === 0` empty, `visibleRows.length === 0` empty) now collapse into the new conditional inside the container — delete the previous three separate early-return blocks (around lines 460–478) to avoid double-rendering. + +- [ ] **Step 5: Run typecheck + page suite** + +```bash +npx tsc --noEmit +npm run test:run -- 'src/app/(app)/collection-pool/page' +``` + +Expected: typecheck clean; page tests still pass. If a test pinned the old `
                                              `, update the selector to find rows by data-od-id on the section or fall back to a more stable test query. + +- [ ] **Step 6: Commit** + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): render supplier-grouped sections" +``` + +--- + +## Task 8: Wire bulk-assign dialog mount + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx` (second `AssignDriverDialog` mount) + +- [ ] **Step 1: Stub the submit handler + bulkBusy state** + +Just below `handleAssignDialogSubmit`, add: + +```ts +const [bulkBusy, setBulkBusy] = useState(false); + +const handleBulkAssignSubmit = useCallback( + async (_payload: AssignDriverSubmitPayload) => { + // Full implementation in Task 9. + }, + [], +); +``` + +- [ ] **Step 2: Add the bulk-assign dialog JSX** + +Just below the existing single-row `` (around line 504), add: + +```tsx + { + if (!open) closeBulkAssign(); + }} + onSubmit={handleBulkAssignSubmit} + bulkCount={bulkAssignDialog?.rowIds.length ?? 1} + eyebrow={ + bulkAssignDialog ? ( + + + {bulkAssignDialog.supplierName || "Unknown supplier"} + · + {bulkAssignDialog.rowIds.length} stops + + ) : undefined + } + busy={bulkBusy} +/> +``` + +- [ ] **Step 3: Run typecheck + commit** + +```bash +npx tsc --noEmit +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): wire bulk-assign dialog mount" +``` + +--- + +## Task 9: Sequential bulk PATCH handler + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx` (`handleBulkAssignSubmit` body) + +- [ ] **Step 1: Replace the stub with the real implementation** + +In `page.tsx`, replace the empty `handleBulkAssignSubmit` with: + +```ts +const handleBulkAssignSubmit = useCallback( + async (payload: AssignDriverSubmitPayload) => { + if (!bulkAssignDialog) return; + const { driverId, date } = payload; + const { rowIds, supplierName } = bulkAssignDialog; + + setBulkBusy(true); + try { + const pb = getPocketBase(); + let successCount = 0; + let firstError: string | null = null; + + // Sequential, not parallel: any failure leaves earlier + // successes committed (matches the single-row PATCH semantics). + for (const id of rowIds) { + try { + await pb.collection("order_items").update(id, { + item_status: "assigned", + drv_id: driverId, + drv_status: "assigned", + assigned_at: `${date}T00:00:00.000Z`, + } as Partial); + successCount++; + } catch (err) { + if (!firstError) { + firstError = err instanceof Error ? err.message : String(err); + } + } + } + + // Clear selections for this supplier on success (partial or full). + setSelectedIds((prev) => { + const next = new Set(prev); + for (const id of rowIds) next.delete(id); + return next; + }); + closeBulkAssign(); + + if (firstError) { + toast.error( + `Assigned ${successCount} of ${rowIds.length} stops at ${supplierName || "supplier"}. First error: ${firstError}`, + ); + } else { + toast.success(`Assigned ${successCount} stops at ${supplierName || "supplier"}.`); + } + + await load(); + } finally { + setBulkBusy(false); + } + }, + [bulkAssignDialog, closeBulkAssign, load], +); +``` + +- [ ] **Step 2: Run typecheck + page suite** + +```bash +npx tsc --noEmit +npm run test:run -- 'src/app/(app)/collection-pool/page' +``` + +Expected: typecheck clean; existing page tests pass. Mock the PocketBase `update` call in tests as needed — see Task 10 for the test wiring. + +- [ ] **Step 3: Commit** + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): sequential bulk-assign PATCH" +``` + +--- + +## Task 10: Page-level tests + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.test.tsx` + +- [ ] **Step 1: Add a test that time-in-list uses `text-primary`** + +Find the existing render fixture in `page.test.tsx` (search for `getPoolAgeLabel` or a row containing `text-foreground`). Append: + +```tsx +import { screen, within } from "@testing-library/react"; + +it("renders time-in-list with text-primary class", async () => { + renderPage(); + const ageLabels = await screen.findAllByText(/^\d+[hd]$/); + expect(ageLabels.length).toBeGreaterThan(0); + for (const label of ageLabels) { + expect(label.className).toMatch(/text-primary/); + } +}); +``` + +(The selector `/^\d+[hd]$/` matches the `Nh` / `Nd` format produced by `getPoolAgeLabel`. Adjust if existing tests use a different fixture pattern — search the file for any pre-existing age assertions first.) + +- [ ] **Step 2: Add a test that the Notes tooltip appears on hover** + +```tsx +it("surfaces the Notes tooltip on row hover", async () => { + // Fixture row must include `notes: "Spoke to David — confirmed pickup tomorrow"`. + renderPage({ rowOverrides: { notes: "Spoke to David — confirmed pickup tomorrow" } }); + const tooltip = await screen.findByRole("tooltip"); + expect(tooltip).toHaveTextContent(/Spoke to David/); + expect(tooltip).toHaveClass("hidden"); // default state +}); +``` + +If the project's `renderPage` helper doesn't accept row overrides, add a parameter that forwards to the fixture builder — it's a one-line change. + +- [ ] **Step 3: Add a test that selecting a row toggles the selection tint** + +```tsx +it("applies a primary-tint background to selected rows", async () => { + const { container } = renderPage(); + const firstCheckbox = await screen.findByRole("checkbox", { name: /Select .* at .*/i }); + fireEvent.click(firstCheckbox); + const row = firstCheckbox.closest("li"); + expect(row).not.toBeNull(); + expect(row!.className).toMatch(/bg-primary\/5/); +}); +``` + +- [ ] **Step 4: Add a test that the section "Select all" toggles every row in the section** + +```tsx +it("section Select all toggles every row in the supplier group", async () => { + renderPage(); + const selectAll = await screen.findByRole("checkbox", { name: /Select all \d+ stops at/i }); + fireEvent.click(selectAll); + const rowCheckboxes = await screen.findAllByRole("checkbox", { name: /^Select .* at/i }); + expect(rowCheckboxes.length).toBeGreaterThan(0); + for (const cb of rowCheckboxes) { + expect(cb).toBeChecked(); + } +}); +``` + +- [ ] **Step 5: Add a test that the Assign-all button opens the bulk dialog with `bulkCount=N`** + +Mock `pb.collection("order_items").update` so the test doesn't actually hit PocketBase. Then: + +```tsx +it("opens bulk-assign dialog with bulkCount equal to selected rows", async () => { + const { container } = renderPage(); + const selectAll = await screen.findByRole("checkbox", { name: /Select all \d+ stops at/i }); + fireEvent.click(selectAll); + const assignAll = screen.getByRole("button", { name: /Assign \d+ stops$/ }); + fireEvent.click(assignAll); + // Dialog title reads "Assign N items to driver" when bulkCount > 1. + const dialogTitle = await screen.findByText(/Assign \d+ items to driver/i); + expect(dialogTitle).toBeInTheDocument(); +}); +``` + +If the `getPocketBase()` mock in the existing suite doesn't intercept the second dialog's submit handler, extend the mock to also intercept the bulk-assign PATCH chain. + +- [ ] **Step 6: Add a test that collapsing a section hides its rows** + +```tsx +it("collapses a supplier section on chevron click", async () => { + renderPage(); + const chevron = await screen.findByRole("button", { name: "" }); // chevron has no accessible name; query by aria-controls + const section = document.querySelector('[aria-labelledby^="supplier-"]'); + const id = section!.querySelector("header")!.id; + const body = document.getElementById(`${id}-body`) || section!.querySelector('[id$="-body"]'); + expect(body!.className).toMatch(/grid-rows-\[1fr\]/); + fireEvent.click(section!.querySelector("button[aria-expanded]")!); + expect(body!.className).toMatch(/grid-rows-\[0fr\]/); +}); +``` + +- [ ] **Step 7: Run the full collection-pool suite** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool' +``` + +Expected: green. Pre-existing failures (documented across the repo, e.g. `page.test.tsx:175`) are acceptable. + +- [ ] **Step 8: Commit** + +```bash +git add 'src/app/(app)/collection-pool/page.test.tsx' +git commit -m "test(collection-pool): cover groups, bulk assign, notes tooltip" +``` + +--- + +## Task 11: Typecheck + size budget + conditional split + +**Files:** +- Modify or create: `src/app/(app)/collection-pool/page.tsx` (or split into `components.tsx`) + +- [ ] **Step 1: Full typecheck** + +```bash +npx tsc --noEmit +``` + +Expected: clean. + +- [ ] **Step 2: Measure `page.tsx` size** + +```bash +wc -c 'src/app/(app)/collection-pool/page.tsx' +``` + +If the result is **≤ 30 KB**, stop here — the file is within budget. Skip to Step 5. + +If the result is **> 30 KB**, continue with the split in Steps 3–4. + +- [ ] **Step 3: Create `components.tsx` with `PoolRow` + `SupplierGroup`** + +Create `src/app/(app)/collection-pool/components.tsx`: + +```tsx +"use client"; + +import { ChevronDown, Truck, AlertTriangle } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import type { CollectionPoolViewRow } from "./view-model"; +import { + type CollectionPoolGroup, + getAssignmentState, + getPoolAgeLabel, +} from "./view-model"; +import { cn } from "@/lib/utils"; + +// Copy `CollectionPoolVisibleRow` type and `markerTokenToClassName` +// helper from page.tsx. They're needed by both PoolRow and SupplierGroup. + +export type CollectionPoolVisibleRow = CollectionPoolViewRow & { + // mirror the type from page.tsx +}; + +function markerTokenToClassName(token?: string): string { + switch (token) { + case "marker-blue": + return "bg-blue-500"; + case "marker-emerald": + return "bg-emerald-500"; + case "marker-violet": + return "bg-violet-500"; + case "marker-amber": + return "bg-amber-500"; + default: + return "bg-slate-300"; + } +} + +// Then paste the FULL `PoolRow` and `SupplierGroup` function bodies +// from page.tsx, unchanged. +``` + +Cut both functions from `page.tsx` and update imports so `page.tsx` imports them from `./components` instead. + +- [ ] **Step 4: Re-measure + retest** + +```bash +wc -c 'src/app/(app)/collection-pool/page.tsx' 'src/app/(app)/collection-pool/components.tsx' +npx tsc --noEmit +npm run test:run -- 'src/app/(app)/collection-pool' +``` + +Expected: each file ≤ 30 KB; typecheck clean; tests green. + +- [ ] **Step 5: Commit (split or no-op)** + +If you split: +```bash +git add 'src/app/(app)/collection-pool/components.tsx' 'src/app/(app)/collection-pool/page.tsx' +git commit -m "refactor(collection-pool): split PoolRow + SupplierGroup into components.tsx" +``` + +If you didn't split, no commit is needed. + +--- + +## Task 12: Manual smoke + full suite + +- [ ] **Step 1: Full test suite** + +```bash +npm run test:run +``` + +Expected: same passing set as before plus the new tests in `view-model.test.ts` and `page.test.tsx`. Pre-existing failures (the 6 we documented across the repo) are acceptable. + +- [ ] **Step 2: Manual smoke on `/collection-pool`** + +1. Load the page. Rows render in collapsible supplier sections, alphabetical. +2. Click the chevron on a section → body collapses with a smooth transition; chevron rotates. +3. Hover the time-in-list label — confirm it's in `text-primary` and bold. +4. Hover the Notes chip on a row with notes — confirm the tooltip appears with the note text (truncated at 280 chars + ellipsis if longer). +5. Tick the section "Select all" — every row checkbox in the section ticks; row backgrounds tint. +6. Click `Assign N stops`. Bulk dialog opens with eyebrow `Supplier · N stops` and title `Assign N items to driver`. +7. Pick a driver + tomorrow's date, submit. Toast confirms. Reload — rows show "Assigned future" badge and the section's selections clear. +8. Mark a row Ready with a long note (>280 chars). Hover the Notes chip — the preview is truncated with `…`. +9. Switch sort to "Nearby suppliers" — order changes; switch back to "Operational" — supplier-grouped order restored. + +- [ ] **Step 3: Commit any test-only follow-ups** + +```bash +git add -A 'src/app/(app)/collection-pool/' +git commit -m "test(collection-pool): post-smoke follow-ups" --allow-empty +``` + +--- + +## Out-of-scope reminders + +- **Bulk mark-ready.** The dialog requires a free-text note per item; a multi-note UI is a separate feature. Don't add it here. +- **Per-section "Mark section ready".** Same reason. +- **Route rename / nav label change.** Not in scope. +- **Mobile-only layout changes.** The existing grid already collapses correctly; no special breakpoint work is needed for the new structure. +- **Transactional batch PATCH endpoint.** Sequential single-row PATCHes are intentional; rollback on partial failure is out of scope. + +--- + +## Risk notes + +- **Page render budget.** Current 23 KB. Estimated growth ~7–10 KB from the new component + state + handlers. If it crosses 30 KB, Task 11 Step 3 splits `PoolRow` + `SupplierGroup` into `components.tsx`. If it still crosses after the split, consider further splitting the dialog wiring into a hook (`useBulkAssign`). +- **Sort change is observable.** Users who relied on the old "by urgency" default will see suppliers grouped together. The Sort radio's "Operational" label now matches this new behaviour; "Nearby" remains for cluster-priority workflows. +- **Bulk PATCH is not transactional.** A failure on row 4 of 5 leaves rows 1–3 committed. The toast reports partial success; the operator can re-open the dialog and retry. + +## Provenance + +Formalized by Open Design from candidate 5cb8fc93-2209-4312-ad86-e1d93493fe2d. diff --git a/plugin-source/collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6svc/open-design.json b/plugin-source/collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6svc/open-design.json new file mode 100644 index 0000000..ed3d859 --- /dev/null +++ b/plugin-source/collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6svc/open-design.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://open-design.ai/schemas/plugin.v1.json", + "specVersion": "1.0.0", + "name": "collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6svc", + "title": "Collection Pool: supplier groups, bulk assign, primary time-in-list, note previe", + "version": "0.1.0", + "description": "> **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 trackin", + "od": { + "kind": "skill", + "taskKind": "new-generation", + "context": { + "skills": [ + { + "path": "./SKILL.md" + } + ] + }, + "capabilities": [ + "prompt:inject" + ] + } +} diff --git a/plugin-source/collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6svc/references/provenance.json b/plugin-source/collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6svc/references/provenance.json new file mode 100644 index 0000000..32acf01 --- /dev/null +++ b/plugin-source/collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6svc/references/provenance.json @@ -0,0 +1,19 @@ +{ + "candidateId": "5cb8fc93-2209-4312-ad86-e1d93493fe2d", + "projectId": "ef67607e-d372-4742-aa1a-f7206ddb1b6b", + "runId": "0fb704d7-d828-4c0f-8b22-4c1999a773d2", + "conversationId": "00d657d1-caca-4511-984d-518155667f66", + "provenance": { + "summary": "Detected from docs/superpowers/plans/2026-07-10-collection-pool-supplier-groups-and-notes.md after a successful run.", + "detectedAt": 1783721909754 + }, + "sourceRefs": [ + { + "kind": "file", + "value": "docs/superpowers/plans/2026-07-10-collection-pool-supplier-groups-and-notes.md", + "label": "docs/superpowers/plans/2026-07-10-collection-pool-supplier-groups-and-notes.md", + "size": 44068, + "copied": true + } + ] +} diff --git a/plugin-source/collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6svc/references/source-1-2026-07-10-collection-pool-supplier-groups-and-notes.md b/plugin-source/collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6svc/references/source-1-2026-07-10-collection-pool-supplier-groups-and-notes.md new file mode 100644 index 0000000..0d896b9 --- /dev/null +++ b/plugin-source/collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6svc/references/source-1-2026-07-10-collection-pool-supplier-groups-and-notes.md @@ -0,0 +1,1254 @@ +# Collection Pool: supplier groups, bulk assign, primary time-in-list, note preview — 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:** Reorganise `/collection-pool` into collapsible supplier sections with bulk-assign (whole-section or selective), make the time-in-list dominant in `text-primary`, and replace the `title`-attribute Notes chip with a proper hover/focus tooltip. + +**Architecture:** Helper `buildSupplierGroups` lives in `view-model.ts`; the sort flips to `(supplierName, assignmentState, updated)` so the group builder can slice the sorted array. New `SupplierGroup` component (kept in `page.tsx`) renders the header strip (dot · supplier · count · PO eyebrow · chevron · Assign-all button) plus collapsible body. A second `AssignDriverDialog` instance handles bulk with `bulkCount`. The existing `PoolRow` gets two new props (`selected`, `onToggleSelected`) and a per-row checkbox. + +**Tech Stack:** Next.js 16 App Router (read `node_modules/next/dist/docs/` before any Next-specific code), React 18, TypeScript, Tailwind, shadcn/ui, PocketBase admin client, lucide-react, sonner, Vitest. + +**Spec:** [docs/superpowers/specs/2026-07-10-collection-pool-supplier-groups-and-notes-design.md](../specs/2026-07-10-collection-pool-supplier-groups-and-notes-design.md) + +**Pre-flight:** + +- [ ] **Read the spec end-to-end** so the helper signatures, sort tiebreakers, and bulk-merge semantics are locked in your head. +- [ ] **Skim `node_modules/next/dist/docs/`** for anything that affects Client Component state patterns (this codebase is "NOT the Next.js you know" per `AGENTS.md`). +- [ ] **Re-read `page.tsx`** (663 lines) — `PoolRow` props, the colour-stripe helper, the existing single-row `AssignDriverDialog` mount, and the `load()` + `updateItemStatus` patterns. + +--- + +## File Structure + +| File | Responsibility | Touched? | +| --- | --- | --- | +| `src/app/(app)/collection-pool/view-model.ts` | Add `CollectionPoolGroup` type + `buildSupplierGroups` helper; flip `buildVisibleRows` sort to lead with `supplierName`. | Modify | +| `src/app/(app)/collection-pool/view-model.test.ts` | Pin the new sort tiebreaker order; cover `buildSupplierGroups` (empty, fold, eyebrow, marker token). | Modify | +| `src/app/(app)/collection-pool/page.tsx` | Add `selectedIds`, `collapsedSuppliers`, `bulkAssignDialog` state; add `SupplierGroup` + `PoolRow` updates (time-in-list, notes tooltip, checkbox); wire bulk dialog and sequential PATCH handler; replace flat `
                                                ` with grouped `
                                                `s. | Modify | +| `src/app/(app)/collection-pool/page.test.tsx` | Cover selection toggling, section select-all, Assign-all opens bulk dialog with `bulkCount=N`, notes tooltip visibility, time-in-list colour class. | Modify | +| `src/app/(app)/collection-pool/components.tsx` (NEW, conditional) | If `page.tsx` exceeds 30 KB after Task 11, split `PoolRow` + `SupplierGroup` here. | Create (conditional) | + +--- + +## Task 1: Add `buildSupplierGroups` helper (TDD) + +**Files:** +- Modify: `src/app/(app)/collection-pool/view-model.test.ts` +- Modify: `src/app/(app)/collection-pool/view-model.ts` + +- [ ] **Step 1: Append failing tests** + +Append to `src/app/(app)/collection-pool/view-model.test.ts` (the existing `makeRow` helper above is reused): + +```ts +import { buildSupplierGroups, type CollectionPoolGroup } from "./view-model"; + +describe("buildSupplierGroups", () => { + it("returns an empty array for empty input", () => { + expect(buildSupplierGroups([], new Map())).toEqual([]); + }); + + it("folds rows by supplierId in input order", () => { + const rows = [ + makeRow({ id: "a", supplierId: "sup-1", supplierName: "Acme", poNumber: "PO-1" }), + makeRow({ id: "b", supplierId: "sup-2", supplierName: "Beta", poNumber: "PO-2" }), + makeRow({ id: "c", supplierId: "sup-1", supplierName: "Acme", poNumber: "PO-3" }), + ]; + const groups = buildSupplierGroups(rows, new Map()); + expect(groups.map((g) => g.supplierId)).toEqual(["sup-1", "sup-2"]); + expect(groups[0].rows.map((r) => r.id)).toEqual(["a", "c"]); + expect(groups[1].rows.map((r) => r.id)).toEqual(["b"]); + }); + + it("captures the top two PO numbers and reports the remaining count", () => { + const rows = [ + makeRow({ supplierId: "sup-1", poNumber: "PO-1" }), + makeRow({ supplierId: "sup-1", poNumber: "PO-2" }), + makeRow({ supplierId: "sup-1", poNumber: "PO-3" }), + makeRow({ supplierId: "sup-1", poNumber: "PO-4" }), + makeRow({ supplierId: "sup-1", poNumber: "PO-4" }), // duplicate PO + ]; + const [group] = buildSupplierGroups(rows, new Map()); + expect(group.topPoNumbers).toEqual(["PO-1", "PO-2"]); + expect(group.remainingPoCount).toBe(2); + }); + + it("uses marker token from supplierMarkerMap", () => { + const map = new Map([["sup-1", "marker-blue"]]); + const [group] = buildSupplierGroups( + [makeRow({ supplierId: "sup-1" })], + map, + ); + expect(group.markerToken).toBe("marker-blue"); + }); + + it("returns empty eyebrow when no rows have a PO number", () => { + const [group] = buildSupplierGroups( + [makeRow({ supplierId: "sup-1", poNumber: "" })], + new Map(), + ); + expect(group.topPoNumbers).toEqual([]); + expect(group.remainingPoCount).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run tests — expect FAIL** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/view-model.test' +``` + +Expected: 5 new tests fail with "buildSupplierGroups is not a function". + +- [ ] **Step 3: Implement `buildSupplierGroups`** + +Append to `src/app/(app)/collection-pool/view-model.ts`: + +```ts +export type CollectionPoolGroup = { + supplierId: string; + supplierName: string; + /** Cluster colour token from the proximity map (may be undefined). */ + markerToken?: string; + /** First two distinct PO numbers for the header eyebrow. */ + topPoNumbers: string[]; + /** Number of distinct POs beyond the top two. */ + remainingPoCount: number; + /** Rows belonging to this group, in render order. */ + rows: CollectionPoolViewRow[]; +}; + +/** + * Folds already-sorted rows into per-supplier groups, preserving input + * order. The caller is responsible for sorting rows by `supplierName` + * (use `buildVisibleRows` first); this helper does not re-sort. + */ +export function buildSupplierGroups( + rows: CollectionPoolViewRow[], + supplierMarkerMap: Map, +): CollectionPoolGroup[] { + const groups: CollectionPoolGroup[] = []; + const bySupplier = new Map(); + + for (const row of rows) { + let group = bySupplier.get(row.supplierId); + if (!group) { + group = { + supplierId: row.supplierId, + supplierName: row.supplierName ?? "", + markerToken: supplierMarkerMap.get(row.supplierId), + topPoNumbers: [], + remainingPoCount: 0, + rows: [], + }; + bySupplier.set(row.supplierId, group); + groups.push(group); + } + group.rows.push(row); + } + + for (const group of groups) { + const uniquePos = Array.from( + new Set(group.rows.map((r) => r.poNumber ?? "").filter(Boolean)), + ); + group.topPoNumbers = uniquePos.slice(0, 2); + group.remainingPoCount = Math.max(0, uniquePos.length - 2); + } + + return groups; +} +``` + +- [ ] **Step 4: Run tests — expect PASS** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/view-model.test' +``` + +Expected: all tests green. + +- [ ] **Step 5: Commit** + +```bash +git add 'src/app/(app)/collection-pool/view-model.ts' 'src/app/(app)/collection-pool/view-model.test.ts' +git commit -m "feat(collection-pool): add buildSupplierGroups helper" +``` + +--- + +## Task 2: Flip sort tiebreaker to lead with `supplierName` + +**Files:** +- Modify: `src/app/(app)/collection-pool/view-model.test.ts` +- Modify: `src/app/(app)/collection-pool/view-model.ts` + +- [ ] **Step 1: Append failing tests** + +Append to `src/app/(app)/collection-pool/view-model.test.ts`: + +```ts +describe("buildVisibleRows — supplier-first sort (operational mode)", () => { + const todayYmd = "2026-07-10"; + + it("sorts by supplierName first when sortMode is operational", () => { + const rows = buildVisibleRows( + [ + makeRow({ id: "beta", supplierName: "Beta" }), + makeRow({ id: "acme", supplierName: "Acme" }), + makeRow({ id: "alpha", supplierName: "Alpha" }), + ], + { todayYmd, searchText: "", readyFilter: "all", sortMode: "operational" }, + ); + expect(rows.map((r) => r.id)).toEqual(["acme", "alpha", "beta"]); + }); + + it("falls through to assignmentState within the same supplier", () => { + const rows = buildVisibleRows( + [ + makeRow({ + id: "acme-unassigned", + supplierName: "Acme", + item_status: "collection_pool", + }), + makeRow({ + id: "acme-today", + supplierName: "Acme", + item_status: "assigned", + drv_id: "drv-1", + assigned_at: `${todayYmd}T00:00:00.000Z`, + }), + ], + { todayYmd, searchText: "", readyFilter: "all", sortMode: "operational" }, + ); + expect(rows.map((r) => r.id)).toEqual(["acme-today", "acme-unassigned"]); + }); + + it("nearby mode still uses cluster prefix before supplier", () => { + const rows = buildVisibleRows( + [ + makeRow({ + id: "beta-cluster-a", + supplierName: "Beta", + clusterSortKey: "cluster-a", + }), + makeRow({ + id: "acme-cluster-b", + supplierName: "Acme", + clusterSortKey: "cluster-b", + }), + makeRow({ + id: "acme-cluster-a", + supplierName: "Acme", + clusterSortKey: "cluster-a", + }), + ], + { todayYmd, searchText: "", readyFilter: "all", sortMode: "nearby" }, + ); + expect(rows.map((r) => r.id)).toEqual([ + "acme-cluster-a", + "beta-cluster-a", + "acme-cluster-b", + ]); + }); +}); +``` + +- [ ] **Step 2: Run tests — expect FAIL** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/view-model.test' +``` + +Expected: 3 new tests fail. The two existing `buildVisibleRows sort modes` tests should still pass (they don't depend on supplier ordering); the original `"sorts assigned today first"` test may start to fail because it expects today-first but the new sort is supplier-first. **Note that failure and proceed — it's expected and will be reconciled when the production change lands in Step 3.** + +- [ ] **Step 3: Update `buildVisibleRows` sort** + +In `src/app/(app)/collection-pool/view-model.ts`, replace the existing `.sort((a, b) => { ... })` callback inside `buildVisibleRows` with: + +```ts +.sort((a, b) => { + if (sortMode === "nearby") { + const clusterDiff = (a.clusterSortKey ?? "zzz").localeCompare(b.clusterSortKey ?? "zzz"); + if (clusterDiff !== 0) return clusterDiff; + } + + // Supplier name is the primary group key. Rows in the same + // supplier are then ordered by assignment urgency (today → + // unassigned → future), then by updated, then by PO + description. + const supplierDiff = (a.supplierName ?? "").localeCompare(b.supplierName ?? ""); + if (supplierDiff !== 0) return supplierDiff; + + const stateDiff = + ASSIGNMENT_SORT_ORDER[getAssignmentState(a, options.todayYmd)] - + ASSIGNMENT_SORT_ORDER[getAssignmentState(b, options.todayYmd)]; + if (stateDiff !== 0) return stateDiff; + + const updatedA = a.updated ?? ""; + const updatedB = b.updated ?? ""; + if (updatedA !== updatedB) return updatedA.localeCompare(updatedB); + + return `${a.poNumber ?? ""}|${a.description ?? ""}`.localeCompare( + `${b.poNumber ?? ""}|${b.description ?? ""}`, + ); +}); +``` + +- [ ] **Step 4: Run tests — expect PASS** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/view-model.test' +``` + +Expected: all tests green. The pre-existing `"sorts assigned today first, then unassigned, then future"` test will now fail because supplier is the primary key; the expected order across multiple suppliers is supplier-alphabetical regardless of assignment state. **If that test was already updated by a previous commit, leave it alone; otherwise update its expectation to include supplier-distinct fixtures so the assertion still holds.** The simplest fix: + +```ts +// Old assertion was: ["today", "unassigned", "future"] with one supplier. +// Update fixture so all three rows share a supplier OR split into +// distinct suppliers and assert alphabetical. Easiest: keep one +// supplier and assert the assignment order. +makeRow({ id: "future", supplierName: "Same", ... }), +makeRow({ id: "unassigned", supplierName: "Same", ... }), +makeRow({ id: "today", supplierName: "Same", ... }), +// Expect ["today", "unassigned", "future"] +``` + +- [ ] **Step 5: Commit** + +```bash +git add 'src/app/(app)/collection-pool/view-model.ts' 'src/app/(app)/collection-pool/view-model.test.ts' +git commit -m "feat(collection-pool): sort by supplier name first" +``` + +--- + +## Task 3: Time-in-list primary colour + "In pool since" sub-label + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx:607` + +- [ ] **Step 1: Update the `PoolRow` order-detail cell** + +In `src/app/(app)/collection-pool/page.tsx`, replace the order-detail cell body that currently contains the time-in-list label (around line 607): + +```tsx +
                                                + {getPoolAgeLabel(row.updated)} +
                                                +{row.updated && ( +
                                                + In pool since {new Date(row.updated).toLocaleDateString(undefined, { + day: "numeric", + month: "short", + })} +
                                                +)} +``` + +Keep the surrounding markup (`
                                                `, the Order/PO labels above, the `Scheduled {date}` line below) untouched. + +- [ ] **Step 2: Run the existing page suite** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/page' +``` + +Expected: existing tests still pass (this change adds markup but doesn't alter data flow). + +- [ ] **Step 3: Commit** + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "style(collection-pool): promote time-in-list to text-primary" +``` + +--- + +## Task 4: Notes chip — proper hover/focus tooltip + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx:625-632` + +- [ ] **Step 1: Replace the native `title` chip with a `group/notes` tooltip** + +In `src/app/(app)/collection-pool/page.tsx`, replace the existing notes chip block: + +```tsx +{row.notes && row.notes.trim().length > 0 && ( + + Notes + + {row.notes.length > 280 ? `${row.notes.slice(0, 280)}…` : row.notes} + + +)} +``` + +If `bg-popover` / `text-popover-foreground` are not defined in the project's `globals.css`, fall back to `bg-background text-foreground` (these always resolve). + +- [ ] **Step 2: Run the existing page suite** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/page' +``` + +Expected: existing tests still pass. If any test pinned the old `title` attribute, update the selector to match the new tooltip (e.g. `findByRole("tooltip")`). + +- [ ] **Step 3: Commit** + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): replace notes title with hover tooltip" +``` + +--- + +## Task 5: Selection state + per-row checkbox on `PoolRow` + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx` (state, props, JSX, render call) + +- [ ] **Step 1: Add `selectedIds` state + handlers** + +In `CollectionPoolPage`, alongside the existing `useState` calls (around line 106–116), add: + +```ts +const [selectedIds, setSelectedIds] = useState>(new Set()); + +const toggleRowSelected = useCallback((rowId: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(rowId)) next.delete(rowId); + else next.add(rowId); + return next; + }); +}, []); + +const toggleSectionSelected = useCallback((rowIds: string[]) => { + setSelectedIds((prev) => { + const allSelected = rowIds.every((id) => prev.has(id)); + const next = new Set(prev); + if (allSelected) { + for (const id of rowIds) next.delete(id); + } else { + for (const id of rowIds) next.add(id); + } + return next; + }); +}, []); +``` + +- [ ] **Step 2: Extend `PoolRow` props and signature** + +Update the `PoolRow` props type and signature in the same file (around line 551): + +```tsx +function PoolRow({ + row, + busy, + markerToken, + todayYmd, + selected, + onToggleSelected, + onOpenAssign, + onToggleReady, +}: { + row: CollectionPoolVisibleRow; + busy: boolean; + markerToken?: string; + todayYmd: string; + selected: boolean; + onToggleSelected: (rowId: string) => void; + onOpenAssign: (row: CollectionPoolVisibleRow) => void; + onToggleReady: (row: CollectionPoolVisibleRow, ready: boolean) => void; +}) { +``` + +- [ ] **Step 3: Add the checkbox and row tint to `PoolRow`'s JSX** + +In `PoolRow`'s `
                                              • ` element (around line 579), apply the selection tint and add a checkbox at the start of the content grid. Replace the opening markup: + +```tsx +
                                              • + + +
                                                +
                                                + onToggleSelected(row.id)} + disabled={busy} + aria-label={`Select ${row.description ?? "item"} at ${row.supplierName ?? "unknown supplier"}`} + className="mt-1 size-4 shrink-0 cursor-pointer accent-primary disabled:cursor-not-allowed" + /> +
                                                +

                                                {row.supplierName}

                                                +

                                                {row.description || "(no description)"}

                                                +
                                                + {row.customerName || "No customer"} + / + {row.companyName || "No company"} +
                                                + {!row.purchase_order && ( +
                                                + + This row has no purchase order link. The PO may have been deleted. +
                                                + )} +
                                                +
                                                + + {/* ...rest of the columns unchanged... */} +
                                                +
                                              • +``` + +(The supplier/description/customer/company/warning block was previously the direct first child of the inner `
                                                `. It is now nested inside a new wrapper `
                                                ` that hosts the checkbox.) + +- [ ] **Step 4: Update the call site (still the flat `
                                                  ` for now)** + +In the rendering block around line 488–500, update the `PoolRow` invocation: + +```tsx +
                                                    + {visibleRows.map((row) => ( + + ))} +
                                                  +``` + +- [ ] **Step 5: Run page suite + commit** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/page' +``` + +Expected: existing tests still pass (PoolRow is exercised via the page). + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): per-row checkbox + selection state" +``` + +--- + +## Task 6: `SupplierGroup` component (renders header + collapsible body) + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx` (new `SupplierGroup` function) + +- [ ] **Step 1: Add the `SupplierGroup` component** + +Append a new `SupplierGroup` function inside `page.tsx`, after `PoolRow`. It renders the section header strip and the collapsible row body: + +```tsx +function SupplierGroup({ + group, + busy, + todayYmd, + selectedIds, + collapsed, + onToggleRowSelected, + onToggleSectionSelected, + onToggleCollapsed, + onOpenAssign, + onOpenBulkAssign, + onToggleReady, +}: { + group: CollectionPoolGroup; + busy: boolean; + todayYmd: string; + selectedIds: Set; + collapsed: boolean; + onToggleRowSelected: (rowId: string) => void; + onToggleSectionSelected: (rowIds: string[]) => void; + onToggleCollapsed: (supplierId: string) => void; + onOpenAssign: (row: CollectionPoolVisibleRow) => void; + onOpenBulkAssign: (supplierId: string, supplierName: string, rowIds: string[]) => void; + onToggleReady: (row: CollectionPoolVisibleRow, ready: boolean) => void; +}) { + const rowIds = group.rows.map((r) => r.id); + const selectedCount = rowIds.filter((id) => selectedIds.has(id)).length; + const allSelected = selectedCount === rowIds.length && rowIds.length > 0; + const someSelected = selectedCount > 0 && !allSelected; + const allAssigned = group.rows.every( + (r) => getAssignmentState(r, todayYmd) !== "unassigned", + ); + + const eyebrowText = (() => { + if (group.topPoNumbers.length === 0) return null; + const head = group.topPoNumbers.map((p) => `PO ${p}`).join(", "); + return group.remainingPoCount > 0 + ? `${head} (+${group.remainingPoCount} more)` + : head; + })(); + + const assignLabel = allSelected + ? `Assign ${rowIds.length} stops` + : selectedCount > 0 + ? `Assign ${selectedCount} selected` + : `Assign all ${rowIds.length}`; + + const onAssignClick = () => { + const ids = allSelected || selectedCount === 0 ? rowIds : rowIds.filter((id) => selectedIds.has(id)); + onOpenBulkAssign(group.supplierId, group.supplierName, ids); + }; + + return ( +
                                                  +
                                                  + +

                                                  + {group.supplierName || "Unknown supplier"} +

                                                  + + · {group.rows.length} {group.rows.length === 1 ? "stop" : "stops"} + {eyebrowText ? <> · {eyebrowText} : null} + +
                                                  + + + +
                                                  +
                                                  +
                                                  +
                                                    + {group.rows.map((row) => ( + + ))} +
                                                  +
                                                  +
                                                  + ); +} +``` + +- [ ] **Step 2: Add the missing imports** + +At the top of `page.tsx`, extend the lucide-react import: + +```tsx +import { AlertTriangle, ChevronDown, Loader2, Search, Truck } from "lucide-react"; +``` + +And update the `./view-model` imports so `buildSupplierGroups`, `CollectionPoolGroup`, and `getAssignmentBadge` are all present: + +```tsx +import { + buildProximityMarkerMap, + buildSupplierGroups, + buildVisibleRows, + type CollectionPoolGroup, + type CollectionPoolSortMode, + getAssignmentBadge, + getAssignmentState, + getPoolAgeLabel, +} from "./view-model"; +``` + +(`getAssignmentBadge` is still consumed by `PoolRow`'s status pill — keep it.) + +- [ ] **Step 3: Run typecheck** + +```bash +npx tsc --noEmit +``` + +Expected: clean. If `Type 'Set' is not assignable to ...` errors fire on `selectedIds`, the `useState>(new Set())` should already be inferring — double-check the import. + +- [ ] **Step 4: Commit (component only — not wired yet)** + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): add SupplierGroup component" +``` + +--- + +## Task 7: Replace flat `
                                                    ` with grouped `
                                                    `s + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx` (rendering block + state) + +- [ ] **Step 1: Add `collapsedSuppliers` state** + +In `CollectionPoolPage`, next to `selectedIds`: + +```ts +const [collapsedSuppliers, setCollapsedSuppliers] = useState>(new Set()); + +const toggleCollapsed = useCallback((supplierId: string) => { + setCollapsedSuppliers((prev) => { + const next = new Set(prev); + if (next.has(supplierId)) next.delete(supplierId); + else next.add(supplierId); + return next; + }); +}, []); +``` + +- [ ] **Step 2: Add `openBulkAssign` handler stub** + +Below `openAssignForRow`, add a stub that just opens state — the actual submit handler comes in Task 9. (The stub is intentional so Task 8 can wire the button without Task 9 needing to be done first.) + +```ts +const [bulkAssignDialog, setBulkAssignDialog] = useState<{ + supplierId: string; + supplierName: string; + rowIds: string[]; +} | null>(null); + +const openBulkAssign = useCallback( + (supplierId: string, supplierName: string, rowIds: string[]) => { + if (rowIds.length === 0) return; + setBulkAssignDialog({ supplierId, supplierName, rowIds }); + }, + [], +); + +const closeBulkAssign = useCallback(() => setBulkAssignDialog(null), []); +``` + +- [ ] **Step 3: Compute `groups` in the page** + +Below `visibleRows`, add: + +```tsx +const groups = useMemo(() => { + if (!visibleRows) return null; + return buildSupplierGroups(visibleRows, supplierMarkerMap); +}, [visibleRows, supplierMarkerMap]); +``` + +- [ ] **Step 4: Replace the flat-list rendering** + +Replace the `
                                                      ` block (around line 488–500 in the current `page.tsx`) with: + +```tsx +
                                                      +
                                                      + Collection Item + Order Detail + Status + Actions +
                                                      + {groups === null ? ( +
                                                      + {Array.from({ length: 4 }).map((_, i) => ( +
                                                      + ))} +
                                                      + ) : groups.length === 0 ? ( + + ) : ( + groups.map((group) => ( + + )) + )} +
                                                      +``` + +Note: the empty-states at the top level (`rows === null` skeleton, `rows.length === 0` empty, `visibleRows.length === 0` empty) now collapse into the new conditional inside the container — delete the previous three separate early-return blocks (around lines 460–478) to avoid double-rendering. + +- [ ] **Step 5: Run typecheck + page suite** + +```bash +npx tsc --noEmit +npm run test:run -- 'src/app/(app)/collection-pool/page' +``` + +Expected: typecheck clean; page tests still pass. If a test pinned the old `
                                                        `, update the selector to find rows by data-od-id on the section or fall back to a more stable test query. + +- [ ] **Step 6: Commit** + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): render supplier-grouped sections" +``` + +--- + +## Task 8: Wire bulk-assign dialog mount + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx` (second `AssignDriverDialog` mount) + +- [ ] **Step 1: Stub the submit handler + bulkBusy state** + +Just below `handleAssignDialogSubmit`, add: + +```ts +const [bulkBusy, setBulkBusy] = useState(false); + +const handleBulkAssignSubmit = useCallback( + async (_payload: AssignDriverSubmitPayload) => { + // Full implementation in Task 9. + }, + [], +); +``` + +- [ ] **Step 2: Add the bulk-assign dialog JSX** + +Just below the existing single-row `` (around line 504), add: + +```tsx + { + if (!open) closeBulkAssign(); + }} + onSubmit={handleBulkAssignSubmit} + bulkCount={bulkAssignDialog?.rowIds.length ?? 1} + eyebrow={ + bulkAssignDialog ? ( + + + {bulkAssignDialog.supplierName || "Unknown supplier"} + · + {bulkAssignDialog.rowIds.length} stops + + ) : undefined + } + busy={bulkBusy} +/> +``` + +- [ ] **Step 3: Run typecheck + commit** + +```bash +npx tsc --noEmit +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): wire bulk-assign dialog mount" +``` + +--- + +## Task 9: Sequential bulk PATCH handler + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx` (`handleBulkAssignSubmit` body) + +- [ ] **Step 1: Replace the stub with the real implementation** + +In `page.tsx`, replace the empty `handleBulkAssignSubmit` with: + +```ts +const handleBulkAssignSubmit = useCallback( + async (payload: AssignDriverSubmitPayload) => { + if (!bulkAssignDialog) return; + const { driverId, date } = payload; + const { rowIds, supplierName } = bulkAssignDialog; + + setBulkBusy(true); + try { + const pb = getPocketBase(); + let successCount = 0; + let firstError: string | null = null; + + // Sequential, not parallel: any failure leaves earlier + // successes committed (matches the single-row PATCH semantics). + for (const id of rowIds) { + try { + await pb.collection("order_items").update(id, { + item_status: "assigned", + drv_id: driverId, + drv_status: "assigned", + assigned_at: `${date}T00:00:00.000Z`, + } as Partial); + successCount++; + } catch (err) { + if (!firstError) { + firstError = err instanceof Error ? err.message : String(err); + } + } + } + + // Clear selections for this supplier on success (partial or full). + setSelectedIds((prev) => { + const next = new Set(prev); + for (const id of rowIds) next.delete(id); + return next; + }); + closeBulkAssign(); + + if (firstError) { + toast.error( + `Assigned ${successCount} of ${rowIds.length} stops at ${supplierName || "supplier"}. First error: ${firstError}`, + ); + } else { + toast.success(`Assigned ${successCount} stops at ${supplierName || "supplier"}.`); + } + + await load(); + } finally { + setBulkBusy(false); + } + }, + [bulkAssignDialog, closeBulkAssign, load], +); +``` + +- [ ] **Step 2: Run typecheck + page suite** + +```bash +npx tsc --noEmit +npm run test:run -- 'src/app/(app)/collection-pool/page' +``` + +Expected: typecheck clean; existing page tests pass. Mock the PocketBase `update` call in tests as needed — see Task 10 for the test wiring. + +- [ ] **Step 3: Commit** + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): sequential bulk-assign PATCH" +``` + +--- + +## Task 10: Page-level tests + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.test.tsx` + +- [ ] **Step 1: Add a test that time-in-list uses `text-primary`** + +Find the existing render fixture in `page.test.tsx` (search for `getPoolAgeLabel` or a row containing `text-foreground`). Append: + +```tsx +import { screen, within } from "@testing-library/react"; + +it("renders time-in-list with text-primary class", async () => { + renderPage(); + const ageLabels = await screen.findAllByText(/^\d+[hd]$/); + expect(ageLabels.length).toBeGreaterThan(0); + for (const label of ageLabels) { + expect(label.className).toMatch(/text-primary/); + } +}); +``` + +(The selector `/^\d+[hd]$/` matches the `Nh` / `Nd` format produced by `getPoolAgeLabel`. Adjust if existing tests use a different fixture pattern — search the file for any pre-existing age assertions first.) + +- [ ] **Step 2: Add a test that the Notes tooltip appears on hover** + +```tsx +it("surfaces the Notes tooltip on row hover", async () => { + // Fixture row must include `notes: "Spoke to David — confirmed pickup tomorrow"`. + renderPage({ rowOverrides: { notes: "Spoke to David — confirmed pickup tomorrow" } }); + const tooltip = await screen.findByRole("tooltip"); + expect(tooltip).toHaveTextContent(/Spoke to David/); + expect(tooltip).toHaveClass("hidden"); // default state +}); +``` + +If the project's `renderPage` helper doesn't accept row overrides, add a parameter that forwards to the fixture builder — it's a one-line change. + +- [ ] **Step 3: Add a test that selecting a row toggles the selection tint** + +```tsx +it("applies a primary-tint background to selected rows", async () => { + const { container } = renderPage(); + const firstCheckbox = await screen.findByRole("checkbox", { name: /Select .* at .*/i }); + fireEvent.click(firstCheckbox); + const row = firstCheckbox.closest("li"); + expect(row).not.toBeNull(); + expect(row!.className).toMatch(/bg-primary\/5/); +}); +``` + +- [ ] **Step 4: Add a test that the section "Select all" toggles every row in the section** + +```tsx +it("section Select all toggles every row in the supplier group", async () => { + renderPage(); + const selectAll = await screen.findByRole("checkbox", { name: /Select all \d+ stops at/i }); + fireEvent.click(selectAll); + const rowCheckboxes = await screen.findAllByRole("checkbox", { name: /^Select .* at/i }); + expect(rowCheckboxes.length).toBeGreaterThan(0); + for (const cb of rowCheckboxes) { + expect(cb).toBeChecked(); + } +}); +``` + +- [ ] **Step 5: Add a test that the Assign-all button opens the bulk dialog with `bulkCount=N`** + +Mock `pb.collection("order_items").update` so the test doesn't actually hit PocketBase. Then: + +```tsx +it("opens bulk-assign dialog with bulkCount equal to selected rows", async () => { + const { container } = renderPage(); + const selectAll = await screen.findByRole("checkbox", { name: /Select all \d+ stops at/i }); + fireEvent.click(selectAll); + const assignAll = screen.getByRole("button", { name: /Assign \d+ stops$/ }); + fireEvent.click(assignAll); + // Dialog title reads "Assign N items to driver" when bulkCount > 1. + const dialogTitle = await screen.findByText(/Assign \d+ items to driver/i); + expect(dialogTitle).toBeInTheDocument(); +}); +``` + +If the `getPocketBase()` mock in the existing suite doesn't intercept the second dialog's submit handler, extend the mock to also intercept the bulk-assign PATCH chain. + +- [ ] **Step 6: Add a test that collapsing a section hides its rows** + +```tsx +it("collapses a supplier section on chevron click", async () => { + renderPage(); + const chevron = await screen.findByRole("button", { name: "" }); // chevron has no accessible name; query by aria-controls + const section = document.querySelector('[aria-labelledby^="supplier-"]'); + const id = section!.querySelector("header")!.id; + const body = document.getElementById(`${id}-body`) || section!.querySelector('[id$="-body"]'); + expect(body!.className).toMatch(/grid-rows-\[1fr\]/); + fireEvent.click(section!.querySelector("button[aria-expanded]")!); + expect(body!.className).toMatch(/grid-rows-\[0fr\]/); +}); +``` + +- [ ] **Step 7: Run the full collection-pool suite** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool' +``` + +Expected: green. Pre-existing failures (documented across the repo, e.g. `page.test.tsx:175`) are acceptable. + +- [ ] **Step 8: Commit** + +```bash +git add 'src/app/(app)/collection-pool/page.test.tsx' +git commit -m "test(collection-pool): cover groups, bulk assign, notes tooltip" +``` + +--- + +## Task 11: Typecheck + size budget + conditional split + +**Files:** +- Modify or create: `src/app/(app)/collection-pool/page.tsx` (or split into `components.tsx`) + +- [ ] **Step 1: Full typecheck** + +```bash +npx tsc --noEmit +``` + +Expected: clean. + +- [ ] **Step 2: Measure `page.tsx` size** + +```bash +wc -c 'src/app/(app)/collection-pool/page.tsx' +``` + +If the result is **≤ 30 KB**, stop here — the file is within budget. Skip to Step 5. + +If the result is **> 30 KB**, continue with the split in Steps 3–4. + +- [ ] **Step 3: Create `components.tsx` with `PoolRow` + `SupplierGroup`** + +Create `src/app/(app)/collection-pool/components.tsx`: + +```tsx +"use client"; + +import { ChevronDown, Truck, AlertTriangle } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import type { CollectionPoolViewRow } from "./view-model"; +import { + type CollectionPoolGroup, + getAssignmentState, + getPoolAgeLabel, +} from "./view-model"; +import { cn } from "@/lib/utils"; + +// Copy `CollectionPoolVisibleRow` type and `markerTokenToClassName` +// helper from page.tsx. They're needed by both PoolRow and SupplierGroup. + +export type CollectionPoolVisibleRow = CollectionPoolViewRow & { + // mirror the type from page.tsx +}; + +function markerTokenToClassName(token?: string): string { + switch (token) { + case "marker-blue": + return "bg-blue-500"; + case "marker-emerald": + return "bg-emerald-500"; + case "marker-violet": + return "bg-violet-500"; + case "marker-amber": + return "bg-amber-500"; + default: + return "bg-slate-300"; + } +} + +// Then paste the FULL `PoolRow` and `SupplierGroup` function bodies +// from page.tsx, unchanged. +``` + +Cut both functions from `page.tsx` and update imports so `page.tsx` imports them from `./components` instead. + +- [ ] **Step 4: Re-measure + retest** + +```bash +wc -c 'src/app/(app)/collection-pool/page.tsx' 'src/app/(app)/collection-pool/components.tsx' +npx tsc --noEmit +npm run test:run -- 'src/app/(app)/collection-pool' +``` + +Expected: each file ≤ 30 KB; typecheck clean; tests green. + +- [ ] **Step 5: Commit (split or no-op)** + +If you split: +```bash +git add 'src/app/(app)/collection-pool/components.tsx' 'src/app/(app)/collection-pool/page.tsx' +git commit -m "refactor(collection-pool): split PoolRow + SupplierGroup into components.tsx" +``` + +If you didn't split, no commit is needed. + +--- + +## Task 12: Manual smoke + full suite + +- [ ] **Step 1: Full test suite** + +```bash +npm run test:run +``` + +Expected: same passing set as before plus the new tests in `view-model.test.ts` and `page.test.tsx`. Pre-existing failures (the 6 we documented across the repo) are acceptable. + +- [ ] **Step 2: Manual smoke on `/collection-pool`** + +1. Load the page. Rows render in collapsible supplier sections, alphabetical. +2. Click the chevron on a section → body collapses with a smooth transition; chevron rotates. +3. Hover the time-in-list label — confirm it's in `text-primary` and bold. +4. Hover the Notes chip on a row with notes — confirm the tooltip appears with the note text (truncated at 280 chars + ellipsis if longer). +5. Tick the section "Select all" — every row checkbox in the section ticks; row backgrounds tint. +6. Click `Assign N stops`. Bulk dialog opens with eyebrow `Supplier · N stops` and title `Assign N items to driver`. +7. Pick a driver + tomorrow's date, submit. Toast confirms. Reload — rows show "Assigned future" badge and the section's selections clear. +8. Mark a row Ready with a long note (>280 chars). Hover the Notes chip — the preview is truncated with `…`. +9. Switch sort to "Nearby suppliers" — order changes; switch back to "Operational" — supplier-grouped order restored. + +- [ ] **Step 3: Commit any test-only follow-ups** + +```bash +git add -A 'src/app/(app)/collection-pool/' +git commit -m "test(collection-pool): post-smoke follow-ups" --allow-empty +``` + +--- + +## Out-of-scope reminders + +- **Bulk mark-ready.** The dialog requires a free-text note per item; a multi-note UI is a separate feature. Don't add it here. +- **Per-section "Mark section ready".** Same reason. +- **Route rename / nav label change.** Not in scope. +- **Mobile-only layout changes.** The existing grid already collapses correctly; no special breakpoint work is needed for the new structure. +- **Transactional batch PATCH endpoint.** Sequential single-row PATCHes are intentional; rollback on partial failure is out of scope. + +--- + +## Risk notes + +- **Page render budget.** Current 23 KB. Estimated growth ~7–10 KB from the new component + state + handlers. If it crosses 30 KB, Task 11 Step 3 splits `PoolRow` + `SupplierGroup` into `components.tsx`. If it still crosses after the split, consider further splitting the dialog wiring into a hook (`useBulkAssign`). +- **Sort change is observable.** Users who relied on the old "by urgency" default will see suppliers grouped together. The Sort radio's "Operational" label now matches this new behaviour; "Nearby" remains for cluster-priority workflows. +- **Bulk PATCH is not transactional.** A failure on row 4 of 5 leaves rows 1–3 committed. The toast reports partial success; the operator can re-open the dialog and retry. \ No newline at end of file diff --git a/plugin-source/collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6tus/SKILL.md b/plugin-source/collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6tus/SKILL.md new file mode 100644 index 0000000..c89c5da --- /dev/null +++ b/plugin-source/collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6tus/SKILL.md @@ -0,0 +1,1258 @@ +# Collection Pool: supplier groups, bulk assign, primary time-in-list, note preview — 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:** Reorganise `/collection-pool` into collapsible supplier sections with bulk-assign (whole-section or selective), make the time-in-list dominant in `text-primary`, and replace the `title`-attribute Notes chip with a proper hover/focus tooltip. + +**Architecture:** Helper `buildSupplierGroups` lives in `view-model.ts`; the sort flips to `(supplierName, assignmentState, updated)` so the group builder can slice the sorted array. New `SupplierGroup` component (kept in `page.tsx`) renders the header strip (dot · supplier · count · PO eyebrow · chevron · Assign-all button) plus collapsible body. A second `AssignDriverDialog` instance handles bulk with `bulkCount`. The existing `PoolRow` gets two new props (`selected`, `onToggleSelected`) and a per-row checkbox. + +**Tech Stack:** Next.js 16 App Router (read `node_modules/next/dist/docs/` before any Next-specific code), React 18, TypeScript, Tailwind, shadcn/ui, PocketBase admin client, lucide-react, sonner, Vitest. + +**Spec:** [docs/superpowers/specs/2026-07-10-collection-pool-supplier-groups-and-notes-design.md](../specs/2026-07-10-collection-pool-supplier-groups-and-notes-design.md) + +**Pre-flight:** + +- [ ] **Read the spec end-to-end** so the helper signatures, sort tiebreakers, and bulk-merge semantics are locked in your head. +- [ ] **Skim `node_modules/next/dist/docs/`** for anything that affects Client Component state patterns (this codebase is "NOT the Next.js you know" per `AGENTS.md`). +- [ ] **Re-read `page.tsx`** (663 lines) — `PoolRow` props, the colour-stripe helper, the existing single-row `AssignDriverDialog` mount, and the `load()` + `updateItemStatus` patterns. + +--- + +## File Structure + +| File | Responsibility | Touched? | +| --- | --- | --- | +| `src/app/(app)/collection-pool/view-model.ts` | Add `CollectionPoolGroup` type + `buildSupplierGroups` helper; flip `buildVisibleRows` sort to lead with `supplierName`. | Modify | +| `src/app/(app)/collection-pool/view-model.test.ts` | Pin the new sort tiebreaker order; cover `buildSupplierGroups` (empty, fold, eyebrow, marker token). | Modify | +| `src/app/(app)/collection-pool/page.tsx` | Add `selectedIds`, `collapsedSuppliers`, `bulkAssignDialog` state; add `SupplierGroup` + `PoolRow` updates (time-in-list, notes tooltip, checkbox); wire bulk dialog and sequential PATCH handler; replace flat `
                                                          ` with grouped `
                                                          `s. | Modify | +| `src/app/(app)/collection-pool/page.test.tsx` | Cover selection toggling, section select-all, Assign-all opens bulk dialog with `bulkCount=N`, notes tooltip visibility, time-in-list colour class. | Modify | +| `src/app/(app)/collection-pool/components.tsx` (NEW, conditional) | If `page.tsx` exceeds 30 KB after Task 11, split `PoolRow` + `SupplierGroup` here. | Create (conditional) | + +--- + +## Task 1: Add `buildSupplierGroups` helper (TDD) + +**Files:** +- Modify: `src/app/(app)/collection-pool/view-model.test.ts` +- Modify: `src/app/(app)/collection-pool/view-model.ts` + +- [ ] **Step 1: Append failing tests** + +Append to `src/app/(app)/collection-pool/view-model.test.ts` (the existing `makeRow` helper above is reused): + +```ts +import { buildSupplierGroups, type CollectionPoolGroup } from "./view-model"; + +describe("buildSupplierGroups", () => { + it("returns an empty array for empty input", () => { + expect(buildSupplierGroups([], new Map())).toEqual([]); + }); + + it("folds rows by supplierId in input order", () => { + const rows = [ + makeRow({ id: "a", supplierId: "sup-1", supplierName: "Acme", poNumber: "PO-1" }), + makeRow({ id: "b", supplierId: "sup-2", supplierName: "Beta", poNumber: "PO-2" }), + makeRow({ id: "c", supplierId: "sup-1", supplierName: "Acme", poNumber: "PO-3" }), + ]; + const groups = buildSupplierGroups(rows, new Map()); + expect(groups.map((g) => g.supplierId)).toEqual(["sup-1", "sup-2"]); + expect(groups[0].rows.map((r) => r.id)).toEqual(["a", "c"]); + expect(groups[1].rows.map((r) => r.id)).toEqual(["b"]); + }); + + it("captures the top two PO numbers and reports the remaining count", () => { + const rows = [ + makeRow({ supplierId: "sup-1", poNumber: "PO-1" }), + makeRow({ supplierId: "sup-1", poNumber: "PO-2" }), + makeRow({ supplierId: "sup-1", poNumber: "PO-3" }), + makeRow({ supplierId: "sup-1", poNumber: "PO-4" }), + makeRow({ supplierId: "sup-1", poNumber: "PO-4" }), // duplicate PO + ]; + const [group] = buildSupplierGroups(rows, new Map()); + expect(group.topPoNumbers).toEqual(["PO-1", "PO-2"]); + expect(group.remainingPoCount).toBe(2); + }); + + it("uses marker token from supplierMarkerMap", () => { + const map = new Map([["sup-1", "marker-blue"]]); + const [group] = buildSupplierGroups( + [makeRow({ supplierId: "sup-1" })], + map, + ); + expect(group.markerToken).toBe("marker-blue"); + }); + + it("returns empty eyebrow when no rows have a PO number", () => { + const [group] = buildSupplierGroups( + [makeRow({ supplierId: "sup-1", poNumber: "" })], + new Map(), + ); + expect(group.topPoNumbers).toEqual([]); + expect(group.remainingPoCount).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run tests — expect FAIL** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/view-model.test' +``` + +Expected: 5 new tests fail with "buildSupplierGroups is not a function". + +- [ ] **Step 3: Implement `buildSupplierGroups`** + +Append to `src/app/(app)/collection-pool/view-model.ts`: + +```ts +export type CollectionPoolGroup = { + supplierId: string; + supplierName: string; + /** Cluster colour token from the proximity map (may be undefined). */ + markerToken?: string; + /** First two distinct PO numbers for the header eyebrow. */ + topPoNumbers: string[]; + /** Number of distinct POs beyond the top two. */ + remainingPoCount: number; + /** Rows belonging to this group, in render order. */ + rows: CollectionPoolViewRow[]; +}; + +/** + * Folds already-sorted rows into per-supplier groups, preserving input + * order. The caller is responsible for sorting rows by `supplierName` + * (use `buildVisibleRows` first); this helper does not re-sort. + */ +export function buildSupplierGroups( + rows: CollectionPoolViewRow[], + supplierMarkerMap: Map, +): CollectionPoolGroup[] { + const groups: CollectionPoolGroup[] = []; + const bySupplier = new Map(); + + for (const row of rows) { + let group = bySupplier.get(row.supplierId); + if (!group) { + group = { + supplierId: row.supplierId, + supplierName: row.supplierName ?? "", + markerToken: supplierMarkerMap.get(row.supplierId), + topPoNumbers: [], + remainingPoCount: 0, + rows: [], + }; + bySupplier.set(row.supplierId, group); + groups.push(group); + } + group.rows.push(row); + } + + for (const group of groups) { + const uniquePos = Array.from( + new Set(group.rows.map((r) => r.poNumber ?? "").filter(Boolean)), + ); + group.topPoNumbers = uniquePos.slice(0, 2); + group.remainingPoCount = Math.max(0, uniquePos.length - 2); + } + + return groups; +} +``` + +- [ ] **Step 4: Run tests — expect PASS** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/view-model.test' +``` + +Expected: all tests green. + +- [ ] **Step 5: Commit** + +```bash +git add 'src/app/(app)/collection-pool/view-model.ts' 'src/app/(app)/collection-pool/view-model.test.ts' +git commit -m "feat(collection-pool): add buildSupplierGroups helper" +``` + +--- + +## Task 2: Flip sort tiebreaker to lead with `supplierName` + +**Files:** +- Modify: `src/app/(app)/collection-pool/view-model.test.ts` +- Modify: `src/app/(app)/collection-pool/view-model.ts` + +- [ ] **Step 1: Append failing tests** + +Append to `src/app/(app)/collection-pool/view-model.test.ts`: + +```ts +describe("buildVisibleRows — supplier-first sort (operational mode)", () => { + const todayYmd = "2026-07-10"; + + it("sorts by supplierName first when sortMode is operational", () => { + const rows = buildVisibleRows( + [ + makeRow({ id: "beta", supplierName: "Beta" }), + makeRow({ id: "acme", supplierName: "Acme" }), + makeRow({ id: "alpha", supplierName: "Alpha" }), + ], + { todayYmd, searchText: "", readyFilter: "all", sortMode: "operational" }, + ); + expect(rows.map((r) => r.id)).toEqual(["acme", "alpha", "beta"]); + }); + + it("falls through to assignmentState within the same supplier", () => { + const rows = buildVisibleRows( + [ + makeRow({ + id: "acme-unassigned", + supplierName: "Acme", + item_status: "collection_pool", + }), + makeRow({ + id: "acme-today", + supplierName: "Acme", + item_status: "assigned", + drv_id: "drv-1", + assigned_at: `${todayYmd}T00:00:00.000Z`, + }), + ], + { todayYmd, searchText: "", readyFilter: "all", sortMode: "operational" }, + ); + expect(rows.map((r) => r.id)).toEqual(["acme-today", "acme-unassigned"]); + }); + + it("nearby mode still uses cluster prefix before supplier", () => { + const rows = buildVisibleRows( + [ + makeRow({ + id: "beta-cluster-a", + supplierName: "Beta", + clusterSortKey: "cluster-a", + }), + makeRow({ + id: "acme-cluster-b", + supplierName: "Acme", + clusterSortKey: "cluster-b", + }), + makeRow({ + id: "acme-cluster-a", + supplierName: "Acme", + clusterSortKey: "cluster-a", + }), + ], + { todayYmd, searchText: "", readyFilter: "all", sortMode: "nearby" }, + ); + expect(rows.map((r) => r.id)).toEqual([ + "acme-cluster-a", + "beta-cluster-a", + "acme-cluster-b", + ]); + }); +}); +``` + +- [ ] **Step 2: Run tests — expect FAIL** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/view-model.test' +``` + +Expected: 3 new tests fail. The two existing `buildVisibleRows sort modes` tests should still pass (they don't depend on supplier ordering); the original `"sorts assigned today first"` test may start to fail because it expects today-first but the new sort is supplier-first. **Note that failure and proceed — it's expected and will be reconciled when the production change lands in Step 3.** + +- [ ] **Step 3: Update `buildVisibleRows` sort** + +In `src/app/(app)/collection-pool/view-model.ts`, replace the existing `.sort((a, b) => { ... })` callback inside `buildVisibleRows` with: + +```ts +.sort((a, b) => { + if (sortMode === "nearby") { + const clusterDiff = (a.clusterSortKey ?? "zzz").localeCompare(b.clusterSortKey ?? "zzz"); + if (clusterDiff !== 0) return clusterDiff; + } + + // Supplier name is the primary group key. Rows in the same + // supplier are then ordered by assignment urgency (today → + // unassigned → future), then by updated, then by PO + description. + const supplierDiff = (a.supplierName ?? "").localeCompare(b.supplierName ?? ""); + if (supplierDiff !== 0) return supplierDiff; + + const stateDiff = + ASSIGNMENT_SORT_ORDER[getAssignmentState(a, options.todayYmd)] - + ASSIGNMENT_SORT_ORDER[getAssignmentState(b, options.todayYmd)]; + if (stateDiff !== 0) return stateDiff; + + const updatedA = a.updated ?? ""; + const updatedB = b.updated ?? ""; + if (updatedA !== updatedB) return updatedA.localeCompare(updatedB); + + return `${a.poNumber ?? ""}|${a.description ?? ""}`.localeCompare( + `${b.poNumber ?? ""}|${b.description ?? ""}`, + ); +}); +``` + +- [ ] **Step 4: Run tests — expect PASS** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/view-model.test' +``` + +Expected: all tests green. The pre-existing `"sorts assigned today first, then unassigned, then future"` test will now fail because supplier is the primary key; the expected order across multiple suppliers is supplier-alphabetical regardless of assignment state. **If that test was already updated by a previous commit, leave it alone; otherwise update its expectation to include supplier-distinct fixtures so the assertion still holds.** The simplest fix: + +```ts +// Old assertion was: ["today", "unassigned", "future"] with one supplier. +// Update fixture so all three rows share a supplier OR split into +// distinct suppliers and assert alphabetical. Easiest: keep one +// supplier and assert the assignment order. +makeRow({ id: "future", supplierName: "Same", ... }), +makeRow({ id: "unassigned", supplierName: "Same", ... }), +makeRow({ id: "today", supplierName: "Same", ... }), +// Expect ["today", "unassigned", "future"] +``` + +- [ ] **Step 5: Commit** + +```bash +git add 'src/app/(app)/collection-pool/view-model.ts' 'src/app/(app)/collection-pool/view-model.test.ts' +git commit -m "feat(collection-pool): sort by supplier name first" +``` + +--- + +## Task 3: Time-in-list primary colour + "In pool since" sub-label + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx:607` + +- [ ] **Step 1: Update the `PoolRow` order-detail cell** + +In `src/app/(app)/collection-pool/page.tsx`, replace the order-detail cell body that currently contains the time-in-list label (around line 607): + +```tsx +
                                                          + {getPoolAgeLabel(row.updated)} +
                                                          +{row.updated && ( +
                                                          + In pool since {new Date(row.updated).toLocaleDateString(undefined, { + day: "numeric", + month: "short", + })} +
                                                          +)} +``` + +Keep the surrounding markup (`
                                                          `, the Order/PO labels above, the `Scheduled {date}` line below) untouched. + +- [ ] **Step 2: Run the existing page suite** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/page' +``` + +Expected: existing tests still pass (this change adds markup but doesn't alter data flow). + +- [ ] **Step 3: Commit** + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "style(collection-pool): promote time-in-list to text-primary" +``` + +--- + +## Task 4: Notes chip — proper hover/focus tooltip + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx:625-632` + +- [ ] **Step 1: Replace the native `title` chip with a `group/notes` tooltip** + +In `src/app/(app)/collection-pool/page.tsx`, replace the existing notes chip block: + +```tsx +{row.notes && row.notes.trim().length > 0 && ( + + Notes + + {row.notes.length > 280 ? `${row.notes.slice(0, 280)}…` : row.notes} + + +)} +``` + +If `bg-popover` / `text-popover-foreground` are not defined in the project's `globals.css`, fall back to `bg-background text-foreground` (these always resolve). + +- [ ] **Step 2: Run the existing page suite** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/page' +``` + +Expected: existing tests still pass. If any test pinned the old `title` attribute, update the selector to match the new tooltip (e.g. `findByRole("tooltip")`). + +- [ ] **Step 3: Commit** + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): replace notes title with hover tooltip" +``` + +--- + +## Task 5: Selection state + per-row checkbox on `PoolRow` + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx` (state, props, JSX, render call) + +- [ ] **Step 1: Add `selectedIds` state + handlers** + +In `CollectionPoolPage`, alongside the existing `useState` calls (around line 106–116), add: + +```ts +const [selectedIds, setSelectedIds] = useState>(new Set()); + +const toggleRowSelected = useCallback((rowId: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(rowId)) next.delete(rowId); + else next.add(rowId); + return next; + }); +}, []); + +const toggleSectionSelected = useCallback((rowIds: string[]) => { + setSelectedIds((prev) => { + const allSelected = rowIds.every((id) => prev.has(id)); + const next = new Set(prev); + if (allSelected) { + for (const id of rowIds) next.delete(id); + } else { + for (const id of rowIds) next.add(id); + } + return next; + }); +}, []); +``` + +- [ ] **Step 2: Extend `PoolRow` props and signature** + +Update the `PoolRow` props type and signature in the same file (around line 551): + +```tsx +function PoolRow({ + row, + busy, + markerToken, + todayYmd, + selected, + onToggleSelected, + onOpenAssign, + onToggleReady, +}: { + row: CollectionPoolVisibleRow; + busy: boolean; + markerToken?: string; + todayYmd: string; + selected: boolean; + onToggleSelected: (rowId: string) => void; + onOpenAssign: (row: CollectionPoolVisibleRow) => void; + onToggleReady: (row: CollectionPoolVisibleRow, ready: boolean) => void; +}) { +``` + +- [ ] **Step 3: Add the checkbox and row tint to `PoolRow`'s JSX** + +In `PoolRow`'s `
                                                        • ` element (around line 579), apply the selection tint and add a checkbox at the start of the content grid. Replace the opening markup: + +```tsx +
                                                        • + + +
                                                          +
                                                          + onToggleSelected(row.id)} + disabled={busy} + aria-label={`Select ${row.description ?? "item"} at ${row.supplierName ?? "unknown supplier"}`} + className="mt-1 size-4 shrink-0 cursor-pointer accent-primary disabled:cursor-not-allowed" + /> +
                                                          +

                                                          {row.supplierName}

                                                          +

                                                          {row.description || "(no description)"}

                                                          +
                                                          + {row.customerName || "No customer"} + / + {row.companyName || "No company"} +
                                                          + {!row.purchase_order && ( +
                                                          + + This row has no purchase order link. The PO may have been deleted. +
                                                          + )} +
                                                          +
                                                          + + {/* ...rest of the columns unchanged... */} +
                                                          +
                                                        • +``` + +(The supplier/description/customer/company/warning block was previously the direct first child of the inner `
                                                          `. It is now nested inside a new wrapper `
                                                          ` that hosts the checkbox.) + +- [ ] **Step 4: Update the call site (still the flat `
                                                            ` for now)** + +In the rendering block around line 488–500, update the `PoolRow` invocation: + +```tsx +
                                                              + {visibleRows.map((row) => ( + + ))} +
                                                            +``` + +- [ ] **Step 5: Run page suite + commit** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/page' +``` + +Expected: existing tests still pass (PoolRow is exercised via the page). + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): per-row checkbox + selection state" +``` + +--- + +## Task 6: `SupplierGroup` component (renders header + collapsible body) + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx` (new `SupplierGroup` function) + +- [ ] **Step 1: Add the `SupplierGroup` component** + +Append a new `SupplierGroup` function inside `page.tsx`, after `PoolRow`. It renders the section header strip and the collapsible row body: + +```tsx +function SupplierGroup({ + group, + busy, + todayYmd, + selectedIds, + collapsed, + onToggleRowSelected, + onToggleSectionSelected, + onToggleCollapsed, + onOpenAssign, + onOpenBulkAssign, + onToggleReady, +}: { + group: CollectionPoolGroup; + busy: boolean; + todayYmd: string; + selectedIds: Set; + collapsed: boolean; + onToggleRowSelected: (rowId: string) => void; + onToggleSectionSelected: (rowIds: string[]) => void; + onToggleCollapsed: (supplierId: string) => void; + onOpenAssign: (row: CollectionPoolVisibleRow) => void; + onOpenBulkAssign: (supplierId: string, supplierName: string, rowIds: string[]) => void; + onToggleReady: (row: CollectionPoolVisibleRow, ready: boolean) => void; +}) { + const rowIds = group.rows.map((r) => r.id); + const selectedCount = rowIds.filter((id) => selectedIds.has(id)).length; + const allSelected = selectedCount === rowIds.length && rowIds.length > 0; + const someSelected = selectedCount > 0 && !allSelected; + const allAssigned = group.rows.every( + (r) => getAssignmentState(r, todayYmd) !== "unassigned", + ); + + const eyebrowText = (() => { + if (group.topPoNumbers.length === 0) return null; + const head = group.topPoNumbers.map((p) => `PO ${p}`).join(", "); + return group.remainingPoCount > 0 + ? `${head} (+${group.remainingPoCount} more)` + : head; + })(); + + const assignLabel = allSelected + ? `Assign ${rowIds.length} stops` + : selectedCount > 0 + ? `Assign ${selectedCount} selected` + : `Assign all ${rowIds.length}`; + + const onAssignClick = () => { + const ids = allSelected || selectedCount === 0 ? rowIds : rowIds.filter((id) => selectedIds.has(id)); + onOpenBulkAssign(group.supplierId, group.supplierName, ids); + }; + + return ( +
                                                            +
                                                            + +

                                                            + {group.supplierName || "Unknown supplier"} +

                                                            + + · {group.rows.length} {group.rows.length === 1 ? "stop" : "stops"} + {eyebrowText ? <> · {eyebrowText} : null} + +
                                                            + + + +
                                                            +
                                                            +
                                                            +
                                                              + {group.rows.map((row) => ( + + ))} +
                                                            +
                                                            +
                                                            + ); +} +``` + +- [ ] **Step 2: Add the missing imports** + +At the top of `page.tsx`, extend the lucide-react import: + +```tsx +import { AlertTriangle, ChevronDown, Loader2, Search, Truck } from "lucide-react"; +``` + +And update the `./view-model` imports so `buildSupplierGroups`, `CollectionPoolGroup`, and `getAssignmentBadge` are all present: + +```tsx +import { + buildProximityMarkerMap, + buildSupplierGroups, + buildVisibleRows, + type CollectionPoolGroup, + type CollectionPoolSortMode, + getAssignmentBadge, + getAssignmentState, + getPoolAgeLabel, +} from "./view-model"; +``` + +(`getAssignmentBadge` is still consumed by `PoolRow`'s status pill — keep it.) + +- [ ] **Step 3: Run typecheck** + +```bash +npx tsc --noEmit +``` + +Expected: clean. If `Type 'Set' is not assignable to ...` errors fire on `selectedIds`, the `useState>(new Set())` should already be inferring — double-check the import. + +- [ ] **Step 4: Commit (component only — not wired yet)** + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): add SupplierGroup component" +``` + +--- + +## Task 7: Replace flat `
                                                              ` with grouped `
                                                              `s + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx` (rendering block + state) + +- [ ] **Step 1: Add `collapsedSuppliers` state** + +In `CollectionPoolPage`, next to `selectedIds`: + +```ts +const [collapsedSuppliers, setCollapsedSuppliers] = useState>(new Set()); + +const toggleCollapsed = useCallback((supplierId: string) => { + setCollapsedSuppliers((prev) => { + const next = new Set(prev); + if (next.has(supplierId)) next.delete(supplierId); + else next.add(supplierId); + return next; + }); +}, []); +``` + +- [ ] **Step 2: Add `openBulkAssign` handler stub** + +Below `openAssignForRow`, add a stub that just opens state — the actual submit handler comes in Task 9. (The stub is intentional so Task 8 can wire the button without Task 9 needing to be done first.) + +```ts +const [bulkAssignDialog, setBulkAssignDialog] = useState<{ + supplierId: string; + supplierName: string; + rowIds: string[]; +} | null>(null); + +const openBulkAssign = useCallback( + (supplierId: string, supplierName: string, rowIds: string[]) => { + if (rowIds.length === 0) return; + setBulkAssignDialog({ supplierId, supplierName, rowIds }); + }, + [], +); + +const closeBulkAssign = useCallback(() => setBulkAssignDialog(null), []); +``` + +- [ ] **Step 3: Compute `groups` in the page** + +Below `visibleRows`, add: + +```tsx +const groups = useMemo(() => { + if (!visibleRows) return null; + return buildSupplierGroups(visibleRows, supplierMarkerMap); +}, [visibleRows, supplierMarkerMap]); +``` + +- [ ] **Step 4: Replace the flat-list rendering** + +Replace the `
                                                                ` block (around line 488–500 in the current `page.tsx`) with: + +```tsx +
                                                                +
                                                                + Collection Item + Order Detail + Status + Actions +
                                                                + {groups === null ? ( +
                                                                + {Array.from({ length: 4 }).map((_, i) => ( +
                                                                + ))} +
                                                                + ) : groups.length === 0 ? ( + + ) : ( + groups.map((group) => ( + + )) + )} +
                                                                +``` + +Note: the empty-states at the top level (`rows === null` skeleton, `rows.length === 0` empty, `visibleRows.length === 0` empty) now collapse into the new conditional inside the container — delete the previous three separate early-return blocks (around lines 460–478) to avoid double-rendering. + +- [ ] **Step 5: Run typecheck + page suite** + +```bash +npx tsc --noEmit +npm run test:run -- 'src/app/(app)/collection-pool/page' +``` + +Expected: typecheck clean; page tests still pass. If a test pinned the old `
                                                                  `, update the selector to find rows by data-od-id on the section or fall back to a more stable test query. + +- [ ] **Step 6: Commit** + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): render supplier-grouped sections" +``` + +--- + +## Task 8: Wire bulk-assign dialog mount + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx` (second `AssignDriverDialog` mount) + +- [ ] **Step 1: Stub the submit handler + bulkBusy state** + +Just below `handleAssignDialogSubmit`, add: + +```ts +const [bulkBusy, setBulkBusy] = useState(false); + +const handleBulkAssignSubmit = useCallback( + async (_payload: AssignDriverSubmitPayload) => { + // Full implementation in Task 9. + }, + [], +); +``` + +- [ ] **Step 2: Add the bulk-assign dialog JSX** + +Just below the existing single-row `` (around line 504), add: + +```tsx + { + if (!open) closeBulkAssign(); + }} + onSubmit={handleBulkAssignSubmit} + bulkCount={bulkAssignDialog?.rowIds.length ?? 1} + eyebrow={ + bulkAssignDialog ? ( + + + {bulkAssignDialog.supplierName || "Unknown supplier"} + · + {bulkAssignDialog.rowIds.length} stops + + ) : undefined + } + busy={bulkBusy} +/> +``` + +- [ ] **Step 3: Run typecheck + commit** + +```bash +npx tsc --noEmit +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): wire bulk-assign dialog mount" +``` + +--- + +## Task 9: Sequential bulk PATCH handler + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx` (`handleBulkAssignSubmit` body) + +- [ ] **Step 1: Replace the stub with the real implementation** + +In `page.tsx`, replace the empty `handleBulkAssignSubmit` with: + +```ts +const handleBulkAssignSubmit = useCallback( + async (payload: AssignDriverSubmitPayload) => { + if (!bulkAssignDialog) return; + const { driverId, date } = payload; + const { rowIds, supplierName } = bulkAssignDialog; + + setBulkBusy(true); + try { + const pb = getPocketBase(); + let successCount = 0; + let firstError: string | null = null; + + // Sequential, not parallel: any failure leaves earlier + // successes committed (matches the single-row PATCH semantics). + for (const id of rowIds) { + try { + await pb.collection("order_items").update(id, { + item_status: "assigned", + drv_id: driverId, + drv_status: "assigned", + assigned_at: `${date}T00:00:00.000Z`, + } as Partial); + successCount++; + } catch (err) { + if (!firstError) { + firstError = err instanceof Error ? err.message : String(err); + } + } + } + + // Clear selections for this supplier on success (partial or full). + setSelectedIds((prev) => { + const next = new Set(prev); + for (const id of rowIds) next.delete(id); + return next; + }); + closeBulkAssign(); + + if (firstError) { + toast.error( + `Assigned ${successCount} of ${rowIds.length} stops at ${supplierName || "supplier"}. First error: ${firstError}`, + ); + } else { + toast.success(`Assigned ${successCount} stops at ${supplierName || "supplier"}.`); + } + + await load(); + } finally { + setBulkBusy(false); + } + }, + [bulkAssignDialog, closeBulkAssign, load], +); +``` + +- [ ] **Step 2: Run typecheck + page suite** + +```bash +npx tsc --noEmit +npm run test:run -- 'src/app/(app)/collection-pool/page' +``` + +Expected: typecheck clean; existing page tests pass. Mock the PocketBase `update` call in tests as needed — see Task 10 for the test wiring. + +- [ ] **Step 3: Commit** + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): sequential bulk-assign PATCH" +``` + +--- + +## Task 10: Page-level tests + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.test.tsx` + +- [ ] **Step 1: Add a test that time-in-list uses `text-primary`** + +Find the existing render fixture in `page.test.tsx` (search for `getPoolAgeLabel` or a row containing `text-foreground`). Append: + +```tsx +import { screen, within } from "@testing-library/react"; + +it("renders time-in-list with text-primary class", async () => { + renderPage(); + const ageLabels = await screen.findAllByText(/^\d+[hd]$/); + expect(ageLabels.length).toBeGreaterThan(0); + for (const label of ageLabels) { + expect(label.className).toMatch(/text-primary/); + } +}); +``` + +(The selector `/^\d+[hd]$/` matches the `Nh` / `Nd` format produced by `getPoolAgeLabel`. Adjust if existing tests use a different fixture pattern — search the file for any pre-existing age assertions first.) + +- [ ] **Step 2: Add a test that the Notes tooltip appears on hover** + +```tsx +it("surfaces the Notes tooltip on row hover", async () => { + // Fixture row must include `notes: "Spoke to David — confirmed pickup tomorrow"`. + renderPage({ rowOverrides: { notes: "Spoke to David — confirmed pickup tomorrow" } }); + const tooltip = await screen.findByRole("tooltip"); + expect(tooltip).toHaveTextContent(/Spoke to David/); + expect(tooltip).toHaveClass("hidden"); // default state +}); +``` + +If the project's `renderPage` helper doesn't accept row overrides, add a parameter that forwards to the fixture builder — it's a one-line change. + +- [ ] **Step 3: Add a test that selecting a row toggles the selection tint** + +```tsx +it("applies a primary-tint background to selected rows", async () => { + const { container } = renderPage(); + const firstCheckbox = await screen.findByRole("checkbox", { name: /Select .* at .*/i }); + fireEvent.click(firstCheckbox); + const row = firstCheckbox.closest("li"); + expect(row).not.toBeNull(); + expect(row!.className).toMatch(/bg-primary\/5/); +}); +``` + +- [ ] **Step 4: Add a test that the section "Select all" toggles every row in the section** + +```tsx +it("section Select all toggles every row in the supplier group", async () => { + renderPage(); + const selectAll = await screen.findByRole("checkbox", { name: /Select all \d+ stops at/i }); + fireEvent.click(selectAll); + const rowCheckboxes = await screen.findAllByRole("checkbox", { name: /^Select .* at/i }); + expect(rowCheckboxes.length).toBeGreaterThan(0); + for (const cb of rowCheckboxes) { + expect(cb).toBeChecked(); + } +}); +``` + +- [ ] **Step 5: Add a test that the Assign-all button opens the bulk dialog with `bulkCount=N`** + +Mock `pb.collection("order_items").update` so the test doesn't actually hit PocketBase. Then: + +```tsx +it("opens bulk-assign dialog with bulkCount equal to selected rows", async () => { + const { container } = renderPage(); + const selectAll = await screen.findByRole("checkbox", { name: /Select all \d+ stops at/i }); + fireEvent.click(selectAll); + const assignAll = screen.getByRole("button", { name: /Assign \d+ stops$/ }); + fireEvent.click(assignAll); + // Dialog title reads "Assign N items to driver" when bulkCount > 1. + const dialogTitle = await screen.findByText(/Assign \d+ items to driver/i); + expect(dialogTitle).toBeInTheDocument(); +}); +``` + +If the `getPocketBase()` mock in the existing suite doesn't intercept the second dialog's submit handler, extend the mock to also intercept the bulk-assign PATCH chain. + +- [ ] **Step 6: Add a test that collapsing a section hides its rows** + +```tsx +it("collapses a supplier section on chevron click", async () => { + renderPage(); + const chevron = await screen.findByRole("button", { name: "" }); // chevron has no accessible name; query by aria-controls + const section = document.querySelector('[aria-labelledby^="supplier-"]'); + const id = section!.querySelector("header")!.id; + const body = document.getElementById(`${id}-body`) || section!.querySelector('[id$="-body"]'); + expect(body!.className).toMatch(/grid-rows-\[1fr\]/); + fireEvent.click(section!.querySelector("button[aria-expanded]")!); + expect(body!.className).toMatch(/grid-rows-\[0fr\]/); +}); +``` + +- [ ] **Step 7: Run the full collection-pool suite** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool' +``` + +Expected: green. Pre-existing failures (documented across the repo, e.g. `page.test.tsx:175`) are acceptable. + +- [ ] **Step 8: Commit** + +```bash +git add 'src/app/(app)/collection-pool/page.test.tsx' +git commit -m "test(collection-pool): cover groups, bulk assign, notes tooltip" +``` + +--- + +## Task 11: Typecheck + size budget + conditional split + +**Files:** +- Modify or create: `src/app/(app)/collection-pool/page.tsx` (or split into `components.tsx`) + +- [ ] **Step 1: Full typecheck** + +```bash +npx tsc --noEmit +``` + +Expected: clean. + +- [ ] **Step 2: Measure `page.tsx` size** + +```bash +wc -c 'src/app/(app)/collection-pool/page.tsx' +``` + +If the result is **≤ 30 KB**, stop here — the file is within budget. Skip to Step 5. + +If the result is **> 30 KB**, continue with the split in Steps 3–4. + +- [ ] **Step 3: Create `components.tsx` with `PoolRow` + `SupplierGroup`** + +Create `src/app/(app)/collection-pool/components.tsx`: + +```tsx +"use client"; + +import { ChevronDown, Truck, AlertTriangle } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import type { CollectionPoolViewRow } from "./view-model"; +import { + type CollectionPoolGroup, + getAssignmentState, + getPoolAgeLabel, +} from "./view-model"; +import { cn } from "@/lib/utils"; + +// Copy `CollectionPoolVisibleRow` type and `markerTokenToClassName` +// helper from page.tsx. They're needed by both PoolRow and SupplierGroup. + +export type CollectionPoolVisibleRow = CollectionPoolViewRow & { + // mirror the type from page.tsx +}; + +function markerTokenToClassName(token?: string): string { + switch (token) { + case "marker-blue": + return "bg-blue-500"; + case "marker-emerald": + return "bg-emerald-500"; + case "marker-violet": + return "bg-violet-500"; + case "marker-amber": + return "bg-amber-500"; + default: + return "bg-slate-300"; + } +} + +// Then paste the FULL `PoolRow` and `SupplierGroup` function bodies +// from page.tsx, unchanged. +``` + +Cut both functions from `page.tsx` and update imports so `page.tsx` imports them from `./components` instead. + +- [ ] **Step 4: Re-measure + retest** + +```bash +wc -c 'src/app/(app)/collection-pool/page.tsx' 'src/app/(app)/collection-pool/components.tsx' +npx tsc --noEmit +npm run test:run -- 'src/app/(app)/collection-pool' +``` + +Expected: each file ≤ 30 KB; typecheck clean; tests green. + +- [ ] **Step 5: Commit (split or no-op)** + +If you split: +```bash +git add 'src/app/(app)/collection-pool/components.tsx' 'src/app/(app)/collection-pool/page.tsx' +git commit -m "refactor(collection-pool): split PoolRow + SupplierGroup into components.tsx" +``` + +If you didn't split, no commit is needed. + +--- + +## Task 12: Manual smoke + full suite + +- [ ] **Step 1: Full test suite** + +```bash +npm run test:run +``` + +Expected: same passing set as before plus the new tests in `view-model.test.ts` and `page.test.tsx`. Pre-existing failures (the 6 we documented across the repo) are acceptable. + +- [ ] **Step 2: Manual smoke on `/collection-pool`** + +1. Load the page. Rows render in collapsible supplier sections, alphabetical. +2. Click the chevron on a section → body collapses with a smooth transition; chevron rotates. +3. Hover the time-in-list label — confirm it's in `text-primary` and bold. +4. Hover the Notes chip on a row with notes — confirm the tooltip appears with the note text (truncated at 280 chars + ellipsis if longer). +5. Tick the section "Select all" — every row checkbox in the section ticks; row backgrounds tint. +6. Click `Assign N stops`. Bulk dialog opens with eyebrow `Supplier · N stops` and title `Assign N items to driver`. +7. Pick a driver + tomorrow's date, submit. Toast confirms. Reload — rows show "Assigned future" badge and the section's selections clear. +8. Mark a row Ready with a long note (>280 chars). Hover the Notes chip — the preview is truncated with `…`. +9. Switch sort to "Nearby suppliers" — order changes; switch back to "Operational" — supplier-grouped order restored. + +- [ ] **Step 3: Commit any test-only follow-ups** + +```bash +git add -A 'src/app/(app)/collection-pool/' +git commit -m "test(collection-pool): post-smoke follow-ups" --allow-empty +``` + +--- + +## Out-of-scope reminders + +- **Bulk mark-ready.** The dialog requires a free-text note per item; a multi-note UI is a separate feature. Don't add it here. +- **Per-section "Mark section ready".** Same reason. +- **Route rename / nav label change.** Not in scope. +- **Mobile-only layout changes.** The existing grid already collapses correctly; no special breakpoint work is needed for the new structure. +- **Transactional batch PATCH endpoint.** Sequential single-row PATCHes are intentional; rollback on partial failure is out of scope. + +--- + +## Risk notes + +- **Page render budget.** Current 23 KB. Estimated growth ~7–10 KB from the new component + state + handlers. If it crosses 30 KB, Task 11 Step 3 splits `PoolRow` + `SupplierGroup` into `components.tsx`. If it still crosses after the split, consider further splitting the dialog wiring into a hook (`useBulkAssign`). +- **Sort change is observable.** Users who relied on the old "by urgency" default will see suppliers grouped together. The Sort radio's "Operational" label now matches this new behaviour; "Nearby" remains for cluster-priority workflows. +- **Bulk PATCH is not transactional.** A failure on row 4 of 5 leaves rows 1–3 committed. The toast reports partial success; the operator can re-open the dialog and retry. + +## Provenance + +Formalized by Open Design from candidate 5cb8fc93-2209-4312-ad86-e1d93493fe2d. diff --git a/plugin-source/collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6tus/open-design.json b/plugin-source/collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6tus/open-design.json new file mode 100644 index 0000000..c42bfa8 --- /dev/null +++ b/plugin-source/collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6tus/open-design.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://open-design.ai/schemas/plugin.v1.json", + "specVersion": "1.0.0", + "name": "collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6tus", + "title": "Collection Pool: supplier groups, bulk assign, primary time-in-list, note previe", + "version": "0.1.0", + "description": "> **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 trackin", + "od": { + "kind": "skill", + "taskKind": "new-generation", + "context": { + "skills": [ + { + "path": "./SKILL.md" + } + ] + }, + "capabilities": [ + "prompt:inject" + ] + } +} diff --git a/plugin-source/collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6tus/references/provenance.json b/plugin-source/collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6tus/references/provenance.json new file mode 100644 index 0000000..32acf01 --- /dev/null +++ b/plugin-source/collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6tus/references/provenance.json @@ -0,0 +1,19 @@ +{ + "candidateId": "5cb8fc93-2209-4312-ad86-e1d93493fe2d", + "projectId": "ef67607e-d372-4742-aa1a-f7206ddb1b6b", + "runId": "0fb704d7-d828-4c0f-8b22-4c1999a773d2", + "conversationId": "00d657d1-caca-4511-984d-518155667f66", + "provenance": { + "summary": "Detected from docs/superpowers/plans/2026-07-10-collection-pool-supplier-groups-and-notes.md after a successful run.", + "detectedAt": 1783721909754 + }, + "sourceRefs": [ + { + "kind": "file", + "value": "docs/superpowers/plans/2026-07-10-collection-pool-supplier-groups-and-notes.md", + "label": "docs/superpowers/plans/2026-07-10-collection-pool-supplier-groups-and-notes.md", + "size": 44068, + "copied": true + } + ] +} diff --git a/plugin-source/collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6tus/references/source-1-2026-07-10-collection-pool-supplier-groups-and-notes.md b/plugin-source/collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6tus/references/source-1-2026-07-10-collection-pool-supplier-groups-and-notes.md new file mode 100644 index 0000000..0d896b9 --- /dev/null +++ b/plugin-source/collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6tus/references/source-1-2026-07-10-collection-pool-supplier-groups-and-notes.md @@ -0,0 +1,1254 @@ +# Collection Pool: supplier groups, bulk assign, primary time-in-list, note preview — 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:** Reorganise `/collection-pool` into collapsible supplier sections with bulk-assign (whole-section or selective), make the time-in-list dominant in `text-primary`, and replace the `title`-attribute Notes chip with a proper hover/focus tooltip. + +**Architecture:** Helper `buildSupplierGroups` lives in `view-model.ts`; the sort flips to `(supplierName, assignmentState, updated)` so the group builder can slice the sorted array. New `SupplierGroup` component (kept in `page.tsx`) renders the header strip (dot · supplier · count · PO eyebrow · chevron · Assign-all button) plus collapsible body. A second `AssignDriverDialog` instance handles bulk with `bulkCount`. The existing `PoolRow` gets two new props (`selected`, `onToggleSelected`) and a per-row checkbox. + +**Tech Stack:** Next.js 16 App Router (read `node_modules/next/dist/docs/` before any Next-specific code), React 18, TypeScript, Tailwind, shadcn/ui, PocketBase admin client, lucide-react, sonner, Vitest. + +**Spec:** [docs/superpowers/specs/2026-07-10-collection-pool-supplier-groups-and-notes-design.md](../specs/2026-07-10-collection-pool-supplier-groups-and-notes-design.md) + +**Pre-flight:** + +- [ ] **Read the spec end-to-end** so the helper signatures, sort tiebreakers, and bulk-merge semantics are locked in your head. +- [ ] **Skim `node_modules/next/dist/docs/`** for anything that affects Client Component state patterns (this codebase is "NOT the Next.js you know" per `AGENTS.md`). +- [ ] **Re-read `page.tsx`** (663 lines) — `PoolRow` props, the colour-stripe helper, the existing single-row `AssignDriverDialog` mount, and the `load()` + `updateItemStatus` patterns. + +--- + +## File Structure + +| File | Responsibility | Touched? | +| --- | --- | --- | +| `src/app/(app)/collection-pool/view-model.ts` | Add `CollectionPoolGroup` type + `buildSupplierGroups` helper; flip `buildVisibleRows` sort to lead with `supplierName`. | Modify | +| `src/app/(app)/collection-pool/view-model.test.ts` | Pin the new sort tiebreaker order; cover `buildSupplierGroups` (empty, fold, eyebrow, marker token). | Modify | +| `src/app/(app)/collection-pool/page.tsx` | Add `selectedIds`, `collapsedSuppliers`, `bulkAssignDialog` state; add `SupplierGroup` + `PoolRow` updates (time-in-list, notes tooltip, checkbox); wire bulk dialog and sequential PATCH handler; replace flat `
                                                                    ` with grouped `
                                                                    `s. | Modify | +| `src/app/(app)/collection-pool/page.test.tsx` | Cover selection toggling, section select-all, Assign-all opens bulk dialog with `bulkCount=N`, notes tooltip visibility, time-in-list colour class. | Modify | +| `src/app/(app)/collection-pool/components.tsx` (NEW, conditional) | If `page.tsx` exceeds 30 KB after Task 11, split `PoolRow` + `SupplierGroup` here. | Create (conditional) | + +--- + +## Task 1: Add `buildSupplierGroups` helper (TDD) + +**Files:** +- Modify: `src/app/(app)/collection-pool/view-model.test.ts` +- Modify: `src/app/(app)/collection-pool/view-model.ts` + +- [ ] **Step 1: Append failing tests** + +Append to `src/app/(app)/collection-pool/view-model.test.ts` (the existing `makeRow` helper above is reused): + +```ts +import { buildSupplierGroups, type CollectionPoolGroup } from "./view-model"; + +describe("buildSupplierGroups", () => { + it("returns an empty array for empty input", () => { + expect(buildSupplierGroups([], new Map())).toEqual([]); + }); + + it("folds rows by supplierId in input order", () => { + const rows = [ + makeRow({ id: "a", supplierId: "sup-1", supplierName: "Acme", poNumber: "PO-1" }), + makeRow({ id: "b", supplierId: "sup-2", supplierName: "Beta", poNumber: "PO-2" }), + makeRow({ id: "c", supplierId: "sup-1", supplierName: "Acme", poNumber: "PO-3" }), + ]; + const groups = buildSupplierGroups(rows, new Map()); + expect(groups.map((g) => g.supplierId)).toEqual(["sup-1", "sup-2"]); + expect(groups[0].rows.map((r) => r.id)).toEqual(["a", "c"]); + expect(groups[1].rows.map((r) => r.id)).toEqual(["b"]); + }); + + it("captures the top two PO numbers and reports the remaining count", () => { + const rows = [ + makeRow({ supplierId: "sup-1", poNumber: "PO-1" }), + makeRow({ supplierId: "sup-1", poNumber: "PO-2" }), + makeRow({ supplierId: "sup-1", poNumber: "PO-3" }), + makeRow({ supplierId: "sup-1", poNumber: "PO-4" }), + makeRow({ supplierId: "sup-1", poNumber: "PO-4" }), // duplicate PO + ]; + const [group] = buildSupplierGroups(rows, new Map()); + expect(group.topPoNumbers).toEqual(["PO-1", "PO-2"]); + expect(group.remainingPoCount).toBe(2); + }); + + it("uses marker token from supplierMarkerMap", () => { + const map = new Map([["sup-1", "marker-blue"]]); + const [group] = buildSupplierGroups( + [makeRow({ supplierId: "sup-1" })], + map, + ); + expect(group.markerToken).toBe("marker-blue"); + }); + + it("returns empty eyebrow when no rows have a PO number", () => { + const [group] = buildSupplierGroups( + [makeRow({ supplierId: "sup-1", poNumber: "" })], + new Map(), + ); + expect(group.topPoNumbers).toEqual([]); + expect(group.remainingPoCount).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run tests — expect FAIL** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/view-model.test' +``` + +Expected: 5 new tests fail with "buildSupplierGroups is not a function". + +- [ ] **Step 3: Implement `buildSupplierGroups`** + +Append to `src/app/(app)/collection-pool/view-model.ts`: + +```ts +export type CollectionPoolGroup = { + supplierId: string; + supplierName: string; + /** Cluster colour token from the proximity map (may be undefined). */ + markerToken?: string; + /** First two distinct PO numbers for the header eyebrow. */ + topPoNumbers: string[]; + /** Number of distinct POs beyond the top two. */ + remainingPoCount: number; + /** Rows belonging to this group, in render order. */ + rows: CollectionPoolViewRow[]; +}; + +/** + * Folds already-sorted rows into per-supplier groups, preserving input + * order. The caller is responsible for sorting rows by `supplierName` + * (use `buildVisibleRows` first); this helper does not re-sort. + */ +export function buildSupplierGroups( + rows: CollectionPoolViewRow[], + supplierMarkerMap: Map, +): CollectionPoolGroup[] { + const groups: CollectionPoolGroup[] = []; + const bySupplier = new Map(); + + for (const row of rows) { + let group = bySupplier.get(row.supplierId); + if (!group) { + group = { + supplierId: row.supplierId, + supplierName: row.supplierName ?? "", + markerToken: supplierMarkerMap.get(row.supplierId), + topPoNumbers: [], + remainingPoCount: 0, + rows: [], + }; + bySupplier.set(row.supplierId, group); + groups.push(group); + } + group.rows.push(row); + } + + for (const group of groups) { + const uniquePos = Array.from( + new Set(group.rows.map((r) => r.poNumber ?? "").filter(Boolean)), + ); + group.topPoNumbers = uniquePos.slice(0, 2); + group.remainingPoCount = Math.max(0, uniquePos.length - 2); + } + + return groups; +} +``` + +- [ ] **Step 4: Run tests — expect PASS** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/view-model.test' +``` + +Expected: all tests green. + +- [ ] **Step 5: Commit** + +```bash +git add 'src/app/(app)/collection-pool/view-model.ts' 'src/app/(app)/collection-pool/view-model.test.ts' +git commit -m "feat(collection-pool): add buildSupplierGroups helper" +``` + +--- + +## Task 2: Flip sort tiebreaker to lead with `supplierName` + +**Files:** +- Modify: `src/app/(app)/collection-pool/view-model.test.ts` +- Modify: `src/app/(app)/collection-pool/view-model.ts` + +- [ ] **Step 1: Append failing tests** + +Append to `src/app/(app)/collection-pool/view-model.test.ts`: + +```ts +describe("buildVisibleRows — supplier-first sort (operational mode)", () => { + const todayYmd = "2026-07-10"; + + it("sorts by supplierName first when sortMode is operational", () => { + const rows = buildVisibleRows( + [ + makeRow({ id: "beta", supplierName: "Beta" }), + makeRow({ id: "acme", supplierName: "Acme" }), + makeRow({ id: "alpha", supplierName: "Alpha" }), + ], + { todayYmd, searchText: "", readyFilter: "all", sortMode: "operational" }, + ); + expect(rows.map((r) => r.id)).toEqual(["acme", "alpha", "beta"]); + }); + + it("falls through to assignmentState within the same supplier", () => { + const rows = buildVisibleRows( + [ + makeRow({ + id: "acme-unassigned", + supplierName: "Acme", + item_status: "collection_pool", + }), + makeRow({ + id: "acme-today", + supplierName: "Acme", + item_status: "assigned", + drv_id: "drv-1", + assigned_at: `${todayYmd}T00:00:00.000Z`, + }), + ], + { todayYmd, searchText: "", readyFilter: "all", sortMode: "operational" }, + ); + expect(rows.map((r) => r.id)).toEqual(["acme-today", "acme-unassigned"]); + }); + + it("nearby mode still uses cluster prefix before supplier", () => { + const rows = buildVisibleRows( + [ + makeRow({ + id: "beta-cluster-a", + supplierName: "Beta", + clusterSortKey: "cluster-a", + }), + makeRow({ + id: "acme-cluster-b", + supplierName: "Acme", + clusterSortKey: "cluster-b", + }), + makeRow({ + id: "acme-cluster-a", + supplierName: "Acme", + clusterSortKey: "cluster-a", + }), + ], + { todayYmd, searchText: "", readyFilter: "all", sortMode: "nearby" }, + ); + expect(rows.map((r) => r.id)).toEqual([ + "acme-cluster-a", + "beta-cluster-a", + "acme-cluster-b", + ]); + }); +}); +``` + +- [ ] **Step 2: Run tests — expect FAIL** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/view-model.test' +``` + +Expected: 3 new tests fail. The two existing `buildVisibleRows sort modes` tests should still pass (they don't depend on supplier ordering); the original `"sorts assigned today first"` test may start to fail because it expects today-first but the new sort is supplier-first. **Note that failure and proceed — it's expected and will be reconciled when the production change lands in Step 3.** + +- [ ] **Step 3: Update `buildVisibleRows` sort** + +In `src/app/(app)/collection-pool/view-model.ts`, replace the existing `.sort((a, b) => { ... })` callback inside `buildVisibleRows` with: + +```ts +.sort((a, b) => { + if (sortMode === "nearby") { + const clusterDiff = (a.clusterSortKey ?? "zzz").localeCompare(b.clusterSortKey ?? "zzz"); + if (clusterDiff !== 0) return clusterDiff; + } + + // Supplier name is the primary group key. Rows in the same + // supplier are then ordered by assignment urgency (today → + // unassigned → future), then by updated, then by PO + description. + const supplierDiff = (a.supplierName ?? "").localeCompare(b.supplierName ?? ""); + if (supplierDiff !== 0) return supplierDiff; + + const stateDiff = + ASSIGNMENT_SORT_ORDER[getAssignmentState(a, options.todayYmd)] - + ASSIGNMENT_SORT_ORDER[getAssignmentState(b, options.todayYmd)]; + if (stateDiff !== 0) return stateDiff; + + const updatedA = a.updated ?? ""; + const updatedB = b.updated ?? ""; + if (updatedA !== updatedB) return updatedA.localeCompare(updatedB); + + return `${a.poNumber ?? ""}|${a.description ?? ""}`.localeCompare( + `${b.poNumber ?? ""}|${b.description ?? ""}`, + ); +}); +``` + +- [ ] **Step 4: Run tests — expect PASS** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/view-model.test' +``` + +Expected: all tests green. The pre-existing `"sorts assigned today first, then unassigned, then future"` test will now fail because supplier is the primary key; the expected order across multiple suppliers is supplier-alphabetical regardless of assignment state. **If that test was already updated by a previous commit, leave it alone; otherwise update its expectation to include supplier-distinct fixtures so the assertion still holds.** The simplest fix: + +```ts +// Old assertion was: ["today", "unassigned", "future"] with one supplier. +// Update fixture so all three rows share a supplier OR split into +// distinct suppliers and assert alphabetical. Easiest: keep one +// supplier and assert the assignment order. +makeRow({ id: "future", supplierName: "Same", ... }), +makeRow({ id: "unassigned", supplierName: "Same", ... }), +makeRow({ id: "today", supplierName: "Same", ... }), +// Expect ["today", "unassigned", "future"] +``` + +- [ ] **Step 5: Commit** + +```bash +git add 'src/app/(app)/collection-pool/view-model.ts' 'src/app/(app)/collection-pool/view-model.test.ts' +git commit -m "feat(collection-pool): sort by supplier name first" +``` + +--- + +## Task 3: Time-in-list primary colour + "In pool since" sub-label + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx:607` + +- [ ] **Step 1: Update the `PoolRow` order-detail cell** + +In `src/app/(app)/collection-pool/page.tsx`, replace the order-detail cell body that currently contains the time-in-list label (around line 607): + +```tsx +
                                                                    + {getPoolAgeLabel(row.updated)} +
                                                                    +{row.updated && ( +
                                                                    + In pool since {new Date(row.updated).toLocaleDateString(undefined, { + day: "numeric", + month: "short", + })} +
                                                                    +)} +``` + +Keep the surrounding markup (`
                                                                    `, the Order/PO labels above, the `Scheduled {date}` line below) untouched. + +- [ ] **Step 2: Run the existing page suite** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/page' +``` + +Expected: existing tests still pass (this change adds markup but doesn't alter data flow). + +- [ ] **Step 3: Commit** + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "style(collection-pool): promote time-in-list to text-primary" +``` + +--- + +## Task 4: Notes chip — proper hover/focus tooltip + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx:625-632` + +- [ ] **Step 1: Replace the native `title` chip with a `group/notes` tooltip** + +In `src/app/(app)/collection-pool/page.tsx`, replace the existing notes chip block: + +```tsx +{row.notes && row.notes.trim().length > 0 && ( + + Notes + + {row.notes.length > 280 ? `${row.notes.slice(0, 280)}…` : row.notes} + + +)} +``` + +If `bg-popover` / `text-popover-foreground` are not defined in the project's `globals.css`, fall back to `bg-background text-foreground` (these always resolve). + +- [ ] **Step 2: Run the existing page suite** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/page' +``` + +Expected: existing tests still pass. If any test pinned the old `title` attribute, update the selector to match the new tooltip (e.g. `findByRole("tooltip")`). + +- [ ] **Step 3: Commit** + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): replace notes title with hover tooltip" +``` + +--- + +## Task 5: Selection state + per-row checkbox on `PoolRow` + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx` (state, props, JSX, render call) + +- [ ] **Step 1: Add `selectedIds` state + handlers** + +In `CollectionPoolPage`, alongside the existing `useState` calls (around line 106–116), add: + +```ts +const [selectedIds, setSelectedIds] = useState>(new Set()); + +const toggleRowSelected = useCallback((rowId: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(rowId)) next.delete(rowId); + else next.add(rowId); + return next; + }); +}, []); + +const toggleSectionSelected = useCallback((rowIds: string[]) => { + setSelectedIds((prev) => { + const allSelected = rowIds.every((id) => prev.has(id)); + const next = new Set(prev); + if (allSelected) { + for (const id of rowIds) next.delete(id); + } else { + for (const id of rowIds) next.add(id); + } + return next; + }); +}, []); +``` + +- [ ] **Step 2: Extend `PoolRow` props and signature** + +Update the `PoolRow` props type and signature in the same file (around line 551): + +```tsx +function PoolRow({ + row, + busy, + markerToken, + todayYmd, + selected, + onToggleSelected, + onOpenAssign, + onToggleReady, +}: { + row: CollectionPoolVisibleRow; + busy: boolean; + markerToken?: string; + todayYmd: string; + selected: boolean; + onToggleSelected: (rowId: string) => void; + onOpenAssign: (row: CollectionPoolVisibleRow) => void; + onToggleReady: (row: CollectionPoolVisibleRow, ready: boolean) => void; +}) { +``` + +- [ ] **Step 3: Add the checkbox and row tint to `PoolRow`'s JSX** + +In `PoolRow`'s `
                                                                  • ` element (around line 579), apply the selection tint and add a checkbox at the start of the content grid. Replace the opening markup: + +```tsx +
                                                                  • + + +
                                                                    +
                                                                    + onToggleSelected(row.id)} + disabled={busy} + aria-label={`Select ${row.description ?? "item"} at ${row.supplierName ?? "unknown supplier"}`} + className="mt-1 size-4 shrink-0 cursor-pointer accent-primary disabled:cursor-not-allowed" + /> +
                                                                    +

                                                                    {row.supplierName}

                                                                    +

                                                                    {row.description || "(no description)"}

                                                                    +
                                                                    + {row.customerName || "No customer"} + / + {row.companyName || "No company"} +
                                                                    + {!row.purchase_order && ( +
                                                                    + + This row has no purchase order link. The PO may have been deleted. +
                                                                    + )} +
                                                                    +
                                                                    + + {/* ...rest of the columns unchanged... */} +
                                                                    +
                                                                  • +``` + +(The supplier/description/customer/company/warning block was previously the direct first child of the inner `
                                                                    `. It is now nested inside a new wrapper `
                                                                    ` that hosts the checkbox.) + +- [ ] **Step 4: Update the call site (still the flat `
                                                                      ` for now)** + +In the rendering block around line 488–500, update the `PoolRow` invocation: + +```tsx +
                                                                        + {visibleRows.map((row) => ( + + ))} +
                                                                      +``` + +- [ ] **Step 5: Run page suite + commit** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool/page' +``` + +Expected: existing tests still pass (PoolRow is exercised via the page). + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): per-row checkbox + selection state" +``` + +--- + +## Task 6: `SupplierGroup` component (renders header + collapsible body) + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx` (new `SupplierGroup` function) + +- [ ] **Step 1: Add the `SupplierGroup` component** + +Append a new `SupplierGroup` function inside `page.tsx`, after `PoolRow`. It renders the section header strip and the collapsible row body: + +```tsx +function SupplierGroup({ + group, + busy, + todayYmd, + selectedIds, + collapsed, + onToggleRowSelected, + onToggleSectionSelected, + onToggleCollapsed, + onOpenAssign, + onOpenBulkAssign, + onToggleReady, +}: { + group: CollectionPoolGroup; + busy: boolean; + todayYmd: string; + selectedIds: Set; + collapsed: boolean; + onToggleRowSelected: (rowId: string) => void; + onToggleSectionSelected: (rowIds: string[]) => void; + onToggleCollapsed: (supplierId: string) => void; + onOpenAssign: (row: CollectionPoolVisibleRow) => void; + onOpenBulkAssign: (supplierId: string, supplierName: string, rowIds: string[]) => void; + onToggleReady: (row: CollectionPoolVisibleRow, ready: boolean) => void; +}) { + const rowIds = group.rows.map((r) => r.id); + const selectedCount = rowIds.filter((id) => selectedIds.has(id)).length; + const allSelected = selectedCount === rowIds.length && rowIds.length > 0; + const someSelected = selectedCount > 0 && !allSelected; + const allAssigned = group.rows.every( + (r) => getAssignmentState(r, todayYmd) !== "unassigned", + ); + + const eyebrowText = (() => { + if (group.topPoNumbers.length === 0) return null; + const head = group.topPoNumbers.map((p) => `PO ${p}`).join(", "); + return group.remainingPoCount > 0 + ? `${head} (+${group.remainingPoCount} more)` + : head; + })(); + + const assignLabel = allSelected + ? `Assign ${rowIds.length} stops` + : selectedCount > 0 + ? `Assign ${selectedCount} selected` + : `Assign all ${rowIds.length}`; + + const onAssignClick = () => { + const ids = allSelected || selectedCount === 0 ? rowIds : rowIds.filter((id) => selectedIds.has(id)); + onOpenBulkAssign(group.supplierId, group.supplierName, ids); + }; + + return ( +
                                                                      +
                                                                      + +

                                                                      + {group.supplierName || "Unknown supplier"} +

                                                                      + + · {group.rows.length} {group.rows.length === 1 ? "stop" : "stops"} + {eyebrowText ? <> · {eyebrowText} : null} + +
                                                                      + + + +
                                                                      +
                                                                      +
                                                                      +
                                                                        + {group.rows.map((row) => ( + + ))} +
                                                                      +
                                                                      +
                                                                      + ); +} +``` + +- [ ] **Step 2: Add the missing imports** + +At the top of `page.tsx`, extend the lucide-react import: + +```tsx +import { AlertTriangle, ChevronDown, Loader2, Search, Truck } from "lucide-react"; +``` + +And update the `./view-model` imports so `buildSupplierGroups`, `CollectionPoolGroup`, and `getAssignmentBadge` are all present: + +```tsx +import { + buildProximityMarkerMap, + buildSupplierGroups, + buildVisibleRows, + type CollectionPoolGroup, + type CollectionPoolSortMode, + getAssignmentBadge, + getAssignmentState, + getPoolAgeLabel, +} from "./view-model"; +``` + +(`getAssignmentBadge` is still consumed by `PoolRow`'s status pill — keep it.) + +- [ ] **Step 3: Run typecheck** + +```bash +npx tsc --noEmit +``` + +Expected: clean. If `Type 'Set' is not assignable to ...` errors fire on `selectedIds`, the `useState>(new Set())` should already be inferring — double-check the import. + +- [ ] **Step 4: Commit (component only — not wired yet)** + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): add SupplierGroup component" +``` + +--- + +## Task 7: Replace flat `
                                                                        ` with grouped `
                                                                        `s + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx` (rendering block + state) + +- [ ] **Step 1: Add `collapsedSuppliers` state** + +In `CollectionPoolPage`, next to `selectedIds`: + +```ts +const [collapsedSuppliers, setCollapsedSuppliers] = useState>(new Set()); + +const toggleCollapsed = useCallback((supplierId: string) => { + setCollapsedSuppliers((prev) => { + const next = new Set(prev); + if (next.has(supplierId)) next.delete(supplierId); + else next.add(supplierId); + return next; + }); +}, []); +``` + +- [ ] **Step 2: Add `openBulkAssign` handler stub** + +Below `openAssignForRow`, add a stub that just opens state — the actual submit handler comes in Task 9. (The stub is intentional so Task 8 can wire the button without Task 9 needing to be done first.) + +```ts +const [bulkAssignDialog, setBulkAssignDialog] = useState<{ + supplierId: string; + supplierName: string; + rowIds: string[]; +} | null>(null); + +const openBulkAssign = useCallback( + (supplierId: string, supplierName: string, rowIds: string[]) => { + if (rowIds.length === 0) return; + setBulkAssignDialog({ supplierId, supplierName, rowIds }); + }, + [], +); + +const closeBulkAssign = useCallback(() => setBulkAssignDialog(null), []); +``` + +- [ ] **Step 3: Compute `groups` in the page** + +Below `visibleRows`, add: + +```tsx +const groups = useMemo(() => { + if (!visibleRows) return null; + return buildSupplierGroups(visibleRows, supplierMarkerMap); +}, [visibleRows, supplierMarkerMap]); +``` + +- [ ] **Step 4: Replace the flat-list rendering** + +Replace the `
                                                                          ` block (around line 488–500 in the current `page.tsx`) with: + +```tsx +
                                                                          +
                                                                          + Collection Item + Order Detail + Status + Actions +
                                                                          + {groups === null ? ( +
                                                                          + {Array.from({ length: 4 }).map((_, i) => ( +
                                                                          + ))} +
                                                                          + ) : groups.length === 0 ? ( + + ) : ( + groups.map((group) => ( + + )) + )} +
                                                                          +``` + +Note: the empty-states at the top level (`rows === null` skeleton, `rows.length === 0` empty, `visibleRows.length === 0` empty) now collapse into the new conditional inside the container — delete the previous three separate early-return blocks (around lines 460–478) to avoid double-rendering. + +- [ ] **Step 5: Run typecheck + page suite** + +```bash +npx tsc --noEmit +npm run test:run -- 'src/app/(app)/collection-pool/page' +``` + +Expected: typecheck clean; page tests still pass. If a test pinned the old `
                                                                            `, update the selector to find rows by data-od-id on the section or fall back to a more stable test query. + +- [ ] **Step 6: Commit** + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): render supplier-grouped sections" +``` + +--- + +## Task 8: Wire bulk-assign dialog mount + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx` (second `AssignDriverDialog` mount) + +- [ ] **Step 1: Stub the submit handler + bulkBusy state** + +Just below `handleAssignDialogSubmit`, add: + +```ts +const [bulkBusy, setBulkBusy] = useState(false); + +const handleBulkAssignSubmit = useCallback( + async (_payload: AssignDriverSubmitPayload) => { + // Full implementation in Task 9. + }, + [], +); +``` + +- [ ] **Step 2: Add the bulk-assign dialog JSX** + +Just below the existing single-row `` (around line 504), add: + +```tsx + { + if (!open) closeBulkAssign(); + }} + onSubmit={handleBulkAssignSubmit} + bulkCount={bulkAssignDialog?.rowIds.length ?? 1} + eyebrow={ + bulkAssignDialog ? ( + + + {bulkAssignDialog.supplierName || "Unknown supplier"} + · + {bulkAssignDialog.rowIds.length} stops + + ) : undefined + } + busy={bulkBusy} +/> +``` + +- [ ] **Step 3: Run typecheck + commit** + +```bash +npx tsc --noEmit +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): wire bulk-assign dialog mount" +``` + +--- + +## Task 9: Sequential bulk PATCH handler + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx` (`handleBulkAssignSubmit` body) + +- [ ] **Step 1: Replace the stub with the real implementation** + +In `page.tsx`, replace the empty `handleBulkAssignSubmit` with: + +```ts +const handleBulkAssignSubmit = useCallback( + async (payload: AssignDriverSubmitPayload) => { + if (!bulkAssignDialog) return; + const { driverId, date } = payload; + const { rowIds, supplierName } = bulkAssignDialog; + + setBulkBusy(true); + try { + const pb = getPocketBase(); + let successCount = 0; + let firstError: string | null = null; + + // Sequential, not parallel: any failure leaves earlier + // successes committed (matches the single-row PATCH semantics). + for (const id of rowIds) { + try { + await pb.collection("order_items").update(id, { + item_status: "assigned", + drv_id: driverId, + drv_status: "assigned", + assigned_at: `${date}T00:00:00.000Z`, + } as Partial); + successCount++; + } catch (err) { + if (!firstError) { + firstError = err instanceof Error ? err.message : String(err); + } + } + } + + // Clear selections for this supplier on success (partial or full). + setSelectedIds((prev) => { + const next = new Set(prev); + for (const id of rowIds) next.delete(id); + return next; + }); + closeBulkAssign(); + + if (firstError) { + toast.error( + `Assigned ${successCount} of ${rowIds.length} stops at ${supplierName || "supplier"}. First error: ${firstError}`, + ); + } else { + toast.success(`Assigned ${successCount} stops at ${supplierName || "supplier"}.`); + } + + await load(); + } finally { + setBulkBusy(false); + } + }, + [bulkAssignDialog, closeBulkAssign, load], +); +``` + +- [ ] **Step 2: Run typecheck + page suite** + +```bash +npx tsc --noEmit +npm run test:run -- 'src/app/(app)/collection-pool/page' +``` + +Expected: typecheck clean; existing page tests pass. Mock the PocketBase `update` call in tests as needed — see Task 10 for the test wiring. + +- [ ] **Step 3: Commit** + +```bash +git add 'src/app/(app)/collection-pool/page.tsx' +git commit -m "feat(collection-pool): sequential bulk-assign PATCH" +``` + +--- + +## Task 10: Page-level tests + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.test.tsx` + +- [ ] **Step 1: Add a test that time-in-list uses `text-primary`** + +Find the existing render fixture in `page.test.tsx` (search for `getPoolAgeLabel` or a row containing `text-foreground`). Append: + +```tsx +import { screen, within } from "@testing-library/react"; + +it("renders time-in-list with text-primary class", async () => { + renderPage(); + const ageLabels = await screen.findAllByText(/^\d+[hd]$/); + expect(ageLabels.length).toBeGreaterThan(0); + for (const label of ageLabels) { + expect(label.className).toMatch(/text-primary/); + } +}); +``` + +(The selector `/^\d+[hd]$/` matches the `Nh` / `Nd` format produced by `getPoolAgeLabel`. Adjust if existing tests use a different fixture pattern — search the file for any pre-existing age assertions first.) + +- [ ] **Step 2: Add a test that the Notes tooltip appears on hover** + +```tsx +it("surfaces the Notes tooltip on row hover", async () => { + // Fixture row must include `notes: "Spoke to David — confirmed pickup tomorrow"`. + renderPage({ rowOverrides: { notes: "Spoke to David — confirmed pickup tomorrow" } }); + const tooltip = await screen.findByRole("tooltip"); + expect(tooltip).toHaveTextContent(/Spoke to David/); + expect(tooltip).toHaveClass("hidden"); // default state +}); +``` + +If the project's `renderPage` helper doesn't accept row overrides, add a parameter that forwards to the fixture builder — it's a one-line change. + +- [ ] **Step 3: Add a test that selecting a row toggles the selection tint** + +```tsx +it("applies a primary-tint background to selected rows", async () => { + const { container } = renderPage(); + const firstCheckbox = await screen.findByRole("checkbox", { name: /Select .* at .*/i }); + fireEvent.click(firstCheckbox); + const row = firstCheckbox.closest("li"); + expect(row).not.toBeNull(); + expect(row!.className).toMatch(/bg-primary\/5/); +}); +``` + +- [ ] **Step 4: Add a test that the section "Select all" toggles every row in the section** + +```tsx +it("section Select all toggles every row in the supplier group", async () => { + renderPage(); + const selectAll = await screen.findByRole("checkbox", { name: /Select all \d+ stops at/i }); + fireEvent.click(selectAll); + const rowCheckboxes = await screen.findAllByRole("checkbox", { name: /^Select .* at/i }); + expect(rowCheckboxes.length).toBeGreaterThan(0); + for (const cb of rowCheckboxes) { + expect(cb).toBeChecked(); + } +}); +``` + +- [ ] **Step 5: Add a test that the Assign-all button opens the bulk dialog with `bulkCount=N`** + +Mock `pb.collection("order_items").update` so the test doesn't actually hit PocketBase. Then: + +```tsx +it("opens bulk-assign dialog with bulkCount equal to selected rows", async () => { + const { container } = renderPage(); + const selectAll = await screen.findByRole("checkbox", { name: /Select all \d+ stops at/i }); + fireEvent.click(selectAll); + const assignAll = screen.getByRole("button", { name: /Assign \d+ stops$/ }); + fireEvent.click(assignAll); + // Dialog title reads "Assign N items to driver" when bulkCount > 1. + const dialogTitle = await screen.findByText(/Assign \d+ items to driver/i); + expect(dialogTitle).toBeInTheDocument(); +}); +``` + +If the `getPocketBase()` mock in the existing suite doesn't intercept the second dialog's submit handler, extend the mock to also intercept the bulk-assign PATCH chain. + +- [ ] **Step 6: Add a test that collapsing a section hides its rows** + +```tsx +it("collapses a supplier section on chevron click", async () => { + renderPage(); + const chevron = await screen.findByRole("button", { name: "" }); // chevron has no accessible name; query by aria-controls + const section = document.querySelector('[aria-labelledby^="supplier-"]'); + const id = section!.querySelector("header")!.id; + const body = document.getElementById(`${id}-body`) || section!.querySelector('[id$="-body"]'); + expect(body!.className).toMatch(/grid-rows-\[1fr\]/); + fireEvent.click(section!.querySelector("button[aria-expanded]")!); + expect(body!.className).toMatch(/grid-rows-\[0fr\]/); +}); +``` + +- [ ] **Step 7: Run the full collection-pool suite** + +```bash +npm run test:run -- 'src/app/(app)/collection-pool' +``` + +Expected: green. Pre-existing failures (documented across the repo, e.g. `page.test.tsx:175`) are acceptable. + +- [ ] **Step 8: Commit** + +```bash +git add 'src/app/(app)/collection-pool/page.test.tsx' +git commit -m "test(collection-pool): cover groups, bulk assign, notes tooltip" +``` + +--- + +## Task 11: Typecheck + size budget + conditional split + +**Files:** +- Modify or create: `src/app/(app)/collection-pool/page.tsx` (or split into `components.tsx`) + +- [ ] **Step 1: Full typecheck** + +```bash +npx tsc --noEmit +``` + +Expected: clean. + +- [ ] **Step 2: Measure `page.tsx` size** + +```bash +wc -c 'src/app/(app)/collection-pool/page.tsx' +``` + +If the result is **≤ 30 KB**, stop here — the file is within budget. Skip to Step 5. + +If the result is **> 30 KB**, continue with the split in Steps 3–4. + +- [ ] **Step 3: Create `components.tsx` with `PoolRow` + `SupplierGroup`** + +Create `src/app/(app)/collection-pool/components.tsx`: + +```tsx +"use client"; + +import { ChevronDown, Truck, AlertTriangle } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import type { CollectionPoolViewRow } from "./view-model"; +import { + type CollectionPoolGroup, + getAssignmentState, + getPoolAgeLabel, +} from "./view-model"; +import { cn } from "@/lib/utils"; + +// Copy `CollectionPoolVisibleRow` type and `markerTokenToClassName` +// helper from page.tsx. They're needed by both PoolRow and SupplierGroup. + +export type CollectionPoolVisibleRow = CollectionPoolViewRow & { + // mirror the type from page.tsx +}; + +function markerTokenToClassName(token?: string): string { + switch (token) { + case "marker-blue": + return "bg-blue-500"; + case "marker-emerald": + return "bg-emerald-500"; + case "marker-violet": + return "bg-violet-500"; + case "marker-amber": + return "bg-amber-500"; + default: + return "bg-slate-300"; + } +} + +// Then paste the FULL `PoolRow` and `SupplierGroup` function bodies +// from page.tsx, unchanged. +``` + +Cut both functions from `page.tsx` and update imports so `page.tsx` imports them from `./components` instead. + +- [ ] **Step 4: Re-measure + retest** + +```bash +wc -c 'src/app/(app)/collection-pool/page.tsx' 'src/app/(app)/collection-pool/components.tsx' +npx tsc --noEmit +npm run test:run -- 'src/app/(app)/collection-pool' +``` + +Expected: each file ≤ 30 KB; typecheck clean; tests green. + +- [ ] **Step 5: Commit (split or no-op)** + +If you split: +```bash +git add 'src/app/(app)/collection-pool/components.tsx' 'src/app/(app)/collection-pool/page.tsx' +git commit -m "refactor(collection-pool): split PoolRow + SupplierGroup into components.tsx" +``` + +If you didn't split, no commit is needed. + +--- + +## Task 12: Manual smoke + full suite + +- [ ] **Step 1: Full test suite** + +```bash +npm run test:run +``` + +Expected: same passing set as before plus the new tests in `view-model.test.ts` and `page.test.tsx`. Pre-existing failures (the 6 we documented across the repo) are acceptable. + +- [ ] **Step 2: Manual smoke on `/collection-pool`** + +1. Load the page. Rows render in collapsible supplier sections, alphabetical. +2. Click the chevron on a section → body collapses with a smooth transition; chevron rotates. +3. Hover the time-in-list label — confirm it's in `text-primary` and bold. +4. Hover the Notes chip on a row with notes — confirm the tooltip appears with the note text (truncated at 280 chars + ellipsis if longer). +5. Tick the section "Select all" — every row checkbox in the section ticks; row backgrounds tint. +6. Click `Assign N stops`. Bulk dialog opens with eyebrow `Supplier · N stops` and title `Assign N items to driver`. +7. Pick a driver + tomorrow's date, submit. Toast confirms. Reload — rows show "Assigned future" badge and the section's selections clear. +8. Mark a row Ready with a long note (>280 chars). Hover the Notes chip — the preview is truncated with `…`. +9. Switch sort to "Nearby suppliers" — order changes; switch back to "Operational" — supplier-grouped order restored. + +- [ ] **Step 3: Commit any test-only follow-ups** + +```bash +git add -A 'src/app/(app)/collection-pool/' +git commit -m "test(collection-pool): post-smoke follow-ups" --allow-empty +``` + +--- + +## Out-of-scope reminders + +- **Bulk mark-ready.** The dialog requires a free-text note per item; a multi-note UI is a separate feature. Don't add it here. +- **Per-section "Mark section ready".** Same reason. +- **Route rename / nav label change.** Not in scope. +- **Mobile-only layout changes.** The existing grid already collapses correctly; no special breakpoint work is needed for the new structure. +- **Transactional batch PATCH endpoint.** Sequential single-row PATCHes are intentional; rollback on partial failure is out of scope. + +--- + +## Risk notes + +- **Page render budget.** Current 23 KB. Estimated growth ~7–10 KB from the new component + state + handlers. If it crosses 30 KB, Task 11 Step 3 splits `PoolRow` + `SupplierGroup` into `components.tsx`. If it still crosses after the split, consider further splitting the dialog wiring into a hook (`useBulkAssign`). +- **Sort change is observable.** Users who relied on the old "by urgency" default will see suppliers grouped together. The Sort radio's "Operational" label now matches this new behaviour; "Nearby" remains for cluster-priority workflows. +- **Bulk PATCH is not transactional.** A failure on row 4 of 5 leaves rows 1–3 committed. The toast reports partial success; the operator can re-open the dialog and retry. \ No newline at end of file diff --git a/src/lib/themes.ts b/src/lib/themes.ts index f9cf7a9..75237b3 100644 --- a/src/lib/themes.ts +++ b/src/lib/themes.ts @@ -242,6 +242,29 @@ export const THEMES: ThemeDefinition[] = [ reference: "Custom", category: "dark", }, + /* ───────────────────────────────────────────────────────────────────── + * Brand theme added 2026-07 — Tangerine. Vendor-supplied OKLCH tokens + * mapped onto the standard WFDash picker-card hex shape. Hex values + * here are picker-only (chip + chip-soft + dark-mode preview). The + * actual on-screen colours come from themes.css via data-theme="tangerine". + * ─────────────────────────────────────────────────────────────────── */ + { + id: "tangerine", + label: "Tangerine", + tagline: "Warm tangerine + cool neutral. Friendly operator surface.", + accent: "#D26A2A", + accentSoft: "#FBE6D6", + accentOnDark: "#F4A465", + surface: "#ECEEF0", + surfaceDark: "#20222E", + ink: "#525252", + inkDark: "#EBEBEB", + fontHeading: "var(--font-inter)", + fontBody: "var(--font-inter)", + radius: "0.75rem", + reference: "Custom", + category: "standard", + }, ]; export const DEFAULT_THEME_ID = "crisp"; diff --git a/src/styles/themes.css b/src/styles/themes.css index 3ebefa0..af87ea8 100644 --- a/src/styles/themes.css +++ b/src/styles/themes.css @@ -603,6 +603,117 @@ --font-body: var(--font-inter); } +/* =========================================================================== + * 14 · TANGERINE — friendly operator surface + * Warm tangerine on a cool neutral ground. Sits between Airtable's cream + * and Energetic's orange — the brand accent carries the page while + * sidebars/borders stay quiet. + * + * Vendor-supplied OKLCH tokens (Tailsmith-style). Adapted to WFDash by + * adding the project's required semantic extras (success / warn / info / + * input-highlight / font-heading / font-body) so every existing utility + * (`text-success`, `bg-warn`, `border-info`, …) resolves at runtime. + * ========================================================================= */ +:root[data-theme="tangerine"] { + --background: oklch(0.9383 0.0042 236.4993); + --foreground: oklch(0.3211 0 0); + --card: oklch(1.0000 0 0); + --card-foreground: oklch(0.3211 0 0); + --popover: oklch(1.0000 0 0); + --popover-foreground: oklch(0.3211 0 0); + --primary: oklch(0.6397 0.1720 36.4421); + --primary-foreground: oklch(1.0000 0 0); + --secondary: oklch(0.9670 0.0029 264.5419); + --secondary-foreground: oklch(0.4461 0.0263 256.8018); + --muted: oklch(0.9846 0.0017 247.8389); + --muted-foreground: oklch(0.5510 0.0234 264.3637); + --accent: oklch(0.9119 0.0222 243.8174); + --accent-foreground: oklch(0.3791 0.1378 265.5222); + --destructive: oklch(0.6368 0.2078 25.3313); + --destructive-foreground: oklch(1.0000 0 0); + --border: oklch(0.9022 0.0052 247.8822); + --input: oklch(0.9700 0.0029 264.5420); + --ring: oklch(0.6397 0.1720 36.4421); + --chart-1: oklch(0.7156 0.0605 248.6845); + --chart-2: oklch(0.7875 0.0917 35.9616); + --chart-3: oklch(0.5778 0.0759 254.1573); + --chart-4: oklch(0.5016 0.0849 259.4902); + --chart-5: oklch(0.4241 0.0952 264.0306); + --sidebar: oklch(0.9030 0.0046 258.3257); + --sidebar-foreground: oklch(0.3211 0 0); + --sidebar-primary: oklch(0.6397 0.1720 36.4421); + --sidebar-primary-foreground: oklch(1.0000 0 0); + --sidebar-accent: oklch(0.9119 0.0222 243.8174); + --sidebar-accent-foreground: oklch(0.3791 0.1378 265.5222); + --sidebar-border: oklch(0.9276 0.0058 264.5313); + --sidebar-ring: oklch(0.6397 0.1720 36.4421); + --input-highlight: oklch(0.93 0.05 30); + /* WFDash semantic extras — required so `text-success` / `bg-warn` / + * `border-info` utilities resolve at runtime. Hues tuned to harmonize + * with the tangerine primary: warm-shifted warn, cool-shifted info, + * green-leaning success with enough chroma to read on cream. */ + --success: oklch(0.62 0.16 155); + --success-foreground: oklch(1 0 0); + --warn: oklch(0.70 0.17 60); + --warn-foreground: oklch(0.20 0.05 60); + --info: oklch(0.58 0.14 240); + --info-foreground: oklch(1 0 0); + --radius: 0.75rem; + --font-heading: var(--font-inter); + --font-body: var(--font-inter); +} + +/* Tangerine ships its own dark variant (cool indigo canvas, different + * surface temperature than the global `.dark` block). Override the + * fallback by pinning `data-theme="tangerine" .dark` so the brand orange + * stays constant while only the canvas flips. */ +:root[data-theme="tangerine"].dark { + --background: oklch(0.2598 0.0306 262.6666); + --foreground: oklch(0.9219 0 0); + --card: oklch(0.3106 0.0301 268.6365); + --card-foreground: oklch(0.9219 0 0); + --popover: oklch(0.2900 0.0249 268.3986); + --popover-foreground: oklch(0.9219 0 0); + --primary: oklch(0.6397 0.1720 36.4421); + --primary-foreground: oklch(1.0000 0 0); + --secondary: oklch(0.3095 0.0266 266.7132); + --secondary-foreground: oklch(0.9219 0 0); + --muted: oklch(0.3095 0.0266 266.7132); + --muted-foreground: oklch(0.7155 0 0); + --accent: oklch(0.3380 0.0589 267.5867); + --accent-foreground: oklch(0.8823 0.0571 254.1284); + --destructive: oklch(0.6368 0.2078 25.3313); + --destructive-foreground: oklch(1.0000 0 0); + --border: oklch(0.3843 0.0301 269.7337); + --input: oklch(0.3843 0.0301 269.7337); + --ring: oklch(0.6397 0.1720 36.4421); + --chart-1: oklch(0.7156 0.0605 248.6845); + --chart-2: oklch(0.7693 0.0876 34.1875); + --chart-3: oklch(0.5778 0.0759 254.1573); + --chart-4: oklch(0.5016 0.0849 259.4902); + --chart-5: oklch(0.4241 0.0952 264.0306); + --sidebar: oklch(0.2598 0.0306 262.6666); + --sidebar-foreground: oklch(0.9219 0 0); + --sidebar-primary: oklch(0.6397 0.1720 36.4421); + --sidebar-primary-foreground: oklch(1.0000 0 0); + --sidebar-accent: oklch(0.3380 0.0589 267.5867); + --sidebar-accent-foreground: oklch(0.8823 0.0571 254.1284); + --sidebar-border: oklch(0.3843 0.0301 269.7337); + --sidebar-ring: oklch(0.6397 0.1720 36.4421); + --input-highlight: oklch(0.32 0.07 30); + /* Dark-mode semantic bumps — extra chroma so the hues punch on the + * near-black canvas. */ + --success: oklch(0.76 0.18 155); + --success-foreground: oklch(0.13 0.02 155); + --warn: oklch(0.78 0.16 65); + --warn-foreground: oklch(0.13 0.02 65); + --info: oklch(0.70 0.14 240); + --info-foreground: oklch(0.13 0.02 240); + --radius: 0.75rem; + --font-heading: var(--font-inter); + --font-body: var(--font-inter); +} + /* =========================================================================== * Dark mode is theme-independent — every theme flips via the `.dark` class. * Use slightly higher chroma + lighter values for accent on dark surfaces