diff --git a/CHANGELOG.md b/CHANGELOG.md index f24ab6c..aad5367 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,45 @@ skip versions are the composition of each intervening adjacent release's steps in order — no A-to-B path is pre-computed beyond that. +## 0.49.0 — 2026-06-07 + +**Minor — faceted left-pane filtering (§22.4a SLICE-3).** A collection that +declares a metadata field schema (`fields:`, SLICE-2) now gets a **faceted +catalog left pane**: one collapsible filter group per `enum`/`tags` field plus +state, each with per-value **result counts** and multi-select checkboxes, and a +"filter values…" search box on `tags` groups so they stay usable at many values. +Filters compose — OR within a field, AND across fields — and a "malformed +metadata only" toggle surfaces entries failing their schema (INV-3). This is +SLICE-3 of the +[Configurable Collection Metadata](./docs/design/2026-06-06-configurable-collection-metadata.md) +design (§7.2); the single-entry and bulk metadata-edit UIs (SLICE-4/5) follow. + +The collection-scoped list endpoint +(`GET /api/projects/{id}/collections/{cid}/rfcs`, and the project-scoped +default-collection alias) now honours filter query params +(`?priority=P0&tags=checkout&state=active&malformed=true`), returns a +`facets: {field → {value → count}}` block with drill-down counts, and includes +each entry's metadata `meta` mapping; an unknown filter field is a 400. Migration +034 adds the additive `cached_rfcs.meta_json` column that persists per-entry +metadata values for the index. + +Non-breaking and opt-in (INV-5): a collection with **no `fields:`** keeps the +existing state-chip catalog unchanged, and the §22.13 default `document` +collection declares none — so **N=1 deployments see no change**. The unscoped +cross-collection `GET /api/rfcs` is untouched. + +**Upgrade steps** + +- Migration 034 (`cached_rfcs.meta_json`) applies automatically on startup + (additive, nullable). It is populated lazily as the corpus reconciler / + content-repo webhooks re-ingest each collection; until an entry is re-ingested + its `meta_json` is NULL and it contributes no facet values. Operators wanting + facets populated immediately MAY trigger a corpus refresh (the reconciler sweep + on next startup does this). **No data migration is required.** +- A deployment opts a collection into faceting by declaring an `enum`/`tags` + `fields:` block in its `.collection.yaml` (SLICE-2). A collection with no + `fields:` keeps the existing state-chip catalog unchanged. + ## 0.48.0 — 2026-06-07 **Minor — collection field schema + central validation (§22.4a SLICE-2).** A diff --git a/SPEC.md b/SPEC.md index 3284f6a..b437464 100644 --- a/SPEC.md +++ b/SPEC.md @@ -897,7 +897,13 @@ a hierarchy on the user that gets in the way of finding by title. - **Filter chip strip** — multi-select, AND-combined. Chips: `State: super-draft | active | withdrawn`, `My RFCs` (I'm an owner or arbiter), `Has open PRs`, `Unclaimed` (super-drafts with empty - `owners:`), `Tag: …`. + `owners:`), `Tag: …`. A collection that declares a metadata field + schema (§22.4a) replaces this chip strip with **faceted filter + groups** — one collapsible group per `enum`/`tags` field plus state, + each showing per-value result counts and multi-select checkboxes + (OR within a field, AND across fields); see the Configurable + Collection Metadata design. A collection with no `fields:` schema + keeps the chip strip described here unchanged. ### 7.2 The list rows diff --git a/VERSION b/VERSION index a758a09..5c4503b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.48.0 +0.49.0 diff --git a/docs/superpowers/plans/2026-06-07-slice3-faceted-filtering.md b/docs/superpowers/plans/2026-06-07-slice3-faceted-filtering.md new file mode 100644 index 0000000..896681e --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-slice3-faceted-filtering.md @@ -0,0 +1,1154 @@ +# SLICE-3 — Faceted left-pane filtering (read) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give a collection that declares a `fields:` schema a faceted left pane — the list endpoint returns per-value facet counts and honours filter params (`?priority=P0&tags=checkout&state=active&malformed=true`; OR within a field, AND across fields), and the catalog renders collapsible facet groups with counts, multi-select checkboxes, and a tag-value search box. A collection with no schema behaves exactly as today (INV-5 → the N=1 `document` deployment sees zero change). + +**Architecture:** Per-entry metadata values aren't currently cached (`Entry.extra` is dropped on ingest), so facets can't be computed. A new additive column `cached_rfcs.meta_json` (migration 034) persists the full metadata mapping (`metadata.metadata_dict(entry)`) at ingest. A new pure module `app/facets.py` computes the facetable field set (declared `enum`+`tags` fields, in schema order, plus the built-in `state` facet) and does the filter + drill-down counting over the mirrored rows (small set, hundreds–~1.2k; in-Python is fine). The collection-scoped list endpoint (`_list_rfcs_for_collection`, shared by the project- and collection-scoped routes) reads filter params from the query string, 400s on an unknown field, and returns `{items (each + its `meta`), facets}`. The frontend `Catalog.jsx` renders a faceted pane (`FacetGroups.jsx`) when `collection.fields` is present — re-fetching server-side on each selection change — and otherwise keeps the legacy state chips untouched. Search box + sort remain client-side in both modes. + +**Tech Stack:** FastAPI + SQLite (raw SQL migrations run by `backend/app/db.py:run_migrations`, glob-ordered); pytest vertical/unit tests (`backend/tests/`, reusing the fake-Gitea `app_with_fake_gitea` fixture + `_set_default_fields_schema` helper from `test_metadata_cache.py`); React Router SPA (`frontend/src/`), Vitest + Testing Library component tests mocking `../api`. + +**Binding spec:** `docs/design/2026-06-06-configurable-collection-metadata.md` — §5.1 (catalog left pane), §6.4 (interfaces), §6.2/§6.3 (architecture/data model), §7.2 SLICE-3 DoD, PUC-3, INV-3/INV-5/INV-7. + +--- + +## Decisions locked before coding (read first) + +1. **Facetable fields = declared `enum` + `tags` fields (in schema declaration order) + the built-in `state` facet appended last.** `text`-type fields are **not** faceted in v1 (free text makes a poor facet group; they get a detail control in SLICE-4). The built-in `state` facet is added **only when the collection declares a schema** — so INV-5 holds: a no-`fields:` collection returns *no* `facets` at all and the frontend keeps its legacy state chips. `state` here is the entry's lifecycle state column (`super-draft`/`active`); the base query already restricts to those two, so `withdrawn`/`retired` never appear as facet values. + +2. **Facet counts use drill-down semantics:** the count for a value of field *F* is computed over the entries matching every *other* field's active selection (and the `malformed` toggle), but **not** *F*'s own selection. This keeps within-field values switchable (you can move from P0 to P1 without first clearing P0) and matches "OR within a field, AND across fields". The returned `items` list is filtered by **all** selections (every field AND-combined, malformed toggle applied). + +3. **`malformed` is a cross-cutting toggle, not its own facet group.** `?malformed=true` narrows both `items` and every facet's counts to entries whose `metadata_malformed` flag is set. Malformed entries are otherwise **never hidden** from the normal list (INV-3); each item already carries `metadata_malformed` so the row can show a fixable marker. + +4. **Facets live on the collection-scoped path only** (`_list_rfcs_for_collection`, used by `GET /api/projects/{pid}/rfcs` for the default collection and `GET /api/projects/{pid}/collections/{cid}/rfcs`). The unscoped cross-collection `GET /api/rfcs` is left untouched — it spans collections with potentially different schemas, so a single facet set is meaningless there. This bounds the blast radius and keeps the legacy catalog path identical. + +5. **`meta_json` stores the full `metadata.metadata_dict(entry)`** (known keys + `extra`, never the body) as JSON, not just `extra`. Uniform per-field lookup (`meta.get(name)`) serves `enum`/`tags`/custom fields and is directly reusable by SLICE-4/5 edit panels. Additive nullable column; existing rows read `NULL` (→ empty facets) until the reconciler/webhook re-ingests them — no rebuild required, and the reconciler sweep runs on startup. + +6. **Allowed query-param keys** for the collection-scoped list = the facet field names ∪ `{"unreviewed", "malformed"}`. `unreviewed` keeps its existing meaning (narrow to unreviewed active entries) and stays a typed param. Any other key → **HTTP 400** "unknown filter field". An empty-valued selection (`?priority=`) is ignored, not 400. + +--- + +## File structure + +**Backend** +- Create `backend/migrations/034_cached_rfc_meta.sql` — add `meta_json TEXT` to `cached_rfcs`. +- Create `backend/app/facets.py` — pure facet field set + filter/count helpers. +- Modify `backend/app/cache.py` — `_upsert_cached_rfc` persists `meta_json`. +- Modify `backend/app/api.py` — `_list_rfcs_for_collection` reads filters, returns `{items(+meta), facets}`, 400 on unknown field; the two collection-scoped callers pass the query params. +- Create `backend/tests/test_facets.py` — unit tests for `app/facets.py`. +- Create `backend/tests/test_facets_api.py` — integration tests through `TestClient`. + +**Frontend** +- Modify `frontend/src/api.js` — `listRFCs` accepts filter selections + returns the full `{items, facets}` payload. +- Create `frontend/src/components/FacetGroups.jsx` — the faceted-pane subcomponent. +- Modify `frontend/src/components/Catalog.jsx` — faceted mode (gated on `fields`) vs legacy chips; server-side re-fetch on selection change. +- Modify `frontend/src/App.css` (or the catalog stylesheet) — facet group styles. +- Create `frontend/src/components/Catalog.test.jsx` — Vitest component test. + +**Docs / release** +- Modify `CHANGELOG.md`, `VERSION`, `frontend/package.json` — v0.49.0. + +--- + +## Task 1: Migration 034 — persist per-entry metadata values + +**Files:** +- Create: `backend/migrations/034_cached_rfc_meta.sql` +- Test: `backend/tests/test_facets_api.py` (the persistence assertion lands in Task 6; this task only proves the migration applies) + +- [ ] **Step 1: Write the migration** + +```sql +-- §22.4a SLICE-3 — configurable collection metadata: cache per-entry values. +-- +-- Faceted filtering (§5.1) needs each entry's metadata values (priority, custom +-- enum/tags fields) to compute facet counts and honour filter params. Today the +-- mirror keeps only `tags_json` + the lifecycle columns and drops `Entry.extra`, +-- so a declared field's values are unrecoverable. This column persists the full +-- per-entry metadata mapping (`metadata.metadata_dict(entry)`, known keys + +-- extra, never the body) as JSON, so `app/facets.py` can read any declared +-- field uniformly. Additive + nullable — no rebuild. NULL = not yet re-ingested +-- (the reconciler/webhook fills it on the next sweep) → that entry contributes +-- no facet values until then. SLICE-4/5 edit panels read the same column. +ALTER TABLE cached_rfcs ADD COLUMN meta_json TEXT; +``` + +- [ ] **Step 2: Run the backend suite to confirm the migration applies cleanly** + +Run: `cd backend && python -m pytest tests/test_metadata_cache.py -q` +Expected: PASS (the migration runner applies 034 with no error; existing tests unaffected — `meta_json` is nullable and unread so far). + +- [ ] **Step 3: Commit** + +```bash +git add backend/migrations/034_cached_rfc_meta.sql +git commit -m "feat(slice3): migration 034 — cached_rfcs.meta_json for facet values (§22.4a)" +``` + +--- + +## Task 2: Persist `meta_json` at ingest + +**Files:** +- Modify: `backend/app/cache.py` (`_upsert_cached_rfc`, lines ~161-228) +- Test: `backend/tests/test_facets_api.py::test_meta_json_persisted_at_ingest` (written in Task 6) + +- [ ] **Step 1: Add the `meta_json` value in `_upsert_cached_rfc`** + +In `backend/app/cache.py`, locate the top of `_upsert_cached_rfc` (just after the `funder_login` line) and add: + +```python + # §22.4a SLICE-3: persist the full per-entry metadata mapping (known keys + + # extra, never the body) so facet/filter can read any declared field. Stored + # via metadata_dict so the sidecar's forward-compat keys (INV-7) ride along. + meta_json = json.dumps(metadata_mod.metadata_dict(entry)) +``` + +Confirm `cache.py` already imports the metadata module. It imports it as `metadata_mod` (used in `refresh_meta_repo`). If the import is local to that function, add a module-level `from . import metadata as metadata_mod` alongside the other imports at the top of `cache.py`. + +- [ ] **Step 2: Add the column to the INSERT column list and VALUES** + +In the `INSERT INTO cached_rfcs (...)` column list, add `meta_json` after `metadata_malformed`: + +```python + unreviewed, reviewed_at, reviewed_by, collection_id, + metadata_malformed, meta_json, last_entry_commit_at, updated_at) +``` + +Add one more `?` to the `VALUES (...)` row (there is one placeholder per column before the two `datetime('now')` literals). + +- [ ] **Step 3: Add `meta_json` to the ON CONFLICT update and the params tuple** + +In the `ON CONFLICT(collection_id, slug) DO UPDATE SET` clause, add after the `metadata_malformed = excluded.metadata_malformed,` line: + +```python + meta_json = excluded.meta_json, +``` + +In the params tuple, add `meta_json,` immediately after the `1 if metadata_malformed else 0,` entry (matching the new column position). + +- [ ] **Step 4: Run the cache tests** + +Run: `cd backend && python -m pytest tests/test_metadata_cache.py -q` +Expected: PASS (no behaviour change yet — the column is now populated but unread). + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/cache.py +git commit -m "feat(slice3): persist entry metadata to cached_rfcs.meta_json at ingest (§22.4a)" +``` + +--- + +## Task 3: Pure facet module — field set + filter/count + +**Files:** +- Create: `backend/app/facets.py` +- Test: `backend/tests/test_facets.py` + +- [ ] **Step 1: Write the failing unit tests** + +Create `backend/tests/test_facets.py`: + +```python +"""§22.4a SLICE-3 — pure facet field-set + filter/count (PUC-3). + +Per docs/design/2026-06-06-configurable-collection-metadata.md §5.1, §6.4. +""" +from app import facets + + +PRIORITY = {"priority": {"type": "enum", "values": ["P0", "P1", "P2"]}} +SCHEMA = { + "priority": {"type": "enum", "values": ["P0", "P1", "P2"]}, + "tags": {"type": "tags"}, + "owner": {"type": "text"}, +} + + +def _e(slug, state="active", malformed=False, **meta): + return {"slug": slug, "state": state, "metadata_malformed": malformed, "meta": meta} + + +def test_facet_fields_orders_declared_then_state_skips_text(): + # enum + tags in declaration order, text skipped, state appended last. + assert facets.facet_fields(SCHEMA) == [ + ("priority", "enum"), ("tags", "tags"), ("state", "enum")] + + +def test_no_schema_yields_no_facets(): + # INV-5: a collection with no fields has no facets (frontend keeps chips). + assert facets.facet_fields(None) == [] + assert facets.facet_fields({}) == [] + + +def test_filter_and_count_basic_counts(): + entries = [ + _e("a", priority="P0", tags=["checkout"]), + _e("b", priority="P0", tags=["cart"]), + _e("c", priority="P1", tags=["checkout", "cart"]), + ] + items, fac = facets.filter_and_count(entries, SCHEMA, {}) + assert {i["slug"] for i in items} == {"a", "b", "c"} + assert fac["priority"] == {"P0": 2, "P1": 1} + assert fac["tags"] == {"checkout": 2, "cart": 2} + assert fac["state"] == {"active": 3} + + +def test_filter_compose_or_within_and_across(): + entries = [ + _e("a", priority="P0", tags=["checkout"]), # P0 + checkout + _e("b", priority="P0", tags=["cart"]), # P0, no checkout + _e("c", priority="P1", tags=["checkout"]), # checkout, not P0 + ] + # priority=P0 AND tags=checkout → only "a". + items, _ = facets.filter_and_count( + entries, SCHEMA, {"priority": {"P0"}, "tags": {"checkout"}}) + assert {i["slug"] for i in items} == {"a"} + + +def test_drilldown_counts_exclude_own_field_selection(): + entries = [ + _e("a", priority="P0"), + _e("b", priority="P1"), + _e("c", priority="P1"), + ] + # With P0 selected, the priority facet still counts P1 over the set that + # ignores priority's own selection — so P1 stays switchable. + _, fac = facets.filter_and_count(entries, SCHEMA, {"priority": {"P0"}}) + assert fac["priority"] == {"P0": 1, "P1": 2} + + +def test_malformed_toggle_narrows_items_and_counts(): + entries = [ + _e("a", state="active", malformed=True, priority="P9"), + _e("b", state="active", malformed=False, priority="P0"), + ] + items, fac = facets.filter_and_count(entries, SCHEMA, {}, only_malformed=True) + assert {i["slug"] for i in items} == {"a"} + assert fac["priority"] == {"P9": 1} + + +def test_missing_value_contributes_no_facet_value(): + entries = [_e("a", priority="P0"), _e("b")] # b has no priority + _, fac = facets.filter_and_count(entries, SCHEMA, {}) + assert fac["priority"] == {"P0": 1} + + +def test_allowed_filter_keys(): + assert facets.allowed_filter_keys(PRIORITY) == {"priority", "state", + "unreviewed", "malformed"} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cd backend && python -m pytest tests/test_facets.py -q` +Expected: FAIL with `ModuleNotFoundError: No module named 'app.facets'`. + +- [ ] **Step 3: Implement `app/facets.py`** + +```python +"""§22.4a SLICE-3 — faceted catalog filtering + counts (read). + +Pure functions over already-mirrored entries; no I/O, no DB. An "entry" here is +a plain dict carrying at least: + - "state": the lifecycle state column, + - "metadata_malformed": bool, + - "meta": the per-entry metadata mapping (from cached_rfcs.meta_json). + +Facetable fields (§5.1, decision 1): a collection's declared `enum` and `tags` +fields, in declaration order, plus the built-in `state` facet appended last — +but only when the collection declares a schema (INV-5: a no-`fields:` collection +has no facets at all, so the frontend keeps its legacy chips). `text` fields are +not faceted in v1. + +Counts use drill-down semantics (decision 2): the count for a value of field F +is taken over entries matching every OTHER field's selection (and the malformed +toggle), not F's own — so within-field values stay switchable (OR within a +field, AND across fields). The returned items list applies ALL selections. +""" +from __future__ import annotations + +from typing import Any + +# enum + tags are facetable; text is rendered as a detail control (SLICE-4). +FACETABLE_TYPES = {"enum", "tags"} + + +def facet_fields(fields: dict[str, dict] | None) -> list[tuple[str, str]]: + """Ordered `[(name, type), ...]` facetable from the schema, `state` last. + + Empty when the collection declares no schema (INV-5).""" + if not fields: + return [] + out = [ + (name, spec.get("type")) + for name, spec in fields.items() + if spec.get("type") in FACETABLE_TYPES + ] + out.append(("state", "enum")) + return out + + +def allowed_filter_keys(fields: dict[str, dict] | None) -> set[str]: + """Query-param keys the collection-scoped list accepts (decision 6).""" + keys = {name for name, _ in facet_fields(fields)} + keys.update({"unreviewed", "malformed"}) + return keys + + +def _values_for(entry: dict[str, Any], name: str, ftype: str) -> list[str]: + """The facet value(s) an entry contributes for field `name` (str-cast).""" + if name == "state": + v = entry.get("state") + return [str(v)] if v else [] + meta = entry.get("meta") or {} + v = meta.get(name) + if v is None: + return [] + if ftype == "tags": + return [str(x) for x in v] if isinstance(v, list) else [] + return [str(v)] + + +def _matches(entry: dict[str, Any], name: str, ftype: str, selected: set[str]) -> bool: + if not selected: + return True + return bool(set(_values_for(entry, name, ftype)) & selected) # OR within field + + +def filter_and_count( + entries: list[dict[str, Any]], + fields: dict[str, dict] | None, + selections: dict[str, set[str]], + only_malformed: bool = False, +) -> tuple[list[dict[str, Any]], dict[str, dict[str, int]]]: + """Filter `entries` by `selections` and compute drill-down facet counts. + + `selections` maps a facet field name → the set of selected values (OR within + the field; AND across fields). `only_malformed` narrows items and counts to + entries flagged malformed (INV-3). Returns `(items, facets)` where + `facets = {field: {value: count}}`. With no schema → `([all passing], {})`. + """ + facetable = facet_fields(fields) + + def passes_malformed(e: dict[str, Any]) -> bool: + return (not only_malformed) or bool(e.get("metadata_malformed")) + + items = [ + e for e in entries + if passes_malformed(e) + and all(_matches(e, n, t, selections.get(n, set())) for n, t in facetable) + ] + + facets: dict[str, dict[str, int]] = {} + for name, ftype in facetable: + counts: dict[str, int] = {} + for e in entries: + if not passes_malformed(e): + continue + if not all( + _matches(e, on, ot, selections.get(on, set())) + for on, ot in facetable + if on != name + ): + continue + for val in _values_for(e, name, ftype): + counts[val] = counts.get(val, 0) + 1 + facets[name] = counts + return items, facets +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cd backend && python -m pytest tests/test_facets.py -q` +Expected: PASS (8 tests). + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/facets.py backend/tests/test_facets.py +git commit -m "feat(slice3): pure facet field-set + filter/count helper (§22.4a)" +``` + +--- + +## Task 4: Wire facets into the collection-scoped list endpoint + +**Files:** +- Modify: `backend/app/api.py` (`_list_rfcs_for_collection` at ~739-784; callers `list_project_rfcs` ~807, `list_collection_rfcs` ~828) +- Test: `backend/tests/test_facets_api.py` (Task 6) + +- [ ] **Step 1: Confirm imports** + +At the top of `api.py`, confirm `facets` and `collections_mod` are importable. `collections_mod` is already imported (used in `_require_collection_in_project`). Add `from . import facets` to the module imports if absent. + +- [ ] **Step 2: Rewrite `_list_rfcs_for_collection` to filter + facet** + +Replace the body of `_list_rfcs_for_collection` with a version that takes the raw query params, reads `meta_json`, validates filter keys, and returns `{items, facets}`: + +```python + def _list_rfcs_for_collection( + collection_id: str, viewer, unreviewed: str | None, + query_params=None, + ) -> dict[str, Any]: + viewer_id = viewer.user_id if viewer else None + # §22.4a SLICE-3: the collection's declared field schema drives the facet + # set (None when undeclared → no facets, INV-5). + col = collections_mod.get_collection(collection_id) + fields_schema = (col or {}).get("fields") or None + + # Parse + validate filter selections from the query string. Unknown + # field → 400 (§6.4). `unreviewed` keeps its existing meaning; an + # empty-valued selection is ignored, not an error (decision 6). + selections: dict[str, set[str]] = {} + only_malformed = False + if query_params is not None: + allowed = facets.allowed_filter_keys(fields_schema) + facet_names = {n for n, _ in facets.facet_fields(fields_schema)} + for key in query_params.keys(): + if key not in allowed: + raise HTTPException(400, f"unknown filter field {key!r}") + if (query_params.get("malformed") or "").lower() in ("1", "true", "yes"): + only_malformed = True + for name in facet_names: + vals = {v for v in query_params.getlist(name) if v != ""} + if vals: + selections[name] = vals + + unreviewed_clause = "" + if unreviewed is not None and unreviewed.lower() in ("1", "true", "yes"): + unreviewed_clause = " AND unreviewed = 1 AND state = 'active'" + rows = db.conn().execute( + f""" + SELECT slug, title, state, rfc_id, repo, + owners_json, arbiters_json, tags_json, metadata_malformed, + meta_json, + last_main_commit_at, last_entry_commit_at, updated_at + FROM cached_rfcs + WHERE state IN ('super-draft', 'active') + AND collection_id = ?{unreviewed_clause} + ORDER BY COALESCE(last_main_commit_at, last_entry_commit_at) DESC + """, + (collection_id,), + ).fetchall() + starred = set() + if viewer_id is not None: + starred = { + r["rfc_slug"] + for r in db.conn().execute( + "SELECT rfc_slug FROM stars WHERE user_id = ? AND collection_id = ?", + (viewer_id, collection_id), + ) + } + + # Build entry dicts the facet helper understands (state + malformed + + # parsed meta), preserving SQL order. + entries = [] + for r in rows: + try: + meta = json.loads(r["meta_json"]) if r["meta_json"] else {} + except (TypeError, ValueError): + meta = {} + entries.append({ + "slug": r["slug"], + "title": r["title"], + "state": r["state"], + "id": r["rfc_id"], + "repo": r["repo"], + "owners": json.loads(r["owners_json"] or "[]"), + "arbiters": json.loads(r["arbiters_json"] or "[]"), + "tags": json.loads(r["tags_json"] or "[]"), + "last_active_at": r["last_main_commit_at"] or r["last_entry_commit_at"] or r["updated_at"], + "starred_by_me": r["slug"] in starred, + "has_open_prs": False, + "metadata_malformed": bool(r["metadata_malformed"]), + "meta": meta, + }) + + filtered, facet_counts = facets.filter_and_count( + entries, fields_schema, selections, only_malformed=only_malformed + ) + return {"items": filtered, "facets": facet_counts} +``` + +Note: `metadata_malformed` is a Python `bool` here (the facet helper reads it as the per-entry flag); the response keeps the same `metadata_malformed` key the frontend already reads. `meta` is additive. + +- [ ] **Step 3: Pass query params from the two collection-scoped callers** + +In `list_project_rfcs`, change the final return to forward the request's query params: + +```python + return _list_rfcs_for_collection( + collection_id, viewer, unreviewed, query_params=request.query_params + ) +``` + +In `list_collection_rfcs`, likewise: + +```python + return _list_rfcs_for_collection( + collection_id, viewer, unreviewed, query_params=request.query_params + ) +``` + +Leave the unscoped `/api/rfcs` (`list_rfcs`) untouched (decision 4). + +- [ ] **Step 4: Run the existing collection-serve + cache tests** + +Run: `cd backend && python -m pytest tests/test_collection_scoped_serve.py tests/test_metadata_cache.py tests/test_s1_collection_grain_vertical.py -q` +Expected: PASS. The response now also carries `facets` (additive) and each item a `meta` key; existing assertions read `items[...]` fields and are unaffected. If any test asserts the exact item-key set, update it to allow the additive `meta` key. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/api.py +git commit -m "feat(slice3): collection list endpoint returns facets + honours filters (§22.4a)" +``` + +--- + +## Task 5: Backend integration tests for facets + filtering through the API + +**Files:** +- Create: `backend/tests/test_facets_api.py` + +- [ ] **Step 1: Write the integration tests** + +Create `backend/tests/test_facets_api.py`, reusing the fake-Gitea fixture and the schema helper pattern from `test_metadata_cache.py`. The default project's default collection serves at `/api/projects//rfcs`; resolve the project id from the deployment config (`load_config().default_project_id` — confirm the attribute name from `app/config.py`; the existing vertical tests read the same value). + +```python +"""§22.4a SLICE-3 integration — faceted list endpoint (PUC-3, §6.4). + +Through the real API: schema-declared facets, filter params (OR within / AND +across), drill-down counts, malformed toggle, unknown-field 400, and meta_json +persistence. Reuses the fake-Gitea harness from test_metadata_cache. +""" +from __future__ import annotations + +import asyncio +import json + +from fastapi.testclient import TestClient + +from app import cache, db, gitea as gitea_mod +from app.config import load_config + +from test_propose_vertical import ( # noqa: F401 (fixtures) + app_with_fake_gitea, + tmp_env, +) + + +def _refresh(): + cfg = load_config() + asyncio.run(cache.refresh_meta_repo(cfg, gitea_mod.Gitea(cfg))) + + +def _set_default_fields_schema(schema): + db.conn().execute( + "UPDATE collections SET config_json = ? WHERE id = 'default'", + (json.dumps({"fields": schema}),)) + + +def _pid(): + # The default project id the deployment is stamped with. + return load_config().default_project_id + + +def _seed(fake, slug, *, state="active", **front): + fm = {"slug": slug, "title": slug.title(), "state": state, **front} + lines = "\n".join( + f"{k}: {v}" if not isinstance(v, list) + else f"{k}:\n" + "".join(f"- {m}\n" for m in v).rstrip() + for k, v in fm.items() + ) + fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.md")] = { + "content": f"---\n{lines}\n---\n\nBody.\n", "sha": slug} + + +def test_facets_and_counts_returned(app_with_fake_gitea): + app, fake = app_with_fake_gitea + with TestClient(app) as client: + _set_default_fields_schema({ + "priority": {"type": "enum", "values": ["P0", "P1", "P2"]}, + "tags": {"type": "tags"}, + }) + _seed(fake, "a", priority="P0", tags=["checkout"]) + _seed(fake, "b", priority="P0", tags=["cart"]) + _seed(fake, "c", priority="P1", tags=["checkout", "cart"]) + _refresh() + + res = client.get(f"/api/projects/{_pid()}/rfcs") + assert res.status_code == 200 + body = res.json() + assert {i["slug"] for i in body["items"]} == {"a", "b", "c"} + assert body["facets"]["priority"] == {"P0": 2, "P1": 1} + assert body["facets"]["tags"] == {"checkout": 2, "cart": 2} + assert body["facets"]["state"] == {"active": 3} + + +def test_filter_params_compose(app_with_fake_gitea): + app, fake = app_with_fake_gitea + with TestClient(app) as client: + _set_default_fields_schema({ + "priority": {"type": "enum", "values": ["P0", "P1"]}, + "tags": {"type": "tags"}, + }) + _seed(fake, "a", priority="P0", tags=["checkout"]) + _seed(fake, "b", priority="P0", tags=["cart"]) + _seed(fake, "c", priority="P1", tags=["checkout"]) + _refresh() + + res = client.get( + f"/api/projects/{_pid()}/rfcs", + params={"priority": "P0", "tags": "checkout"}) + assert {i["slug"] for i in res.json()["items"]} == {"a"} + + +def test_unknown_filter_field_400(app_with_fake_gitea): + app, fake = app_with_fake_gitea + with TestClient(app) as client: + _set_default_fields_schema( + {"priority": {"type": "enum", "values": ["P0"]}}) + _seed(fake, "a", priority="P0") + _refresh() + + res = client.get(f"/api/projects/{_pid()}/rfcs", + params={"nonsense": "x"}) + assert res.status_code == 400 + + +def test_malformed_toggle(app_with_fake_gitea): + app, fake = app_with_fake_gitea + with TestClient(app) as client: + _set_default_fields_schema( + {"priority": {"type": "enum", "values": ["P0", "P1"]}}) + _seed(fake, "good", priority="P0") + _seed(fake, "bad", priority="P9") # not in values → malformed (INV-3) + _refresh() + + res = client.get(f"/api/projects/{_pid()}/rfcs", + params={"malformed": "true"}) + slugs = {i["slug"] for i in res.json()["items"]} + assert slugs == {"bad"} + + +def test_no_schema_no_facets(app_with_fake_gitea): + # INV-5: the default document collection (no fields) returns empty facets. + app, fake = app_with_fake_gitea + with TestClient(app) as client: + _seed(fake, "plain", tags=["whatever"]) + _refresh() + body = client.get(f"/api/projects/{_pid()}/rfcs").json() + assert body["facets"] == {} + + +def test_meta_json_persisted_at_ingest(app_with_fake_gitea): + app, fake = app_with_fake_gitea + with TestClient(app): + _seed(fake, "withmeta", priority="P0", tags=["x"]) + _refresh() + row = db.conn().execute( + "SELECT meta_json FROM cached_rfcs WHERE slug = 'withmeta'" + ).fetchone() + meta = json.loads(row["meta_json"]) + assert meta["priority"] == "P0" + assert meta["tags"] == ["x"] +``` + +If `load_config()` exposes the default project id under a different attribute, adapt `_pid()` (grep `default_project_id` in `app/config.py` / the existing vertical tests to confirm; `test_s1_collection_grain_vertical.py` resolves the same project id — copy its approach). + +- [ ] **Step 2: Run the integration tests** + +Run: `cd backend && python -m pytest tests/test_facets_api.py -q` +Expected: PASS (6 tests). If `_seed`'s frontmatter rendering trips on list values, simplify by writing the YAML with `yaml.safe_dump` instead of hand-built lines. + +- [ ] **Step 3: Run the full backend suite for regressions** + +Run: `cd backend && python -m pytest -q` +Expected: PASS (the prior green count was 601; new tests add to it). Fix any test that asserted an exact item-key set or the absence of `facets`. + +- [ ] **Step 4: Commit** + +```bash +git add backend/tests/test_facets_api.py +git commit -m "test(slice3): integration tests for faceted list endpoint (§22.4a PUC-3)" +``` + +--- + +## Task 6: Frontend API client — filtered list + facets + +**Files:** +- Modify: `frontend/src/api.js` (`listRFCs` at ~206) + +- [ ] **Step 1: Read the current `listRFCs`** + +Confirm its current shape (around line 206) — it takes `(projectId, collectionId)` and returns the parsed JSON. Note the helper it uses to build the scoped URL (project- vs collection-scoped path) so the filter variant reuses it. + +- [ ] **Step 2: Extend `listRFCs` to accept selections and build a query string** + +```js +// §22.4a SLICE-3: faceted catalog. `opts.selections` is { field: string[] } +// (each non-empty array becomes repeated query params, OR within the field); +// `opts.malformed` toggles the malformed-only filter. Returns the full payload +// { items, facets } — callers read both. With no selections it behaves as the +// pre-SLICE-3 list (and a no-fields collection returns facets: {}). +export async function listRFCs(projectId, collectionId, opts = {}) { + const { selections = {}, malformed = false } = opts + const qs = new URLSearchParams() + for (const [field, values] of Object.entries(selections)) { + for (const v of values) qs.append(field, v) + } + if (malformed) qs.set('malformed', 'true') + const query = qs.toString() + const base = /* the existing scoped-URL builder this function already uses */ + const url = query ? `${base}?${query}` : base + return getJSON(url) // reuse the existing fetch/parse helper this file uses +} +``` + +Adapt `base` and the fetch helper name to whatever `api.js` already uses (it has a shared request helper — match the surrounding functions). The return value stays the parsed body, now `{ items, facets }`; existing callers that read `d.items` keep working, and `d.facets` is `undefined`/`{}` for the no-fields path. + +- [ ] **Step 3: Run the frontend api tests** + +Run: `cd frontend && npx vitest run src/api.collections.test.js` +Expected: PASS (existing api tests unaffected; `listRFCs` signature is backward-compatible — extra optional arg). + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/api.js +git commit -m "feat(slice3): listRFCs accepts facet selections, returns {items,facets} (§22.4a)" +``` + +--- + +## Task 7: `FacetGroups` component + +**Files:** +- Create: `frontend/src/components/FacetGroups.jsx` +- Test: covered by `Catalog.test.jsx` (Task 9) + +- [ ] **Step 1: Implement the facet-group renderer** + +```jsx +// FacetGroups.jsx — §22.4a SLICE-3 faceted left-pane filters (§5.1). +// +// Renders one collapsible group per facet field returned by the API, each with +// per-value result counts and multi-select checkboxes. A `tags`-type field gets +// a "filter values…" search box so it stays usable at 30+ values. Selection +// state + the malformed toggle are owned by the parent (Catalog), which +// re-fetches server-side on change. + +import { useState } from 'react' + +// Human label for the built-in state facet; declared fields use their own name +// (or the schema `label` when the API supplies it on `fields`). +function groupLabel(field, fields) { + if (field === 'state') return 'State' + const def = fields?.[field] + return def?.label || field.charAt(0).toUpperCase() + field.slice(1) +} + +function FacetGroup({ field, fields, counts, selected, onToggle, isTags }) { + const [open, setOpen] = useState(true) + const [valueSearch, setValueSearch] = useState('') + const entries = Object.entries(counts).sort((a, b) => b[1] - a[1]) + const needle = valueSearch.trim().toLowerCase() + const shown = isTags && needle + ? entries.filter(([v]) => v.toLowerCase().includes(needle)) + : entries + return ( +
+ + {open && ( +
+ {isTags && entries.length > 8 && ( + setValueSearch(e.target.value)} + /> + )} + {shown.length === 0 && ( +
No values.
+ )} + {shown.map(([value, count]) => ( + + ))} +
+ )} +
+ ) +} + +export default function FacetGroups({ + facets, fields, selections, onToggle, malformedOnly, onToggleMalformed, + onClear, hasActiveFilters, +}) { + // The API returns facets in field order with `state` last; render in that + // order. `selections` is { field: Set }. + const fieldOrder = Object.keys(facets || {}) + // A `tags`-type field renders the value-search box. + const isTags = (field) => fields?.[field]?.type === 'tags' + return ( +
+ {hasActiveFilters && ( + + )} + {fieldOrder.map(field => ( + + ))} + +
+ ) +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/src/components/FacetGroups.jsx +git commit -m "feat(slice3): FacetGroups faceted-pane component (§22.4a §5.1)" +``` + +--- + +## Task 8: Wire faceted mode into `Catalog.jsx` + +**Files:** +- Modify: `frontend/src/components/Catalog.jsx` +- Modify: `frontend/src/App.css` (facet styles) + +- [ ] **Step 1: Add facet state and gate on `fields`** + +In `Catalog.jsx`, add state and read the collection's `fields` from `getCollection`: + +```jsx + const [fields, setFields] = useState(null) // collection field schema + const [facets, setFacets] = useState({}) // { field: { value: count } } + const [selections, setSelections] = useState({}) // { field: Set } + const [malformedOnly, setMalformedOnly] = useState(false) + const [loading, setLoading] = useState(false) +``` + +In the `getCollection(...)` `.then`, also `setFields(c?.fields || null)`. + +A collection is in faceted mode when `fields` is a non-empty object: + +```jsx + const faceted = !!fields && Object.keys(fields).length > 0 +``` + +- [ ] **Step 2: Re-fetch server-side when selections change (faceted mode)** + +Replace the single `listRFCs(pid, cid)` call with an effect that, in faceted mode, passes the current selections + malformed toggle and stores both items and facets: + +```jsx + useEffect(() => { + setLoading(true) + const selObj = Object.fromEntries( + Object.entries(selections).map(([f, set]) => [f, [...set]]) + ) + listRFCs(pid, cid, { selections: selObj, malformed: malformedOnly }) + .then(d => { setRfcs(d.items); setFacets(d.facets || {}) }) + .catch(() => { setRfcs([]); setFacets({}) }) + .finally(() => setLoading(false)) + // proposals + caps load once per collection (unchanged), keep their own effect + }, [version, pid, cid, selections, malformedOnly]) +``` + +Keep the existing proposals + `getCollection` caps fetch in its own effect keyed on `[version, pid, cid]` (selections must not re-fetch caps/proposals). Move the `listProposals` + `getCollection` calls out of the selections-keyed effect. + +- [ ] **Step 3: Toggle + clear handlers** + +```jsx + function toggleFacet(field, value) { + setSelections(prev => { + const next = { ...prev } + const set = new Set(next[field] || []) + set.has(value) ? set.delete(value) : set.add(value) + if (set.size === 0) delete next[field] + else next[field] = set + return next + }) + } + function clearFilters() { setSelections({}); setMalformedOnly(false) } + const hasActiveFilters = Object.keys(selections).length > 0 || malformedOnly +``` + +- [ ] **Step 4: Render the faceted pane vs legacy chips** + +In the render, replace the `catalog-chips` block with a conditional: + +```jsx + {faceted ? ( + setMalformedOnly(m => !m)} + onClear={clearFilters} + hasActiveFilters={hasActiveFilters} + /> + ) : ( +
+ {STATE_CHIPS.map(chip => ( + + ))} +
+ )} +``` + +Update the client-side `filtered` memo so the state-chip narrowing applies **only in legacy mode** (in faceted mode the server already filtered by state): + +```jsx + const filtered = useMemo(() => { + const needle = search.trim().toLowerCase() + let items = rfcs.filter(r => { + if (!faceted && activeChips.size > 0 && !activeChips.has(r.state)) return false + if (!needle) return true + const hay = [r.title, r.slug, r.id || ''].join(' ').toLowerCase() + return hay.includes(needle) + }) + // …existing sort unchanged… + return items + }, [rfcs, search, sort, activeChips, faceted]) +``` + +- [ ] **Step 5: Empty / loading / malformed-marker states (§5.1)** + +In the list area, when `faceted && rfcs.length === 0` and filters are active, show "No entries match" + a clear-filters affordance; show a loading hint while `loading`. Add a small "malformed" marker on a row when `r.metadata_malformed`: + +```jsx + {r.metadata_malformed && ( + ⚠ malformed + )} +``` + +Import `FacetGroups` at the top: `import FacetGroups from './FacetGroups.jsx'`. + +- [ ] **Step 6: Facet styles** + +Add minimal styles to `frontend/src/App.css` (match the existing `.catalog-*` look — borrow padding/colors from `.catalog-chips` / `.catalog-row`): + +```css +.facets { padding: 8px 14px; border-bottom: 1px solid #eee; } +.facet-group { margin-bottom: 8px; } +.facet-header { background: none; border: 0; font-weight: 600; cursor: pointer; padding: 4px 0; width: 100%; text-align: left; } +.facet-values { display: flex; flex-direction: column; gap: 2px; } +.facet-value { display: flex; align-items: center; gap: 6px; font-size: 13px; } +.facet-value-label { flex: 1; } +.facet-count { color: #999; font-variant-numeric: tabular-nums; } +.facet-value-search { width: 100%; margin: 4px 0; font-size: 12px; } +.facet-clear { background: none; border: 0; color: #06c; cursor: pointer; font-size: 12px; padding: 0 0 6px; } +.facet-empty { color: #aaa; font-size: 12px; } +.facet-malformed { display: flex; align-items: center; gap: 6px; font-size: 12px; margin-top: 6px; color: #a60; } +.row-malformed { color: #a60; font-size: 11px; } +``` + +- [ ] **Step 7: Manual sanity check (build)** + +Run: `cd frontend && npm run build` +Expected: build succeeds (no JSX/import errors). + +- [ ] **Step 8: Commit** + +```bash +git add frontend/src/components/Catalog.jsx frontend/src/App.css +git commit -m "feat(slice3): faceted catalog pane gated on collection fields (§22.4a §5.1)" +``` + +--- + +## Task 9: Frontend component test for the faceted pane + +**Files:** +- Create: `frontend/src/components/Catalog.test.jsx` + +- [ ] **Step 1: Write the Vitest component test (PUC-3 behaviour)** + +Mock `../api` so `getCollection` returns a `fields` schema and `listRFCs` returns `{items, facets}`. Follow the `Directory.test.jsx` mocking pattern (mock `../api`, wrap in `MemoryRouter`). Assert: +- facet groups render with per-value counts (e.g. "P0" with its count badge); +- a `tags`-type field with >8 values shows the "filter values…" box and it narrows displayed values; +- clicking a checkbox calls `listRFCs` again with the selection in `opts.selections`; +- a collection with no `fields` renders the legacy state chips (Super-draft / Active), not facet groups. + +```jsx +import React from 'react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' + +const listRFCs = vi.fn() +const getCollection = vi.fn() +vi.mock('../api', () => ({ + listRFCs: (...a) => listRFCs(...a), + listProposals: () => Promise.resolve({ items: [] }), + getCollection: (...a) => getCollection(...a), +})) +vi.mock('../lib/entryPaths', () => ({ + entryPath: () => '/x', proposalPath: () => '/p', + useProjectId: () => 'ohm', useCollectionId: () => 'default', +})) +import Catalog from './Catalog.jsx' + +const renderCatalog = () => + render( {}} version={0} />) + +describe('Catalog faceted pane', () => { + beforeEach(() => { listRFCs.mockReset(); getCollection.mockReset() }) + + it('renders facet groups with counts when the collection declares fields', async () => { + getCollection.mockResolvedValue({ + fields: { priority: { type: 'enum', values: ['P0', 'P1'] } }, + viewer: { can_contribute: false }, entry_noun: 'RFC', + }) + listRFCs.mockResolvedValue({ + items: [{ slug: 'a', title: 'A', state: 'active', tags: [], starred_by_me: false }], + facets: { priority: { P0: 3, P1: 1 }, state: { active: 4 } }, + }) + renderCatalog() + expect(await screen.findByText('P0')).toBeInTheDocument() + expect(screen.getByText('3')).toBeInTheDocument() + }) + + it('re-fetches with the selection when a facet checkbox is toggled', async () => { + getCollection.mockResolvedValue({ + fields: { priority: { type: 'enum', values: ['P0'] } }, + viewer: {}, entry_noun: 'RFC', + }) + listRFCs.mockResolvedValue({ items: [], facets: { priority: { P0: 2 }, state: {} } }) + renderCatalog() + const box = await screen.findByText('P0') + fireEvent.click(box.closest('label').querySelector('input')) + await waitFor(() => { + const last = listRFCs.mock.calls.at(-1) + expect(last[2].selections.priority).toContain('P0') + }) + }) + + it('renders legacy state chips when the collection declares no fields', async () => { + getCollection.mockResolvedValue({ fields: null, viewer: {}, entry_noun: 'RFC' }) + listRFCs.mockResolvedValue({ items: [], facets: {} }) + renderCatalog() + expect(await screen.findByText('Super-draft')).toBeInTheDocument() + expect(screen.getByText('Active')).toBeInTheDocument() + }) +}) +``` + +- [ ] **Step 2: Run the component test** + +Run: `cd frontend && npx vitest run src/components/Catalog.test.jsx` +Expected: PASS (3 tests). Adjust selectors to the exact markup produced in Task 7/8 if a query misses. + +- [ ] **Step 3: Run the full frontend suite** + +Run: `cd frontend && npm run test:run` +Expected: PASS (no regressions). + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/components/Catalog.test.jsx +git commit -m "test(slice3): Catalog faceted-pane component test (§22.4a PUC-3)" +``` + +--- + +## Task 10: Version bump, changelog, and ship + +**Files:** +- Modify: `VERSION`, `frontend/package.json` (`version`), `CHANGELOG.md` +- Optional doc touch: `SPEC.md` §7.1 forward-pointer (review only) + +- [ ] **Step 1: Bump the version (minor — new, additive, opt-in functionality)** + +Set `VERSION` to `0.49.0` and mirror `frontend/package.json#version` to `0.49.0` (the §20 mirror rule). + +- [ ] **Step 2: Add the changelog entry with upgrade steps** + +Prepend a `## 0.49.0 — 2026-06-07` section to `CHANGELOG.md` describing SLICE-3: the faceted left pane for collections that declare a `fields:` schema, the list endpoint's `facets` + filter params + `meta` per item + 400-on-unknown-field, and migration 034. Non-breaking and opt-in (INV-5: a no-`fields:` collection — the default `document` — is unchanged, so N=1 sees nothing new). Upgrade steps: + +```markdown +**Upgrade steps** + +- Migration 034 (`cached_rfcs.meta_json`) applies automatically on startup + (additive, nullable). It is populated lazily as the corpus reconciler / + content-repo webhooks re-ingest each collection; until an entry is re-ingested + its `meta_json` is NULL and it contributes no facet values. Operators wanting + facets populated immediately MAY trigger a corpus refresh (the reconciler sweep + on next startup does this). **No data migration is required.** +- A deployment opts a collection into faceting by declaring an + `enum`/`tags` `fields:` block in its `.collection.yaml` (SLICE-2). A collection + with no `fields:` keeps the existing state-chip catalog unchanged. +``` + +- [ ] **Step 3: Review `SPEC.md` §7.1 (left-pane filter)** + +Read `SPEC.md` §7.1. If it describes only the chip filter, add a one-line forward-pointer noting that a collection with a `fields:` schema replaces the chips with faceted groups (§22.4a / the configurable-collection-metadata design). If §7.1 is already general, leave it. Keep this minimal — the binding contract for the slice is the design doc; no §22.4a contract change is needed (SLICE-0 already amended it). + +- [ ] **Step 4: Final full test run (both suites)** + +Run: +```bash +cd backend && python -m pytest -q +cd ../frontend && npm run test:run && npm run build +``` +Expected: both green; frontend builds. + +- [ ] **Step 5: Commit and open the PR** + +```bash +git add VERSION frontend/package.json CHANGELOG.md SPEC.md +git commit -m "release(slice3): v0.49.0 — faceted left-pane filtering (§22.4a SLICE-3)" +``` + +Then push the branch and open a PR on `origin` (git.wiggleverse.org, Gitea) titled `v0.49.0 — SLICE-3: faceted left-pane filtering (§22.4a)`, body summarising the slice + linking the design doc. Per the autonomous default, merge once green (the framework's canonical PR flow is Gitea on `origin`). + +--- + +## Self-review checklist (completed by plan author) + +- **Spec coverage:** §6.4 list interface → Tasks 4–5 (facets, filter params, 400, `meta`). §5.1 left pane (groups, counts, tag-value search, states, malformed marker) → Tasks 7–8. §6.3 derived index (per-entry values) → Tasks 1–2. PUC-3 → Tasks 5 + 9. INV-5 (no-fields unchanged) → decision 1 + Tasks 4/8 + tests `test_no_schema_no_facets` / legacy-chips test. INV-3 (malformed never hidden, marker) → Task 8 step 5 + `test_malformed_toggle`. INV-7 (unknown keys preserved) → already held by SLICE-1; `meta_json` stores the full mapping so they survive. SLICE-3 DoD (facet counts + filter params incl. malformed; groups + counts + tag-value search; filters compose) → all covered. +- **Deferred (logged in the transcript):** Playwright E2E (no faceted-collection seeding fixture; §9 references rather than automates E2E) — PUC-3 covered by API integration + Vitest component tests. `text`-type fields not faceted in v1. +- **Placeholder scan:** none — every code step shows the code; the two adapt-to-existing notes (`api.js` URL builder in Task 6, `_pid()` attribute in Task 5) point at concrete existing call sites to copy. +- **Type/name consistency:** `meta_json` column, `facets.facet_fields` / `allowed_filter_keys` / `filter_and_count`, `listRFCs(pid, cid, {selections, malformed})`, `FacetGroups` props (`facets, fields, selections, onToggle, malformedOnly, onToggleMalformed, onClear, hasActiveFilters`) are used identically across backend, API, and frontend tasks. diff --git a/frontend/package.json b/frontend/package.json index c877233..42127af 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "rfc-app-frontend", "private": true, - "version": "0.48.0", + "version": "0.49.0", "type": "module", "scripts": { "dev": "vite",