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.
40 KiB
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
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" perAGENTS.md). - Re-read
src/app/(app)/collection-map/page.tsx(1,331 lines, 53 KB) —buildRoutedBuckets, the filter strip JSX, the Drivers sidebar card, theMapWatcher+StopClusterMarkermount, theassignDialogflow. 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:
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
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):
/* ============================================================================
* 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
npm run test:run -- src/lib/geo.test
Expected: 10 tests pass.
- Step 5: Typecheck
npx tsc --noEmit
Expected: no new errors in src/lib/geo.ts.
- Step 6: Commit
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:
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
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:
/**
* 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
npm run test:run -- src/components/leaflet-markers.test
Expected: 8 tests pass.
- Step 5: Typecheck
npx tsc --noEmit
Expected: no new errors in src/components/leaflet-markers.ts.
- Step 6: Commit
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):
// 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:
<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
filteredItemsuseMemo to drop the deleted filters
Replace the filteredItems useMemo (around lines 451–472) with:
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
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
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
enabledDriverIdsstate +toggleDriverhelper
Remove:
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
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
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
buildRoutedBucketsand the per-(driver×status×failed) render
In collection-map/page.tsx:
-
Delete the
buildRoutedBucketsfunction definition and its associatedRoutedPinKey/RoutedPinBuckettypes (around lines 560–578). -
Delete the entire
{showIndividualStops && stopsWithCoords.map((stop) => { … })}block (around lines 877–941) that emits onepinMarkerKind("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 onedriverMarkerper (supplier, driver) pair. -
Delete the
colorForStop,DRIVER_PALETTE,driverIndexFor,STOP_STATUS_COLOR,pinMarkerKind,driverMarker, andinitialsOfimports that become unused. (Double-check before deleting —colorForStopandSTOP_STATUS_COLORmay still be used inStopPopupBody; if so, keep those imports.) -
Delete the
dominantStatusForStophelper (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:
{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:
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
npx tsc --noEmit
Expected: no new errors. Pre-existing collection-confirm/view-model.test.ts:278 error stays.
- Step 5: Commit
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):
/**
* 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:
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:
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
npm run test:run -- src/app/(app)/collection-map/page.test
Expected: 5 tests pass.
- Step 4: Typecheck
npx tsc --noEmit
Expected: no new errors.
- Step 5: Commit
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:
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
npm run test:run -- src/app/(app)/collection-map/page.test
Expected: 5 + 2 = 7 tests pass.
- Step 3: Commit
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
npx tsc --noEmit
Expected: no new errors. Pre-existing collection-confirm/view-model.test.ts:278 error stays.
- Step 2: Measure
page.tsxsize
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.tsxis 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 helpersplitSupplierBucket.src/app/(app)/collection-map/kpi-strip.tsx— extract the three<Card>blocks (Mapped stops / Drivers with stops / Needs coordinates) into aKpiStripcomponent.src/app/(app)/collection-map/map-toolbar.tsx— extract the toolbar JSX (date + quick chips + Refresh) into aMapToolbarcomponent that receivesdate,setDate,loading,onRefreshas 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
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)
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
npm run dev
-
Step 2: Open
/collection-mapin 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" →
AssignDriverDialogopens. -
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
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-branchflow.
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=trueshows ✓; otherwise ✕. - The yellow-ringed
driverMarkeroverlay does not render anywhere on the collection-map page. - Clustered zoom (zoom < 12) renders
clusterBubbleexactly 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 --noEmitclean (no new errors incollection-map/page.tsx,leaflet-markers.ts,geo.ts, or the new helpers).- Existing
route.test.tsfor/api/v1/collection-map/items/[id]/assignstill passes (no backend contract change). - 18 new tests added (10 in
geo.test.ts, 8 inleaflet-markers.test.ts, 5 + 2 inpage.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 revertof the page diff. - Page size risk: low. The page was 53 KB; the filter JSX + sidebar card +
buildRoutedBucketstogether 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
findIndexlookup uses the id so the call site always gets the right one. Names that start with the same letter fall back tolocaleCompareon 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.