docs(collection-map): spec — drop filter console + collapse pin taxonomy
Two-pin model per supplier bucket: blue circle + letter per driver, amber circle + ✓/✕ for unassigned. Filters (status, readiness, driver multi-select, search) all leave; date picker stays. No backend or schema changes. Awaiting user review before plan + implementation.
This commit is contained in:
parent
ed9503706d
commit
191668088c
1 changed files with 274 additions and 0 deletions
|
|
@ -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.
|
||||
Loading…
Reference in a new issue