From f479578942ae2ed7222919dddc67f0d5be373dd6 Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Mon, 5 Oct 2026 03:11:02 -0400 Subject: [PATCH] Live wall: filter bar, discipline presets and text search (#235) lib/liveFilter.ts (pure): WallFilter {types, sevFloor, systems, talkgroups, ageH, tags, q, preset}, applyFilter over loaded incidents + calls, parse / serialise to the f.* hash keys #234 reserved. Presets: Fire (fire + EMS assists heard on a fire channel; Radar on), Storm (weather/wires/tree/flood tags; Radar on), Traffic (accident / MVA tags; Traffic Events on). components/live/FilterBar.tsx: one wrapping row under the wall bar; less-used controls fold behind a toggle below md; read-only summary in kiosk. LiveView applies the filter once and feeds the filtered lists to MapView (pins, paths, rail), RegionPanel, TranscriptTicker, TimeScrubber and the auto-tour; KPIs count the filtered set and say "filtered". Presets turn layers on through the saved-view layer state, not a MapView edit. Pipeline health stays unfiltered. liveWall.ts keeps commas literal in f.* values. Co-Authored-By: Claude Opus 5.5 --- drb-frontend/components/LiveView.tsx | 102 ++++++++- drb-frontend/components/live/FilterBar.tsx | 171 +++++++++++++++ drb-frontend/lib/liveFilter.ts | 236 +++++++++++++++++++++ drb-frontend/lib/liveWall.ts | 10 +- 4 files changed, 504 insertions(+), 15 deletions(-) create mode 100644 drb-frontend/components/live/FilterBar.tsx create mode 100644 drb-frontend/lib/liveFilter.ts diff --git a/drb-frontend/components/LiveView.tsx b/drb-frontend/components/LiveView.tsx index 02ddf1b..3f828c0 100644 --- a/drb-frontend/components/LiveView.tsx +++ b/drb-frontend/components/LiveView.tsx @@ -13,10 +13,16 @@ * back the same; each browser also remembers its last view (lib/wallPrefs.ts). * Precedence: hash > remembered > defaults. `?kiosk=1` / `#kiosk` is the * wall-display mode (lib/useKiosk.ts). + * + * Filter bar (server-26#235): the `f.*` hash keys (lib/liveFilter.ts) are + * applied ONCE here and the filtered lists feed the map (pins, paths, rail), + * the region panel, ticker, scrubber bands, auto-tour and KPIs. Pipeline + * health stays on the unfiltered data — a filter must never hide an outage. */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import dynamic from "next/dynamic"; import { useNodes } from "@/lib/useNodes"; +import { useSystems } from "@/lib/useSystems"; import { useActiveCalls, useCalls } from "@/lib/useCalls"; import { useActiveIncidents } from "@/lib/useIncidents"; import { useAircraft } from "@/lib/useAircraft"; @@ -26,9 +32,11 @@ import { c2api } from "@/lib/c2api"; import { severityRank } from "@/lib/severity"; import type { Camera, Ny511Response } from "@/lib/types"; import { - NEAR_CAMERA_KM, bboxAround, groupCallsByIncident, incidentPoints, km, nearestCameras, onAirIncidentIds, - parseWallHash, sameView, wallHash, worstFirst, type LatLon, type NearCamera, type WallLink, + DEFAULT_LAYERS, NEAR_CAMERA_KM, bboxAround, groupCallsByIncident, incidentPoints, km, nearestCameras, normaliseLayers, + onAirIncidentIds, parseWallHash, sameView, wallHash, worstFirst, type LatLon, type NearCamera, type WallLink, } from "@/lib/liveWall"; +import { PRESETS, applyFilter, parseFilter, serialiseFilter, type PresetKey, type WallFilter } from "@/lib/liveFilter"; +import { FilterBar, type FilterOption } from "@/components/live/FilterBar"; import { loadWallPrefs, resolveWallView, saveWallPrefs } from "@/lib/wallPrefs"; import { announceKioskChange, kioskInQuery, stripKioskQuery, useKiosk } from "@/lib/useKiosk"; import { KioskBanner, KioskControls } from "@/components/live/KioskControls"; @@ -55,6 +63,7 @@ const KIOSK_TYPE = [ "[&[data-kiosk]_.w-56]:w-80", ].join(" "); const PIN_KEY = "drb.wall.pinnedCameras"; +const MAX_TAG_CHIPS = 8; const MAX_PINNED = 6; const EVENTS_POLL_MS = 2 * 60 * 1000; @@ -114,6 +123,7 @@ export function LiveView() { // Wide-ish recent window: feeds incident paths, the call spine, the ticker // and the scrubber's density bars. Real recent calls, not a fixture. const { calls: recentCalls, loading: callsLoading, error: callsError } = useCalls(500); + const { systems } = useSystems(); const { aircraft } = useAircraft(); const { vessels } = useVessels(); const [clock, setClock] = useState(""); @@ -166,6 +176,22 @@ export function LiveView() { announceKioskChange(); }, [link, mapState]); const patchLink = useCallback((p: Partial) => setLinkState((cur) => ({ ...cur, ...p })), []); + // ── Filter (server-26#235) ──────────────────────────────────────────────── + const filterKey = JSON.stringify(link.filter ?? {}); + // eslint-disable-next-line react-hooks/exhaustive-deps + const filter = useMemo(() => parseFilter(link.filter), [filterKey]); + const setFilter = useCallback((f: WallFilter) => patchLink({ filter: serialiseFilter(f) }), [patchLink]); + /** A preset switches its map layers on through the saved-view layer state (no MapView edit). */ + const onPreset = useCallback((p: PresetKey) => { + const cur = mapStateRef.current; + if (!cur) return; + const have = cur.layers ?? DEFAULT_LAYERS; + if (PRESETS[p].layers.every((l) => have.includes(l))) return; + const next = { ...cur, layers: normaliseLayers([...have, ...PRESETS[p].layers]) }; + saveWallPrefs(next); + setMapState(next); + setMapGen((g) => g + 1); + }, []); const onViewStateChange = useCallback((s: MapViewState) => { setMapState((cur) => { // Keep a known viewport if the map hasn't reported one yet. @@ -214,7 +240,11 @@ export function LiveView() { // ── Geography ───────────────────────────────────────────────────────────── const callsByIncident = useMemo(() => groupCallsByIncident(recentCalls), [recentCalls]); - const sorted = useMemo(() => worstFirst(activeIncidents), [activeIncidents]); + const filtered = useMemo( + () => applyFilter(activeIncidents, recentCalls, filter, callsByIncident), + [activeIncidents, recentCalls, filter, callsByIncident], + ); + const sorted = useMemo(() => worstFirst(filtered.incidents), [filtered.incidents]); const pointsById = useMemo(() => { const m = new Map(); for (const inc of activeIncidents) m.set(inc.incident_id, incidentPoints(inc, callsByIncident.get(inc.incident_id))); @@ -247,6 +277,44 @@ export function LiveView() { const onAir = useMemo(() => onAirIncidentIds(activeIncidents, activeCalls), [activeIncidents, activeCalls]); const selected = link.inc ? activeIncidents.find((i) => i.incident_id === link.inc) ?? null : null; + // A drilled-into incident stays on the map even if the filter excludes it + // (a shared #inc= link must still land); it is not added to the tour. + const keepSelected = !!selected && !filtered.ids.has(selected.incident_id); + const mapIncidents = useMemo( + () => (keepSelected && selected ? [...filtered.incidents, selected] : filtered.incidents), + [keepSelected, selected, filtered.incidents], + ); + const mapCalls = useMemo(() => { + if (!keepSelected || !selected) return filtered.calls; + const have = new Set(filtered.calls.map((c) => c.call_id)); + return [...filtered.calls, ...(callsByIncident.get(selected.incident_id) ?? []).filter((c) => !have.has(c.call_id))]; + }, [keepSelected, selected, filtered.calls, callsByIncident]); + + // Filter choices come from what the wall has loaded (plus system names). + const filterOptions = useMemo(() => { + const sysNames = new Map(systems.map((s) => [s.system_id, s.name])); + const sysIds = new Set(); + const tgNames = new Map(); + for (const c of recentCalls) { + if (c.system_id) sysIds.add(c.system_id); + if (c.talkgroup_id != null) { + const id = String(c.talkgroup_id); + if (c.talkgroup_name || !tgNames.has(id)) tgNames.set(id, c.talkgroup_name || id); + } + } + const tagCount = new Map(); + for (const i of activeIncidents) { + for (const s of i.system_ids ?? []) sysIds.add(String(s)); + for (const t of i.talkgroup_ids ?? []) if (!tgNames.has(String(t))) tgNames.set(String(t), String(t)); + for (const t of i.tags ?? []) tagCount.set(t.toLowerCase(), (tagCount.get(t.toLowerCase()) ?? 0) + 1); + } + const byLabel = (a: FilterOption, b: FilterOption) => a.label.localeCompare(b.label); + return { + systems: Array.from(sysIds).map((id) => ({ id, label: sysNames.get(id) ?? id })).sort(byLabel), + talkgroups: Array.from(tgNames.entries()).map(([id, name]) => ({ id, label: name === id ? id : `${name} (${id})` })).sort(byLabel), + tags: Array.from(tagCount.entries()).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, MAX_TAG_CHIPS).map(([t]) => t), + }; + }, [systems, recentCalls, activeIncidents]); const selectedPoints = selected ? pointsById.get(selected.incident_id) ?? [] : []; const near = selected ? nearById.get(selected.incident_id) ?? [] : []; const drillCam: Camera | null = link.cam @@ -305,7 +373,7 @@ export function LiveView() { const tourPct = link.tour && tourStart ? Math.min(100, ((tourNow - tourStart) / tourMs) * 100) : 0; // ── KPIs ────────────────────────────────────────────────────────────────── - const count = (sev: string) => activeIncidents.filter((i) => i.severity === sev).length; + const count = (sev: string) => filtered.incidents.filter((i) => i.severity === sev).length; // "Nodes hearing": a call in the last N min, not merely not-offline — a node // that checks in but hears nothing is not contributing to the picture. const nodesHearing = health.nodesHearing; @@ -337,7 +405,8 @@ export function LiveView() {
- + @@ -364,6 +433,17 @@ export function LiveView() { {clock}
+ @@ -378,8 +458,8 @@ export function LiveView() { ) : ( - - + + {drillCam && ( = { + fire: "Fire", ems: "EMS", police: "Police", accident: "Accident", other: "Other", +}; +const SEV_FLOORS: Severity[] = ["minor", "moderate", "major"]; +const Q_DEBOUNCE_MS = 250; + +const chip = (on: boolean) => + `px-2 py-0.5 rounded-full border whitespace-nowrap ${on ? "bg-accent text-white border-accent" : "border-line text-ink-2 hover:text-ink"}`; +const select = "px-1.5 py-0.5 rounded border border-line bg-surface text-ink-2 max-w-[11rem]"; + +const toggle = (arr: T[], v: T): T[] => (arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v]); + +export function FilterBar({ + filter, onChange, onPreset, systems, talkgroups, tags, shown, total, readOnly = false, +}: { + filter: WallFilter; + onChange: (f: WallFilter) => void; + /** A preset was switched on — the caller also turns its map layers on. */ + onPreset: (p: PresetKey) => void; + systems: FilterOption[]; + talkgroups: FilterOption[]; + /** Most common tags in the loaded incidents (selected ones are always shown). */ + tags: string[]; + shown: number; + total: number; + /** Kiosk: no controls, just the summary when a filter is on. */ + readOnly?: boolean; +}) { + const active = isFilterActive(filter); + const [more, setMore] = useState(false); + + // Search is debounced locally so each keystroke doesn't rewrite the hash + // and re-filter every transcript; an outside change (hash edit, Clear) wins. + const [q, setQ] = useState(filter.q); + const committed = useRef(filter.q); + useEffect(() => { + if (filter.q !== committed.current) { committed.current = filter.q; setQ(filter.q); } + }, [filter.q]); + const filterRef = useRef(filter); + filterRef.current = filter; + useEffect(() => { + if (q === committed.current) return; + const id = setTimeout(() => { committed.current = q; onChange({ ...filterRef.current, q }); }, Q_DEBOUNCE_MS); + return () => clearTimeout(id); + }, [q, onChange]); + + const sysName = (id: string) => systems.find((s) => s.id === id)?.label ?? id; + const tgName = (id: string) => talkgroups.find((t) => t.id === id)?.label ?? id; + const summary = ( + + {active ? `Filtered: ${shown} of ${total}` : `${total} active`} + + ); + + if (readOnly) { + if (!active) return null; + return ( +
+ {summary} + {describeFilter(filter, { sys: sysName, tg: tgName })} +
+ ); + } + + const set = (p: Partial) => onChange({ ...filter, ...p }); + const tagList = Array.from(new Set([...filter.tags, ...tags])); + const tgValue = filter.talkgroups.length === 1 ? filter.talkgroups[0] : ""; + const sysValue = filter.systems.length === 1 ? filter.systems[0] : ""; + + return ( +
+
+ {PRESET_KEYS.map((k) => ( + + ))} +
+ setQ(e.target.value)} placeholder="Search title, location, transcript" + aria-label="Search incidents and transcripts" + className="min-w-0 flex-1 basis-40 max-w-xs px-2 py-0.5 rounded border border-line bg-surface text-ink placeholder:text-ink-muted" /> + + +
+
+ {INCIDENT_TYPES.map((t) => ( + + ))} +
+ + + {systems.length > 1 || sysValue ? ( + + ) : null} + {talkgroups.length > 0 || tgValue ? ( + + ) : null} + {tagList.length > 0 && ( +
+ {tagList.map((t) => ( + + ))} +
+ )} +
+ +
+ {summary} + {active && ( + + )} +
+
+ ); +} diff --git a/drb-frontend/lib/liveFilter.ts b/drb-frontend/lib/liveFilter.ts new file mode 100644 index 0000000..22d13b6 --- /dev/null +++ b/drb-frontend/lib/liveFilter.ts @@ -0,0 +1,236 @@ +/** + * Live wall filter (server-26#235) — pure helpers, no React, no window. + * + * One filter narrows everything the wall plots: pins, paths, the rail, the + * region panel, the transcript ticker, the scrubber bands and the auto-tour. + * It is client-side over the incidents and calls the wall already loaded; + * server-side search is UI_REDESIGN chunk 12. + * + * Hash form (rides #234's `f.` extension point in lib/liveWall.ts): + * f.type=fire,ems f.sev=moderate f.sys=,… f.tg=,… f.age=6 + * f.tag=wires,tree f.q= f.p=fire|storm|traffic + * "fire, moderate+, last 6h" = `#f.age=6&f.sev=moderate&f.type=fire`. + * Every key is optional; an unknown or malformed value is dropped, never fatal. + */ +import type { CallRecord, IncidentRecord } from "@/lib/types"; +import { isKnownSeverity, severityRank, type Severity } from "@/lib/severity"; +import type { LayerKey } from "@/lib/liveWall"; + +/** The incident types intelligence.py emits (`_VALID_INCIDENT_TYPES`). */ +export const INCIDENT_TYPES = ["fire", "ems", "police", "accident", "other"] as const; +export type IncidentType = (typeof INCIDENT_TYPES)[number]; + +export const AGE_WINDOWS_H = [1, 6, 24] as const; + +export const PRESETS = { + fire: { + label: "Fire", + title: "Fire incidents plus EMS assists heard on a fire channel; turns Weather Radar on", + layers: ["radar"] as LayerKey[], + }, + storm: { + label: "Storm", + title: "Weather damage: wires, trees, flooding, storm tags; turns Weather Radar on", + layers: ["radar"] as LayerKey[], + }, + traffic: { + label: "Traffic", + title: "Accidents / MVAs; turns the Traffic Events layer on", + layers: ["traffic"] as LayerKey[], + }, +} as const; +export type PresetKey = keyof typeof PRESETS; +export const PRESET_KEYS = Object.keys(PRESETS) as PresetKey[]; + +export interface WallFilter { + /** Incident type is one of these (any). */ + types: IncidentType[]; + /** Severity floor: severityRank(incident) >= this. */ + sevFloor: Severity | null; + systems: string[]; + talkgroups: string[]; + /** Activity (last update) within this many hours. */ + ageH: number | null; + /** Any incident tag contains any of these (case-insensitive). */ + tags: string[]; + /** Free text over title/location/summary and the incident's loaded transcripts. */ + q: string; + /** Discipline preset rule, ANDed with everything above. */ + preset: PresetKey | null; +} + +export const EMPTY_FILTER: WallFilter = { + types: [], sevFloor: null, systems: [], talkgroups: [], ageH: null, tags: [], q: "", preset: null, +}; + +export function isFilterActive(f: WallFilter): boolean { + return !!(f.types.length || f.sevFloor || f.systems.length || f.talkgroups.length || f.ageH + || f.tags.length || f.q.trim() || f.preset); +} + +// ── Hash (de)serialisation ─────────────────────────────────────────────────── + +const list = (v: string | undefined): string[] => + Array.from(new Set((v ?? "").split(",").map((s) => s.trim()).filter(Boolean))); + +function isPresetKey(k: string): k is PresetKey { + return Object.prototype.hasOwnProperty.call(PRESETS, k); +} + +/** `WallLink.filter` (the raw `f.*` hash pairs) → a validated filter. */ +export function parseFilter(raw: Record | undefined): WallFilter { + if (!raw) return EMPTY_FILTER; + const types = list(raw.type).map((t) => t.toLowerCase()) + .filter((t): t is IncidentType => (INCIDENT_TYPES as readonly string[]).includes(t)); + const sev = (raw.sev ?? "").toLowerCase(); + const age = Number(raw.age); + const p = (raw.p ?? "").toLowerCase(); + return { + types: INCIDENT_TYPES.filter((t) => types.includes(t)), + sevFloor: isKnownSeverity(sev) ? sev : null, + systems: list(raw.sys), + talkgroups: list(raw.tg), + ageH: Number.isFinite(age) && age > 0 ? Math.min(age, 24 * 30) : null, + tags: list(raw.tag).map((t) => t.toLowerCase()), + q: (raw.q ?? "").slice(0, 200), + preset: isPresetKey(p) ? p : null, + }; +} + +/** A filter → `WallLink.filter`; undefined when nothing is set, so the hash stays clean. */ +export function serialiseFilter(f: WallFilter): Record | undefined { + const out: Record = {}; + if (f.types.length) out.type = f.types.join(","); + if (f.sevFloor) out.sev = f.sevFloor; + if (f.systems.length) out.sys = f.systems.join(","); + if (f.talkgroups.length) out.tg = f.talkgroups.join(","); + if (f.ageH) out.age = String(f.ageH); + if (f.tags.length) out.tag = f.tags.join(","); + if (f.q.trim()) out.q = f.q.trim(); + if (f.preset) out.p = f.preset; + return Object.keys(out).length ? out : undefined; +} + +// ── Matching ───────────────────────────────────────────────────────────────── + +const FIRE_CHANNEL = /fire|\bfd\b|rescue/i; +const STORM_TAGS = ["weather", "storm", "wire", "tree", "flood", "lightning", "power-line", "pole"]; +const TRAFFIC_TAGS = ["mva", "mvc", "crash", "collision", "accident", "rollover", "hit-and-run"]; + +const tagHit = (tags: string[] | undefined, terms: readonly string[]) => + (tags ?? []).some((t) => { const l = t.toLowerCase(); return terms.some((term) => l.includes(term)); }); + +const callText = (c: CallRecord) => `${c.transcript_corrected || c.transcript || ""} ${c.location ?? ""}`; + +interface MatchCtx { + calls: Map; + /** Talkgroups (as strings) that carry fire traffic in the loaded data. */ + fireTgs: Set; + now: number; +} + +function presetMatch(p: PresetKey, inc: IncidentRecord, ctx: MatchCtx): boolean { + if (p === "fire") { + if (inc.type === "fire") return true; + if (inc.type !== "ems") return false; + // An EMS assist counts when it was heard on a fire channel. + return (inc.talkgroup_ids ?? []).some((t) => ctx.fireTgs.has(String(t))) + || (ctx.calls.get(inc.incident_id) ?? []).some((c) => FIRE_CHANNEL.test(c.talkgroup_name ?? "")); + } + if (p === "storm") return tagHit(inc.tags, STORM_TAGS); + return inc.type === "accident" || tagHit(inc.tags, TRAFFIC_TAGS); +} + +function lastActivity(inc: IncidentRecord): number { + const ts = [inc.updated_at, inc.last_thin_at, inc.started_at].map((s) => (s ? Date.parse(s) : NaN)); + return Math.max(...ts.map((t) => (Number.isFinite(t) ? t : -Infinity))); +} + +function incidentMatch(inc: IncidentRecord, f: WallFilter, ctx: MatchCtx): boolean { + if (f.types.length && !f.types.includes(inc.type as IncidentType)) return false; + if (f.sevFloor && severityRank(inc.severity) < severityRank(f.sevFloor)) return false; + if (f.systems.length && !(inc.system_ids ?? []).some((s) => f.systems.includes(String(s)))) return false; + if (f.talkgroups.length && !(inc.talkgroup_ids ?? []).some((t) => f.talkgroups.includes(String(t)))) return false; + if (f.ageH && lastActivity(inc) < ctx.now - f.ageH * 3_600_000) return false; + if (f.tags.length && !tagHit(inc.tags, f.tags)) return false; + if (f.preset && !presetMatch(f.preset, inc, ctx)) return false; + const q = f.q.trim().toLowerCase(); + if (q) { + const own = `${inc.title ?? ""} ${inc.location ?? ""} ${inc.summary ?? ""} ${(inc.location_mentions ?? []).join(" ")}`; + if (!own.toLowerCase().includes(q) + && !(ctx.calls.get(inc.incident_id) ?? []).some((c) => callText(c).toLowerCase().includes(q))) return false; + } + return true; +} + +/** An unlinked call shown only when the filter has no incident-only criteria (type/preset). */ +function looseCallMatch(c: CallRecord, f: WallFilter, now: number): boolean { + if (f.types.length || f.preset) return false; + if (f.sevFloor && severityRank(c.severity) < severityRank(f.sevFloor)) return false; + if (f.systems.length && !f.systems.includes(String(c.system_id ?? ""))) return false; + if (f.talkgroups.length && !f.talkgroups.includes(String(c.talkgroup_id ?? ""))) return false; + if (f.ageH && Date.parse(c.started_at) < now - f.ageH * 3_600_000) return false; + if (f.tags.length && !tagHit(c.tags, f.tags)) return false; + const q = f.q.trim().toLowerCase(); + if (q && !`${callText(c)} ${c.talkgroup_name ?? ""}`.toLowerCase().includes(q)) return false; + return true; +} + +const callIncidentIds = (c: CallRecord) => (c.incident_ids?.length ? c.incident_ids : c.incident_id ? [c.incident_id] : []); + +export interface FilterResult { + active: boolean; + incidents: IncidentRecord[]; + calls: CallRecord[]; + /** Ids of incidents that pass. */ + ids: Set; +} + +/** + * Applies the filter once. An incident passes on every set criterion; a call + * passes when it belongs to a passing incident, or — if the filter carries no + * incident-only criterion (type, preset) — when the call itself matches. + * `callsByIncident` is optional (pass the wall's existing grouping to avoid + * regrouping). + */ +export function applyFilter( + incidents: IncidentRecord[], + calls: CallRecord[], + f: WallFilter, + callsByIncident?: Map, + now: number = Date.now(), +): FilterResult { + if (!isFilterActive(f)) return { active: false, incidents, calls, ids: new Set(incidents.map((i) => i.incident_id)) }; + let grouped = callsByIncident; + if (!grouped) { + grouped = new Map(); + for (const c of calls) for (const id of callIncidentIds(c)) { + if (!grouped.has(id)) grouped.set(id, []); + grouped.get(id)!.push(c); + } + } + const fireTgs = new Set(); + if (f.preset === "fire") { + for (const c of calls) if (c.talkgroup_id != null && FIRE_CHANNEL.test(c.talkgroup_name ?? "")) fireTgs.add(String(c.talkgroup_id)); + for (const i of incidents) if (i.type === "fire") for (const t of i.talkgroup_ids ?? []) fireTgs.add(String(t)); + } + const ctx: MatchCtx = { calls: grouped, fireTgs, now }; + const passing = incidents.filter((i) => incidentMatch(i, f, ctx)); + const ids = new Set(passing.map((i) => i.incident_id)); + const keptCalls = calls.filter((c) => callIncidentIds(c).some((id) => ids.has(id)) || looseCallMatch(c, f, now)); + return { active: true, incidents: passing, calls: keptCalls, ids }; +} + +/** One-line human summary, e.g. "fire · moderate+ · last 6h". */ +export function describeFilter(f: WallFilter, names?: { sys?: (id: string) => string; tg?: (id: string) => string }): string { + const parts: string[] = []; + if (f.preset) parts.push(`${PRESETS[f.preset].label} mode`); + if (f.types.length) parts.push(f.types.join("/")); + if (f.sevFloor) parts.push(`${f.sevFloor}+`); + if (f.ageH) parts.push(`last ${f.ageH}h`); + if (f.systems.length) parts.push(f.systems.map((s) => names?.sys?.(s) ?? s).join("/")); + if (f.talkgroups.length) parts.push(`TG ${f.talkgroups.map((t) => names?.tg?.(t) ?? t).join("/")}`); + if (f.tags.length) parts.push(f.tags.map((t) => `#${t}`).join(" ")); + if (f.q.trim()) parts.push(`"${f.q.trim()}"`); + return parts.join(" · "); +} diff --git a/drb-frontend/lib/liveWall.ts b/drb-frontend/lib/liveWall.ts index c765a58..a5c7d55 100644 --- a/drb-frontend/lib/liveWall.ts +++ b/drb-frontend/lib/liveWall.ts @@ -213,9 +213,10 @@ export interface WallLink extends WallView { /** Wall/kiosk presentation (no nav, large type, wake lock). */ kiosk?: boolean; /** - * Extension point for the filter bar (server-26#235): every `f.=` - * hash key round-trips here untouched, so filters can be added without - * reshaping WallLink or breaking older links. + * The filter bar's state (server-26#235): every `f.=` hash key + * round-trips here untouched, so filters can be added without reshaping + * WallLink or breaking older links. lib/liveFilter.ts parseFilter / + * serialiseFilter give it meaning. */ filter?: Record; } @@ -280,7 +281,8 @@ export function wallHash(link: WallLink): string { } if (link.base) parts.push(`base=${link.base}`); if (link.filter) { - for (const k of Object.keys(link.filter).sort()) parts.push(`f.${enc(k)}=${enc(link.filter[k])}`); + // Filter lists (f.type=fire,ems) keep literal commas too (server-26#235). + for (const k of Object.keys(link.filter).sort()) parts.push(`f.${enc(k)}=${enc(link.filter[k]).replace(/%2C/g, ",")}`); } return parts.length ? `#${parts.join("&")}` : ""; }