# ADR-027: Multi-Dimension List Filtering Uses Leave-One-Out Faceted Availability

## Status

Accepted, 2026-06-25.

## Status History

```yaml
status_history:
  - date: 2026-06-25
    status: Proposed
    changed_by: hkl
    reason: Formalising faceted filter decision implemented in Activity PWA v23
    changed_via: adr-kit (360lm)
  - date: 2026-06-25
    status: Accepted
    changed_by: hkl
    reason: CiC verified 2026-06-02 on 137 responses, 4 areas, 2 months — all pass
    changed_via: adr-kit (360lm)
```

## Context

The Activity PWA displays a list of field responses that users need to slice across multiple dimensions simultaneously (Area, Month, Date, Lead Type, Submission status). With simple AND filtering, selecting "Area = Amritsar" hides all chips from other dimensions that have zero records in Amritsar — the user loses visibility of what options exist in the dataset, and has no indication whether a combo is impossible or just not yet selected. With 137+ responses across 4 areas and 2+ months, users need to understand which filter combinations are valid before committing to them. This pattern was designed and CiC-verified in Activity PWA v23 (2026-06-02).

## Decision

When a PWA has a list that must be filtered across 3+ dimensions, use **leave-one-out faceted availability**:

- For each chip in dimension D, compute availability by applying ALL currently active filters EXCEPT those in D itself (`matchesFiltersExcept(record, exceptDimension)`)
- Chips with zero matching records across that scoped set are **greyed** (unavailable, still visible)
- If a chip IS selected but conflicts with another active filter producing zero results, show it **red** (impossible combination — user must deselect one)
- Selection order is tracked via `filterSeqCounter` + `filterSeqMap` — each chip gets a sequence badge (indigo number, top-right) so user can see and reverse the order of selections
- Stats panel always reflects the active filter state ("Filtered: N / Total: M" shown when any filter is active)
- All computation is **client-side** on the already-loaded dataset — no server round-trips per filter change

Core helpers: `computeAvailable()` (builds availability map across all dimensions) + `matchesFiltersExcept(record, dim)` (AND-matches all active filters except one dimension).

**Decision Maker:** hkl

## Alternatives Considered

- **Simple AND filter — hide zero-result chips.** Rejected: user cannot see what options exist in the full dataset; discovering valid combinations requires trial and error; "where did Area=Chandigarh go?" is a confusing UX when it silently disappears.
- **Server-side filtering (POST params to PostgREST, re-fetch).** Rejected: network round-trip per filter tap makes the UI feel sluggish on mobile; entire response dataset is already loaded client-side for offline support (ADR-020); client-side computation is instant.
- **Independent per-dimension filters (each dimension ignores others).** Rejected: shows chips as available even when the combination with other active filters has zero results — user selects an "available" chip and gets an empty list with no explanation.
- **Dropdown selects instead of chips.** Rejected: chips allow seeing all options at once and their availability status simultaneously; dropdowns require opening to discover; chips work better on mobile touch for multi-select exploration.

## Consequences

**Positive:**
- User always sees all options in each dimension — no silent disappearing chips.
- Grey = "available in dataset but not with current combo" vs Red = "impossible with current selection" — clear distinction.
- Sequence badges let user undo selections in reverse order without losing context.
- Stats panel always shows "Filtered: N / Total: M" — user always knows how much data is hidden.
- No server dependency for filter UX — works fully offline.

**Negative / Trade-offs:**
- `computeAvailable()` runs on every filter change across the full in-memory dataset — O(N×D) where N = records, D = dimensions. Acceptable for < 1000 records; may need optimisation above that.
- More complex implementation than simple AND filter — two helpers (`computeAvailable`, `matchesFiltersExcept`) plus sequence tracking state.
- Red conflict state can surprise users who expect the chip to simply be disabled.

**Risks and mitigations:**
- Dataset grows beyond 1000 records and filter computation becomes perceptible: mitigated by debouncing `computeAvailable()` on rapid taps; current Activity dataset is ~137 responses with no performance issues. // ponytail: upgrade trigger=visible lag on filter tap with > 500 records
- Conflict (red) chips confuse users: mitigated by tooltip/hint text explaining the conflict; CiC walkthrough confirmed behaviour is understandable after brief use.

## Related Decisions

- ADR-020 (offline-first IndexedDB) — full dataset loaded client-side enables instant client-side filtering.
- ADR-003 (mobile scroll root document) — chip rows scroll naturally in the root document; no nested overflow needed.

## References

- `memory/dbt_activity.md` — v23 build record (cascading filters, CiC verified 2026-06-02)
- `activity/index.html` — `computeAvailable()`, `matchesFiltersExcept()`, `filterSeqCounter`, `filterSeqMap`
- `cic-prompts/activity_v23_cascading_filters.txt` — CiC verification walkthrough
