# Collection Pool Redesign 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:** Replace the current grouped collection-pool page with one unified operational list that keeps required row details visible, preserves ready-state actions, and differentiates assigned rows with compact status indicators instead of separate sections. **Architecture:** Keep the existing data load and assignment PATCH endpoints, but extract the page's derived view logic into a small helper module so sorting, assignment-state classification, and proximity marker grouping can be tested independently. Then simplify `page.tsx` to render a single filtered `visibleRows` list and a consistent `PoolRow` layout for all states. **Tech Stack:** Next.js app router, React 19 client components, TypeScript, Tailwind CSS, Vitest, Playwright --- ## File Structure - Modify: `src/app/(app)/collection-pool/page.tsx` - Flatten the page-level view model and remove assigned/proximity section rendering. - Create: `src/app/(app)/collection-pool/view-model.ts` - Hold pure helper functions for assignment status, pool age text, proximity marker grouping, and unified row sorting. - Create: `src/app/(app)/collection-pool/view-model.test.ts` - Cover the pure helper behavior with focused Vitest cases. - Optional Modify: `e2e/management.spec.ts` - Do not change this file unless its shared helpers are needed. - Create: `e2e/collection-pool.spec.ts` - Add one smoke-like browser spec only if stable seeded data is available locally; otherwise rely on manual browser validation in Task 5 and skip this file. - Modify: `docs/superpowers/plans/2026-07-07-collection-pool-redesign.md` - Check off steps during execution. ### Task 1: Extract And Test The Unified View Model **Files:** - Create: `src/app/(app)/collection-pool/view-model.ts` - Create: `src/app/(app)/collection-pool/view-model.test.ts` - Modify: `src/app/(app)/collection-pool/page.tsx` - [ ] **Step 1: Write the failing view-model tests** ```ts import { describe, expect, it } from "vitest"; import { buildVisibleRows, buildProximityMarkerMap, getAssignmentState, getPoolAgeLabel, type CollectionPoolViewRow, } from "./view-model"; function makeRow(overrides: Partial = {}): CollectionPoolViewRow { return { id: "row-1", description: "Wheel hub kit", updated: "2026-07-05T08:00:00.000Z", item_status: "collection_pool", drv_id: "", assigned_at: "", ready_for_collection: false, supplierId: "sup-1", supplierName: "Alpha Bearings", companyName: "Anglo", customerName: "North Pit", poNumber: "PO-1", ...overrides, }; } describe("getAssignmentState", () => { it("returns unassigned when driver fields are empty", () => { expect(getAssignmentState(makeRow(), "2026-07-07")).toBe("unassigned"); }); it("returns assigned_today when assigned_at lands on today", () => { expect( getAssignmentState( makeRow({ item_status: "assigned", drv_id: "drv-1", assigned_at: "2026-07-07T00:00:00.000Z", }), "2026-07-07", ), ).toBe("assigned_today"); }); it("returns assigned_future when assigned_at is after today", () => { expect( getAssignmentState( makeRow({ item_status: "assigned", drv_id: "drv-1", assigned_at: "2026-07-09T00:00:00.000Z", }), "2026-07-07", ), ).toBe("assigned_future"); }); }); describe("getPoolAgeLabel", () => { it("formats fresh rows in hours and older rows in days", () => { expect(getPoolAgeLabel("2026-07-07T06:00:00.000Z", new Date("2026-07-07T12:00:00.000Z"))).toBe("6h"); expect(getPoolAgeLabel("2026-07-03T12:00:00.000Z", new Date("2026-07-07T12:00:00.000Z"))).toBe("4d"); }); }); describe("buildVisibleRows", () => { it("sorts assigned today first, then unassigned, then future", () => { const rows = buildVisibleRows( [ makeRow({ id: "future", item_status: "assigned", drv_id: "drv-2", assigned_at: "2026-07-08T00:00:00.000Z" }), makeRow({ id: "unassigned" }), makeRow({ id: "today", item_status: "assigned", drv_id: "drv-1", assigned_at: "2026-07-07T00:00:00.000Z" }), ], { todayYmd: "2026-07-07", searchText: "", readyFilter: "all" }, ); expect(rows.map((row) => row.id)).toEqual(["today", "unassigned", "future"]); }); }); describe("buildProximityMarkerMap", () => { it("assigns the same marker token to nearby suppliers", () => { const markers = buildProximityMarkerMap( [ { supplierId: "sup-a", lat: -26.2, lng: 28.0 }, { supplierId: "sup-b", lat: -26.2008, lng: 28.0008 }, { supplierId: "sup-c", lat: -26.4, lng: 28.4 }, ], 2, ); expect(markers.get("sup-a")).toBe(markers.get("sup-b")); expect(markers.get("sup-a")).not.toBe(markers.get("sup-c")); }); }); ``` - [ ] **Step 2: Run the tests to verify they fail** Run: `npm run test:run -- "src/app/(app)/collection-pool/view-model.test.ts"` Expected: FAIL with `Cannot find module './view-model'` or missing export errors. - [ ] **Step 3: Write the minimal helper module** ```ts export type AssignmentState = "unassigned" | "assigned_today" | "assigned_future"; export type CollectionPoolViewRow = { id: string; description?: string; updated?: string; item_status?: string; drv_id?: string; assigned_at?: string; ready_for_collection?: boolean | null; supplierId: string; supplierName?: string; companyName?: string; customerName?: string; poNumber?: string; }; export function getAssignmentState(row: Pick, todayYmd: string): AssignmentState { const hasAssignment = Boolean(row.drv_id) || row.item_status === "assigned"; if (!hasAssignment) return "unassigned"; const assignedYmd = row.assigned_at ? row.assigned_at.slice(0, 10) : ""; return assignedYmd > todayYmd ? "assigned_future" : "assigned_today"; } export function getPoolAgeLabel(updatedAt: string | undefined, now = new Date()): string { if (!updatedAt) return "—"; const updated = new Date(updatedAt); const diffMs = Math.max(0, now.getTime() - updated.getTime()); const hours = Math.floor(diffMs / 3_600_000); if (hours < 24) return `${hours}h`; return `${Math.floor(hours / 24)}d`; } export function buildVisibleRows(rows: CollectionPoolViewRow[], options: { todayYmd: string; searchText: string; readyFilter: "all" | "ready" | "unready" }) { const q = options.searchText.trim().toLowerCase(); return rows .filter((row) => { if (options.readyFilter === "ready" && !row.ready_for_collection) return false; if (options.readyFilter === "unready" && row.ready_for_collection) return false; if (!q) return true; return [ row.supplierName ?? "", row.description ?? "", row.poNumber ?? "", row.customerName ?? "", row.companyName ?? "", ].some((value) => value.toLowerCase().includes(q)); }) .sort((a, b) => { const order = { assigned_today: 0, unassigned: 1, assigned_future: 2 } as const; const stateDiff = order[getAssignmentState(a, options.todayYmd)] - 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.supplierName ?? ""}|${a.poNumber ?? ""}|${a.description ?? ""}`.localeCompare( `${b.supplierName ?? ""}|${b.poNumber ?? ""}|${b.description ?? ""}`, ); }); } ``` - [ ] **Step 4: Add the proximity marker helper** ```ts import { buildClusters } from "@/lib/geo"; const MARKER_TOKENS = ["marker-blue", "marker-emerald", "marker-violet", "marker-amber"] as const; export function buildProximityMarkerMap( suppliers: Array<{ supplierId: string; lat: number; lng: number }>, radiusKm: number, ) { const clusters = buildClusters(suppliers, radiusKm); const result = new Map(); clusters.forEach((cluster, index) => { const token = MARKER_TOKENS[index % MARKER_TOKENS.length]; cluster.items.forEach((item) => { result.set(item.supplierId, token); }); }); return result; } ``` - [ ] **Step 5: Run the tests to verify they pass** Run: `npm run test:run -- "src/app/(app)/collection-pool/view-model.test.ts"` Expected: PASS with 4 passing tests. - [ ] **Step 6: Commit** ```bash git add "src/app/(app)/collection-pool/view-model.ts" "src/app/(app)/collection-pool/view-model.test.ts" git commit -m "test: cover collection pool view model" ``` ### Task 2: Flatten Page State And Remove Grouped Rendering **Files:** - Modify: `src/app/(app)/collection-pool/page.tsx` - Modify: `src/app/(app)/collection-pool/view-model.ts` - Test: `src/app/(app)/collection-pool/view-model.test.ts` - [ ] **Step 1: Extend the tests for unified filtering inputs** ```ts it("keeps assigned rows visible when ready-only filters are active", () => { const rows = buildVisibleRows( [ makeRow({ id: "assigned-ready", item_status: "assigned", drv_id: "drv-1", assigned_at: "2026-07-07T00:00:00.000Z", ready_for_collection: true, }), makeRow({ id: "awaiting", ready_for_collection: false }), ], { todayYmd: "2026-07-07", searchText: "", readyFilter: "ready" }, ); expect(rows.map((row) => row.id)).toEqual(["assigned-ready"]); }); ``` - [ ] **Step 2: Run the tests to verify the new case fails if needed** Run: `npm run test:run -- "src/app/(app)/collection-pool/view-model.test.ts"` Expected: PASS or targeted failure that confirms the helper still needs adjustment. - [ ] **Step 3: Replace the page's split derived state with one visible list** ```ts const supplierMarkerMap = useMemo(() => { if (!supplierCoords) return new Map(); return buildProximityMarkerMap( Array.from(supplierCoords.entries()).map(([supplierId, coord]) => ({ supplierId, lat: coord.lat, lng: coord.lng, })), 2, ); }, [supplierCoords]); const visibleRows = useMemo(() => { if (!rows) return null; return buildVisibleRows( rows.map((row) => ({ ...row, supplierId: row.expand?.purchase_order?.supplier ?? "unknown", supplierName: row.expand?.purchase_order?.expand?.supplier?.suppliername ?? "Unknown supplier", companyName: row.expand?.order?.expand?.company?.company ?? "", customerName: row.expand?.order?.expand?.customer?.customer ?? "", poNumber: row.expand?.purchase_order?.po_number ?? "", })), { todayYmd: new Date().toISOString().slice(0, 10), searchText: filterText, readyFilter, }, ); }, [rows, supplierCoords, filterText, readyFilter]); ``` - [ ] **Step 4: Delete the old grouped UI branches** ```ts // Remove these page-level branches and all JSX that depends on them: // - assignedRows / unassignedRows // - assignedByDriver // - grouped // - renderGroups // - clusterOn / clusterRadiusKm / showIsolated // - bulk supplier and cluster toolbars ``` - [ ] **Step 5: Replace the body render with a single list** ```tsx {rows !== null && visibleRows && visibleRows.length > 0 && (
    {visibleRows.map((row) => ( ))}
)} ``` - [ ] **Step 6: Run the focused test suite** Run: `npm run test:run -- "src/app/(app)/collection-pool/view-model.test.ts"` Expected: PASS. - [ ] **Step 7: Commit** ```bash git add "src/app/(app)/collection-pool/page.tsx" "src/app/(app)/collection-pool/view-model.ts" "src/app/(app)/collection-pool/view-model.test.ts" git commit -m "refactor: flatten collection pool list model" ``` ### Task 3: Rebuild The Filter Bar And Row Layout **Files:** - Modify: `src/app/(app)/collection-pool/page.tsx` - Modify: `src/app/(app)/collection-pool/view-model.ts` - Test: `src/app/(app)/collection-pool/view-model.test.ts` - [ ] **Step 1: Keep only search plus ready filters in the header** ```tsx
Ready
{(["all", "ready", "unready"] as const).map((opt) => ( ))}
``` - [ ] **Step 2: Introduce an assignment badge helper** ```ts export function getAssignmentBadge(state: AssignmentState) { switch (state) { case "assigned_today": return { label: "Assigned today", className: "bg-blue-100 text-blue-800 ring-1 ring-blue-200/60" }; case "assigned_future": return { label: "Assigned future", className: "bg-violet-100 text-violet-800 ring-1 ring-violet-200/60" }; default: return { label: "Unassigned", className: "bg-slate-100 text-slate-700 ring-1 ring-slate-200/60" }; } } ``` - [ ] **Step 3: Rebuild `PoolRow` as one operational row layout** ```tsx function PoolRow({ row, busy, markerToken, onOpenAssign, onToggleReady, }: { row: CollectionPoolRow; busy: boolean; markerToken?: string; onOpenAssign: (row: CollectionPoolRow) => void; onToggleReady: (id: string, ready: boolean) => void; }) { const assignmentState = getAssignmentState(row, new Date().toISOString().slice(0, 10)); const assignmentBadge = getAssignmentBadge(assignmentState); const isAssigned = assignmentState !== "unassigned"; return (
  • {row.expand?.purchase_order?.expand?.supplier?.suppliername ?? "Unknown supplier"}

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

    {row.expand?.order?.expand?.customer?.customer ?? "No customer"} · {row.expand?.order?.expand?.company?.company ?? "No company"}
    {row.expand?.purchase_order?.po_number ?? "No PO"}
    {getPoolAgeLabel(row.updated)}
    {assignmentBadge.label} {row.ready_for_collection ? "Ready" : "Awaiting"}
  • ); } ``` - [ ] **Step 4: Remove row-level driver-name output and return-to-pool chrome** ```tsx // Delete these row fragments: // - drv_id slices inside the assigned badge // - per-driver section headers // - Return to pool button in the main row action stack // - dimmed assigned-row styling ``` - [ ] **Step 5: Run lint and the focused test suite** Run: `npm run lint -- "src/app/(app)/collection-pool/page.tsx" "src/app/(app)/collection-pool/view-model.ts" "src/app/(app)/collection-pool/view-model.test.ts"` Expected: PASS with no ESLint errors. Run: `npm run test:run -- "src/app/(app)/collection-pool/view-model.test.ts"` Expected: PASS. - [ ] **Step 6: Commit** ```bash git add "src/app/(app)/collection-pool/page.tsx" "src/app/(app)/collection-pool/view-model.ts" "src/app/(app)/collection-pool/view-model.test.ts" git commit -m "feat: redesign collection pool rows" ``` ### Task 4: Preserve Existing Assignment And Ready Actions In The New Layout **Files:** - Modify: `src/app/(app)/collection-pool/page.tsx` - Modify: `src/components/ui/assign-driver-dialog.tsx` - Test: `src/app/(app)/collection-pool/view-model.test.ts` - [ ] **Step 1: Confirm the dialog still opens in single-row mode from both states** ```tsx const openAssignForRow = useCallback((row: CollectionPoolRow) => { setAssignDialog({ mode: "single", rowId: row.id, initialDriverId: row.drv_id ?? "", initialDate: assignedAtToYmd(row.assigned_at), }); }, []); ``` - [ ] **Step 2: Keep the existing submit handler and remove dead bulk branches only if no longer reachable** ```ts const handleAssignDialogSubmit = useCallback(async (payload: AssignDriverSubmitPayload) => { const { driverId, date } = payload; if (!assignDialog || assignDialog.mode !== "single") return; setBusyId(assignDialog.rowId); try { await updateItemStatus(assignDialog.rowId, { item_status: "assigned", drv_id: driverId, drv_status: "assigned", assigned_at: `${date}T00:00:00.000Z`, } as Partial); closeAssignDialog(); } finally { setBusyId(null); } }, [assignDialog, closeAssignDialog, updateItemStatus]); ``` - [ ] **Step 3: Keep the ready toggle path untouched** ```ts const handleToggleReady = useCallback(async (itemId: string, ready: boolean) => { const res = await fetch(`/api/v1/collection-map/items/${itemId}/assign`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ready_for_collection: ready }), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); await load(); }, [load]); ``` - [ ] **Step 4: Run lint on the touched files** Run: `npm run lint -- "src/app/(app)/collection-pool/page.tsx" "src/components/ui/assign-driver-dialog.tsx"` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add "src/app/(app)/collection-pool/page.tsx" "src/components/ui/assign-driver-dialog.tsx" git commit -m "refactor: preserve collection pool actions in unified layout" ``` ### Task 5: Validate The Redesigned Page End To End **Files:** - Modify: `e2e/collection-pool.spec.ts` - Modify: `docs/superpowers/plans/2026-07-07-collection-pool-redesign.md` - [ ] **Step 1: Add a browser spec only if local seeded data is predictable** ```ts import { test, expect, HAS_AUTH_FIXTURE } from "./_helpers"; test.describe("collection pool page", () => { test.skip(!HAS_AUTH_FIXTURE, "E2E auth fixture required"); test("renders the unified list shell and ready filter controls", async ({ page }) => { await page.goto("/login"); await page.getByLabel(/email/i).fill(process.env.E2E_TEST_EMAIL!); await page.getByLabel(/password/i).fill(process.env.E2E_TEST_PASSWORD!); await page.getByRole("button", { name: /sign in|log in|login/i }).click(); await page.waitForURL((u) => !u.pathname.startsWith("/login"), { timeout: 10_000 }); await page.goto("/collection-pool"); await expect(page.getByRole("heading", { name: /collection pool/i })).toBeVisible(); await expect(page.getByRole("radio", { name: /ready only/i })).toBeVisible(); await expect(page.locator("[data-od-id='collection-pool-unified-list']")).toBeVisible(); }); }); ``` - [ ] **Step 2: Run the optional Playwright spec** Run: `npm run e2e -- e2e/collection-pool.spec.ts` Expected: PASS if seeded data is available, or SKIP if auth fixture/data is unavailable. - [ ] **Step 3: Run the full local verification set** Run: `npm run test:run -- "src/app/(app)/collection-pool/view-model.test.ts"` Expected: PASS. Run: `npm run lint -- "src/app/(app)/collection-pool/page.tsx" "src/app/(app)/collection-pool/view-model.ts" "src/app/(app)/collection-pool/view-model.test.ts" "e2e/collection-pool.spec.ts"` Expected: PASS. - [ ] **Step 4: Perform manual browser validation** ```text Open /collection-pool and verify: 1. Assigned and unassigned rows appear in one list. 2. Supplier, item, customer, company, age, and PO are visible on every row. 3. Assignment badges read Unassigned, Assigned today, or Assigned future. 4. Unassigned rows show Assign Driver. 5. Assigned rows show Reschedule. 6. Mark Ready and Mark Awaiting still work. 7. Ready only and Awaiting only filter correctly. 8. Nearby suppliers share the same side marker color without cluster headers. ``` - [ ] **Step 5: Commit** ```bash git add "e2e/collection-pool.spec.ts" "src/app/(app)/collection-pool/page.tsx" "src/app/(app)/collection-pool/view-model.ts" "src/app/(app)/collection-pool/view-model.test.ts" "docs/superpowers/plans/2026-07-07-collection-pool-redesign.md" git commit -m "test: validate redesigned collection pool" ``` ## Self-Review - Spec coverage: - Unified single list: Task 2 and Task 3 - Required row fields: Task 3 - Assignment status indicator: Task 3 - Context-aware actions: Task 3 and Task 4 - Proximity side marker: Task 1 and Task 2 - Ready and awaiting filters: Task 2 and Task 3 - Browser validation: Task 5 - Placeholder scan: - Removed `TODO` style language from implementation steps. - Kept the Playwright file explicitly optional and described the skip condition instead of leaving it vague. - Type consistency: - `AssignmentState`, `CollectionPoolViewRow`, `buildVisibleRows`, `buildProximityMarkerMap`, and `getPoolAgeLabel` use the same names across tasks.