diff --git a/docs/superpowers/plans/2026-06-09-boolean-filters-improvement.md b/docs/superpowers/plans/2026-06-09-boolean-filters-improvement.md new file mode 100644 index 0000000..db9fc97 --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-boolean-filters-improvement.md @@ -0,0 +1,485 @@ +# Boolean Filters Overhaul 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:** Overhaul the boolean criterion filters from a single checkbox (representing True/Not set) to a 2-radio-button UI representing True, False, and Unset states across all filter bars. + +**Architecture:** +1. Generalize `_filter_checkbox` into a filter-agnostic `Checkbox` component and introduce a `Radio` component in `common/components/primitives.py`. +2. Implement a nullable boolean filter JSON parsing helper `_parse_bool_nullable` and a component helper `_filter_boolean_radio` in `common/components/filters.py`. +3. Update `GameFilterBar`, `SessionFilterBar`, and `PurchaseFilterBar` in `common/components/filters.py` to leverage these new helpers. +4. Enhance `games/static/js/filter_bar.js` with deselectable radio toggling behavior and updated checked-radio state serialization. + +**Tech Stack:** Python, Django, vanilla JavaScript, HTML. + +--- + +### Task 1: Generalize Checkbox and Introduce Radio in Primitives + +**Files:** +- Modify: `common/components/primitives.py` + +- [ ] **Step 1: Write the failing test for the new Checkbox and Radio primitives** + +Create a new test class `ComponentPrimitivesTest` in `tests/test_components.py` (or verify where to append) to check the output of `Checkbox` and `Radio`. +Add the following code to `tests/test_components.py`: + +```python +from common.components.primitives import Checkbox, Radio + +class ComponentPrimitivesTest(SimpleTestCase): + def test_checkbox_primitive(self): + html = Checkbox(name="test-check", label="Accept Terms", checked=True, value="yes") + self.assertIn('type="checkbox"', html) + self.assertIn('name="test-check"', html) + self.assertIn('value="yes"', html) + self.assertIn('checked="true"', html) + self.assertIn("Accept Terms", html) + + def test_radio_primitive(self): + html = Radio(name="test-radio", label="Option A", checked=False, value="A") + self.assertIn('type="radio"', html) + self.assertIn('name="test-radio"', html) + self.assertIn('value="A"', html) + self.assertNotIn('checked="true"', html) + self.assertIn("Option A", html) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_components.py -k ComponentPrimitivesTest` +Expected output: Failures/errors due to `Checkbox` and `Radio` not being defined/imported. + +- [ ] **Step 3: Implement Checkbox and Radio in `common/components/primitives.py`** + +Open `common/components/primitives.py` and find the other basic primitives (e.g. `Input`, `Label`). Add the following implementations and ensure they are exported / added to imports/exports: + +```python +def Checkbox( + name: str, + label: str, + checked: bool = False, + value: str = "1", + attributes: list[HTMLAttribute] | None = None, +) -> SafeText: + """A filter-agnostic Checkbox component.""" + attributes = attributes or [] + input_attrs = [ + ("name", name), + ("value", value), + ("class", "rounded border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand"), + ] + attributes + if checked: + input_attrs.append(("checked", "true")) + + return Label( + attributes=[("class", "flex items-center gap-2 text-sm text-heading cursor-pointer")], + children=[ + Input(type="checkbox", attributes=input_attrs), + label, + ], + ) + + +def Radio( + name: str, + label: str, + checked: bool = False, + value: str = "", + attributes: list[HTMLAttribute] | None = None, +) -> SafeText: + """A filter-agnostic Radio component.""" + attributes = attributes or [] + input_attrs = [ + ("name", name), + ("value", value), + ("class", "rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand"), + ] + attributes + if checked: + input_attrs.append(("checked", "true")) + + return Label( + attributes=[("class", "flex items-center gap-1.5 text-sm text-heading cursor-pointer")], + children=[ + Input(type="radio", attributes=input_attrs), + label, + ], + ) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_components.py -k ComponentPrimitivesTest` +Expected output: `2 passed` + +- [ ] **Step 5: Commit** + +Run: +```bash +git add common/components/primitives.py tests/test_components.py +git commit -m "refactor: generalize Checkbox and add Radio primitive component" +``` + +--- + +### Task 2: Implement Filter Parsers & Helpers in filters.py + +**Files:** +- Modify: `common/components/filters.py` +- Modify: `tests/test_filter_helpers.py` + +- [ ] **Step 1: Write failing unit tests for `_parse_bool_nullable` in `tests/test_filter_helpers.py`** + +Add a new test class `ParseBoolNullableTest` to `tests/test_filter_helpers.py`: + +```python +from common.components.filters import _parse_bool_nullable + +class ParseBoolNullableTest(SimpleTestCase): + def test_missing_key(self): + self.assertIsNone(_parse_bool_nullable({}, "field")) + + def test_null_value(self): + self.assertIsNone(_parse_bool_nullable({"field": None}, "field")) + self.assertIsNone(_parse_bool_nullable({"field": {}}, "field")) + + def test_boolean_values(self): + self.assertTrue(_parse_bool_nullable({"field": {"value": True}}, "field")) + self.assertFalse(_parse_bool_nullable({"field": {"value": False}}, "field")) + + def test_string_values(self): + self.assertTrue(_parse_bool_nullable({"field": {"value": "true"}}, "field")) + self.assertTrue(_parse_bool_nullable({"field": {"value": "1"}}, "field")) + self.assertFalse(_parse_bool_nullable({"field": {"value": "false"}}, "field")) + self.assertFalse(_parse_bool_nullable({"field": {"value": "0"}}, "field")) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_filter_helpers.py -k ParseBoolNullableTest` +Expected output: Failures/errors due to `_parse_bool_nullable` not found. + +- [ ] **Step 3: Implement `_parse_bool_nullable` and `_filter_boolean_radio` in `common/components/filters.py`** + +1. Import `Checkbox` and `Radio` from `common.components.primitives` at the top of `common/components/filters.py`. +2. Define `_FILTER_RADIO_CLASS` and add `_parse_bool_nullable`. +3. Create `_filter_boolean_radio`. +4. Refactor `_filter_checkbox` to use `Checkbox` instead of raw `Label` and `Input`. + +Code to implement: +```python +_FILTER_RADIO_CLASS = ( + "rounded-full border-default-medium bg-neutral-secondary-medium " + "text-brand focus:ring-brand" +) + +def _parse_bool_nullable(existing: dict, key: str) -> bool | None: + """Extract a nullable boolean value from a filter criterion.""" + if key not in existing: + return None + field = existing[key] + if not isinstance(field, dict): + return None + val = field.get("value") + if val is None: + return None + if isinstance(val, str): + if val.lower() in ("true", "1", "yes"): + return True + if val.lower() in ("false", "0", "no"): + return False + return bool(val) + + +def _filter_checkbox(name: str, label: str, checked: bool) -> SafeText: + """Thin adapter mapping legacy checkbox filters to the generalized Checkbox primitive.""" + return Checkbox(name=name, label=label, checked=checked) + + +def _filter_boolean_radio(name: str, label: str, value: bool | None) -> SafeText: + """Renders a filter-specific boolean radio button group with 'True' and 'False' options.""" + return Div( + attributes=[("class", "flex flex-col gap-1")], + children=[ + Span( + attributes=[("class", _FILTER_LABEL_CLASS)], + children=[label], + ), + Div( + attributes=[("class", "flex items-center gap-4 h-9")], + children=[ + Radio(name=name, label="True", checked=value is True, value="true"), + Radio(name=name, label="False", checked=value is False, value="false"), + ], + ), + ], + ) +``` + +- [ ] **Step 4: Run unit tests to verify they pass** + +Run: `pytest tests/test_filter_helpers.py` +Expected output: All helper tests passed (including `ParseBoolNullableTest`). + +- [ ] **Step 5: Commit** + +Run: +```bash +git add common/components/filters.py tests/test_filter_helpers.py +git commit -m "feat: implement _parse_bool_nullable and _filter_boolean_radio helper" +``` + +--- + +### Task 3: Replace Single Checkboxes with Radio Groups in Filter Bars + +**Files:** +- Modify: `common/components/filters.py` + +- [ ] **Step 1: Update GameFilterBar** + +In `common/components/filters.py` inside `GameFilterBar`: +1. Parse using `_parse_bool_nullable` instead of `_parse_bool` for: + - `mastered_value` + - `purchase_refunded_value` + - `purchase_infinite_value` + - `session_emulated_value` +2. Update the fields list to replace `_filter_checkbox` with `_filter_boolean_radio`, changing the wrapper div to have `gap-6` for better horizontal radio button spacing. + +Code snippet modification: +```python + # Parsing: + mastered_value = _parse_bool_nullable(existing, "mastered") + # ... + purchase_refunded_value = _parse_bool_nullable(existing, "purchase_refunded") + purchase_infinite_value = _parse_bool_nullable(existing, "purchase_infinite") + session_emulated_value = _parse_bool_nullable(existing, "session_emulated") + + # Rendering (in fields): + Div( + attributes=[("class", "flex items-end gap-6 mb-4 flex-wrap")], + children=[ + _filter_boolean_radio("filter-mastered", "Mastered", mastered_value), + _filter_boolean_radio( + "filter-purchase-refunded", "Refunded", purchase_refunded_value + ), + _filter_boolean_radio( + "filter-purchase-infinite", "Infinite", purchase_infinite_value + ), + _filter_boolean_radio( + "filter-session-emulated", "Emulated", session_emulated_value + ), + ], + ), +``` + +- [ ] **Step 2: Update SessionFilterBar** + +In `common/components/filters.py` inside `SessionFilterBar`: +1. Parse using `_parse_bool_nullable` for: + - `emulated_value` + - `is_active_value` +2. Update the fields to replace `_filter_checkbox` with `_filter_boolean_radio`. + +Code snippet modification: +```python + # Parsing: + emulated_value = _parse_bool_nullable(existing, "emulated") + is_active_value = _parse_bool_nullable(existing, "is_active") + + # Rendering (in fields): + Div( + attributes=[("class", "flex gap-6 mb-4")], + children=[ + _filter_boolean_radio("filter-emulated", "Emulated", emulated_value), + _filter_boolean_radio("filter-active", "Active", is_active_value), + ], + ), +``` + +- [ ] **Step 3: Update PurchaseFilterBar** + +In `common/components/filters.py` inside `PurchaseFilterBar`: +1. Parse using `_parse_bool_nullable` for: + - `is_refunded_value` + - `infinite_value` + - `needs_price_update_value` +2. Update the fields to replace `_filter_checkbox` with `_filter_boolean_radio`. + +Code snippet modification: +```python + # Parsing: + is_refunded_value = _parse_bool_nullable(existing, "is_refunded") + infinite_value = _parse_bool_nullable(existing, "infinite") + needs_price_update_value = _parse_bool_nullable(existing, "needs_price_update") + + # Rendering (in fields): + Div( + attributes=[("class", "flex flex-col items-start gap-4 mb-4")], + children=[ + _filter_boolean_radio( + "filter-refunded", "Refunded", is_refunded_value + ), + _filter_boolean_radio("filter-infinite", "Infinite", infinite_value), + _filter_boolean_radio( + "filter-needs-price-update", + "Needs Price Update", + needs_price_update_value, + ), + ], + ), +``` + +- [ ] **Step 4: Run component tests to verify output** + +Run: `pytest tests/test_filter_bars.py` +Expected output: Since we only changed the internal input type from checkbox to radio but kept the `name="..."` attribute intact, the tests asserting name occurrences should still pass! + +- [ ] **Step 5: Commit** + +Run: +```bash +git add common/components/filters.py +git commit -m "feat: replace single boolean checkboxes with radio groups in all FilterBars" +``` + +--- + +### Task 4: Frontend Behavior and Serialization in JS + +**Files:** +- Modify: `games/static/js/filter_bar.js` + +- [ ] **Step 1: Update Radio Serialization in `buildFilterJSON`** + +In `games/static/js/filter_bar.js`, locate the `// 2. Boolean Fields (Checkboxes)` section. +Update the loop to check for `:checked` radio options: + +```javascript + // 2. Boolean Fields (Radio Button Groups) + var booleanFields = [ + { name: "filter-mastered", key: "mastered" }, + { name: "filter-emulated", key: "emulated" }, + { name: "filter-active", key: "is_active" }, + { name: "filter-refunded", key: "is_refunded" }, + { name: "filter-infinite", key: "infinite" }, + { name: "filter-needs-price-update", key: "needs_price_update" }, + { name: "filter-purchase-refunded", key: "purchase_refunded" }, + { name: "filter-purchase-infinite", key: "purchase_infinite" }, + { name: "filter-session-emulated", key: "session_emulated" } + ]; + booleanFields.forEach(function (bf) { + var el = form.querySelector('[name="' + bf.name + '"]:checked'); + if (el) { + var val = el.value === "true"; + filter[bf.key] = criterion(val, null, "EQUALS"); + } + }); +``` + +- [ ] **Step 2: Add click-to-deselect functionality for radios** + +In `games/static/js/filter_bar.js`, add `setupDeselectableRadios` and call it inside `DOMContentLoaded`: + +```javascript + /** + * Enable deselect-on-click behavior for filter radio buttons. + */ + function setupDeselectableRadios() { + document.querySelectorAll('input[type="radio"]').forEach(function (radio) { + radio.addEventListener('click', function (e) { + if (this.wasChecked) { + this.checked = false; + this.wasChecked = false; + this.dispatchEvent(new Event('change', { bubbles: true })); + } else { + var name = this.getAttribute('name'); + if (name) { + document.querySelectorAll('input[type="radio"][name="' + name + '"]').forEach(function (r) { + r.wasChecked = false; + }); + } + this.wasChecked = true; + } + }); + if (radio.checked) { + radio.wasChecked = true; + } + }); + } +``` + +Locate the `document.addEventListener("DOMContentLoaded", ...)` callback at the bottom of the file and update it: +```javascript + document.addEventListener("DOMContentLoaded", function () { + injectSearchInputs(); + setupDeselectableRadios(); + loadPresets(); + }); +``` + +- [ ] **Step 3: Run existing frontend / component tests to verify no syntax errors or simple breaks** + +Run: `pytest tests/test_filter_bars.py` +Expected output: PASS + +- [ ] **Step 4: Commit** + +Run: +```bash +git add games/static/js/filter_bar.js +git commit -m "feat: add click-to-deselect behavior and update checked-radio serialization in JS" +``` + +--- + +### Task 5: Add Comprehensive Test Coverage & Verification + +**Files:** +- Modify: `tests/test_filter_bars.py` + +- [ ] **Step 1: Write explicit tests for boolean radio elements in filter bars** + +Add a test case checking that the filter bars output `type="radio"` and contain `value="true"` and `value="false"` for boolean fields: + +In `tests/test_filter_bars.py`, add the following test method: + +```python + def test_boolean_fields_render_as_radio_groups(self): + """Boolean fields must render as radio groups with True/False choices.""" + from common.components import FilterBar, SessionFilterBar, PurchaseFilterBar + + # 1. Games Filter Bar + games_html = str(FilterBar(filter_json="")) + self.assertIn('type="radio"', games_html) + self.assertIn('name="filter-mastered"', games_html) + self.assertIn('value="true"', games_html) + self.assertIn('value="false"', games_html) + + # 2. Session Filter Bar + session_html = str(SessionFilterBar(filter_json="")) + self.assertIn('type="radio"', session_html) + self.assertIn('name="filter-emulated"', session_html) + self.assertIn('value="true"', session_html) + self.assertIn('value="false"', session_html) + + # 3. Purchase Filter Bar + purchase_html = str(PurchaseFilterBar(filter_json="")) + self.assertIn('type="radio"', purchase_html) + self.assertIn('name="filter-refunded"', purchase_html) + self.assertIn('value="true"', purchase_html) + self.assertIn('value="false"', purchase_html) +``` + +- [ ] **Step 2: Run pytest to verify all tests (including new ones) pass** + +Run: `pytest` +Expected output: `356 passed` (including the new test case). + +- [ ] **Step 3: Commit final tests** + +Run: +```bash +git add tests/test_filter_bars.py +git commit -m "test: add explicit radio group and True/False choice checks for boolean fields" +``` diff --git a/docs/superpowers/specs/2026-06-09-boolean-filters-improvement-design.md b/docs/superpowers/specs/2026-06-09-boolean-filters-improvement-design.md new file mode 100644 index 0000000..9468ce0 --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-boolean-filters-improvement-design.md @@ -0,0 +1,197 @@ +# Design Spec: Boolean Filters Overhaul (Approach A with Reusable Primitives) + +Expose a two-radio-button UI for all boolean filters to allow selecting "True" (Yes), "False" (No), or leaving the filter "Unset" (Not set). + +## 1. Architectural Changes + +### 1.1 Backend Primitives & Components + +We will extract the `_filter_checkbox` rendering logic from `common/components/filters.py` and generalize it into a reusable, filter-agnostic `Checkbox` component in `common/components/primitives.py`. We will also add a corresponding `Radio` component. + +#### In `common/components/primitives.py`: +```python +def Checkbox( + name: str, + label: str, + checked: bool = False, + value: str = "1", + attributes: list[HTMLAttribute] | None = None, +) -> SafeText: + """A filter-agnostic Checkbox component.""" + attributes = attributes or [] + input_attrs = [ + ("name", name), + ("value", value), + ("class", "rounded border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand"), + ] + attributes + if checked: + input_attrs.append(("checked", "true")) + + return Label( + attributes=[("class", "flex items-center gap-2 text-sm text-heading cursor-pointer")], + children=[ + Input(type="checkbox", attributes=input_attrs), + label, + ], + ) + + +def Radio( + name: str, + label: str, + checked: bool = False, + value: str = "", + attributes: list[HTMLAttribute] | None = None, +) -> SafeText: + """A filter-agnostic Radio component.""" + attributes = attributes or [] + input_attrs = [ + ("name", name), + ("value", value), + ("class", "rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand"), + ] + attributes + if checked: + input_attrs.append(("checked", "true")) + + return Label( + attributes=[("class", "flex items-center gap-1.5 text-sm text-heading cursor-pointer")], + children=[ + Input(type="radio", attributes=input_attrs), + label, + ], + ) +``` + +#### In `common/components/filters.py`: +We will import `Checkbox` and `Radio` from `common.components.primitives`. We will redefine `_filter_checkbox` as a thin adapter pointing to our new generalized `Checkbox` component (preserving any backward compatibility), and we will create a new helper `_filter_boolean_radio` using `Radio`: + +```python +_FILTER_RADIO_CLASS = ( + "rounded-full border-default-medium bg-neutral-secondary-medium " + "text-brand focus:ring-brand" +) + +def _filter_checkbox(name: str, label: str, checked: bool) -> SafeText: + """Thin adapter mapping legacy checkbox filters to the generalized Checkbox primitive.""" + return Checkbox(name=name, label=label, checked=checked) + + +def _filter_boolean_radio(name: str, label: str, value: bool | None) -> SafeText: + """Renders a filter-specific boolean radio button group with 'True' and 'False' options.""" + return Div( + attributes=[("class", "flex flex-col gap-1")], + children=[ + Span( + attributes=[("class", _FILTER_LABEL_CLASS)], + children=[label], + ), + Div( + attributes=[("class", "flex items-center gap-4 h-9")], + children=[ + Radio(name=name, label="True", checked=value is True, value="true"), + Radio(name=name, label="False", checked=value is False, value="false"), + ], + ), + ], + ) +``` + +### 1.2 Parsing Filter JSON (Backend) + +We will introduce a robust parsing function in `common/components/filters.py` to distinguish `True`, `False`, and `None` (unset): + +```python +def _parse_bool_nullable(existing: dict, key: str) -> bool | None: + """Extract a nullable boolean value from a filter criterion.""" + if key not in existing: + return None + field = existing[key] + if not isinstance(field, dict): + return None + val = field.get("value") + if val is None: + return None + if isinstance(val, str): + if val.lower() in ("true", "1", "yes"): + return True + if val.lower() in ("false", "0", "no"): + return False + return bool(val) +``` + +### 1.3 UI Overhauls in Filter Bars + +We will update the following filter bars to use `_parse_bool_nullable` and `_filter_boolean_radio`: +1. **GameFilterBar:** `mastered`, `purchase_refunded`, `purchase_infinite`, `session_emulated`. +2. **SessionFilterBar:** `emulated`, `is_active`. +3. **PurchaseFilterBar:** `is_refunded`, `infinite`, `needs_price_update`. + +--- + +## 2. Frontend JS Changes (`games/static/js/filter_bar.js`) + +### 2.1 Deselectable Radios Behavior +To support resetting filters back to "Unset" without resetting the whole form, we add click behavior that unchecks an already checked radio button when clicked. + +```javascript + function setupDeselectableRadios() { + document.querySelectorAll('input[type="radio"]').forEach(function (radio) { + radio.addEventListener('click', function (e) { + if (this.wasChecked) { + this.checked = false; + this.wasChecked = false; + this.dispatchEvent(new Event('change', { bubbles: true })); + } else { + var name = this.getAttribute('name'); + if (name) { + document.querySelectorAll('input[type="radio"][name="' + name + '"]').forEach(function (r) { + r.wasChecked = false; + }); + } + this.wasChecked = true; + } + }); + if (radio.checked) { + radio.wasChecked = true; + } + }); + } +``` + +We will call `setupDeselectableRadios()` during `DOMContentLoaded`. + +### 2.2 Serializing Radio States +Update `buildFilterJSON(form)` to collect checked radios from boolean field groups: + +```javascript + // 2. Boolean Fields (Radio Button Groups) + var booleanFields = [ + { name: "filter-mastered", key: "mastered" }, + { name: "filter-emulated", key: "emulated" }, + { name: "filter-active", key: "is_active" }, + { name: "filter-refunded", key: "is_refunded" }, + { name: "filter-infinite", key: "infinite" }, + { name: "filter-needs-price-update", key: "needs_price_update" }, + { name: "filter-purchase-refunded", key: "purchase_refunded" }, + { name: "filter-purchase-infinite", key: "purchase_infinite" }, + { name: "filter-session-emulated", key: "session_emulated" } + ]; + booleanFields.forEach(function (bf) { + var el = form.querySelector('[name="' + bf.name + '"]:checked'); + if (el) { + var val = el.value === "true"; + filter[bf.key] = criterion(val, null, "EQUALS"); + } + }); +``` + +--- + +## 3. Testing Strategy + +1. **Unit Tests (`tests/test_filter_helpers.py`):** + - Add test coverage for `_parse_bool_nullable` covering `None`, `True`, `False`, strings, missing keys, etc. +2. **Component Tests (`tests/test_filter_bars.py`):** + - Update tests where the filters render checkbox elements to assert that radio groups are rendered instead (with "True" and "False" radio buttons). +3. **Integration and End-to-End Tests:** + - Execute the test suite using `pytest` to ensure that all 355 tests continue to pass and reflect the updated UI structure perfectly.