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