From 4e2183d9a3fb6aafbe2c7470e767f71d8a03551d Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 23 Jul 2026 18:40:12 +0200 Subject: [PATCH] =?UTF-8?q?design(434,435,438):=20RuleBuilder=20bundle=20s?= =?UTF-8?q?pec=20=E2=80=94=20validation,=20relative-date=20operators,=20fa?= =?UTF-8?q?cet=20typeahead?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-07-23-rulebuilder-bundle-design.md | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-23-rulebuilder-bundle-design.md diff --git a/docs/superpowers/specs/2026-07-23-rulebuilder-bundle-design.md b/docs/superpowers/specs/2026-07-23-rulebuilder-bundle-design.md new file mode 100644 index 000000000..bc4ad1ca6 --- /dev/null +++ b/docs/superpowers/specs/2026-07-23-rulebuilder-bundle-design.md @@ -0,0 +1,125 @@ +# RuleBuilder bundle — design (#438 + #435 + #434) + +**Date:** 2026-07-23 +**Issues:** #438 (validation & polish), #435 (relative-date operators), #434 (facet-value typeahead) +**Branch:** `feat/rulebuilder-bundle` +**Left open:** #436 (deeper nesting — self-labeled YAGNI), #437 (inline RuleBuilder adoption) + +The visual rule builder (`web/src/builder/rules/`, landed #176 / PR #433) compiles a visual rule tree +to a Lucene query string and parses it back, with a lossless-round-trip contract. It has one consumer +today: `CollectionsScreen.tsx` (SmartCollection create/edit, mounted at line ~347). Convention doc: +`docs/spa-conventions.md` **§12** (not §11 — #437's citation is wrong and will be corrected when #437 +is worked). + +## Slicing & workflow + +- **One feature branch / one PR** closing all three (`fixes #438 #435 #434`). They share + `RuleBuilder.tsx` (and #435/#438 share `types.ts`/`compile.ts`/`parse.ts`) too heavily to split + without rebase churn, and read as one cohesive polish sweep. +- **#434's backend endpoint is the only disjoint slice** — delegated to a parallel worktree agent + (branched off the feature branch, merged back early) so its C# + OpenAPI regen runs while the + frontend is built. **No parallel agents over the shared frontend module** (the one-file fan-out + trap). +- Everything else is **sequential, single committing agent** in the main worktree: + **#438 → #435 → #434-combobox** (combobox last, after the endpoint's generated API types exist). +- Independent adversarial review over the whole diff before push (new API endpoint + >~150 lines). + Live-E2E on the SmartCollection screen for the round-trip. + +## #438 — validation & polish (frontend only) + +Confirmed current behavior (compile.ts): +- `between` with empty `value2` emits `field:[v TO ]` (malformed). — compile.ts:45 +- empty `contains` → `field:**`; empty `startsWith` → `field:*`. — compile.ts:33–36 +- single-child group drops its connective (`join` over one element). — compile.ts:52–57 +- `isNot`/`notMatches` → `NOT field:"v"`, which matches nothing alone (no MatchAllDocs). — compile.ts:31 + +Fixes: +1. **`between` requires both bounds.** A `between` rule with empty `value` or `value2` is *invalid* — + surface a per-rule validation error and block compile/save. Never emit `field:[v TO ]`. +2. **Empty text values are invalid.** `contains`/`startsWith`/`is`/`isNot` with an empty `value` are + incomplete (not a wildcard) — same per-rule validation error; block compile/save. Never emit + `field:*` or `field:**`. +3. **Single-child group round-trips its connective.** Fix on the **parser** side: a compiled + single-child group parses back to the builder's default `match` so a 1-child `{match:'any'}` no + longer reopens as `'all'`. Output (compile) is unchanged; this is a parse-side alignment only. +4. **Pure-negative top group → non-blocking warning.** When every child of the *top* group is negative + (`isNot`/`notMatches`), show a warning (a `NOT`-only query matches nothing) and rely on the live + preview count. **Do not block** — a positive branch nested deeper can legitimately rescue it, so a + hard block would create false negatives. (Decision confirmed with user 2026-07-23.) + +Validation surface: a per-rule "incomplete" flag rendered inline in `RuleBuilder.tsx`, and an +aggregate "has-errors" boolean the consumer (`CollectionsScreen`) uses to disable Save. Compile of an +invalid tree returns a sentinel (empty / null) rather than a malformed string. + +Tests: extend `compile.test.ts` / `parse.test.ts` / `roundtrip.test.ts` for each case (cases 1–3 are +hard assertions; case 4 asserts the warning predicate, not a block). + +## #435 — relative-date operators (frontend only — backend macros already exist) + +The Lucene macros exist in `ErsatzTV.Infrastructure/Search/CustomMultiFieldQueryParser.cs`: +`released_inthelast` / `released_notinthelast` / `added_inthelast` / `added_notinthelast`, value form +`" day|week|month|year"` (unit substring-matched; negated from `DateTime.Today`). **No C# change** — +they are absent from `SearchFieldCatalog` only because they are query-time synthetic fields. (There is +no `inthenext` macro, so "in the next N" is out of scope — matches the issue.) + +- **types.ts:** add operators `inLast` and `notInLast`, offered only on `date` fields (extend + `OPERATORS_BY_TYPE.date`). Add optional `unit?: 'day' | 'week' | 'month' | 'year'` to `Rule` (cleaner + than overloading `value2`, which is the `between` upper bound). `value` holds the integer N. +- **RuleBuilder.tsx:** for `inLast`/`notInLast`, render a number input + a unit `` for an autocomplete combobox (debounced query to the endpoint). Purely + additive — the emitted value and compile/parse are unchanged. Falls back to free-text when the + endpoint returns nothing (so a typo still compiles, keeping the preview-count safety net). + +## Docs updated in-PR + +| Doc | Change | +|---|---| +| `api-conventions.md` | checklist entry for the new `search/fields/{name}/values` endpoint | +| `v1.json` / `v1.d.ts` / `endpoint-index.md` | regenerated (#434 endpoint) | +| `spa-conventions.md` §12 | relative-date operators (#435) + typeahead combobox (#434) behavior | +| `docs/decisions.md` | record for the #434 distinct-values endpoint; record for the #435 relative-date field-mapping convention | +| `docs/blazor-route-parity.md` | not required (no route change) | + +## Out of scope / deferred + +- **#436** (deeper group nesting): `compile.ts` already recurses arbitrarily deep; the cap is solely + `parse.ts:121` passing `false`. Left open — self-labeled YAGNI until a real query needs it. +- **#437** (inline RuleBuilder adoption in ChannelBuilder + Auto-Tune): larger integration + live-E2E, + its own session. Note for then: cite `spa-conventions.md` **§12**, not §11. +- **`does not contain` / date `is`**: these operators do not exist in the set and are not being added + (only #435's `inLast`/`notInLast`).