Closes#10.
Backend (common/criteria.py):
- Treat `excludes` as an always-orthogonal AND'd negative across both
MultiCriterion and ChoiceCriterion; the modifier now governs only the
`value` (include) set. This removes the prior divergence where
MultiCriterion.EXCLUDES dropped the excludes list and ChoiceCriterion.EXCLUDES
swapped include/exclude into a positive.
- Fold INCLUDES / INCLUDES_ALL / EXCLUDES (+ EQUALS/NOT_EQUALS aliases) into the
shared _SetCriterion base so the two subclasses cannot drift; remove _extra_q.
M2M "has all" (games/filters.py):
- PurchaseFilter._games_to_q builds a pk__in subquery with one join per value so
INCLUDES_ALL on the many-to-many games field works in a single .filter()
(a naive Q(games=a) & Q(games=b) collapses to one join and matches nothing).
UI (FilterSelect + filter_bar.js):
- Add an optional any/all/none match-mode <select> (INCLUDES/INCLUDES_ALL/
EXCLUDES) rendered before the pills via a new `leading` slot on the shared
combobox shell. A native control so its value is its state. readSearchSelect
serialises it to data-match; filter_bar folds it into the criterion modifier.
Orthogonal to the (Any)/(None) presence pseudo-options and the exclude channel.
- Enable it for the M2M Purchase.games field (INCLUDES_ALL is only meaningful
for multi-valued relations). Styled with already-compiled utilities.
Tests: harmonized EXCLUDES + INCLUDES_ALL for both criterion types, a DB-backed
INCLUDES_ALL vs INCLUDES contrast on Purchase.games, and FilterSelect /
PurchaseFilterBar rendering + round-trip of the match mode.
https://claude.ai/code/session_01KwVrGFbq13mZdhDL9G6zhg
- Move {id,label} stripping into _SetCriterion.from_json() so both
MultiCriterion and ChoiceCriterion normalise at the parse boundary;
the querying layer stays typed (list[int] / list[str]) and clean.
- Revert MultiCriterion to a thin _extra_q() override; _SetCriterion.to_q()
is no longer duplicated.
- JS: readSearchSelect always emits {id, label} objects — no conditional
mixed-type arrays. filter_bar.js stores them as-is for all fields,
removing the fragile isIdField hardcoded list.
- Update tests to use the {id, label} filter format.
https://claude.ai/code/session_01EyAJcMoDktLrY9tSbdHViA
Store {id, label} objects instead of bare IDs in MultiCriterion value/excludes.
FilterSelect pills now render directly from the embedded labels — no DB round-trip
to _resolve_game/device/platform_options. The filter URL and saved presets are
self-describing. MultiCriterion.to_q() extracts ids for querying; bare ints are
still accepted for backward compatibility.
Closes#9https://claude.ai/code/session_01EyAJcMoDktLrY9tSbdHViA
MultiCriterion and ChoiceCriterion were near-duplicate copies whose INCLUDES
branches had drifted — the exclude-only bug existed in one but not the other.
Extract the shared include/exclude/null set-membership logic into a _SetCriterion
base implemented once (INCLUDES with empty-list guards, EQUALS as an alias,
IS_NULL/NOT_NULL); subclasses contribute only their value type and their own
modifiers via _extra_q (INCLUDES_ALL for Multi; EXCLUDES/NOT_EQUALS for Choice).
Behaviour preserved (full modifier vocabulary kept); the duplication that caused
the drift is gone. Surfacing the modifier axis and harmonizing EXCLUDES is
tracked in #10.
https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
MultiCriterion.to_q (used by SessionFilter for game/device) unconditionally added
field__in=value even when value was empty, and __in=[] matches no rows — so a
filter with only excludes (e.g. device excludes 11, no game/device includes)
returned zero results. Guard the empty value like ChoiceCriterion already does,
so an exclude-only criterion means 'all rows except the excluded ids'.
https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
FilterSelect fully replaces it: delete SelectableFilter and its _selectable_*
helpers, the now-unused _get_filter_options, selectable_filter.js, and the .sf-*
rules in input.css (rebuilt base.css). The three list views load search_select.js
instead of selectable_filter.js. Drop the SelectableFilter export and refresh
docs/comments that referenced it.
https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS