Looks up the open PR for the pushed branch via the Gitea API using the
workflow token and posts the staging URL once (skips if the same
comment already exists).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The e2e tests launch chromium, but uv sync only installs the playwright
package, not the browser binaries, so CI failed with "Executable
doesn't exist" for the headless shell.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Branch pushes deploy a per-branch staging instance at
tracker-<slug>.home.arpa via the NAS act_runner; branch deletion tears
it down. build.yml ports the GitHub CI workflow so prod image builds
keep running on Gitea now that .gitea/workflows/ exists (Gitea ignores
.github/workflows/ when it does).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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
- Remove _filter_number() — defined but never called; take _FILTER_INPUT_CLASS
with it since it was only used there.
- Remove the isinstance(value/excluded, str) single-string guards in
_filter_get_choice — JS always emits arrays, this was backward-compat
dead code.
- Remove identity-copy list comprehensions in PurchaseFilterBar; pass
Purchase.TYPES and Purchase.OWNERSHIP_TYPES directly.
- Fix stale section comment that said model fields "resolve selected ids
to labels" — they now use labels embedded in the filter JSON.
- Drop the now-unused escape import.
https://claude.ai/code/session_01EyAJcMoDktLrY9tSbdHViA
The JS always emits {id, label} objects now; the else branch was dead code
and the docstring was wrong. Update the remaining test that was still
passing bare strings.
https://claude.ai/code/session_01EyAJcMoDktLrY9tSbdHViA
Replace all tuple[str, str] annotations with purpose-specific names:
- LabeledOption = tuple[str, str] for (value, label) pairs used in
FilterChoice, FilterSelect params, _modifier_options, _find_label,
and _extract_labeled.
- RangeValues(min, max) NamedTuple for _parse_range return values,
making the two fields self-documenting at every call site.
Export LabeledOption from common.components alongside SearchSelectOption.
Document the "name compound types explicitly" convention in CLAUDE.md.
https://claude.ai/code/session_01EyAJcMoDktLrY9tSbdHViA
- 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