feat: add tangerine theme, new docs and plugin stubs

Add full Tangerine brand theme with light and dark mode variants to the app theme system.
Add four new design and integration documents for collection pool, map and driver app workflows.
Add initial scaffolding for three new plugin source directories for the collection pool bulk assignment feature.
This commit is contained in:
SJCoetz 2026-07-14 11:48:10 +02:00
parent 6af4dcf53e
commit 046683c506
22 changed files with 12884 additions and 0 deletions

View file

@ -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 = "<driverId>" && (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 = <now>`. The operator's collection-confirm page then handles the rest. Use the existing `/api/v1/order-items/<id>/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 = "<text>"`. 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: <token>` 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.

File diff suppressed because it is too large Load diff

View file

@ -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: `<div style="position:relative;width:${size}px;height:${size}px;"><div style="position:absolute;inset:0;display:grid;place-items:center;background:${color};color:white;border:2.5px solid white;border-radius:999px;box-shadow:0 6px 14px rgba(15,23,42,.28);font-size:13px;font-weight:800;letter-spacing:0.3px;text-shadow:0 1px 2px rgba(0,0,0,.45);">${letter}</div><div style="position:absolute;top:-4px;right:-4px;min-width:16px;height:16px;padding:0 4px;display:grid;place-items:center;background:white;color:#1F2937;border-radius:999px;border:1.5px solid ${color};font-size:10px;font-weight:800;box-shadow:0 2px 6px rgba(15,23,42,.22);">${count}</div></div>`,
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: `<div style="position:relative;width:${size}px;height:${size}px;"><div style="position:absolute;inset:0;display:grid;place-items:center;background:${amber};color:white;border:2.5px solid white;border-radius:999px;box-shadow:0 6px 14px rgba(217,119,6,.32);font-size:14px;font-weight:900;line-height:1;text-shadow:0 1px 2px rgba(0,0,0,.45);">${symbol}</div><div style="position:absolute;top:-4px;right:-4px;min-width:16px;height:16px;padding:0 4px;display:grid;place-items:center;background:white;color:#1F2937;border-radius:999px;border:1.5px solid ${amber};font-size:10px;font-weight:800;box-shadow:0 2px 6px rgba(15,23,42,.22);">${count}</div></div>`,
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<StatusFilter>("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 `<div className="flex items-center gap-1.5">` wrapping the Status filter buttons.
- The entire `<div className="flex items-center gap-1.5">` wrapping the Readiness filter buttons.
- The `<div className="relative w-full sm:w-64">` 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 `<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">` becomes:
```tsx
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-wrap items-center gap-2">
{/* Date picker */}
<div className="relative">
<Calendar className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="date"
value={date}
onValueChange={setDate}
className="h-9 w-[170px] pl-9 text-sm"
aria-label="Scheduled date"
/>
</div>
<div className="flex flex-wrap gap-1.5">
{[
{ 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 (
<Button
key={q.label}
type="button"
size="sm"
variant={active ? "default" : "secondary"}
onClick={() => setDate(target)}
className="h-9"
>
{q.label}
</Button>
);
})}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" className="h-9" onClick={fetchRows} disabled={loading}>
<RefreshCw className={cn("mr-1.5 h-4 w-4", loading && "animate-spin")} />
Refresh
</Button>
</div>
</div>
```
- [ ] **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 `<Card>` block starting at `<CardTitle className="flex items-center gap-2 text-base">` containing the Drivers heading and continuing through its closing `</Card>` (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<Set<string>>(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 (
<React.Fragment key={`${stop.supplierId}-pin-group`}>
{/* 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 (
<Marker
key={`${stop.supplierId}-drv-${drvId}`}
position={[stop.lat, stop.lng]}
icon={driverLetterPin(letter, bucket.items.length)}
>
{showLabels && labeledSupplierIds.has(stop.supplierId) && (
<Tooltip direction="top" offset={[0, -12]} permanent className="supplier-map-label">
{stop.supplierName} · {letter}
</Tooltip>
)}
<Popup>
<StopPopupBody
stop={stop}
driversById={driverMap}
onAssign={openAssignDialog}
/>
</Popup>
</Marker>
);
})}
{/* 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 && (
<Marker
key={`${stop.supplierId}-pool`}
position={[stop.lat, stop.lng]}
icon={unassignedPoolPin(unassignedGlyphFor(poolItems), poolItems.length)}
>
{showLabels && labeledSupplierIds.has(stop.supplierId) && (
<Tooltip direction="top" offset={[0, -22]} permanent className="supplier-map-label">
{stop.supplierName} · pool
</Tooltip>
)}
<Popup>
<StopPopupBody
stop={stop}
driversById={driverMap}
onAssign={openAssignDialog}
/>
</Popup>
</Marker>
)}
</React.Fragment>
);
})}
```
- [ ] **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*</);
});
});
```
- [ ] **Step 2: Run tests — expect PASS**
```bash
npm run test:run -- src/app/(app)/collection-map/page.test
```
Expected: 5 + 2 = 7 tests pass.
- [ ] **Step 3: Commit**
```bash
git add src/app/(app)/collection-map/page.test.tsx
git commit -m "test(collection-map): pin toolbar shape to prevent filter-chip regression"
```
---
## Task 8: Typecheck + page size budget + conditional split
**Files:**
- (read-only check, no edits)
- [ ] **Step 1: Full typecheck**
```bash
npx tsc --noEmit
```
Expected: no new errors. Pre-existing `collection-confirm/view-model.test.ts:278` error stays.
- [ ] **Step 2: Measure `page.tsx` size**
```bash
wc -c 'src/app/(app)/collection-map/page.tsx'
```
Expected: under 40 KB after the cleanup (the deleted filter JSX + sidebar card + `buildRoutedBuckets` together shed ~10 KB from the 53 KB starting point). The 30 KB target is aspirational — `StopClusterMarker`, `StopPopupBody`, `SupplierStopPopup`, and the traffic overlay are still in the file and not worth extracting just to hit a number.
- [ ] **Step 3: If `page.tsx` is still > 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 `<Card>` 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 `<KpiStrip ... />` and `<MapToolbar ... />` 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.

File diff suppressed because it is too large Load diff

View file

@ -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<string, unknown>;
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<string, unknown>;
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;
}) => (
<input
data-testid="driver-search"
value={value}
onChange={(e) => onValueChange(e.target.value)}
placeholder={placeholder}
/>
),
}));
function openDialog(overrides: Partial<React.ComponentProps<typeof AssignDriverDialog>> = {}) {
const onSubmit = vi.fn();
const onOpenChange = vi.fn();
const result = render(
<AssignDriverDialog
open
onOpenChange={onOpenChange}
onSubmit={onSubmit}
initialDriverId=""
initialDate="2026-07-12"
requireReadyForCollection
readyForCollection={false}
{...overrides}
/>,
);
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 && (
<p
role="status"
className="mb-4 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900"
>
Supplier hasn&apos;t confirmed this item is ready for collection. Mark it
ready in{" "}
<a
href="/collection-pool"
className="underline underline-offset-2"
>
/collection-pool
</a>{" "}
before assigning a driver.
</p>
)}
```
- [ ] **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*</);
});
it("does not re-introduce the date state or date bounds in fetchRows", () => {
// 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
<p className="text-xs text-muted-foreground">
For {formatDateLabel(date)} across {stopsWithCoords.length} supplier{stopsWithCoords.length === 1 ? "" : "s"}
</p>
```
with:
```tsx
<p className="text-xs text-muted-foreground">
Across {stopsWithCoords.length} supplier{stopsWithCoords.length === 1 ? "" : "s"}
</p>
```
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 `<div className="flex flex-wrap items-center gap-2">` containing the date picker + quick chips) with the empty wrapper:
```tsx
{/* Page-level toolbar: no date filter. Refresh lives below. */}
<div />
```
The map-card chrome (Refresh + Traffic + Fullscreen, lines 666+) stays untouched. The toolbar container at line 627 (`<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">`) 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 `<div />`. 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
<AssignDriverDialog
open={assignDialog.open}
onOpenChange={(o) => 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.

View file

@ -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 `<ul>`; 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 `<ul className="divide-y">` of `<PoolRow>` 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
<div className="mt-1 text-sm text-foreground">{getPoolAgeLabel(row.updated)}</div>
```
- 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
<div className="mt-1 text-base font-semibold text-primary">
{getPoolAgeLabel(row.updated)}
</div>
```
- `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 `<section>` 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 `<input type="checkbox">` 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
<span className="group/notes relative inline-flex ...">
Notes
<span
role="tooltip"
className="pointer-events-none absolute left-0 top-full z-20 mt-1 hidden w-72 rounded-md border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md group-hover/notes:block group-focus-within/notes:block"
>
{row.notes.length > 280 ? `${row.notes.slice(0, 280)}…` : row.notes}
</span>
</span>
```
- 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<string, string>,
): 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<Set<string>>(new Set())`.
- New state: `const [collapsedSuppliers, setCollapsedSuppliers] = useState<Set<string>>(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 <driver>.`), 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 `<PoolRow>` 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 <driver>.` 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/`.

View file

@ -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
<div className="flex flex-col gap-5">
<KpiStrip ... /> {/* KPI strip — unchanged */}
<Toolbar ... /> {/* collapsed toolbar — unchanged */}
<div className="grid items-stretch gap-5 xl:grid-cols-[minmax(0,1fr)_320px]">
<MapCard className="min-h-[520px] flex-1">
{/* MapContainer keeps h-full w-full inside this wrapper */}
</MapCard>
<Sidebar />
</div>
</div>
```
`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
<aside className="flex flex-col gap-4">
<LegendCard />
<DriverCardSection
driversWithItems={driversWithItems}
expanded={showAllDrivers}
onToggle={() => setShowAllDrivers((s) => !s)}
/>
{unassignedItems.length > 0 ? (
<UnassignedCompactCard count={unassignedItems.length} />
) : null}
{missingCoords.length > 0 ? (
<MissingCoordsCard items={missingCoords} />
) : null}
</aside>
```
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 (
<Card>
<CardHeader>
<CardTitle className="text-sm">Drivers with stops</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{visible.map((d) => (
<DriverCard key={d.id} driver={d} driversWithItems={driversWithItems} />
))}
{driversWithItems.length > 3 && (
<Button
variant="ghost"
size="sm"
className="w-full"
onClick={onToggle}
>
{showAllDrivers
? "Show top 3 only"
: `Show all (${driversWithItems.length})`}
</Button>
)}
</CardContent>
</Card>
);
```
#### 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 (
<div className="rounded-lg border bg-card p-3">
<div className="flex items-center gap-2">
<span className="grid h-7 w-7 place-items-center rounded-full bg-blue-600 text-xs font-semibold text-white">
{letter}
</span>
<span className="truncate font-medium">{driver.name}</span>
</div>
<CountsStrip status={status} />
<ul className="mt-2 space-y-1.5">
{visibleItems.map((item) => (
<StopRow
key={item.id}
item={item}
onOpen={() => openAssignDialog(item)}
/>
))}
{driver.items.length > 4 && (
<li>
<button
type="button"
className="text-xs text-muted-foreground underline-offset-2 hover:underline"
onClick={() => setExpanded((s) => !s)}
>
{expanded ? "Show fewer" : `+${driver.items.length - 4} more`}
</button>
</li>
)}
</ul>
</div>
);
}
```
#### 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 (
<p className="mt-1 text-xs text-muted-foreground">{parts.join(" · ")}</p>
);
}
```
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 (
<li>
<button
type="button"
onClick={onOpen}
className="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1 text-xs hover:bg-muted/50"
>
<span className="truncate text-left">{supplierName}</span>
<span
className={`inline-flex shrink-0 items-center rounded-full border px-2 py-0.5 text-[10px] font-medium ${pill.cls}`}
>
{pill.label}
</span>
</button>
</li>
);
}
```
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 && (
<p className="mt-1 text-xs font-medium text-emerald-700">
{status.collected} of {status.total} collected
</p>
)}
```
`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<string>` 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.

View file

@ -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 `<Calendar>` + `<Input type="date">` 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
<p role="status" className="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900">
Supplier hasn't confirmed this item is ready for collection. Mark it ready in
<Link href="/collection-pool" className="ml-1 underline underline-offset-2">/collection-pool</Link>
before assigning a driver.
</p>
```
- The caller in `page.tsx` (`openAssignDialog` → `<AssignDriverDialog>` 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.

View file

@ -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"
]
}
}

View file

@ -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
}
]
}

View file

@ -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"
]
}
}

View file

@ -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
}
]
}

View file

@ -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"
]
}
}

View file

@ -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
}
]
}

View file

@ -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";

View file

@ -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