WFDASH-WEBAPP/docs/superpowers/specs/2026-07-11-collection-map-simplify-pins-design.md

17 KiB
Raw Permalink Blame History

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:

  1. Drop the filter console. Status, readiness, driver multi-select, and search all leave. The map becomes "show me every stop on this day".
  2. 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.
  3. Move driver identity into the pin label. The existing yellow-ringed driverMarker overlay 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 at page.tsx:451-472.
  • Sidebar Drivers card at page.tsx:975-1045 — drives enabledDriverIds. 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 (current DRIVER_PALETTE[1]), 2.5 px white border, white letter centred (A–Z, then 1–9 if more than 26 drivers). Item count as a 13 px round badge in the top-right corner — same pinMarker superscript treatment the existing pinMarker(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" has ready_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 driverMarker square 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 statusFilter state and its segmented control.
  • Delete readyFilter state and its segmented control.
  • Delete search state and its input.
  • Delete enabledDriverIds state and its toggle card.
  • Delete the filteredItems useMemo and the driversWithItems sidebar render.
  • Keep driversWithItems as 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).
  • KPI tile "Drivers with stops" continues to render driversWithItems.length — the data shape doesn't change, just the role (informational, not a filter input).
  • buildRoutedBuckets is 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";
  • driverLabelFor uses the existing localeCompare-sorted driversWithItems as input. Unknown driver ids (no match in the list) fall back to "?".
  • unassignedGlyphFor returns "unready" if the input is empty or if any item has ready_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);
  • driverLetterPin is 28 px filled circle, white border, white letter at 14 px / weight 800, count as a 13 px round superscript in the top-right.
  • unassignedPoolPin is 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

  1. Default date is today. Map loads, all stops for today render.
  2. Toolbar shows date + quick chips + Traffic + Fullscreen.
  3. KPIs render unchanged.
  4. Map shows pins per the new model.
  5. 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 receives initialDriverId and initialDate, no bulkCount here (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 (inside collection-map/page.tsx) — unchanged.
  • MapWatcher — unchanged.
  • clusterBubble — unchanged.
  • pinMarker — still used by StopClusterMarker for single-item clusters. Unchanged.

Components changed

  • StopClusterMarker — no functional change (single-item clusters still use pinMarker(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 clusterBubble or pinMarker signatures.
  • 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)

  • driverLabelFor returns "A" for the first driver, "B" for the second, … "Z" for the 26th, "1" for the 27th, "2" for the 28th.
  • driverLabelFor returns "?" for an unknown driver id.
  • driverLabelFor is stable for a given input — re-rendering with the same sortedDrivers produces the same letter.
  • unassignedGlyphFor returns "ready" when every item has ready_for_collection === true.
  • unassignedGlyphFor returns "unready" when at least one item has ready_for_collection !== true.
  • unassignedGlyphFor returns "unready" for an empty array.

leaflet-markers.test.ts (extend existing or new)

  • driverLetterPin(letter, count) returns an L.DivIcon with iconSize [28, 28] and the letter rendered inside.
  • unassignedPoolPin("ready", 3) returns an L.DivIcon containing a ✓ glyph and the count 3.
  • unassignedPoolPin("unready", 3) returns an L.DivIcon containing a ✕ glyph and the count 3.

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 driverLetterPin markers with the alphabetical letters.
  • A supplier bucket with no assigned drivers and three pool items renders one unassignedPoolPin with ✓ 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=true shows ✓; otherwise ✕.
  • The yellow-ringed driverMarker overlay does not render anywhere on this page.
  • Clustered zoom (zoom < 12) renders clusterBubble exactly 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 --noEmit clean (no new errors in collection-map/page.tsx, leaflet-markers.ts, or the new helpers).
  • Existing route.test.ts for /api/v1/collection-map/items/[id]/assign still 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 revert of 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 into src/app/(app)/collection-map/pin-model.tsx (helpers + popup body) and src/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 localeCompare which 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.