# ADR-061 Dual-Mode Search: Lazy Proxy Search + Eager Client-Side Dashboard

## Status

Accepted, 2026-06-25.

## Status History

```yaml
status_history:
  - date: 2026-06-25
    status: Accepted
    changed_by: hkl
    reason: First applied in tour-pg counter search; pattern generalises to any PWA with a bounded, enumerable dataset
    changed_via: adr-kit (360lm)
```

## Context

Several PWAs need to let a user find records from a bounded, relatively small dataset
(counters, employees, SKUs) and then act on them. Two competing needs:

1. **Fast first-paint** — don't load 500 records on screen open; let the user narrow first.
2. **Zero-latency drill-down** — once the user is in "browse" mode, every chip toggle should
   feel instant; a round-trip to the proxy per chip click is unacceptable.

The counter search in `tour-pg` was the first place both needs appeared together. The initial
implementation (Mode 1 only) required at least one search term before showing results. Users
wanted to browse the full counter list by geography without knowing a store name — a different
interaction model, not a refinement of the same one.

Prior art in this codebase: the Activity PWA (ADR-027) proved that `computeAvailable()` +
`Array.filter()` works well for client-side faceted filtering. The constraint there was the
dataset loaded on screen open — fine for activities (bounded per employee), but loading all
counters on every counter-search entry would waste data on the common "I just want to search
by name" case.

## Decision

For any PWA feature that accesses a bounded dataset (≤ ~500 records, enumerable by a single
query) and must support both targeted search and exploratory browsing:

**Implement two tab-panel modes behind a single screen:**

- **Search mode (lazy):** Server-side proxy call per user action. Requires ≥1 filter parameter
  before executing. Returns a limited result set (LIMIT 100). Zero data cost until the user acts.

- **Dashboard mode (eager):** On first tab activation, load the full active dataset once via a
  dedicated `*/all` endpoint (no filter gate, higher LIMIT e.g. 500). Cache in a module-level
  variable for the session. All subsequent filtering is `Array.filter()` — no API calls.
  Chip counts and summary tiles recompute from the in-memory filtered set on every toggle.

**The two modes share:**
- The same result card template and stop/record factory.
- The same chip value definitions (`_DB_CHIPS_DEF`).
- The same `.filter-chip` / `.filter-chip.active` / `.filter-chip.unavailable` CSS.
- The same "Add selected to tour / action" button logic.

**The two modes do NOT share:**
- Filter state (`_csChips` vs `_dbChips`) — switching tabs preserves each side's work.
- Status subtitle text — updated by `switchCsMode()` to reflect the active mode.

**Endpoint contract for dashboard mode:**
- Endpoint named `*/all` (e.g. `/db/counters-all`).
- No filter parameters accepted — always returns the full active set.
- `WHERE is_active = true ORDER BY display_name LIMIT 500`.
- Auth still required (Hub session); no public data.

**Decision Maker:** hkl

## Alternatives Considered

- **Single search mode with "Load All" button.** Rejected: awkward UX — user has to click a
  separate button, wait, then the same chip UI appears. Two labelled tabs communicate the
  interaction model more clearly.

- **Client-side-only (always load all on open).** Rejected: wastes data for the common case
  (targeted name search); adds latency to screen-open for no benefit when user just wants
  to find one store by name.

- **Search + infinite scroll (server-paginated).** Rejected: pagination is poor UX for
  "browse by geography" — user wants to see all Punjab counters at once, not page through.
  Bounded dataset (≤500) makes pagination unnecessary.

- **Adopt itemsjs / Orama for faceted engine.** Rejected: at ≤100 records, `Array.filter()`
  is sub-millisecond; a library adds CDN dependency, parse overhead, and an API to learn for
  no measurable gain. Re-evaluate if dataset exceeds ~500 records (see Risks).

## Consequences

**Positive:**
- Search tab feels snappy: no data loaded until user types/selects.
- Dashboard tab is zero-latency after first load: chip toggles are synchronous.
- `computeDashAvail()` gives correct leave-one-out counts (ADR-027 compliant) — chips
  with zero remaining matches dim to `.unavailable`, guiding the user away from dead ends.
- Summary tiles (total / GPS-ready / states / clusters) give an instant population overview.
- No new npm/CDN dependencies; fits the single-HTML-file constraint (ADR-013).
- Pattern is reusable: any PWA with a bounded dataset can apply the same two-tab structure.

**Negative / Trade-offs:**
- Two parallel filter state objects (`_csChips` + `_dbChips`) to maintain. Chips added to
  one mode must be mirrored in the other's definition.
- The `*/all` endpoint bypasses the "require ≥1 param" guard intentionally — it must be
  auth-gated (Hub session) and have a hard LIMIT to prevent accidental full-table scans
  on larger future datasets.
- `renderDashboard()` rebuilds the full chip + card HTML on every toggle. Acceptable at
  ≤100 records; revisit if list exceeds ~300 visible cards (consider `requestAnimationFrame`
  or virtual scrolling at that scale).

**Risks and mitigations:**
- **Dataset growth past 500:** the LIMIT 500 cap will silently truncate. Mitigation: add a
  "showing 500 of N — use Search tab for full results" banner when `rows.length === 500`.
- **Stale `_dbAll` within a session:** data added to counters during a session won't appear
  until refresh. Acceptable for a planning tool; add a "↺ Reload" button if user reports it.

## Related Decisions

- ADR-027 — Leave-one-out faceted filtering (the `computeAvailable` pattern this ADR extends).
- ADR-013 — Single HTML file, no framework (constrains the client-side filter engine choice).
- ADR-010 — Cross-schema data via proxy (the `*/all` endpoint lives in the domain proxy).

## References

- First implementation: `tour-pg/index.html` — `switchCsMode()`, `loadDashboard()`,
  `computeDashAvail()`, `renderDashboard()`, `applyDashFilters()`.
- Companion endpoint: `tour-pg-proxy:/app/server.js` — `GET /db/counters-all`.
- MDD section: `tour-pg/MDD_tour_pg.md` — Extension F (tour-pg-v20).
- Scout research log (2026-06-25): itemsjs, Orama, MiniSearch, Fuse.js all evaluated and
  rejected in favour of `Array.filter()` for this dataset size.
