WFDASH-WEBAPP/docs/superpowers/specs/2026-07-12-collection-map-driver-cards-redesign-design.md
SJCoetz 046683c506 feat: add tangerine theme, new docs and plugin stubs
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.
2026-07-14 11:48:10 +02:00

20 KiB
Raw Permalink Blame History

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

  1. Drop the bottom "Today's stops" list card. Operators do not use it (confirmed).
  2. 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 vh height.
  3. 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 driversWithItems list (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.
  4. 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.
  5. 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.
  6. Per-stop rows show up to 4 stops, with +N more link 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.
  7. "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).
  8. Map popup body gains a single line: X of Y collected when the supplier bucket has at least one collected item. No third pin color.
  9. New pure helper aggregateDriverStatus(items) in src/lib/pin-model.ts. Returns { total, collected, ready, waiting }. TDD'd via pin-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 driversWithItems list 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_collection undefined / null → treated as false (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, unassignedPoolPin all stay as-is.
  • The filteredItems memo stays (currently useMemo(() => 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 (fake driversWithItems array with 5 entries, assert the first 3 names render).
  • renders "Show all (N)" when more than 3 drivers have stops and clicking 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" (assert bg-emerald-100 class 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.ts existing splitSupplierBucket tests stay valid (collected items still routed into assignedByDriver).
  • 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-map shows: 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 stops plus 🟢 X collected and 🟠 Y waiting only 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 collected whenever 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 more expansion introduces per-row state. If expansion becomes a source of jank, lift it to expandedDriverIds: Set<string> in the page state. Defaulting to per-card useState for 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 — add aggregateDriverStatus + DriverStatus interface.
  • Modify src/lib/pin-model.test.ts — add tests for aggregateDriverStatus.
  • 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). If page.tsx exceeds 41 KB after the changes, split the sidebar components into src/app/(app)/collection-map/sidebar.tsx as a follow-up.

Open questions for the reviewer

  1. 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 by items.length descending 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.
  2. Letter badge on Show-all. driverLabelFor is alphabetical across all drivers, so the top 3 always get A / 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 from A for the visible set?
  3. Counts strip color. Spec proposes Lucide Circle icons filled + text-emerald-600 / text-amber-600 for 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.
  4. 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.
  5. +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.