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.
43 KiB
Collection Map v3 — Sidebar Driver Cards 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: Turn /collection-map into the operator's at-a-glance control surface — full-vertical map + 320px right sidebar with a legend and the top-3 driver cards (counts strip + per-stop rows with green-collected anchor), and remove the unused bottom "Today's stops" list card.
Architecture: Pure helper aggregateDriverStatus added to src/lib/pin-model.ts (TDD'd). Page layout flipped to a flex column with the map card using flex-1 so it fills the row height, paired with the existing 320px sidebar grid. Sidebar rebuilt from inner components in page.tsx (LegendCard, DriverCardSection, DriverCard, CountsStrip, StopRow, UnassignedCompactCard) — keeping the existing alphabetical driversWithItems ordering and the phase-2 assign dialog wire-up untouched. The "X of Y collected" line lives in StopPopupBody and reuses aggregateDriverStatus against stop.items. The bottom "Today's stops" card is deleted. No third Leaflet pin state; no API/DB change.
Tech Stack: Next.js App Router (React 18/19, TypeScript), PocketBase (admin client), Leaflet via react-leaflet, Vitest + React Testing Library, shadcn/ui Card + Button primitives, lucide-react icons.
Reference spec: docs/superpowers/specs/2026-07-12-collection-map-driver-cards-redesign-design.md (this plan is a faithful translation — every behavior section maps to a task below).
Pre-flight (every subagent must read):
docs/superpowers/specs/2026-07-12-collection-map-driver-cards-redesign-design.md(full)- This plan
node_modules/next/dist/docs/— skim before any Next-specific code, per AGENTS.md ("This is NOT the Next.js you know")src/lib/pin-model.ts+src/lib/geo.ts(existing helpers used unchanged)
Branch: minimax_ui_rev1 — do NOT switch.
Size budget: src/app/(app)/collection-map/page.tsx is currently 39.9 KB. The conditional split into src/app/(app)/collection-map/sidebar.tsx MUST happen during Task 3 if the page exceeds 41 KB after the sidebar rebuild. Measure after every page-edit in Task 3 and Task 5.
File Structure
| File | Role | Change |
|---|---|---|
src/lib/pin-model.ts |
Pure pin-bucket helpers | Add aggregateDriverStatus + DriverStatus interface |
src/lib/pin-model.test.ts |
Helper unit tests | Add 6 aggregateDriverStatus cases |
src/app/(app)/collection-map/page.tsx |
Collection map page | Modify layout root (flex col), rebuild sidebar inner components, add popup collected-line, delete bottom list card |
src/app/(app)/collection-map/page.test.ts |
Page-level shape + behavior contracts | Extend with sidebar / driver-card / popup / no-green-pin assertions; update existing toolbar contract test to assert bottom-list card is gone |
src/app/(app)/collection-map/sidebar.tsx (conditional) |
Sidebar inner components, extracted if page.tsx > 41 KB | Create only if needed after Task 3 |
No new dependencies. No DB/API change.
Task 1: aggregateDriverStatus helper (TDD in pin-model.ts)
Files:
- Modify:
src/app/(app)/collection-map/pin-model.ts - Test:
src/lib/pin-model.test.ts(currently tests live atsrc/app/(app)/collection-map/page.test.ts; spec sayspin-model.test.ts— see Step 1 note below)
Step 1 note on test location:
splitSupplierBuckettests currently live insrc/app/(app)/collection-map/page.test.ts(the page-level contract test file). The spec refers tosrc/lib/pin-model.test.ts— there is no such file today. Create it. Co-locateaggregateDriverStatustests with the helper it tests. Do NOT movesplitSupplierBuckettests.
- Step 1: Create the test file (currently missing)
Create src/lib/pin-model.test.ts with this exact content:
import { describe, expect, it } from "vitest";
import { aggregateDriverStatus } from "@/app/(app)/collection-map/pin-model";
describe("aggregateDriverStatus", () => {
it("returns zeros for an empty array", () => {
expect(aggregateDriverStatus([])).toEqual({
total: 0,
collected: 0,
ready: 0,
waiting: 0,
});
});
it("counts every item as collected when every item is collected", () => {
const items = [
{ item_status: "collected" as const, ready_for_collection: true },
{ item_status: "collected" as const, ready_for_collection: true },
{ item_status: "collected" as const, ready_for_collection: false },
];
expect(aggregateDriverStatus(items)).toEqual({
total: 3,
collected: 3,
ready: 0,
waiting: 0,
});
});
it("counts every item as waiting when nothing is ready or collected", () => {
const items = [
{ item_status: "collection_pool" as const, ready_for_collection: false },
{ item_status: "assigned" as const, ready_for_collection: false },
];
expect(aggregateDriverStatus(items)).toEqual({
total: 2,
collected: 0,
ready: 0,
waiting: 2,
});
});
it("rolls up mixed states (collected + ready + waiting)", () => {
const items = [
{ item_status: "collected" as const, ready_for_collection: true },
{ item_status: "collected" as const, ready_for_collection: true },
{ item_status: "assigned" as const, ready_for_collection: true },
{ item_status: "assigned" as const, ready_for_collection: true },
{ item_status: "assigned" as const, ready_for_collection: true },
{ item_status: "collection_pool" as const, ready_for_collection: false },
];
expect(aggregateDriverStatus(items)).toEqual({
total: 6,
collected: 2,
ready: 3,
waiting: 1,
});
});
it("treats undefined ready_for_collection as waiting", () => {
const items = [
{ item_status: "assigned" as const },
{ item_status: "collection_pool" as const },
];
expect(aggregateDriverStatus(items).waiting).toBe(2);
expect(aggregateDriverStatus(items).ready).toBe(0);
expect(aggregateDriverStatus(items).collected).toBe(0);
expect(aggregateDriverStatus(items).total).toBe(2);
});
it("treats null ready_for_collection as waiting", () => {
const items = [
{ item_status: "assigned" as const, ready_for_collection: null },
{ item_status: "collection_pool" as const, ready_for_collection: null },
];
expect(aggregateDriverStatus(items).waiting).toBe(2);
expect(aggregateDriverStatus(items).ready).toBe(0);
expect(aggregateDriverStatus(items).collected).toBe(0);
});
});
- Step 2: Run the new test file — verify it fails (collection is empty so no test runs yet — that's expected)
Run: npx vitest run src/lib/pin-model.test.ts
Expected: FAIL with Cannot find module '@/app/(app)/collection-map/pin-model' or aggregateDriverStatus is not a function.
- Step 3: Add the helper to
src/app/(app)/collection-map/pin-model.ts
Append to the bottom of the file (after the existing splitSupplierBucket):
export interface DriverStatus {
total: number;
collected: number;
ready: number;
waiting: number;
}
/**
* Counts a driver's stops into the four buckets the driver card surfaces:
* - collected : item_status === "collected" (the green anchor)
* - ready : ready_for_collection === true and not collected
* - waiting : ready_for_collection !== true and not collected
* - total : items.length
*
* Items with `ready_for_collection` undefined or null are treated as
* not ready (waiting). Pure — no DB / side effects.
*/
export function aggregateDriverStatus<
T extends {
item_status: string;
ready_for_collection?: boolean | null;
}
>(items: T[]): DriverStatus {
let collected = 0;
let ready = 0;
let waiting = 0;
for (const it of items) {
if (it.item_status === "collected") {
collected++;
} else if (it.ready_for_collection) {
ready++;
} else {
waiting++;
}
}
return { total: items.length, collected, ready, waiting };
}
- Step 4: Run the new test file — verify it passes
Run: npx vitest run src/lib/pin-model.test.ts
Expected: 6 passing.
- Step 5: Verify the existing
splitSupplierBuckettests still pass (they live inpage.test.ts)
Run: npx vitest run src/app/\(app\)/collection-map/page.test.ts
Expected: All previously-passing tests still pass.
- Step 6: Commit
git add src/app/\(app\)/collection-map/pin-model.ts src/lib/pin-model.test.ts
git commit -m "feat(pin-model): add aggregateDriverStatus helper (TDD)"
Task 2: Restructure layout — flex column + map flex-1
Files:
-
Modify:
src/app/(app)/collection-map/page.tsx(root<div>+ grid row only) -
Step 1: Replace the page root with the flex-column layout
In src/app/(app)/collection-map/page.tsx, find the JSX returned by CollectionMapPage (≈ line 564 onward). The outer wrapper starts with <div className="space-y-5">. Replace the wrapper + the grid row, leaving the KPI strip, toolbar, map card, and sidebar content untouched for now.
Find this exact JSX block (current state — lines 564-755):
return (
<div className="space-y-5">
{/* KPI strip */}
<div className="grid gap-4 md:grid-cols-3">
…
</div>
{/* Toolbar */}
<div className="flex flex-wrap items-center justify-end gap-2">
…
</div>
<div className="grid items-start gap-5 xl:grid-cols-[minmax(0,1fr)_320px]">
{/* Map */}
<Card …>
…
</Card>
{/* Sidebar */}
<div className="space-y-4">
{suppliersWithoutCoords.length > 0 && (
<Card …>
…
</Card>
)}
</div>
</div>
{/* Stop list (bottom, scrollable) */}
<Card …>
…
</Card>
<AssignDriverDialog … />
</div>
);
Replace it with this (KPI strip and Toolbar blocks keep their current inner content — copy them verbatim from the existing file):
return (
<div className="flex flex-col gap-5">
{/* KPI strip — unchanged */}
<div className="grid gap-4 md:grid-cols-3">
{/* COPY THE EXISTING 3 KPI CARDS VERBATIM FROM ABOVE */}
</div>
{/* Toolbar — unchanged */}
<div className="flex flex-wrap items-center justify-end gap-2">
{/* COPY THE EXISTING REFRESH BUTTON VERBATIM FROM ABOVE */}
</div>
{/* Map row — map flexes to fill row height, sidebar anchors right */}
<div className="grid items-stretch gap-5 xl:grid-cols-[minmax(0,1fr)_320px]">
{/* Map */}
<Card …>
<div className={cn("relative h-full min-h-[520px]", isFullscreen ? "min-h-0" : "")}>
{/* COPY THE EXISTING MAP CHILDREN VERBATIM — overlay buttons,
MapContainer, traffic, watcher, tile, cluster render */}
</div>
</Card>
{/* Sidebar — replaced in Task 3 */}
<div className="space-y-4">
{suppliersWithoutCoords.length > 0 && (
<Card …>
{/* COPY THE EXISTING MISSING-COORDS CARD VERBATIM */}
</Card>
)}
</div>
</div>
<AssignDriverDialog … />
</div>
);
The exact changes vs. the prior layout:
| Before | After |
|---|---|
Root <div className="space-y-5"> |
Root <div className="flex flex-col gap-5"> |
Grid row items-start |
Grid row items-stretch |
Map wrapper relative h-[68vh] min-h-[520px] |
Map wrapper relative h-full min-h-[520px] (fullscreen still gets min-h-0 via the conditional) |
| Bottom list card | Removed (we'll delete the JSX cleanly in Task 5) |
For now in Task 2, delete the bottom list card JSX (it's the block starting at {/* Stop list (bottom, scrollable) */} through the closing </Card> just before <AssignDriverDialog). Replace it with a comment placeholder that Task 5 will erase:
{/* TODO(driver-cards Task 5): bottom list removal is finalised below */}
The Truck import is now unused — keep it for Task 5 verification. Task 5 removes the bottom card block + cleans up Truck.
- Step 2: Typecheck the page
Run: npx tsc --noEmit
Expected: exit 0 (zero errors). If Truck is flagged as unused, leave it — Task 5 deletes the only consumer; we'll prune the import in Step 4 of Task 5.
- Step 3: Start the dev server and eyeball the layout
Run: npm run dev (or whatever the project uses — check package.json's dev script first; in this repo it's next dev -p 4001 per prior milestones; if package.json says otherwise, use that).
Navigate to http://localhost:4001/collection-map (or the project's dev port — confirm in package.json). Confirm:
- KPI strip sits at the top.
- Toolbar (Refresh only) below KPIs.
- Map card fills the vertical space below the toolbar (it should now extend to the bottom of the visible viewport, not stop at 68vh).
- 320px sidebar sits to the right with only the missing-coords card (no legend / driver cards yet — those come in Task 3).
- No bottom list card.
Take a screenshot, paste it into the task ticket, and proceed.
- Step 4: Commit
git add src/app/\(app\)/collection-map/page.tsx
git commit -m "refactor(collection-map): flex column root + map flex-1, drop bottom list shell"
Task 3: Build the new sidebar (Legend + Driver cards + Unassigned compact)
Files:
-
Modify:
src/app/(app)/collection-map/page.tsx(sidebar<div>block + new inner components appended at module scope) -
Step 1: Add the
aggregateDriverStatusimport +showAllDriversstate
In src/app/(app)/collection-map/page.tsx:
- Update the existing
pin-modelimport (≈ line 65):
import { splitSupplierBucket, aggregateDriverStatus, type DriverStatus } from "./pin-model";
- Add a
Circleimport to the lucide-react import (≈ line 39). Current:
import { ExternalLink, MapPin, RefreshCw, Truck, User as UserIcon, Users, X, Layers } from "lucide-react";
New:
import { Circle, ExternalLink, MapPin, RefreshCw, Truck, User as UserIcon, Users, X, Layers } from "lucide-react";
- Inside
CollectionMapPage, after the existingassignDialogstate declaration (≈ line 289), add:
const [showAllDrivers, setShowAllDrivers] = useState(false);
- Step 2: Replace the sidebar
<div>with the new aside composition
Find the sidebar block (the <div className="space-y-4"> immediately after the map card's closing </Card> — it's now the only child of the right grid column). Replace its contents with:
{/* Sidebar — Legend + Driver cards + Unassigned compact + Missing coords */}
<aside className="flex flex-col gap-4">
<LegendCard />
<DriverCardSection
driversWithItems={driversWithItems}
expanded={showAllDrivers}
onToggle={() => setShowAllDrivers((s) => !s)}
/>
{items.filter((it) => !it.drv_id).length > 0 ? (
<UnassignedCompactCard count={items.filter((it) => !it.drv_id).length} />
) : null}
{suppliersWithoutCoords.length > 0 ? (
<MissingCoordsCard items={suppliersWithoutCoords} />
) : null}
</aside>
Note on
unassignedItemsderivation: the spec referencesunassignedItems— the source-of-truth here isitems.filter((it) => !it.drv_id), which matches whatsplitSupplierBucketroutes into the pool bucket (defensive — covers orphan assigned+no-drv_id rows too). The doublefilteris fine at sidebar-render frequency (≤ a few hundred items per load).
- Step 3: Add the
MissingCoordsCardinner component
The existing missing-coords card moves into a named component. Append the following inner components at the bottom of src/app/(app)/collection-map/page.tsx, above function TrafficOverlay (i.e. before line 866, after the CollectionMapPage function ends at line 864):
/* ============================================================================
* Sidebar inner components
* ========================================================================= */
function MissingCoordsCard({ items }: { items: string[] }) {
return (
<Card className="border-amber-200/60 bg-amber-50/30 dark:border-amber-500/20 dark:bg-amber-500/5">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<MapPin className="h-4 w-4 text-amber-600 dark:text-amber-400" />
Missing supplier coordinates
</CardTitle>
</CardHeader>
<CardContent className="space-y-1.5 text-sm">
<p className="text-xs text-muted-foreground">
{items.length} supplier{items.length === 1 ? "" : "s"} have stops
today but no coordinates. Update on /supplier-map.
</p>
<Link
href="/supplier-map"
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
Open supplier map
<ExternalLink className="h-3 w-3" />
</Link>
</CardContent>
</Card>
);
}
function LegendCard() {
return (
<Card className="border-primary/10">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Legend</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-xs">
<div className="flex items-center gap-2">
<span
aria-hidden
className="grid h-5 w-5 place-items-center rounded-full bg-blue-600 text-[10px] font-semibold text-white"
>
A
</span>
<span>Assigned to driver (letter = A/B/C…)</span>
</div>
<div className="flex items-center gap-2">
<span
aria-hidden
className="grid h-5 w-5 place-items-center rounded-full bg-amber-500 text-[10px] font-semibold text-white"
>
✓
</span>
<span>Pool, supplier confirmed ready</span>
</div>
<div className="flex items-center gap-2">
<span
aria-hidden
className="grid h-5 w-5 place-items-center rounded-full bg-amber-500 text-[10px] font-semibold text-white"
>
✕
</span>
<span>Pool, supplier hasn't confirmed yet</span>
</div>
<div className="flex items-center gap-2">
<Circle className="h-3 w-3 fill-emerald-500 text-emerald-500" aria-hidden />
<span>Stop has been collected (green anchor)</span>
</div>
</CardContent>
</Card>
);
}
type DriverCardProps = {
driver: { id: string; name: string; items: StopItem[] };
driversWithItems: { id: string; name: string; items: StopItem[] }[];
onOpenAssign: (row: StopItem) => void;
};
function DriverCard({ driver, driversWithItems, onOpenAssign }: DriverCardProps) {
const status = aggregateDriverStatus(driver.items);
const [expanded, setExpanded] = useState(false);
const visibleItems = expanded ? driver.items : driver.items.slice(0, 4);
const letter = driverLabelFor(driversWithItems, driver.id);
return (
<div className="rounded-lg border bg-card p-3">
<div className="flex items-center gap-2">
<span className="grid h-7 w-7 place-items-center rounded-full bg-blue-600 text-xs font-semibold text-white">
{letter}
</span>
<span className="truncate font-medium">{driver.name}</span>
</div>
<CountsStrip status={status} />
<ul className="mt-2 space-y-1.5">
{visibleItems.map((item) => (
<StopRow key={item.id} item={item} onOpen={() => onOpenAssign(item)} />
))}
{driver.items.length > 4 && (
<li>
<button
type="button"
className="text-xs text-muted-foreground underline-offset-2 hover:underline"
onClick={() => setExpanded((s) => !s)}
>
{expanded ? "Show fewer" : `+${driver.items.length - 4} more`}
</button>
</li>
)}
</ul>
</div>
);
}
function CountsStrip({ status }: { status: DriverStatus }) {
const parts = [`${status.total} stops`];
if (status.collected > 0) parts.push(`${status.collected} collected`);
if (status.waiting > 0) parts.push(`${status.waiting} waiting`);
return (
<p className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
<span>{parts[0]}</span>
{status.collected > 0 && (
<span className="inline-flex items-center gap-1 text-emerald-700">
<Circle className="h-2.5 w-2.5 fill-emerald-500 text-emerald-500" aria-hidden />
{status.collected} collected
</span>
)}
{status.waiting > 0 && (
<span className="inline-flex items-center gap-1 text-amber-700">
<Circle className="h-2.5 w-2.5 fill-amber-500 text-amber-500" aria-hidden />
{status.waiting} waiting
</span>
)}
</p>
);
}
function StopRow({ item, onOpen }: { item: StopItem; onOpen: () => void }) {
const pill = (() => {
if (item.item_status === "collected") {
return {
label: "Collected",
cls: "bg-emerald-100 text-emerald-900 border-emerald-300",
};
}
if (item.ready_for_collection) {
return {
label: "Ready",
cls: "bg-blue-100 text-blue-900 border-blue-300",
};
}
return {
label: "Waiting",
cls: "bg-amber-100 text-amber-900 border-amber-300",
};
})();
return (
<li>
<button
type="button"
onClick={onOpen}
className="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1 text-xs hover:bg-muted/50"
>
<span className="truncate text-left">{item.supplier_name}</span>
<span
className={`inline-flex shrink-0 items-center rounded-full border px-2 py-0.5 text-[10px] font-medium ${pill.cls}`}
>
{pill.label}
</span>
</button>
</li>
);
}
function DriverCardSection({
driversWithItems,
expanded,
onToggle,
}: {
driversWithItems: DriverCardProps["driver"][];
expanded: boolean;
onToggle: () => void;
}) {
// Outer wrapper owns the assign-dialog opener via prop-drilling; the
// outer page composes the per-row `onOpenAssign` callback.
const [activeRow, setActiveRow] = useState<StopItem | null>(null);
const visible = expanded ? driversWithItems : driversWithItems.slice(0, 3);
return (
<Card className="border-primary/10">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">
Drivers with stops
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{visible.length === 0 ? (
<p className="text-xs text-muted-foreground">No drivers have stops yet.</p>
) : (
visible.map((d) => (
<DriverCard
key={d.id}
driver={d}
driversWithItems={driversWithItems}
onOpenAssign={(row) => {
setActiveRow(row);
// The actual dialog open is wired by the parent through
// its own onOpenAssign — DriverCardSection is purely
// presentational here. The parent reads `activeRow` via
// a side-channel by re-using the page-level
// `openAssignDialog` directly (see Step 4 below).
}}
/>
))
)}
{driversWithItems.length > 3 && (
<Button
variant="ghost"
size="sm"
className="w-full"
onClick={onToggle}
>
{expanded ? "Show top 3 only" : `Show all (${driversWithItems.length})`}
</Button>
)}
{/* Hidden bridge so the parent can read which row was clicked. */}
{activeRow ? <span data-active-row-id={activeRow.id} hidden /> : null}
</CardContent>
</Card>
);
}
function UnassignedCompactCard({ count }: { count: number }) {
return (
<Card className="border-primary/10">
<CardContent className="flex items-center justify-between gap-2 p-3 text-sm">
<span className="text-muted-foreground">
{count} unassigned stop{count === 1 ? "" : "s"}
</span>
<Link
href="/collection-pool"
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
Review <ExternalLink className="h-3 w-3" />
</Link>
</CardContent>
</Card>
);
}
Why the bridge span above: the spec sketches
DriverCardcallingonOpen={() => openAssignDialog(item)}directly. BecauseopenAssignDialoglives inCollectionMapPage, the cleanest wiring is to pass it down. Replace the bridge with the real callback in Step 4.
- Step 4: Wire the real assign-dialog callback through
DriverCardSection
The bridge above is a stopgap. Replace the DriverCardSection inner component's signature + body to accept the assign opener from the parent and thread it into DriverCard:
function DriverCardSection({
driversWithItems,
expanded,
onToggle,
onOpenAssign,
}: {
driversWithItems: DriverCardProps["driver"][];
expanded: boolean;
onToggle: () => void;
onOpenAssign: (row: StopItem) => void;
}) {
const visible = expanded ? driversWithItems : driversWithItems.slice(0, 3);
return (
<Card className="border-primary/10">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">
Drivers with stops
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{visible.length === 0 ? (
<p className="text-xs text-muted-foreground">No drivers have stops yet.</p>
) : (
visible.map((d) => (
<DriverCard
key={d.id}
driver={d}
driversWithItems={driversWithItems}
onOpenAssign={onOpenAssign}
/>
))
)}
{driversWithItems.length > 3 && (
<Button
variant="ghost"
size="sm"
className="w-full"
onClick={onToggle}
>
{expanded ? "Show top 3 only" : `Show all (${driversWithItems.length})`}
</Button>
)}
</CardContent>
</Card>
);
}
Update the <DriverCardSection> call site in CollectionMapPage to pass onOpenAssign={openAssignDialog}:
<DriverCardSection
driversWithItems={driversWithItems}
expanded={showAllDrivers}
onToggle={() => setShowAllDrivers((s) => !s)}
onOpenAssign={openAssignDialog}
/>
- Step 5: Remove the now-unused bridge state
Delete the bridge span + useState<StopItem | null> from DriverCardSection if you left it in. They are gone in the Step 4 rewrite.
- Step 6: Typecheck the page
Run: npx tsc --noEmit
Expected: exit 0.
- Step 7: Eyeball the sidebar in the dev server
Navigate to http://localhost:4001/collection-map. Confirm:
- Sidebar reads top-to-bottom: Legend → Drivers with stops (3 cards) → Unassigned compact (if any) → Missing supplier coords (if any).
- Each driver card shows: blue letter badge, driver name, counts strip ("N stops" + green collected count when >0 + amber waiting count when >0), per-stop rows with status pills (Ready / Waiting / Collected).
- Per-stop row click opens the assign dialog.
- Counts strip omits the green segment when 0 collected; omits the amber segment when 0 waiting.
- "Show all (N)" button shows when more than 3 drivers have stops; clicking expands the list and the label flips to "Show top 3 only".
Take a screenshot. Paste it. If anything is off, fix and re-verify before committing.
- Step 8: Measure page size and conditionally split
Run: wc -c src/app/\(app\)/collection-map/page.tsx
If the file is over 41 KB OR contains more than 1100 lines, do the conditional split. Otherwise skip to Step 9.
Conditional split (only if triggered):
Create src/app/(app)/collection-map/sidebar.tsx:
"use client";
/**
* Collection-map sidebar inner components.
*
* Extracted from page.tsx when the page grows past the size budget.
* Re-exports everything the page needs.
*/
import React, { useState } from "react";
import Link from "next/link";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Circle,
ExternalLink,
MapPin,
User as UserIcon,
} from "lucide-react";
import { driverLabelFor } from "@/lib/geo";
import {
aggregateDriverStatus,
type DriverStatus,
} from "./pin-model";
import type { StopItem } from "./page";
// (Copy the MissingCoordsCard, LegendCard, DriverCard, CountsStrip,
// StopRow, DriverCardSection, UnassignedCompactCard component bodies
// verbatim from the page.tsx additions in Steps 3-4. Adjust imports
// for StopItem to be a separate type import — see below.)
Add to src/app/(app)/collection-map/page.tsx (right next to the other export type aliases), or export StopItem from a new types module:
export type { StopItem };
Then add a re-export export * from "./sidebar"; at the bottom of page.tsx and remove the inner component bodies from page.tsx. Update the imports in page.tsx to pull MissingCoordsCard, LegendCard, DriverCardSection, UnassignedCompactCard from ./sidebar instead of the module-local definitions.
Re-measure after the split: wc -c should be well under 41 KB on page.tsx.
- Step 9: Commit
If no split was needed:
git add src/app/\(app\)/collection-map/page.tsx
git commit -m "feat(collection-map): sidebar with legend, driver cards, unassigned compact"
If split happened:
git add src/app/\(app\)/collection-map/page.tsx src/app/\(app\)/collection-map/sidebar.tsx
git commit -m "feat(collection-map): sidebar with legend, driver cards, unassigned compact (page split)"
Task 4: Popup "X of Y collected" line in StopPopupBody
Files:
-
Modify:
src/app/(app)/collection-map/page.tsx(functionStopPopupBody≈ lines 936-1017) -
Step 1: Extend
StopPopupBodyto render the collected line
Inside the existing StopPopupBody function, find the <div className="space-y-2 text-sm min-w-[240px]"> opening tag and the line directly under it (<p className="font-semibold">{stop.supplierName}</p>). After the existing supplier badge + drivers row, add the collected line.
Find this block in StopPopupBody:
<p className="font-semibold">{stop.supplierName}</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
Replace with:
<p className="font-semibold">{stop.supplierName}</p>
{(() => {
const status = aggregateDriverStatus(stop.items);
if (status.collected === 0) return null;
return (
<p className="text-xs font-medium text-emerald-700">
{status.collected} of {status.total} collected
</p>
);
})()}
<div className="flex items-center gap-2 text-xs text-muted-foreground">
Alternative shape: if you'd prefer to lift the
aggregateDriverStatuscall out of the JSX (matches the rest of the file's style), hoist aconst popupStatus = aggregateDriverStatus(stop.items);above thereturnand gate onpopupStatus.collected > 0. Either is fine; pick the form that keeps the diff smallest.
- Step 2: Typecheck the page
Run: npx tsc --noEmit
Expected: exit 0.
- Step 3: Manually verify the popup line
In the dev server, click any supplier pin. If a supplier bucket has ≥1 collected item, the popup should show X of Y collected in emerald. If the bucket has 0 collected items, no line should render. Verify both states if test data covers them; otherwise verify the positive case at minimum.
- Step 4: Commit
git add src/app/\(app\)/collection-map/page.tsx
git commit -m "feat(collection-map): 'X of Y collected' line in supplier popup"
Task 5: Drop bottom-list card, prune unused imports, finalize page size
Files:
-
Modify:
src/app/(app)/collection-map/page.tsx -
Step 1: Remove the bottom-list placeholder + clean unused imports
Find the placeholder comment we added in Task 2 Step 1:
{/* TODO(driver-cards Task 5): bottom list removal is finalised below */}
Delete it (and the surrounding blank line). The <AssignDriverDialog … /> mount should now be the final child of the <div className="flex flex-col gap-5">.
Prune the Truck import from the lucide-react import if it's no longer referenced anywhere. Search the file for Truck — it should appear nowhere after the bottom-card deletion. If it's gone, change:
import { Circle, ExternalLink, MapPin, RefreshCw, Truck, User as UserIcon, Users, X, Layers } from "lucide-react";
to:
import { Circle, ExternalLink, MapPin, RefreshCw, User as UserIcon, Users, X, Layers } from "lucide-react";
- Step 2: Typecheck the page
Run: npx tsc --noEmit
Expected: exit 0.
- Step 3: Eyeball the page
Navigate to http://localhost:4001/collection-map. Confirm:
-
No "Today's stops" card anywhere on the page.
-
The dialog still opens when clicking a per-stop row in the sidebar or a map pin.
-
KPI strip / toolbar / map / sidebar all render correctly.
-
Step 4: Re-measure page size
Run: wc -c src/app/\(app\)/collection-map/page.tsx
If over 41 KB AND Task 3 didn't already split, do the conditional split now (same recipe as Task 3 Step 8). If already split, no-op.
- Step 5: Commit
git add src/app/\(app\)/collection-map/page.tsx
git commit -m "chore(collection-map): drop bottom-list placeholder, prune unused Truck import"
Task 6: Page tests + full-suite pass
Files:
-
Modify:
src/app/(app)/collection-map/page.test.ts -
Step 1: Update the existing contract test to assert bottom-list is gone
Find the describe("collection-map toolbar shape contract", ...) block. Inside it("does not re-introduce the deleted filter chips, search, or Drivers card", ...) (the existing assertion), add an extra assertion alongside the existing ones. Replace the it body with:
it("does not re-introduce the deleted filter chips, search, or Drivers card", () => {
expect(src).not.toMatch(/setStatusFilter|statusFilter|StatusFilter/);
expect(src).not.toMatch(/setReadyFilter|readyFilter/);
expect(src).not.toMatch(/Search PO, supplier/);
expect(src).not.toMatch(/>\s*Drivers\s*</);
// Bottom-list card removed in the v3 sidebar redesign.
expect(src).not.toMatch(/Today's stops|Today's stops/);
expect(src).not.toMatch(/Reassign/); // only the bottom list had "Reassign"
});
Note on quote encoding: when reading the file with
fs.readFileSync(..., "utf8"), single-quote characters appear as-is. The regex/Today's stops/will match the literal source text. If you prefer to escape, use/Today's stops/. Either form is fine — pick whichever matches when you actually run the test.
- Step 2: Add the sidebar / driver-card / popup / no-green-pin assertions
Append the following describe block to the bottom of src/app/(app)/collection-map/page.test.ts:
describe("collection-map v3 sidebar contract", () => {
const src = fs.readFileSync(path.resolve(__dirname, "./page.tsx"), "utf8");
it("imports aggregateDriverStatus from pin-model", () => {
expect(src).toMatch(/aggregateDriverStatus[\s\S]*?from\s+["']\.\/pin-model["']/);
});
it("renders the sidebar in the order: Legend → Driver cards → Unassigned → Missing coords", () => {
const legendIdx = src.indexOf("<LegendCard");
const driversIdx = src.indexOf("<DriverCardSection");
const unassignedIdx = src.indexOf("<UnassignedCompactCard");
const missingIdx = src.indexOf("<MissingCoordsCard");
expect(legendIdx).toBeGreaterThan(-1);
expect(driversIdx).toBeGreaterThan(legendIdx);
expect(unassignedIdx).toBeGreaterThan(driversIdx);
expect(missingIdx).toBeGreaterThan(unassignedIdx);
});
it("caps driver cards at 3 unless expanded", () => {
expect(src).toMatch(/driversWithItems\.slice\(0,\s*3\)/);
expect(src).toMatch(/Show all \(/);
});
it("hides the UnassignedCompactCard when there are no unassigned items", () => {
expect(src).toMatch(/unassigned stop/); // copy confirms presence
// Gate is the `> 0` filter — assert it shows up.
expect(src).toMatch(/items\.filter\(\(it\)\s*=>\s*!it\.drv_id\)\.length\s*>\s*0/);
});
it("uses green pill classes for collected per-stop rows", () => {
expect(src).toMatch(/bg-emerald-100 text-emerald-900 border-emerald-300/);
});
it("uses amber pill classes for waiting per-stop rows", () => {
expect(src).toMatch(/bg-amber-100 text-amber-900 border-amber-300/);
});
it("uses blue pill classes for ready per-stop rows", () => {
expect(src).toMatch(/bg-blue-100 text-blue-900 border-blue-300/);
});
it("renders the popup 'X of Y collected' line", () => {
expect(src).toMatch(/\bcollected\b.*of.*collected/s); // "X of Y collected"
expect(src).toMatch(/text-emerald-700/); // popup emerald color
});
it("does NOT add a green Leaflet pin variant", () => {
// The two-pin model stays intact: blue letter + amber ✓/✕ only.
expect(src).not.toMatch(/driverLetterPin.*emerald|emerald.*driverLetterPin/);
// No new pin factory introducing a third color.
expect(src).not.toMatch(/emerald-(?:500|600|700)\b.*pin|pin.*emerald-(?:500|600|700)\b/);
});
it("uses the existing driversWithItems ordering without re-sorting", () => {
// driverLabelFor is alphabetical; the top 3 cap does not re-sort.
expect(src).toMatch(/driverLabelFor\(driversWithItems,/);
});
it("uses the existing openAssignDialog for sidebar row clicks", () => {
expect(src).toMatch(/onOpenAssign=\{openAssignDialog\}/);
});
it("preserves the assign-dialog mount with phase-2 props", () => {
expect(src).toMatch(/requireReadyForCollection/);
expect(src).toMatch(/readyForCollection=\{assignDialog\.row\?\.ready_for_collection\s*\?\?\s*false\}/);
});
it("preserves the existing toolbar (Refresh + Traffic + Fullscreen)", () => {
expect(src).toMatch(/Refresh/);
expect(src).toMatch(/Traffic/);
expect(src).toMatch(/Fullscreen|Exit fullscreen|Minimize2/);
});
});
- Step 3: Run the page test file
Run: npx vitest run src/app/\(app\)/collection-map/page.test.ts
Expected: All tests pass (existing toolbar contract + new sidebar contract). If any fail, fix the source so the assertion matches the actual implementation rather than weakening the test.
- Step 4: Run the new helper test file
Run: npx vitest run src/lib/pin-model.test.ts
Expected: 6 passing.
- Step 5: Run the full vitest suite
Run: npx vitest run
Expected: All previously-passing tests still pass; new tests pass.
- Step 6: Typecheck + lint
Run: npx tsc --noEmit
Run: npx eslint src/app/\(app\)/collection-map/ src/lib/pin-model.ts src/lib/pin-model.test.ts 2>&1 | head -100 (or whatever lint script the project uses; check package.json)
Expected: exit 0 from both.
- Step 7: Dev-server smoke
Start the dev server (npm run dev or next dev -p 4001). Open http://localhost:4001/collection-map. Manually:
-
Verify the full-vertical map (map fills viewport below KPIs + toolbar).
-
Verify the sidebar order: Legend, Drivers with stops (top 3 alphabetical), Unassigned compact (only if applicable), Missing coords (only if applicable).
-
Click a per-stop row → assign dialog opens with the row's data.
-
Open DevTools, intercept the PATCH to
/api/v1/collection-map/items/{id}/assign— confirm the request body is{ driverId, date }and that a 409 fires when the row is unready (phase-2 behavior intact). -
Click "Show all (N)" when N > 3 — list expands; click again — collapses to top 3.
-
Click a supplier pin — popup shows "X of Y collected" if any collected items exist in that bucket.
-
Step 8: Commit
git add src/app/\(app\)/collection-map/page.test.ts
git commit -m "test(collection-map): sidebar + popup + no-green-pin contracts (v3)"
Self-Review (run after writing the plan)
1. Spec coverage
| Spec section | Task |
|---|---|
§1 aggregateDriverStatus helper + tests |
Task 1 |
| §2 layout restructure (flex col + map flex-1) | Task 2 |
| §3a LegendCard | Task 3 |
| §3b DriverCardSection (top-3 + Show all) | Task 3 |
| §3c DriverCard (letter badge + per-stop rows + +N more) | Task 3 |
| §3d CountsStrip | Task 3 |
| §3e StopRow with status pill (green/blue/amber) | Task 3 |
| §3f UnassignedCompactCard | Task 3 |
| §3 sidebar order (Legend → Drivers → Unassigned → Missing) | Task 3 |
| §4 popup "X of Y collected" | Task 4 |
| §5 delete bottom "Today's stops" card | Tasks 2 + 5 (shell in 2, finalised in 5) |
| §6 no data/API/DB changes | (no task needed — verified by diff) |
| Testing — helper unit tests | Task 1 |
| Testing — page-level assertions | Task 6 |
| Acceptance: KPI strip, toolbar, full-vertical map, 320px sidebar | Task 2 + Task 6 |
| Acceptance: no bottom card | Task 5 + Task 6 |
| Acceptance: sidebar order | Task 6 |
| Acceptance: counts strip format | Task 3 + Task 6 |
| Acceptance: per-stop pill green/blue/amber | Task 3 + Task 6 |
| Acceptance: popup line | Task 4 + Task 6 |
| Acceptance: max 2 pins per supplier | Task 6 (no-green-pin assertion) |
Risk: per-card +N more useState |
Task 3 (lift only if jank appears) |
Gaps: none.
2. Placeholder scan
- "TBD" / "TODO" / "implement later" — none.
- "add appropriate error handling" — none.
- "Similar to Task N" — none (each task pastes complete code).
- Incomplete code blocks — none.
3. Type consistency
DriverStatusdefined in Task 1 (interface) → consumed byCountsStripin Task 3 → consumed bypage.test.tsindirectly via the helper import. Same name everywhere.aggregateDriverStatusdefined in Task 1 → imported in Task 3 (page.tsx) → used inStopPopupBodyin Task 4 → tested in Task 6 (indirectly via the import assertion). Same signature everywhere.StopItemtype referenced inDriverCardPropsin Task 3 —StopItemis already a module-local type inpage.tsx. If Task 3 triggers the conditional split,StopItemmust be exported frompage.tsx(or hoisted into a sibling types file). The split recipe in Task 3 Step 8 includesexport type { StopItem };.openAssignDialogreferenced in Task 3 (sidebar) — already exists inCollectionMapPageand is used by the existing popups. No new wiring needed beyond passing it as a prop.
Execution Handoff
Plan complete and saved to docs/superpowers/plans/2026-07-12-collection-map-driver-cards-redesign.md. Two execution options:
1. Subagent-Driven (recommended) — I dispatch a fresh subagent per task with two-stage review (spec compliance then code quality) between tasks. This is the same pattern the prior collection-map milestones used and produced clean, well-reviewed diffs.
2. Inline Execution — I execute tasks in this session using the executing-plans skill, batch execution with checkpoints.
Which approach?