Remove redundant Assign Drivers top-level navigation link since driver assignment is now embedded directly in the collection pool page per-row, and add an Other nav section for leftover navigation items Extract collection pool view logic into a dedicated view-model module with full Vitest test coverage for assignment state classification, row sorting, pool age labeling, and proximity marker grouping Flatten the collection pool page into a single unified list replacing the old grouped assigned/unassigned layout, with updated row UI showing all required operational details and context-aware actions Add database migration to add optional `collection_photo` field to order_items for driver pickup proof photos Add complete design documentation, planning artifacts, and brainstorming session files for the collection pool redesign project
22 KiB
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
import { describe, expect, it } from "vitest";
import {
buildVisibleRows,
buildProximityMarkerMap,
getAssignmentState,
getPoolAgeLabel,
type CollectionPoolViewRow,
} from "./view-model";
function makeRow(overrides: Partial<CollectionPoolViewRow> = {}): 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
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<CollectionPoolViewRow, "item_status" | "drv_id" | "assigned_at">, 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
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<string, string>();
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
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
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
const supplierMarkerMap = useMemo(() => {
if (!supplierCoords) return new Map<string, string>();
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
// 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
{rows !== null && visibleRows && visibleRows.length > 0 && (
<ul className="space-y-2" data-od-id="collection-pool-unified-list">
{visibleRows.map((row) => (
<PoolRow
key={row.id}
row={row}
busy={busyId === row.id}
markerToken={supplierMarkerMap.get(row.expand?.purchase_order?.supplier ?? "unknown")}
onOpenAssign={openAssignForRow}
onToggleReady={handleToggleReady}
/>
))}
</ul>
)}
- 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
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
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="font-medium uppercase tracking-wide">Ready</span>
<div role="radiogroup" aria-label="Filter by ready-for-collection status" className="inline-flex rounded-md border bg-card shadow-sm">
{(["all", "ready", "unready"] as const).map((opt) => (
<button
key={opt}
type="button"
role="radio"
aria-checked={readyFilter === opt}
onClick={() => setReadyFilter(opt)}
className={cn(
"px-3 py-1 text-xs capitalize transition-colors",
readyFilter === opt ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted",
)}
>
{opt === "all" ? "All" : opt === "ready" ? "Ready only" : "Awaiting only"}
</button>
))}
</div>
</div>
- Step 2: Introduce an assignment badge helper
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
PoolRowas one operational row layout
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 (
<li className="grid grid-cols-[6px_minmax(0,1.3fr)_minmax(0,1fr)_auto_auto] overflow-hidden rounded-md border bg-card shadow-sm">
<span className={cn("min-h-full", markerTokenToClassName(markerToken))} aria-hidden />
<div className="min-w-0 p-3">
<p className="truncate text-sm font-semibold">{row.expand?.purchase_order?.expand?.supplier?.suppliername ?? "Unknown supplier"}</p>
<p className="truncate text-sm text-foreground">{row.description || "(no description)"}</p>
<div className="mt-1 flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>{row.expand?.order?.expand?.customer?.customer ?? "No customer"}</span>
<span>·</span>
<span>{row.expand?.order?.expand?.company?.company ?? "No company"}</span>
</div>
</div>
<div className="p-3 text-sm">
<div className="font-mono text-xs text-muted-foreground">{row.expand?.purchase_order?.po_number ?? "No PO"}</div>
<div className="mt-1 text-sm">{getPoolAgeLabel(row.updated)}</div>
</div>
<div className="flex items-center gap-2 p-3">
<span className={cn("inline-flex rounded-full px-2 py-0.5 text-xs font-medium", assignmentBadge.className)}>
{assignmentBadge.label}
</span>
<span className={cn(
"inline-flex rounded-full px-2 py-0.5 text-xs font-medium",
row.ready_for_collection ? "bg-emerald-100 text-emerald-800 ring-1 ring-emerald-200/60" : "bg-amber-100 text-amber-800 ring-1 ring-amber-200/60",
)}>
{row.ready_for_collection ? "Ready" : "Awaiting"}
</span>
</div>
<div className="flex flex-col items-stretch gap-2 p-3">
<Button size="sm" variant={isAssigned ? "outline" : "default"} onClick={() => onOpenAssign(row)} disabled={busy}>
{isAssigned ? "Reschedule" : "Assign Driver"}
</Button>
<Button size="sm" variant={row.ready_for_collection ? "outline" : "default"} onClick={() => onToggleReady(row.id, !row.ready_for_collection)} disabled={busy}>
{row.ready_for_collection ? "Mark Awaiting" : "Mark Ready"}
</Button>
</div>
</li>
);
}
- Step 4: Remove row-level driver-name output and return-to-pool chrome
// 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
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
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
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<OrderItem>);
closeAssignDialog();
} finally {
setBusyId(null);
}
}, [assignDialog, closeAssignDialog, updateItemStatus]);
- Step 3: Keep the ready toggle path untouched
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
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
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
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
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
TODOstyle language from implementation steps. - Kept the Playwright file explicitly optional and described the skip condition instead of leaving it vague.
- Removed
- Type consistency:
AssignmentState,CollectionPoolViewRow,buildVisibleRows,buildProximityMarkerMap, andgetPoolAgeLabeluse the same names across tasks.