diff --git a/docs/superpowers/specs/2026-07-11-collection-map-simplify-pins-design.md b/docs/superpowers/specs/2026-07-11-collection-map-simplify-pins-design.md new file mode 100644 index 0000000..766829e --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-collection-map-simplify-pins-design.md @@ -0,0 +1,274 @@ +# 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`) + +```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): + +```ts +/** + * 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`) — open question; see 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, should it: + +(A) Stay in the top-right of the toolbar (next to Traffic + Fullscreen), or +(B) Move into the KPI strip as a small icon button on the "Mapped stops" tile, or +(C) Be removed entirely (operators wait for the next date change or the 20-second live poll already in place elsewhere)? + +Default if you don'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. \ No newline at end of file