feat(filtering): chip-picker filters, search, time presets, active pills (v0.7.1)

Biggest PR of the v0.7.x GUI rework arc. Replaces the single-select /events
filter row with a multi-select, URL-addressable filtering surface.

- Search: full-width box, debounced 300ms, server-side ILIKE over the inner
  adapter payload (covers the derived subject + location); parameterized with
  LIKE wildcards escaped (ESCAPE '\'). Injection-safe.
- Adapter / Category / Event Type / Severity: multi-select chip-pickers (shared
  _chip_picker.html macro). Adapter is grouped by domain with color swatches and
  an in-panel search. Backend uses `= ANY(...)`. URL state is comma-separated.
- Event Type is derived as split_part(category,'.',1) (no event_type column yet;
  a stand-in until the v0.8 canonical schema). Severity maps labels to the
  numeric scale (4=critical..1=low, 0/NULL=unknown).
- Time: preset dropdown (15m/1h/6h/24h/7d/active/all) + custom from/to range,
  encoded in a single `time` token. GUI defaults to last_24h; events.json keeps
  its single-value adapter/since/until contract (no default).
- Active pills: server-rendered from parsed state, updated out-of-band on each
  HTMX swap; each x clears that filter and re-submits.
- URL state persistence: every filter in the query string; /events/rows sets
  HX-Push-Url to the /events?... full-page URL so bookmarking/back-forward work.

Filter options are rendered server-side at page load (DISTINCT category +
split_part, registry adapters, severity enum) -- no new AJAX endpoints.

Vanilla JS + HTMX (no framework added). CSS is functional-only; visual polish
is deferred to a later pass per the rework plan.

Adds TestEventsFiltering (24 tests: multi-value parse, ILIKE injection safety,
time-preset resolution with injected clock, severity/NULL handling, active-pill
descriptors, URL round-trip). Updates four TestEventsFeedFrontend assertions to
the new filter_state/adapters contract.

Full suite: 658 passed, 1 skipped (central and unprivileged zvx). No adapter
base class change -> central-gui restart only (no supervisor restart).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Johnson 2026-05-25 00:58:38 +00:00
commit 380cde31f8
7 changed files with 785 additions and 141 deletions

View file

@ -5,8 +5,9 @@ import html
import json import json
import logging import logging
import re import re
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
from urllib.parse import urlencode
logger = logging.getLogger("central.gui.routes") logger = logging.getLogger("central.gui.routes")
@ -2616,10 +2617,159 @@ class EventsQueryResult:
self.error = error self.error = error
def _parse_events_params(params) -> tuple[dict | None, str | None]: # --- v0.7.1 filtering: shared constants + pure helpers ----------------------
# Map a severity label (UI) to the numeric severity values it covers. Numeric
# scale per nws.SEVERITY_MAP (Extreme=4..Minor=1, Unknown=None). "unknown"
# also covers NULL and sev 0 (no assessment) -- handled in the query builder.
SEVERITY_LABELS: dict[str, list[int]] = {
"critical": [4], "high": [3], "moderate": [2], "low": [1], "unknown": [0],
}
SEVERITY_ORDER = ["critical", "high", "moderate", "low", "unknown"]
TIME_PRESETS: dict[str, timedelta] = {
"last_15m": timedelta(minutes=15),
"last_1h": timedelta(hours=1),
"last_6h": timedelta(hours=6),
"last_24h": timedelta(hours=24),
"last_7d": timedelta(days=7),
}
TIME_PRESET_LABELS = {
"last_15m": "Last 15 min", "last_1h": "Last 1 hour", "last_6h": "Last 6 hours",
"last_24h": "Last 24 hours", "last_7d": "Last 7 days",
"active": "Active now", "all": "All time",
}
DEFAULT_TIME = "last_24h"
# Adapter -> domain grouping for the chip-picker (registry-derived; any adapter
# not listed here falls into "Other"). Group order is the display order.
ADAPTER_GROUPS = {
"Disasters": ["gdacs", "firms", "inciweb", "wfigs_incidents", "wfigs_perimeters"],
"Weather": ["nws"],
"Space": ["swpc_alerts", "swpc_kindex", "swpc_protons"],
"Geophysical": ["usgs_quake", "nwis"],
"Earth Observation": ["eonet"],
}
# Same palette the map legend uses, indexed by sorted-adapter position.
EVENTS_PALETTE = [
"#f59e0b", "#dc2626", "#7c3aed", "#2563eb", "#059669", "#db2777",
"#0891b2", "#65a30d", "#ea580c", "#4f46e5", "#9333ea", "#0d9488",
]
def _csv_param(params, name: str) -> list[str]:
"""Parse a comma-separated multi-value query param into a clean list."""
raw = (params.get(name) or "").strip()
return [v.strip() for v in raw.split(",") if v.strip()] if raw else []
def _query_items(params):
"""(key, value) pairs from Starlette QueryParams (multi-valued) or a plain
dict (as used in unit tests)."""
if hasattr(params, "multi_items"):
return params.multi_items()
return list(params.items())
def _ilike_escape(term: str) -> str:
"""Escape LIKE wildcards so user input is matched literally (ESCAPE '\\')."""
return term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _resolve_time(token: str | None, now: datetime):
"""Resolve a time token to (since, until, active, error).
'all'/None -> no time filter; 'active' -> active-now flag; presets -> since;
'custom:<from_iso>,<to_iso>' -> explicit range. Pure (clock injected).
"""
if token in (None, "", "all"):
return None, None, False, None
if token == "active":
return None, None, True, None
if token in TIME_PRESETS:
return now - TIME_PRESETS[token], None, False, None
if token.startswith("custom:"):
f, _, t = token[len("custom:"):].partition(",")
try:
since = datetime.fromisoformat(f.replace("Z", "+00:00")) if f else None
until = datetime.fromisoformat(t.replace("Z", "+00:00")) if t else None
except ValueError:
return None, None, False, "Invalid datetime in custom time range"
if since and until and since > until:
return None, None, False, "time 'from' must be before 'to'"
return since, until, False, None
return None, None, False, f"Unknown time preset: {token}"
def _adapter_filter_options():
"""Registry-derived (flat, grouped) adapter options for the chip-picker.
flat: [{name, display_name, color}] sorted by name (color by sorted index,
matching the map legend). grouped: [(group_label, [{value,label,color}])].
"""
reg = discover_adapters()
ordered = sorted(reg.values(), key=lambda c: c.name)
color_by_name = {
cls.name: EVENTS_PALETTE[i % len(EVENTS_PALETTE)] for i, cls in enumerate(ordered)
}
flat = [
{"name": cls.name, "display_name": cls.display_name, "color": color_by_name[cls.name]}
for cls in ordered
]
grouped, grouped_names = [], set()
for group_label, names in ADAPTER_GROUPS.items():
items = [
{"value": n, "label": reg[n].display_name, "color": color_by_name[n]}
for n in names if n in reg
]
if items:
grouped.append((group_label, items))
grouped_names.update(items_n["value"] for items_n in items)
leftover = [
{"value": c.name, "label": c.display_name, "color": color_by_name[c.name]}
for c in ordered if c.name not in grouped_names
]
if leftover:
grouped.append(("Other", leftover))
return flat, grouped
def _build_active_pills(parsed: dict, adapter_total: int) -> list[dict]:
"""Server-side active-filter pills (descriptors) from parsed filter state.
Each pill: {"key": <param name to clear>, "label": <human text>}.
"""
pills: list[dict] = []
if parsed.get("q"):
pills.append({"key": "q", "label": f'Search: "{parsed["q"]}"'})
if parsed.get("adapters"):
pills.append({"key": "adapter",
"label": f'Adapters: {len(parsed["adapters"])} of {adapter_total}'})
if parsed.get("categories"):
pills.append({"key": "category", "label": f'Categories: {len(parsed["categories"])}'})
if parsed.get("event_types"):
pills.append({"key": "event_type",
"label": "Event types: " + ", ".join(parsed["event_types"])})
if parsed.get("severities"):
rank = {lbl: i for i, lbl in enumerate(SEVERITY_ORDER)}
labs = sorted(parsed["severities"], key=lambda s: rank.get(s, 99))
pills.append({"key": "severity", "label": "Severity: " + ", ".join(labs)})
token = parsed.get("time_token")
if token and token != "all":
label = "Custom range" if token.startswith("custom:") else TIME_PRESET_LABELS.get(token, token)
pills.append({"key": "time", "label": label})
return pills
def _parse_events_params(params, default_time: str | None = None) -> tuple[dict | None, str | None]:
""" """
Parse and validate events query parameters. Parse and validate events query parameters.
Multi-value filters (adapter, category, event_type, severity) are
comma-separated. `time` is a single token (preset / active / all /
custom:from,to); legacy since/until are still honored for the JSON API.
`default_time` (GUI only) supplies the time token when none is given.
Returns: Returns:
(parsed_params, error_message) (parsed_params, error_message)
If error_message is not None, parsed_params is None. If error_message is not None, parsed_params is None.
@ -2634,37 +2784,42 @@ def _parse_events_params(params) -> tuple[dict | None, str | None]:
if limit < 1 or limit > 200: if limit < 1 or limit > 200:
return None, "limit must be between 1 and 200" return None, "limit must be between 1 and 200"
# Parse adapter filter # Multi-value filters (comma-separated; a single value is just a CSV of
adapter = params.get("adapter") # length 1, preserving the old single-value API). Unknown severity labels
if adapter == "": # are ignored rather than erroring.
adapter = None q = (params.get("q") or "").strip()[:200] or None
adapters = _csv_param(params, "adapter")
# Parse category filter categories = _csv_param(params, "category")
category = params.get("category") event_types = _csv_param(params, "event_type")
if category == "": severities = [s for s in _csv_param(params, "severity") if s in SEVERITY_LABELS]
category = None
# Parse since/until filters
since = None
until = None
# Time: a single token wins; otherwise legacy since/until (JSON API); then
# the GUI-supplied default_time when nothing else is given.
time_token = params.get("time") or None
since = until = None
active = False
if time_token:
since, until, active, terr = _resolve_time(time_token, datetime.now(timezone.utc))
if terr:
return None, terr
else:
since_str = params.get("since") since_str = params.get("since")
if since_str: if since_str:
try: try:
since = datetime.fromisoformat(since_str.replace("Z", "+00:00")) since = datetime.fromisoformat(since_str.replace("Z", "+00:00"))
except ValueError: except ValueError:
return None, f"Invalid ISO 8601 datetime for since: {since_str}" return None, f"Invalid ISO 8601 datetime for since: {since_str}"
until_str = params.get("until") until_str = params.get("until")
if until_str: if until_str:
try: try:
until = datetime.fromisoformat(until_str.replace("Z", "+00:00")) until = datetime.fromisoformat(until_str.replace("Z", "+00:00"))
except ValueError: except ValueError:
return None, f"Invalid ISO 8601 datetime for until: {until_str}" return None, f"Invalid ISO 8601 datetime for until: {until_str}"
# Validate since <= until
if since and until and since > until: if since and until and since > until:
return None, "since must be before or equal to until" return None, "since must be before or equal to until"
if since is None and until is None and default_time:
time_token = default_time
since, until, active, _ = _resolve_time(time_token, datetime.now(timezone.utc))
# Parse region bbox # Parse region bbox
region_north = params.get("region_north") region_north = params.get("region_north")
@ -2729,10 +2884,15 @@ def _parse_events_params(params) -> tuple[dict | None, str | None]:
return { return {
"limit": limit, "limit": limit,
"adapter": adapter, "q": q,
"category": category, "adapters": adapters,
"categories": categories,
"event_types": event_types,
"severities": severities,
"time_token": time_token,
"since": since, "since": since,
"until": until, "until": until,
"active": active,
"bbox": bbox, "bbox": bbox,
"cursor_time": cursor_time, "cursor_time": cursor_time,
"cursor_id": cursor_id, "cursor_id": cursor_id,
@ -2766,10 +2926,14 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
pool = get_pool() pool = get_pool()
limit = parsed_params["limit"] limit = parsed_params["limit"]
adapter = parsed_params["adapter"] q = parsed_params.get("q")
category = parsed_params["category"] adapters = parsed_params.get("adapters") or []
categories = parsed_params.get("categories") or []
event_types = parsed_params.get("event_types") or []
severities = parsed_params.get("severities") or []
since = parsed_params["since"] since = parsed_params["since"]
until = parsed_params["until"] until = parsed_params["until"]
active = parsed_params.get("active", False)
bbox = parsed_params["bbox"] bbox = parsed_params["bbox"]
cursor_time = parsed_params["cursor_time"] cursor_time = parsed_params["cursor_time"]
cursor_id = parsed_params["cursor_id"] cursor_id = parsed_params["cursor_id"]
@ -2779,16 +2943,43 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
query_params = [] query_params = []
param_idx = 1 param_idx = 1
if adapter: if q:
conditions.append(f"adapter = ${param_idx}") # Subject and location are both derived from the inner adapter payload
query_params.append(adapter) # (payload->'data'->'data'), so a case-insensitive match over that
# subtree's text covers both. Parameterized + LIKE-wildcards escaped.
conditions.append(f"(payload->'data'->'data')::text ILIKE ${param_idx} ESCAPE '\\'")
query_params.append(f"%{_ilike_escape(q)}%")
param_idx += 1 param_idx += 1
if category: if adapters:
conditions.append(f"category = ${param_idx}") conditions.append(f"adapter = ANY(${param_idx})")
query_params.append(category) query_params.append(adapters)
param_idx += 1 param_idx += 1
if categories:
conditions.append(f"category = ANY(${param_idx})")
query_params.append(categories)
param_idx += 1
if event_types:
conditions.append(f"split_part(category, '.', 1) = ANY(${param_idx})")
query_params.append(event_types)
param_idx += 1
if severities:
nums = sorted({n for lbl in severities for n in SEVERITY_LABELS.get(lbl, [])})
if "unknown" in severities:
# sev 0 and NULL both read as "unknown" (no assessment).
conditions.append(f"(severity = ANY(${param_idx}) OR severity IS NULL)")
else:
conditions.append(f"severity = ANY(${param_idx})")
query_params.append(nums)
param_idx += 1
if active:
# Active now: started and not yet ended.
conditions.append("time <= now() AND (expires IS NULL OR expires > now())")
if since: if since:
conditions.append(f"time >= ${param_idx}") conditions.append(f"time >= ${param_idx}")
query_params.append(since) query_params.append(since)
@ -2979,38 +3170,34 @@ async def events_list(request: Request) -> HTMLResponse:
params = request.query_params params = request.query_params
# Parse parameters # Parse parameters (GUI defaults to Last 24h when no time filter is given).
parsed, error = _parse_events_params(params) parsed, error = _parse_events_params(params, default_time=DEFAULT_TIME)
# Get system settings for map tiles # Get system settings for map tiles + DISTINCT filter-option lists.
pool = get_pool() pool = get_pool()
all_categories: list[str] = []
all_event_types: list[str] = []
async with pool.acquire() as conn: async with pool.acquire() as conn:
system_row = await conn.fetchrow("SELECT map_tile_url, map_attribution FROM config.system") system_row = await conn.fetchrow("SELECT map_tile_url, map_attribution FROM config.system")
try:
cat_rows = await conn.fetch("SELECT DISTINCT category FROM events ORDER BY 1")
all_categories = [r["category"] for r in cat_rows]
et_rows = await conn.fetch(
"SELECT DISTINCT split_part(category, '.', 1) AS et FROM events ORDER BY 1"
)
all_event_types = [r["et"] for r in et_rows]
except Exception:
logger.warning("Failed to load filter options", exc_info=True)
tile_url = system_row["map_tile_url"] if system_row else "https://tile.openstreetmap.org/{z}/{x}/{y}.png" tile_url = system_row["map_tile_url"] if system_row else "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
tile_attribution = system_row["map_attribution"] if system_row else "OpenStreetMap" tile_attribution = system_row["map_attribution"] if system_row else "OpenStreetMap"
# Prepare filter values for template
filter_values = {
"adapter": params.get("adapter", ""),
"category": params.get("category", ""),
"since": params.get("since", ""),
"until": params.get("until", ""),
"region_north": params.get("region_north", ""),
"region_south": params.get("region_south", ""),
"region_east": params.get("region_east", ""),
"region_west": params.get("region_west", ""),
"limit": params.get("limit", "50"),
}
events = [] events = []
next_cursor = None next_cursor = None
if error: if error:
# Validation error - show error banner but don't fail the page # Validation error - show error banner but don't fail the page
pass pass
else: else:
# Fetch events
result = await _fetch_events(parsed) result = await _fetch_events(parsed)
if result.error: if result.error:
error = result.error error = result.error
@ -3018,15 +3205,25 @@ async def events_list(request: Request) -> HTMLResponse:
events = result.events events = result.events
next_cursor = result.next_cursor next_cursor = result.next_cursor
# Add table-only display fields (time_human, adapter_display, geometry_summary)
_decorate_table_events(events) _decorate_table_events(events)
# Registry-derived adapter list for the filter <select> and map legend. adapters_flat, adapters_grouped = _adapter_filter_options()
# Sorted by name for stable ordering; index drives the legend color palette. pstate = parsed or {}
adapters = [ filter_state = {
{"name": cls.name, "display_name": cls.display_name} "q": pstate.get("q") or "",
for cls in sorted(discover_adapters().values(), key=lambda c: c.name) "adapters": pstate.get("adapters") or [],
] "categories": pstate.get("categories") or [],
"event_types": pstate.get("event_types") or [],
"severities": pstate.get("severities") or [],
"time_token": pstate.get("time_token") or DEFAULT_TIME,
"region_north": params.get("region_north", ""),
"region_south": params.get("region_south", ""),
"region_east": params.get("region_east", ""),
"region_west": params.get("region_west", ""),
"limit": str(pstate.get("limit", 50)),
}
active_pills = _build_active_pills(pstate, len(adapters_flat)) if parsed else []
query_string = urlencode([(k, v) for k, v in _query_items(params) if k != "cursor"])
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
@ -3036,11 +3233,19 @@ async def events_list(request: Request) -> HTMLResponse:
"csrf_token": csrf_token, "csrf_token": csrf_token,
"events": events, "events": events,
"next_cursor": next_cursor, "next_cursor": next_cursor,
"filter_values": filter_values,
"filter_error": error, "filter_error": error,
"tile_url": tile_url, "tile_url": tile_url,
"tile_attribution": tile_attribution, "tile_attribution": tile_attribution,
"adapters": adapters, "adapters": adapters_flat,
"adapters_grouped": adapters_grouped,
"all_categories": all_categories,
"all_event_types": all_event_types,
"severity_order": SEVERITY_ORDER,
"time_presets": [(t, TIME_PRESET_LABELS[t]) for t in
("last_15m", "last_1h", "last_6h", "last_24h", "last_7d", "active", "all")],
"filter_state": filter_state,
"active_pills": active_pills,
"query_string": query_string,
}, },
) )
@ -3052,25 +3257,11 @@ async def events_rows(request: Request) -> HTMLResponse:
params = request.query_params params = request.query_params
# Parse parameters # Parse parameters (same GUI default as the page).
parsed, error = _parse_events_params(params) parsed, error = _parse_events_params(params, default_time=DEFAULT_TIME)
# Prepare filter values for template
filter_values = {
"adapter": params.get("adapter", ""),
"category": params.get("category", ""),
"since": params.get("since", ""),
"until": params.get("until", ""),
"region_north": params.get("region_north", ""),
"region_south": params.get("region_south", ""),
"region_east": params.get("region_east", ""),
"region_west": params.get("region_west", ""),
"limit": params.get("limit", "50"),
}
events = [] events = []
next_cursor = None next_cursor = None
if error: if error:
pass pass
else: else:
@ -3081,16 +3272,25 @@ async def events_rows(request: Request) -> HTMLResponse:
events = result.events events = result.events
next_cursor = result.next_cursor next_cursor = result.next_cursor
# Add table-only display fields (time_human, adapter_display, geometry_summary)
_decorate_table_events(events) _decorate_table_events(events)
return templates.TemplateResponse( adapters_flat, _ = _adapter_filter_options()
active_pills = _build_active_pills(parsed or {}, len(adapters_flat)) if parsed else []
query_string = urlencode([(k, v) for k, v in _query_items(params) if k != "cursor"])
response = templates.TemplateResponse(
request=request, request=request,
name="_events_rows.html", name="_events_rows.html",
context={ context={
"events": events, "events": events,
"next_cursor": next_cursor, "next_cursor": next_cursor,
"filter_values": filter_values,
"filter_error": error, "filter_error": error,
"active_pills": active_pills,
"query_string": query_string,
"oob_pills": True, # emit an out-of-band #active-pills update on swap
}, },
) )
# Push the bookmarkable full-page URL (not the /events/rows fragment path),
# so back/forward + bookmarking land on the same filtered view.
response.headers["HX-Push-Url"] = "/events?" + str(request.url.query)
return response

