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.
20 KiB
g# Collection Map v3 — sidebar driver cards, green-collected anchor, drop bottom list
Goal: Turn /collection-map into the operator's at-a-glance control surface: the map fills the viewport vertically, the right sidebar shows a legend plus the 2–3 drivers with their assigned stops (with a clear green visual anchor for what has been collected), and the unused bottom "Today's stops" list is removed.
Scope
In Scope
- Drop the bottom "Today's stops" list card. Operators do not use it (confirmed).
- Make the map full-length vertically. The map card fills the height between the KPI strip + toolbar at top and the page bottom, with the sidebar as its right neighbor. No fixed
vhheight. - Replace the empty sidebar with operational content. In order: (a) Legend explaining pin colors and status pills, (b) Driver cards showing the top 3 drivers from the existing alphabetical
driversWithItemslist (each card = name + letter badge, status counts strip, per-stop rows with status pills), (c) Unassigned (compact) card only when there is at least one unassigned item, (d) Missing coordinates warning card when applicable. - Green-collected visual anchor on driver cards and per-stop rows. Items where
item_status === "collected"show a green pill on the per-stop row, and contribute a green count to the driver card's counts strip. The map pin model stays at two pins per supplier bucket (blue letter + amber ✓/✕) — collected is a presentation anchor, not a third pin state. - Driver card counts strip format.
{total} stops · 🟢 {collected} collected · 🟠 {waiting} waiting. The "X collected" segment appears only when count > 0; the "Y waiting" segment appears only when count > 0; if every stop is ready+assigned (and zero collected), the strip shows just{total} stops. - Per-stop rows show up to 4 stops, with
+N morelink to expand. Expanded rows show all stops. Each row: supplier name (truncate), status pill (Ready / Waiting / Collected), and click-through to the existing assign-driver dialog. - "Show all (N)" button on the driver section when more than 3 drivers have stops. Toggling it expands to show every driver (no re-sort, no re-letter — alphabetical + A/B/C… stays).
- Map popup body gains a single line:
X of Y collectedwhen the supplier bucket has at least one collected item. No third pin color. - New pure helper
aggregateDriverStatus(items)insrc/lib/pin-model.ts. Returns{ total, collected, ready, waiting }. TDD'd viapin-model.test.ts. Driver cards consume it directly; popups use it inline.
Out of Scope
- A third Leaflet pin state for collected items (the map stays at two pins per supplier bucket).
- A "mark ready" combo action from the driver card (still goes through
/collection-pool). - Live count updates / refresh policy changes. The existing 20s positions poll stays.
- Changing the assign API or assign dialog (these are stable from phase 2).
- Driver-location markers or anything that touches
driver_locations. - Per-row selection / bulk-assign UX (the assign dialog handles single-row assign already).
Current State
Sidebar is empty
src/app/(app)/collection-map/page.tsx right column (sidebar) only renders the conditional "Missing supplier coordinates" warning card. There is no legend, no driver summary, no operational affordance. Approximate line range: 728-754.
Bottom "Today's stops" card is unused
page.tsx renders a card with the per-item list (supplier name, status badge, driver picker, assign action). User confirmed they do not use it; it is dead surface area. Approximate line range: 757-836.
Map height is fixed
page.tsx map wrapper is className={cn("relative", isFullscreen ? "h-dvh min-h-0" : "h-[68vh] min-h-[520px]")} (≈ line 615). The map needs to flex to fill remaining viewport height between the top controls and the page bottom (with fullscreen behavior preserved).
Two-pin model is the right baseline
src/lib/pin-model.ts splitSupplierBucket already routes collected items into the assigned-by-driver bucket (isRouted predicate treats item_status === "collected" as routed). No change needed to the pin layer.
Status spectrum (locked)
| State | Per-stop row pill | Counts strip | Map pin |
|---|---|---|---|
collection_pool + ready_for_collection=false |
Waiting (amber) | 🟠 +1 waiting |
amber ✕ |
collection_pool + ready_for_collection=true |
Ready (blue) | (counts as ready) | amber ✓ |
assigned + ready_for_collection=false |
Waiting (amber) | 🟠 +1 waiting |
blue letter |
assigned + ready_for_collection=true |
Ready (blue) | (counts as ready) | blue letter |
collected |
Collected (green) | 🟢 +1 collected |
blue letter (no change) |
User Confirmed Rule
- The map is the layout's vertical anchor; the sidebar is its operational sidebar.
- The bottom list is gone — operators read driver state from the sidebar instead.
- Green = collected, full stop. It is the at-a-glance "done" anchor that lets the operator see remaining work without reading every row.
- No third pin state. Driver cards + popups carry the green; the pin layer stays untouched.
- Top 3 drivers + Show all is the default surface (the operator doesn't want to scroll through 12 drivers; the top 3 from the existing alphabetical
driversWithItemslist are what they see first, with the counts strip on each card telling them who is busiest at a glance).
Behavior
1. Add aggregateDriverStatus(items) helper in pin-model.ts
Add to src/lib/pin-model.ts:
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 };
}
Edge cases captured by the type test surface:
- Empty array →
{ total: 0, collected: 0, ready: 0, waiting: 0 }. - All collected →
{ total: N, collected: N, ready: 0, waiting: 0 }. - All unready pool →
{ total: N, collected: 0, ready: 0, waiting: N }. - Mix: counts roll up correctly.
ready_for_collectionundefined / null → treated asfalse(waiting).
2. Restructure the layout
page.tsx becomes a flex column root so the map can flex:
<div className="flex flex-col gap-5">
<KpiStrip ... /> {/* KPI strip — unchanged */}
<Toolbar ... /> {/* collapsed toolbar — unchanged */}
<div className="grid items-stretch gap-5 xl:grid-cols-[minmax(0,1fr)_320px]">
<MapCard className="min-h-[520px] flex-1">
{/* MapContainer keeps h-full w-full inside this wrapper */}
</MapCard>
<Sidebar />
</div>
</div>
items-stretch makes the map row match the tallest column. The sidebar is intrinsically short, so the map fills the available row height. On fullscreen (isFullscreen toggle), the wrapper switches to h-dvh min-h-0 and the map becomes h-full (existing behavior preserved).
The current xl:grid-cols-[minmax(0,1fr)_320px] pattern (≈ line 607) is preserved — only the row height source changes.
3. Build the new sidebar (replaces the empty aside)
<aside className="flex flex-col gap-4">
<LegendCard />
<DriverCardSection
driversWithItems={driversWithItems}
expanded={showAllDrivers}
onToggle={() => setShowAllDrivers((s) => !s)}
/>
{unassignedItems.length > 0 ? (
<UnassignedCompactCard count={unassignedItems.length} />
) : null}
{missingCoords.length > 0 ? (
<MissingCoordsCard items={missingCoords} />
) : null}
</aside>
State added to page.tsx:
const [showAllDrivers, setShowAllDrivers] = useState(false);
3a. LegendCard
A static card explaining the visual language:
- Blue circle + letter → assigned to that driver (letter =
driverLabelFor(...)) - Amber ✓ → pool, supplier confirmed ready
- Amber ✕ → pool, supplier hasn't confirmed yet
- Green pill on driver card / popup → stop has been collected
Icons reused from the existing lucide-react import. Each row = a tiny icon swatch + a one-line label. No interaction.
3b. DriverCardSection
When !expanded, show driversWithItems.slice(0, 3). When expanded, show all driversWithItems. The existing sort (alphabetical by name at ≈ line 545-559) stays; the top 3 cap does not change which 3 are chosen.
const visible = showAllDrivers ? driversWithItems : driversWithItems.slice(0, 3);
return (
<Card>
<CardHeader>
<CardTitle className="text-sm">Drivers with stops</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{visible.map((d) => (
<DriverCard key={d.id} driver={d} driversWithItems={driversWithItems} />
))}
{driversWithItems.length > 3 && (
<Button
variant="ghost"
size="sm"
className="w-full"
onClick={onToggle}
>
{showAllDrivers
? "Show top 3 only"
: `Show all (${driversWithItems.length})`}
</Button>
)}
</CardContent>
</Card>
);
3c. DriverCard
function DriverCard({ driver, driversWithItems }) {
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={() => openAssignDialog(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>
);
}
3d. CountsStrip
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 text-xs text-muted-foreground">{parts.join(" · ")}</p>
);
}
The 🟢 / 🟠 are presentation cues; the visual encoding is carried by the rendered Lucide Circle icons (filled, colored) so screen readers / accessibility tools see the count text, not an emoji. The text-only form 2 collected / 1 waiting is sufficient for SR users.
3e. StopRow
function StopRow({ item, onOpen }) {
const supplier = suppliersById[item.supplier];
const supplierName = supplier?.name ?? "Unknown supplier";
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">{supplierName}</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>
);
}
Click-through reuses the existing openAssignDialog flow. Ready/Unready gate (phase 2) is preserved inside the dialog.
3f. UnassignedCompactCard
When unassignedItems.length > 0. Compact card with one line:
{N} unassigned stops Review →
The Review link navigates to /collection-pool (where operators currently triage unassigned items). No per-item rows — drivers can scan it at a glance.
4. Add the popup line X of Y collected
In StopPopupBody (page.tsx, ≈ lines 936-1011), inside the bucket render path, after the supplier name and before the existing popup body content:
const status = aggregateDriverStatus(bucket.items);
// ...
{status.collected > 0 && (
<p className="mt-1 text-xs font-medium text-emerald-700">
{status.collected} of {status.total} collected
</p>
)}
bucket.items is the per-bucket array (already grouped per splitSupplierBucket). The popup body already iterates the bucket items; the new line is a small header add.
5. Drop the bottom list card
Delete page.tsx ≈ lines 757-836. The filteredItems / items state and the assignDialog mount stay (the driver cards and the map popups both still open it). The "Map of stops" footer copy at the bottom of that card goes with it.
6. No data, API, or DB changes
- No PocketBase schema change.
- No API route change.
driversWithItems,splitSupplierBucket,driverLabelFor,unassignedGlyphFor,driverLetterPin,unassignedPoolPinall stay as-is.- The
filteredItemsmemo stays (currentlyuseMemo(() => items, [items])).
Testing
src/lib/pin-model.test.ts (extend the existing file)
Add tests for aggregateDriverStatus:
empty array → { total: 0, collected: 0, ready: 0, waiting: 0 }.all collected → { total: N, collected: N, ready: 0, waiting: 0 }.all unready pool → { total: N, collected: 0, ready: 0, waiting: N }.mixed: 2 collected + 3 ready assigned + 1 waiting pool → { total: 6, collected: 2, ready: 3, waiting: 1 }.ready_for_collection undefined → treated as false (waiting).ready_for_collection null → treated as false (waiting).
src/app/(app)/collection-map/page.test.ts (extend the existing file)
does not render the bottom "Today's stops" card(assert the test id / heading is absent).renders driver cards for top 3 by alphabetical order when more than 3 drivers exist(fakedriversWithItemsarray with 5 entries, assert the first 3 names render).renders "Show all (N)" when more than 3 drivers have stopsandclicking it expands the list to all drivers.renders UnassignedCompactCard only when unassignedItems.length > 0(and hides it when 0).renders LegendCard and its four entries(Blue circle, Amber ✓, Amber ✕, Green pill).renders the collected pill on per-stop rows whose item_status === "collected"(assertbg-emerald-100class present).renders the waiting pill on per-stop rows whose ready_for_collection === false.renders "X of Y collected" line in the popup when the bucket has collected items.does NOT render a green pin variant— assert that the map render output uses only blue + amber icon classes (the two-pin model is intact).- Existing toolbar shape test stays valid (no date picker — already removed in phase 2).
- Existing assign-dialog wire-up tests stay valid.
Regression
- All phase-2 tests still pass.
pin-model.test.tsexistingsplitSupplierBuckettests stay valid (collected items still routed intoassignedByDriver).- The map render path is unchanged in structure; the popup body adds one line but the per-pin markup does not change.
Acceptance Criteria
/collection-mapshows: KPI strip, toolbar (Refresh only), map filling the available vertical space, 320px sidebar on the right.- No bottom "Today's stops" card anywhere on the page.
- Sidebar shows (in this order): legend, top 3 driver cards with counts strip + per-stop rows + status pills, "Show all (N)" button when more than 3 drivers exist, an unassigned summary card only when there are unassigned items, and the missing-coords warning when applicable.
- Each driver card counts strip reads
N stopsplus🟢 X collectedand🟠 Y waitingonly when those counts are non-zero. - Each per-stop row shows the supplier name + a colored pill: green for
collected, blue for ready+assigned or ready+pool, amber for waiting. - Map popups add
X of Y collectedwhenever the supplier bucket has at least one collected item. - The map renders at most two pins per supplier bucket (one blue letter, one amber ✓/✕) — no green pin variant added.
- All targeted tests pass; full suite non-regressive.
Risk / Rollback
- Low-to-moderate risk. Layout + presentational helper. No API/DB change.
- Rollback: restore the deleted bottom-list card and revert the layout flex change. The helper add is additive and can stay.
- Per-card
+N moreexpansion introduces per-row state. If expansion becomes a source of jank, lift it toexpandedDriverIds: Set<string>in the page state. Defaulting to per-carduseStatefor v1.
File Changes (summary)
- Modify
src/app/(app)/collection-map/page.tsx— drop bottom list (≈ lines 757-836), make layout flex column with map flex-1, build new sidebar (Legend + Driver cards + Unassigned compact + Missing coords), add popup "X of Y collected" line. - Modify
src/lib/pin-model.ts— addaggregateDriverStatus+DriverStatusinterface. - Modify
src/lib/pin-model.test.ts— add tests foraggregateDriverStatus. - Modify
src/app/(app)/collection-map/page.test.ts— drop bottom-list assertions; add sidebar / driver-card / popup assertions; add the no-green-pin guard. - No new files. DriverCard / DriverCardSection / CountsStrip / StopRow / LegendCard / UnassignedCompactCard live as inner components in
page.tsx(they are tightly coupled to existing state). Ifpage.tsxexceeds 41 KB after the changes, split the sidebar components intosrc/app/(app)/collection-map/sidebar.tsxas a follow-up.
Open questions for the reviewer
- Sort order of the top 3. Spec proposes keeping the existing alphabetical order from
driversWithItems(matches what the map's pin letters already imply). Alternative: sort the top 3 byitems.lengthdescending so the busiest driver is first. The counts strip on each card tells the operator who is busiest either way, so this is presentation vs. operational-default. Flag if you want busiest-first; default is alphabetical for v1 to stay consistent with the existing pin-letter convention. - Letter badge on Show-all.
driverLabelForis alphabetical across all drivers, so the top 3 always getA/B/C. When the operator expands to "Show all", letters continue A/B/C…/N in the same alphabetical order. OK to leave as-is, or do we want the badges to re-letter starting fromAfor the visible set? - Counts strip color. Spec proposes Lucide
Circleicons filled +text-emerald-600/text-amber-600for the green / orange dots, with the count text (e.g.2 collected) for accessibility. Flag if you'd rather literal emoji or a different visual. - Unassigned card link target. Spec proposes
/collection-pool. If the operator's mental model is "the row's supplier" rather than "the pool page", flag and we'll route to a supplier-side action. - +N more expansion scope. Spec proposes per-card
useState. If we anticipate >50 drivers in production, lift to page-level state up-front. Defaulting to per-card for v1.