17 KiB
Collection Map — simplify pins, drop the filter console
Date: 2026-07-11 Status: Design — awaiting user review
Goal
The /collection-map page currently has four parallel filters (status, readiness, driver multi-select, search) on top of a date picker, plus a per-(driver × status × failed-flag) pin explosion at every supplier coordinate. The operator's complaint: "we have too many filters and it does not feel like every stop is showing."
Three asks:
- Drop the filter console. Status, readiness, driver multi-select, and search all leave. The map becomes "show me every stop on this day".
- Collapse the pin taxonomy to two shapes per supplier. One blue pin per assigned driver (with their letter inside) + one amber pin per unassigned bucket (with ✓ or ✕ inside). No more per-status pin variants.
- Move driver identity into the pin label. The existing yellow-ringed
driverMarkeroverlay goes away — the assigned pin itself carries the letter, so driver identity reads at a glance without a stacked icon.
User-confirmed rules
- Keep the date filter. Today / Tomorrow / +3 days / Next week quick chips + the date input stay. Date is a planning tool, not a filter console.
- Show all stops matching the date. No status, readiness, driver, or text filtering on the map.
- Assigned = blue circle, white border, letter inside. A = first driver alphabetically by name, B = second, … Z, then 1, 2, 3. Stable across days.
- Unassigned = amber circle, white border, ✓ or ✕ inside. ✓ = every pool item at that supplier is
ready_for_collection=true. ✕ = at least one isn't. Single amber colour for both — only the glyph differs. - Unassigned colour stays amber (
STOP_STATUS_COLOR.unassigned, currently#f59e0b). Operators are already trained on amber meaning "in pool". - No backend changes. No PB schema changes, no new routes, no API signature changes.
Current state
- File:
src/app/(app)/collection-map/page.tsx(1,331 lines, 53 KB — the page budget threshold). - Render shape: KPI strip → toolbar (date + status filter + readiness filter + driver-card-as-filter + search) → map (clustered + per-stop pin set) → sidebar (Drivers card, Missing coordinates card) → bottom list ("Today's stops").
- Pin logic at
page.tsx:566-578(buildRoutedBuckets) emits one pin per(driver × item_status × failed-flag). A supplier with 3 David-assigned + 2 Nathan-collected + 1 failed render three routed pins, plus the existing unassigned diamond if any pool items exist, plus driver overlays stacked on top. - Driver palette at
src/lib/geo.ts:189:DRIVER_PALETTE = ["#E64B4B", "#1B76BB"]— two colours used to differentiate drivers. Replaced by the letter-in-pin scheme. - Pin factories at
src/components/leaflet-markers.ts:pinMarker(color, label)— 22 px filled circle, white border.pinMarkerKind("unassigned", color, label)— 28 px rotated diamond, dashed inner ring.pinMarkerKind("assigned" | "collected" | "failed", ...)— circle variants.driverMarker(color, initials)— 28 px rounded square with a yellow ring (the dedicated "this is a driver" overlay). Goes away.
- Filters at
page.tsx:312-323:statusFilter,readyFilter,search,enabledDriverIds. Filtering atpage.tsx:451-472. - Sidebar Drivers card at
page.tsx:975-1045— drivesenabledDriverIds. Goes away (kept only as a read-only legend if we add one — see "Components reused").
Visual design
Filter strip — final shape
┌──────────────────────────────────────────────────────────────────────────────┐
│ 📅 [2026-07-11] [Today] [Tomorrow] [+3 days] [Next week] [Traffic] [⛶] │
└──────────────────────────────────────────────────────────────────────────────┘
- Status filter chip, Readiness filter chip, Drivers sidebar card, Search input, and the Refresh button all leave. (Refresh can be re-added to the top-right if useful — see "Open question" at the bottom.)
- Date picker + quick chips stay exactly as they are.
- Traffic + Fullscreen map toggles stay (they're overlays, not filters).
- No visible driver multi-select. Driver letters on the pins are the only "who" indicator.
Pin model — two shapes per supplier bucket
At every supplier coordinate we render at most two kinds of pin:
Supplier coordinate
├── assigned pin(s) ── one per distinct driver at this supplier
│ ┌──────┐
│ │ A │⁵ ← blue circle, white letter inside, count superscript
│ └──────┘
│ ┌──────┐
│ │ B │² ← second driver gets its own pin
│ └──────┘
│
└── unassigned pin ── one per supplier (only if any pool items exist)
┌──────┐
│ ✓ │³ ← amber circle, ✓ = all pool items ready
└──────┘
┌──────┐
│ ✕ │² ← amber circle, ✕ = at least one pool item unready
└──────┘
- Assigned pin: 28 px filled circle in
#1B76BB(currentDRIVER_PALETTE[1]), 2.5 px white border, white letter centred (A–Z, then1–9if more than 26 drivers). Item count as a 13 px round badge in the top-right corner — samepinMarkersuperscript treatment the existingpinMarker(color, label)already uses for the count, but with the letter as the inner glyph and the count overlaid. - Unassigned pin: 28 px filled circle in
#f59e0b(STOP_STATUS_COLOR.unassigned), 2.5 px white border, white ✓ or ✕ glyph centred (Unicode✓and✕, 14 px). Count superscript same as assigned. - Unassigned readiness logic: ✓ if every item at that supplier with
item_status === "collection_pool"hasready_for_collection === true. ✕ otherwise. (Mix-and-match supplier with some-ready-some-not shows ✕ — operator opens the popup to triage.) - No more driver-marker overlay. The yellow-ringed
driverMarkersquare is removed entirely; driver identity now lives in the assigned pin's letter.
Cluster (zoom < 12)
Unchanged. The existing clusterBubble(color, count) continues to collapse suppliers into one bubble per cluster. The cluster colour still comes from CLUSTER_PALETTE so densely packed regions stay visually distinguishable. Click-to-zoom-in behaviour unchanged.
Popup body (zoom ≥ 12)
Unchanged for content. StopPopupBody already lists items with their ready badge, supplier name, and an "Assign driver" / "Reassign driver" button. The popup keeps doing that — only the map-level pin shape changes.
Data model
No changes. All data already lives on order_items (item_status, drv_id, ready_for_collection, assigned_at) and suppliers (id, coordinates). The page already expands purchase_order.supplier to get supplier coordinates.
Sort + filter changes
- Delete
statusFilterstate and its segmented control. - Delete
readyFilterstate and its segmented control. - Delete
searchstate and its input. - Delete
enabledDriverIdsstate and its toggle card. - Delete the
filteredItemsuseMemo and thedriversWithItemssidebar render. - Keep
driversWithItemsas a derived, non-filtering list — sorted by name — used by:- The new
driverLetterFor(driverId, driversWithItems)helper. - The bottom "Today's stops" list (still shows driver names per row).
- The new
- KPI tile "Drivers with stops" continues to render
driversWithItems.length— the data shape doesn't change, just the role (informational, not a filter input). buildRoutedBucketsis removed. Replaced by the new two-pin render below.
New helpers
src/lib/geo.ts (or new src/app/(app)/collection-map/pin-model.ts)
/**
* Returns the driver label (letter or digit) for a driver id, given the
* alphabetically-sorted driver list. Position 0 → "A", 1 → "B", ..., 25 → "Z",
* 26 → "1", 27 → "2", and so on. Stable across re-renders because the input
* list is already sorted by name.
*/
export function driverLabelFor(
sortedDrivers: { id: string }[],
driverId: string,
): string;
/**
* Returns the readiness glyph for a supplier's unassigned bucket:
* "ready" → ✓ (every pool item is ready_for_collection)
* "unready" → ✕ (at least one isn't)
*/
export function unassignedGlyphFor(
poolItems: { ready_for_collection?: boolean }[],
): "ready" | "unready";
driverLabelForuses the existinglocaleCompare-sorteddriversWithItemsas input. Unknown driver ids (no match in the list) fall back to"?".unassignedGlyphForreturns"unready"if the input is empty or if any item hasready_for_collection !== true. Otherwise"ready".
src/components/leaflet-markers.ts
Add three new factories, keeping pinMarker / pinMarkerKind / driverMarker / clusterBubble signatures backwards-compatible (other pages still import them):
/**
* Blue circle with the driver letter (or digit) inside. Used for the new
* "one pin per driver per supplier" model. Letter is white, weight 800,
* centred. Count superscript top-right.
*/
export function driverLetterPin(letter: string, count: number, color = "#1B76BB");
/**
* Amber circle with ✓ or ✕ inside, for unassigned buckets.
*/
export function unassignedPoolPin(glyph: "ready" | "unready", count: number);
driverLetterPinis 28 px filled circle, white border, whiteletterat 14 px / weight 800, count as a 13 px round superscript in the top-right.unassignedPoolPinis 28 px filled amber circle, white border, white ✓ or ✕ at 14 px / weight 800, count superscript.
The existing driverMarker (yellow-ringed square) stays exported but the collection-map page stops importing it. Other pages may still use it.
Behaviour
Operator flow — open /collection-map
- Default date is today. Map loads, all stops for today render.
- Toolbar shows date + quick chips + Traffic + Fullscreen.
- KPIs render unchanged.
- Map shows pins per the new model.
- Bottom list shows every item (no filter), grouped implicit through the order they appear in
driversWithItems(assigned by driver, then any unassigned at the end).
Operator flow — change date
Unchanged. Click a quick chip or pick a date in the input → re-fetch + re-render.
Operator flow — click an assigned pin
Popup opens with the supplier name, item count, and per-item rows (description, order number, ready badge). "Reassign driver" button closes the popup and opens AssignDriverDialog pre-filled with the current driver.
Operator flow — click an unassigned pin
Same popup layout, no driver line. "Assign driver" button opens AssignDriverDialog with no pre-filled driver.
Operator flow — click a row in "Today's stops"
Same behaviour as today — opens AssignDriverDialog for that row.
Operator flow — toggle Traffic / Fullscreen
Unchanged.
Components reused
AssignDriverDialog(src/components/ui/assign-driver-dialog.tsx) — unchanged. Still receivesinitialDriverIdandinitialDate, nobulkCounthere (per-row assignment only; the new pins still represent a supplier bucket, but the operator assigns per-row through the popup or bottom list, matching today).TrafficOverlay(insidecollection-map/page.tsx) — unchanged.MapWatcher— unchanged.clusterBubble— unchanged.pinMarker— still used byStopClusterMarkerfor single-item clusters. Unchanged.
Components changed
StopClusterMarker— no functional change (single-item clusters still usepinMarker(cluster.color, "1")).StopPopupBody— unchanged.SupplierStopPopup— unchanged.
Components removed (this page only)
- The Drivers sidebar card (
page.tsx:975-1045). - The status filter button group (
page.tsx:757-776). - The readiness filter button group (
page.tsx:780-800). - The search input (
page.tsx:803-812). - The Refresh button (
page.tsx:813-816) — see the "Open question" section below.
Out of scope
- New cluster behaviours (clusters already work; the per-stop pin model is what changes).
- Per-item pins (one pin per item, not per supplier bucket). Bucketing is the right level of abstraction here.
- Changing
clusterBubbleorpinMarkersignatures. - Renaming the route or the sidebar nav label.
- Keyboard shortcuts for filter changes (there are no filters left).
- Mobile-specific layout work beyond what the existing grid already handles.
- Backend changes — none needed.
Open question
The Refresh button (page.tsx:813) is technically not a filter, but it lives in the toolbar next to the filters. After the cleanup, three options:
- (A) Stay in the toolbar — keep it where it is, just visually adjacent to Traffic + Fullscreen now that the filters are gone.
- (B) Move into the KPI strip — small icon button on the "Mapped stops" tile, so refresh lives next to the number it refreshes.
- (C) Remove it entirely — operators wait for the next date change or rely on the 20-second live poll already in place elsewhere in the app.
Default if the user doesn't pick: (A) — stay in the toolbar. It costs nothing, the icon is universally understood, and removing it removes a manual escape hatch.
Tests
geo.test.ts (or new pin-model.test.ts)
driverLabelForreturns"A"for the first driver,"B"for the second, …"Z"for the 26th,"1"for the 27th,"2"for the 28th.driverLabelForreturns"?"for an unknown driver id.driverLabelForis stable for a given input — re-rendering with the samesortedDriversproduces the same letter.unassignedGlyphForreturns"ready"when every item hasready_for_collection === true.unassignedGlyphForreturns"unready"when at least one item hasready_for_collection !== true.unassignedGlyphForreturns"unready"for an empty array.
leaflet-markers.test.ts (extend existing or new)
driverLetterPin(letter, count)returns anL.DivIconwithiconSize[28, 28]and the letter rendered inside.unassignedPoolPin("ready", 3)returns anL.DivIconcontaining a✓glyph and the count3.unassignedPoolPin("unready", 3)returns anL.DivIconcontaining a✕glyph and the count3.
collection-map/page.test.tsx (new — currently no page-level test)
- Toolbar renders the date input + the four quick chips. Does NOT render the status filter, readiness filter, search input, or Drivers sidebar card.
- Changing the date input updates the rendered pin set after the fetch resolves (mock PB).
- A supplier bucket with two assigned drivers renders two
driverLetterPinmarkers with the alphabetical letters. - A supplier bucket with no assigned drivers and three pool items renders one
unassignedPoolPinwith ✓ if all three are ready, ✕ otherwise. - The Drivers KPI tile still shows the count of distinct drivers.
Acceptance criteria
- Toolbar shows date + four quick chips + 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 name 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 this page. - Clustered zoom (zoom < 12) renders
clusterBubbleexactly as today. - The bottom "Today's stops" list still renders every item (no filter), 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, or the new helpers).- Existing
route.test.tsfor/api/v1/collection-map/items/[id]/assignstill passes (no backend contract change).
Risk / rollback
- Visual risk: medium. Removing the per-(driver × status × failed) pins flattens a lot of detail that operators may have learned to read. Mitigation: the popup + bottom list still expose the same per-item detail; the map-level collapse is intentional 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 is at 53 KB today (over the 30 KB budget — this would normally trigger a file split). The pin-model collapse removes
buildRoutedBuckets, the three filter segments, the search input, and the Drivers card, which together is ~6–8 KB. Net page should land at ~45 KB; still over budget but with much less complexity. If the post-change page is still above 30 KB after the first commit, split the pin render intosrc/app/(app)/collection-map/pin-model.tsx(helpers + popup body) andsrc/app/(app)/collection-map/kpi-strip.tsx(the three tiles). - Driver-letter collisions. If two drivers share a first name (e.g. "John Smith" and "John Doe") they fall back to the full
localeComparewhich uses the entire name — still distinct. We don't surface "John S vs John D" unless there's a real collision; that's the alphabetical sort's job.