View file

@ -0,0 +1,16 @@
{# Active-filter pills (v0.7.1). Server-rendered from the parsed filter state so
the operator always sees what's applied. Each × clears that filter (JS) and
re-submits; this partial is re-rendered out-of-band into #active-pills. #}
{% if active_pills %}
<div class="active-pills">
<span class="active-pills-label">Active filters:</span>
{% for pill in active_pills %}
<span class="filter-pill" data-pill-key="{{ pill.key }}">
{{ pill.label }}
<button type="button" class="pill-remove" data-remove-filter="{{ pill.key }}"
aria-label="Remove {{ pill.label }}">&times;</button>
</span>
{% endfor %}
<button type="button" class="pill-clear-all" data-clear-all>Clear all</button>
</div>
{% endif %}

View file

@ -0,0 +1,47 @@
{# Multi-select chip-picker (v0.7.1). Shared by the adapter / category /
event-type / severity filters. Selection is held in a hidden CSV input
(name=field); the checkboxes are synced into it by JS and the form
auto-applies when the panel closes. `options` is either a flat list of
strings/dicts {value,label,color} or, when grouped=True, a list of
(group_label, [dict,...]) tuples. `selected` is the list of chosen values. #}
{% macro _chip_row(field, opt, selected, with_swatch) %}
{% set val = opt.value if opt.value is defined else opt %}
{% set lbl = opt.label if opt.label is defined else opt %}
<label class="chip-row" data-chip-label="{{ lbl | lower }}">
<input type="checkbox" class="chip-cb" data-field="{{ field }}" value="{{ val }}"
{{ 'checked' if val in selected else '' }}>
{% if with_swatch and opt.color is defined %}
<span class="chip-swatch" style="background:{{ opt.color }}"></span>
{% endif %}
<span>{{ lbl }}</span>
</label>
{% endmacro %}
{% macro chip_picker(field, label, options, selected, grouped=False, searchable=False, with_swatch=False) %}
<div class="chip-picker" data-field="{{ field }}">
<button type="button" class="chip-picker-toggle outline" data-toggle="{{ field }}"
aria-haspopup="true" aria-expanded="false">
{{ label }}<span class="chip-count" data-count-for="{{ field }}">{{ (' (' ~ selected | length ~ ')') if selected else '' }}</span>
</button>
<input type="hidden" name="{{ field }}" id="filter-{{ field }}" value="{{ selected | join(',') }}">
<div class="chip-picker-panel" data-panel="{{ field }}" hidden>
{% if searchable %}
<input type="text" class="chip-picker-search" data-search-for="{{ field }}"
placeholder="Filter {{ label | lower }}…" autocomplete="off">
{% endif %}
<div class="chip-picker-list">
{% if grouped %}
{% for group_label, items in options %}
<div class="chip-group">
<div class="chip-group-header">{{ group_label }}</div>
{% for opt in items %}{{ _chip_row(field, opt, selected, with_swatch) }}{% endfor %}
</div>
{% endfor %}
{% else %}
{% for opt in options %}{{ _chip_row(field, opt, selected, with_swatch) }}{% endfor %}
{% endif %}
</div>
</div>
</div>
{% endmacro %}

View file

@ -70,9 +70,10 @@
<div class="pagination-info"> <div class="pagination-info">
<span>Showing {{ events | length }} event{{ 's' if events | length != 1 else '' }}.</span> <span>Showing {{ events | length }} event{{ 's' if events | length != 1 else '' }}.</span>
{% if next_cursor %} {% if next_cursor %}
<a href="/events?cursor={{ next_cursor }}{% if filter_values.adapter %}&amp;adapter={{ filter_values.adapter }}{% endif %}{% if filter_values.category %}&amp;category={{ filter_values.category | urlencode }}{% endif %}{% if filter_values.since %}&amp;since={{ filter_values.since }}{% endif %}{% if filter_values.until %}&amp;until={{ filter_values.until }}{% endif %}{% if filter_values.region_north %}&amp;region_north={{ filter_values.region_north }}&amp;region_south={{ filter_values.region_south }}&amp;region_east={{ filter_values.region_east }}&amp;region_west={{ filter_values.region_west }}{% endif %}&amp;limit={{ filter_values.limit }}" {% set next_qs = "cursor=" ~ next_cursor ~ ("&" ~ query_string if query_string else "") %}
<a href="/events?{{ next_qs }}"
role="button" role="button"
hx-get="/events/rows?cursor={{ next_cursor }}{% if filter_values.adapter %}&amp;adapter={{ filter_values.adapter }}{% endif %}{% if filter_values.category %}&amp;category={{ filter_values.category | urlencode }}{% endif %}{% if filter_values.since %}&amp;since={{ filter_values.since }}{% endif %}{% if filter_values.until %}&amp;until={{ filter_values.until }}{% endif %}{% if filter_values.region_north %}&amp;region_north={{ filter_values.region_north }}&amp;region_south={{ filter_values.region_south }}&amp;region_east={{ filter_values.region_east }}&amp;region_west={{ filter_values.region_west }}{% endif %}&amp;limit={{ filter_values.limit }}" hx-get="/events/rows?{{ next_qs }}"
hx-target="#events-rows" hx-target="#events-rows"
hx-push-url="true"> hx-push-url="true">
Next &rarr; Next &rarr;
@ -86,3 +87,7 @@
<p>No events match the filters.</p> <p>No events match the filters.</p>
</article> </article>
{% endif %} {% endif %}
{% if oob_pills %}
{# Out-of-band update of the page-level active-pills bar on each HTMX swap. #}
<div id="active-pills" hx-swap-oob="true">{% include "_active_pills.html" %}</div>
{% endif %}

View file

@ -102,6 +102,42 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
} }
/* --- v0.7.1 filter row (functional layout; full polish in a later pass) --- */
.filter-search { width: 100%; margin-bottom: 0.5rem; }
.filter-row { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: flex-start; }
.filter-actions { display: flex; gap: 0.5rem; margin-top: 0.5rem; justify-content: flex-end; }
.filter-apply { width: 120px; }
.chip-picker { position: relative; }
.chip-picker-toggle { white-space: nowrap; }
.chip-picker-panel {
position: absolute; z-index: 1000; top: 100%; left: 0; margin-top: 0.25rem;
min-width: 16rem; max-height: 22rem; overflow-y: auto;
background: var(--pico-card-background-color);
border: 1px solid var(--pico-muted-border-color);
border-radius: 0.375rem; padding: 0.5rem; box-shadow: 0 4px 14px rgba(0,0,0,0.18);
}
.chip-picker-search { width: 100%; margin-bottom: 0.5rem; }
.chip-group-header { font-size: 0.75rem; font-weight: 600; color: var(--pico-muted-color);
text-transform: uppercase; margin: 0.4rem 0 0.2rem; }
.chip-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.15rem 0;
cursor: pointer; font-weight: 400; }
.chip-row input[type="checkbox"] { width: auto; margin: 0; }
.chip-swatch { width: 0.85rem; height: 0.85rem; border-radius: 2px; flex: 0 0 auto; }
.chip-row.chip-hidden { display: none; }
.time-preset { display: block; width: 100%; text-align: left; background: none;
border: none; padding: 0.35rem 0.4rem; color: var(--pico-color); cursor: pointer; }
.time-preset:hover, .time-preset.selected { background: var(--pico-primary-focus); }
.time-custom { border-top: 1px solid var(--pico-muted-border-color); margin-top: 0.4rem;
padding-top: 0.4rem; display: flex; flex-direction: column; gap: 0.3rem; }
#active-pills .active-pills { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center;
margin: 0.5rem 0; }
.active-pills-label { font-size: 0.8rem; color: var(--pico-muted-color); }
.filter-pill { display: inline-flex; align-items: center; gap: 0.35rem;
background: var(--pico-primary-focus); border-radius: 999px; padding: 0.15rem 0.6rem;
font-size: 0.85rem; }
.pill-remove, .pill-clear-all { background: none; border: none; cursor: pointer;
color: var(--pico-color); padding: 0; font-size: 1rem; line-height: 1; width: auto; }
.pill-clear-all { font-size: 0.8rem; text-decoration: underline; }
</style> </style>
{% endblock %} {% endblock %}
@ -120,52 +156,58 @@
</article> </article>
{% endif %} {% endif %}
<details open> {% from "_chip_picker.html" import chip_picker %}
<summary>Filters</summary> <form id="filter-form" class="filter-form" action="/events" method="get"
<form id="filter-form" class="filter-form" action="/events" method="get"
hx-get="/events/rows" hx-target="#events-rows" hx-push-url="true"> hx-get="/events/rows" hx-target="#events-rows" hx-push-url="true">
<div class="grid"> {# Full-width search (server-side ILIKE over subject + location). #}
<div> <input type="search" id="filter-q" name="q" class="filter-search"
<label for="adapter">Adapter</label> placeholder="Search subject or location…" autocomplete="off"
<select id="adapter" name="adapter"> value="{{ filter_state.q }}">
<option value="">All</option>
{% for a in adapters %} {# Chip-picker row: adapter (grouped, swatches), category, event type,
<option value="{{ a.name }}" {% if filter_values.adapter == a.name %}selected{% endif %}>{{ a.display_name }}</option> severity, plus the time-preset dropdown. #}
<div class="filter-row">
{{ chip_picker("adapter", "Adapters", adapters_grouped, filter_state.adapters,
grouped=True, searchable=True, with_swatch=True) }}
{{ chip_picker("category", "Categories", all_categories, filter_state.categories,
searchable=True) }}
{{ chip_picker("event_type", "Event Types", all_event_types, filter_state.event_types) }}
{{ chip_picker("severity", "Severity", severity_order, filter_state.severities) }}
{# Time preset dropdown (bespoke; not a chip-picker). #}
<div class="chip-picker time-picker" data-field="time">
<button type="button" class="chip-picker-toggle outline" aria-expanded="false"
data-toggle="time" id="time-toggle">Time</button>
<input type="hidden" name="time" id="filter-time" value="{{ filter_state.time_token }}">
<div class="chip-picker-panel" data-panel="time" hidden>
{% for token, label in time_presets %}
<button type="button" class="time-preset" data-time-value="{{ token }}">{{ label }}</button>
{% endfor %} {% endfor %}
</select> <div class="time-custom">
<label>From <input type="datetime-local" id="time-from"></label>
<label>To <input type="datetime-local" id="time-to"></label>
<button type="button" class="time-custom-apply" data-time-custom>Apply range</button>
</div> </div>
<div>
<label for="category">Category</label>
<input type="text" id="category" name="category" placeholder="Exact match"
value="{{ filter_values.category }}">
</div> </div>
<div>
<label for="since">From</label>
<input type="datetime-local" id="since" name="since"
value="{{ filter_values.since }}">
</div>
<div>
<label for="until">To</label>
<input type="datetime-local" id="until" name="until"
value="{{ filter_values.until }}">
</div> </div>
</div> </div>
<!-- Hidden region inputs (managed by map viewport) --> {# Hidden region inputs (managed by map viewport) + limit. #}
<input type="hidden" id="region_north" name="region_north" value="{{ filter_values.region_north }}"> <input type="hidden" id="region_north" name="region_north" value="{{ filter_state.region_north }}">
<input type="hidden" id="region_south" name="region_south" value="{{ filter_values.region_south }}"> <input type="hidden" id="region_south" name="region_south" value="{{ filter_state.region_south }}">
<input type="hidden" id="region_east" name="region_east" value="{{ filter_values.region_east }}"> <input type="hidden" id="region_east" name="region_east" value="{{ filter_state.region_east }}">
<input type="hidden" id="region_west" name="region_west" value="{{ filter_values.region_west }}"> <input type="hidden" id="region_west" name="region_west" value="{{ filter_state.region_west }}">
<input type="hidden" name="limit" value="{{ filter_state.limit }}">
<input type="hidden" name="limit" value="{{ filter_values.limit }}"> <div class="filter-actions">
<button type="submit" class="filter-apply">Apply</button>
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;"> <a href="/events" role="button" class="outline" id="filter-clear-all">Clear all</a>
<button type="submit">Apply</button>
<a href="/events" role="button" class="outline">Clear Filters</a>
</div> </div>
</form> </form>
</details>
{# Active filter pills — server-rendered; updated out-of-band on each swap. #}
<div id="active-pills">{% include "_active_pills.html" %}</div>
<div id="events-map"></div> <div id="events-map"></div>
<div class="map-controls"> <div class="map-controls">
@ -566,4 +608,124 @@
})(); })();
</script> </script>
<script>
// --- v0.7.1 filter behavior (vanilla JS + HTMX; no framework) --------------
(function () {
var form = document.getElementById("filter-form");
if (!form) return;
function submitForm() { if (window.htmx) htmx.trigger(form, "submit"); }
// Sync a chip-picker's checked checkboxes into its hidden CSV input + badge.
function syncField(field) {
var boxes = form.querySelectorAll('.chip-cb[data-field="' + field + '"]');
var vals = [];
boxes.forEach(function (b) { if (b.checked) vals.push(b.value); });
var hidden = document.getElementById("filter-" + field);
if (hidden) hidden.value = vals.join(",");
var badge = form.querySelector('[data-count-for="' + field + '"]');
if (badge) badge.textContent = vals.length ? " (" + vals.length + ")" : "";
}
function closeAllPanels(except) {
form.querySelectorAll(".chip-picker-panel").forEach(function (p) {
if (p !== except && !p.hidden) {
p.hidden = true;
var t = form.querySelector('[data-toggle="' + p.dataset.panel + '"]');
if (t) t.setAttribute("aria-expanded", "false");
// Auto-apply on close for the multi-select chip-pickers.
if (p.dataset.panel && p.dataset.panel !== "time") submitForm();
}
});
}
// Toggle panels open/closed.
form.querySelectorAll(".chip-picker-toggle").forEach(function (btn) {
btn.addEventListener("click", function (e) {
e.preventDefault();
var field = btn.dataset.toggle;
var panel = form.querySelector('.chip-picker-panel[data-panel="' + field + '"]');
if (!panel) return;
var willOpen = panel.hidden;
closeAllPanels(willOpen ? panel : null);
panel.hidden = !willOpen;
btn.setAttribute("aria-expanded", willOpen ? "true" : "false");
});
});
// Checkbox change -> keep hidden CSV in sync (apply happens on close).
form.querySelectorAll(".chip-cb").forEach(function (cb) {
cb.addEventListener("change", function () { syncField(cb.dataset.field); });
});
// In-panel search: filter visible chip rows by label substring.
form.querySelectorAll(".chip-picker-search").forEach(function (inp) {
inp.addEventListener("input", function () {
var field = inp.dataset.searchFor;
var term = inp.value.trim().toLowerCase();
var panel = form.querySelector('.chip-picker-panel[data-panel="' + field + '"]');
panel.querySelectorAll(".chip-row").forEach(function (row) {
var lbl = row.dataset.chipLabel || "";
row.classList.toggle("chip-hidden", term && lbl.indexOf(term) === -1);
});
});
});
// Time presets.
var timeHidden = document.getElementById("filter-time");
form.querySelectorAll(".time-preset").forEach(function (btn) {
btn.addEventListener("click", function () {
timeHidden.value = btn.dataset.timeValue;
closeAllPanels(null);
submitForm();
});
});
var customBtn = form.querySelector("[data-time-custom]");
if (customBtn) {
customBtn.addEventListener("click", function () {
var f = document.getElementById("time-from").value;
var t = document.getElementById("time-to").value;
if (!f && !t) return;
timeHidden.value = "custom:" + (f || "") + "," + (t || "");
closeAllPanels(null);
submitForm();
});
}
// Debounced search box (300ms) -> auto-apply.
var qInput = document.getElementById("filter-q");
if (qInput) {
var qTimer = null;
qInput.addEventListener("input", function () {
if (qTimer) clearTimeout(qTimer);
qTimer = setTimeout(submitForm, 300);
});
}
// Close panels when clicking outside.
document.addEventListener("click", function (e) {
if (!e.target.closest(".chip-picker")) closeAllPanels(null);
});
// Active-pill removal + clear-all (delegated; #active-pills is OOB-swapped).
document.addEventListener("click", function (e) {
var rm = e.target.closest("[data-remove-filter]");
if (rm) {
var key = rm.dataset.removeFilter;
if (key === "q") { if (qInput) qInput.value = ""; }
else if (key === "time") { timeHidden.value = "all"; }
else {
form.querySelectorAll('.chip-cb[data-field="' + key + '"]').forEach(function (b) {
b.checked = false;
});
syncField(key);
}
submitForm();
return;
}
if (e.target.closest("[data-clear-all]")) { window.location.href = "/events"; }
});
})();
</script>
{% endblock %} {% endblock %}

View file

@ -104,7 +104,8 @@ class TestEventsFeedFrontendAuthenticated:
assert result.status_code == 200 assert result.status_code == 200
context = mock_templates.TemplateResponse.call_args.kwargs.get("context") context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
assert context["filter_values"]["adapter"] == "nws" # v0.7.1: adapter is now a multi-select; single value parses to a 1-list.
assert context["filter_state"]["adapters"] == ["nws"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_events_since_until_filter(self): async def test_events_since_until_filter(self):
@ -154,8 +155,9 @@ class TestEventsFeedFrontendAuthenticated:
mock_templates.TemplateResponse.assert_called_once() mock_templates.TemplateResponse.assert_called_once()
call_kwargs = mock_templates.TemplateResponse.call_args.kwargs call_kwargs = mock_templates.TemplateResponse.call_args.kwargs
context = call_kwargs.get("context", call_kwargs) context = call_kwargs.get("context", call_kwargs)
assert context["filter_values"]["since"] == "2026-05-17T00:00:00" # v0.7.1: the GUI uses time presets, but legacy since/until are still
assert context["filter_values"]["until"] == "2026-05-17T12:00:00" # honored (JSON API + bookmarks); they parse without error and apply.
assert context["filter_error"] is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_events_region_filter(self): async def test_events_region_filter(self):
@ -207,10 +209,10 @@ class TestEventsFeedFrontendAuthenticated:
mock_templates.TemplateResponse.assert_called_once() mock_templates.TemplateResponse.assert_called_once()
call_kwargs = mock_templates.TemplateResponse.call_args.kwargs call_kwargs = mock_templates.TemplateResponse.call_args.kwargs
context = call_kwargs.get("context", call_kwargs) context = call_kwargs.get("context", call_kwargs)
assert context["filter_values"]["region_north"] == "49.5" assert context["filter_state"]["region_north"] == "49.5"
assert context["filter_values"]["region_south"] == "31" assert context["filter_state"]["region_south"] == "31"
assert context["filter_values"]["region_east"] == "-102" assert context["filter_state"]["region_east"] == "-102"
assert context["filter_values"]["region_west"] == "-124.5" assert context["filter_state"]["region_west"] == "-124.5"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_events_partial_region_shows_error_banner(self): async def test_events_partial_region_shows_error_banner(self):
@ -772,9 +774,11 @@ class TestRegistryDrivenAdapterFilter:
# The list is exactly the registry, sorted by name (stable), no extras. # The list is exactly the registry, sorted by name (stable), no extras.
assert [a["name"] for a in context["adapters"]] == sorted(registry.keys()) assert [a["name"] for a in context["adapters"]] == sorted(registry.keys())
# Each entry carries name + display_name straight from the adapter class. # Each entry carries name + display_name (v0.7.1 adds a positional color).
by_name = {a["name"]: a for a in context["adapters"]}
for cls in registry.values(): for cls in registry.values():
assert {"name": cls.name, "display_name": cls.display_name} in context["adapters"] assert by_name[cls.name]["display_name"] == cls.display_name
assert by_name[cls.name]["color"].startswith("#")
class TestPerAdapterRowPartials: class TestPerAdapterRowPartials:

View file

@ -0,0 +1,210 @@
"""Tests for the v0.7.1 /events filtering (search, multi-select, time presets,
active pills, URL round-trip). Covers the pure helpers and the query builder
(via a captured-SQL mock), with no live DB.
"""
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from central.gui import routes
NOW = datetime(2026, 5, 24, 12, 0, tzinfo=timezone.utc)
# --- pure helpers -----------------------------------------------------------
def test_csv_param_splits_and_trims():
assert routes._csv_param({"adapter": "nws, firms ,usgs_quake"}, "adapter") == \
["nws", "firms", "usgs_quake"]
assert routes._csv_param({}, "adapter") == []
assert routes._csv_param({"adapter": ""}, "adapter") == []
def test_ilike_escape_escapes_wildcards():
assert routes._ilike_escape("50%_off\\x") == "50\\%\\_off\\\\x"
@pytest.mark.parametrize("token,delta", [
("last_15m", timedelta(minutes=15)),
("last_1h", timedelta(hours=1)),
("last_6h", timedelta(hours=6)),
("last_24h", timedelta(hours=24)),
("last_7d", timedelta(days=7)),
])
def test_resolve_time_presets(token, delta):
since, until, active, err = routes._resolve_time(token, NOW)
assert err is None and active is False and until is None
assert since == NOW - delta
def test_resolve_time_active_all_custom():
assert routes._resolve_time("active", NOW) == (None, None, True, None)
assert routes._resolve_time("all", NOW) == (None, None, False, None)
assert routes._resolve_time(None, NOW) == (None, None, False, None)
since, until, active, err = routes._resolve_time("custom:2026-05-01T00:00,2026-05-02T00:00", NOW)
assert err is None and active is False
# datetime-local values carry no tz (naive), matching the legacy since/until path.
assert since == datetime(2026, 5, 1)
assert until == datetime(2026, 5, 2)
def test_resolve_time_custom_inverted_is_error():
_, _, _, err = routes._resolve_time("custom:2026-05-02T00:00,2026-05-01T00:00", NOW)
assert err and "before" in err
def test_resolve_time_unknown_token_is_error():
_, _, _, err = routes._resolve_time("yesterday", NOW)
assert err and "Unknown" in err
# --- _parse_events_params ---------------------------------------------------
def test_parse_multi_value_csv():
parsed, err = routes._parse_events_params({
"adapter": "nws,firms", "category": "quake.event.light,wx.alert.tornado_warning",
"event_type": "fire,quake", "severity": "critical,high", "q": "wildfire", "time": "all",
})
assert err is None
assert parsed["adapters"] == ["nws", "firms"]
assert parsed["categories"] == ["quake.event.light", "wx.alert.tornado_warning"]
assert parsed["event_types"] == ["fire", "quake"]
assert parsed["severities"] == ["critical", "high"]
assert parsed["q"] == "wildfire"
def test_parse_drops_unknown_severity_labels():
parsed, err = routes._parse_events_params({"severity": "critical,bogus,low"})
assert err is None
assert parsed["severities"] == ["critical", "low"]
def test_parse_gui_defaults_to_last_24h():
parsed, _ = routes._parse_events_params({}, default_time="last_24h")
assert parsed["time_token"] == "last_24h"
assert parsed["since"] is not None
def test_parse_json_api_has_no_default_time():
parsed, _ = routes._parse_events_params({}) # no default_time (events.json)
assert parsed["time_token"] is None
assert parsed["since"] is None and parsed["until"] is None
def test_parse_legacy_since_until_still_honored():
parsed, err = routes._parse_events_params(
{"since": "2026-05-17T00:00:00", "until": "2026-05-17T12:00:00"},
default_time="last_24h",
)
assert err is None
assert parsed["since"] == datetime(2026, 5, 17, 0, 0)
assert parsed["time_token"] is None # legacy path, not a preset
# --- query builder (captured SQL) -------------------------------------------
async def _capture(parsed):
captured = {}
async def fake_fetch(query, *args):
captured["query"] = query
captured["params"] = list(args)
return []
conn = MagicMock()
conn.fetch = fake_fetch
pool = MagicMock()
pool.acquire.return_value.__aenter__ = AsyncMock(return_value=conn)
pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
with patch("central.gui.routes.get_pool", return_value=pool):
await routes._fetch_events(parsed)
return captured
@pytest.mark.asyncio
async def test_multi_adapter_uses_any():
parsed, _ = routes._parse_events_params({"adapter": "nws,firms,usgs_quake", "time": "all"})
cap = await _capture(parsed)
assert "adapter = ANY($" in cap["query"]
assert ["nws", "firms", "usgs_quake"] in cap["params"]
@pytest.mark.asyncio
async def test_event_type_uses_split_part():
parsed, _ = routes._parse_events_params({"event_type": "fire,quake", "time": "all"})
cap = await _capture(parsed)
assert "split_part(category, '.', 1) = ANY($" in cap["query"]
assert ["fire", "quake"] in cap["params"]
@pytest.mark.asyncio
async def test_severity_unknown_includes_null():
parsed, _ = routes._parse_events_params({"severity": "critical,unknown", "time": "all"})
cap = await _capture(parsed)
assert "severity = ANY($" in cap["query"] and "OR severity IS NULL" in cap["query"]
assert [0, 4] in cap["params"] # unknown->0, critical->4 (sorted)
@pytest.mark.asyncio
async def test_severity_without_unknown_has_no_null_clause():
parsed, _ = routes._parse_events_params({"severity": "high", "time": "all"})
cap = await _capture(parsed)
assert "OR severity IS NULL" not in cap["query"]
assert [3] in cap["params"]
@pytest.mark.asyncio
async def test_active_now_condition():
parsed, _ = routes._parse_events_params({"time": "active"})
cap = await _capture(parsed)
assert "time <= now() AND (expires IS NULL OR expires > now())" in cap["query"]
@pytest.mark.asyncio
async def test_search_is_parameterized_and_escaped():
# A term loaded with SQL/LIKE metacharacters must be safely parameterized.
parsed, _ = routes._parse_events_params({"q": "50%_x'; DROP TABLE events;--", "time": "all"})
cap = await _capture(parsed)
assert "(payload->'data'->'data')::text ILIKE $" in cap["query"]
assert "ESCAPE '\\'" in cap["query"]
# The dangerous term never appears inline in the SQL; only as a bound param.
assert "DROP TABLE" not in cap["query"]
assert cap["params"][0] == "%50\\%\\_x'; DROP TABLE events;--%"
# --- active pills + URL round-trip ------------------------------------------
def test_build_active_pills():
parsed = {
"q": "fire", "adapters": ["nws", "firms"], "categories": ["a", "b"],
"event_types": ["fire"], "severities": ["high", "critical"], "time_token": "last_24h",
}
pills = {p["key"]: p["label"] for p in routes._build_active_pills(parsed, adapter_total=12)}
assert pills["q"] == 'Search: "fire"'
assert pills["adapter"] == "Adapters: 2 of 12"
assert pills["category"] == "Categories: 2"
assert pills["event_type"] == "Event types: fire"
assert pills["severity"] == "Severity: critical, high" # ranked order
assert pills["time"] == "Last 24 hours"
def test_no_time_pill_for_all():
pills = routes._build_active_pills({"time_token": "all"}, adapter_total=12)
assert all(p["key"] != "time" for p in pills)
def test_url_round_trip():
q = {"q": "fire", "adapter": "nws,firms", "category": "quake.event.light",
"event_type": "fire", "severity": "critical,high", "time": "last_1h", "limit": "50"}
parsed, err = routes._parse_events_params(q)
assert err is None
# Re-serialize the parsed multi-values as CSV and re-parse: identical state.
requery = {
"q": parsed["q"], "adapter": ",".join(parsed["adapters"]),
"category": ",".join(parsed["categories"]), "event_type": ",".join(parsed["event_types"]),
"severity": ",".join(parsed["severities"]), "time": parsed["time_token"], "limit": "50",
}
reparsed, _ = routes._parse_events_params(requery)
for k in ("q", "adapters", "categories", "event_types", "severities", "time_token"):
assert reparsed[k] == parsed[k]