Numeric range filters could only express BETWEEN/GREATER_THAN/LESS_THAN
via the RangeSlider widget — no way to match NULL/missing values (the
original ask in #32) or exact/not-between. The criteria backend already
supported all 8 numeric modifiers + value2, so this is a UI swap.
- Add NumberFilter component, modeled 1:1 on StringFilter: an 8-modifier
radio grid plus two number inputs, with the second input revealed only
for BETWEEN/NOT_BETWEEN and both disabled for IS_NULL/NOT_NULL. Initial
state is server-rendered so the widget never flashes.
- Migrate all 17 numeric range fields (game/session/purchase/playevent)
to NumberFilter; drop the now-dead min/max aggregate queries.
- filter-bar.ts: serialize numberFields by modifier (mirroring textFields)
and wire the modifier radios via event delegation on the persistent
custom element so they survive htmx swaps of the inner bar body. Apply
the same delegation fix to the existing string filters.
- Remove RangeSlider entirely: component, range-slider.ts, its custom
element registration/props, and the range-slider e2e tests.
Backward compatible: old slider filters stored {value, value2, modifier},
the same JSON shape NumberFilter reads, so saved presets keep working.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire the stats page to filter_url()/stats_links:
- Per-row session links on game superlatives, games-by-playtime, platform
and month rows (game rows keep their detail GameLink, add a session icon).
- Count links: sessions, games, total/refunded/dropped/unfinished/backlog
purchases.
- Cap preview lists to 5 with a 'View all (N)' link passing ?sort= for order
parity; remove the redundant 'All Purchases' list.
- stats_data: carry platform_id for platform links; drop the all-time
games-by-playtime [:10] slice so the view-all count is honest (rendering
caps the preview).
Also make the filter bar's _extract_labeled tolerate bare choice/multi values
so a programmatically-built filter URL renders instead of crashing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RF5L4HtbcykTfY9YUYGds3
Add Started and Finished DateRangePicker widgets to the PlayEvent filter bar
and wire filter-started / filter-ended into the filter-bar date-range
serializer, so the started/ended DateCriterion fields (added for #67) are
reachable from the UI — enabling "finished in year Y" range filtering.
Builds on #67 (PlayEventFilter.started/ended are DateCriterion); the bare
field names round-trip through _parse_range like the Purchase date fields.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RangeSlider does not render its own label — the field label is emitted by
the _filter_field wrapper. The PlayEvent filter bar added the Days to Finish
slider bare, so it showed no label. Wrap it in _filter_field like every
other slider (GameFilterBar/PurchaseFilterBar).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Finish the behavioural refactor from #28: no first-party JS lives on the
global object solely to be reachable from a server-rendered inline on*
attribute, and no inline Alpine blobs remain in the filter bar / year picker.
- Filter-bar collapse: drop the inline onclick for a delegated click listener
on the persistent <filter-bar> custom element (data-filter-bar-toggle). The
inner #filter-bar body is htmx-swapped while connectedCallback does not re-run,
so delegation on the host preserves the swap-survival the inline handler had.
- YearPicker: convert the Alpine x-data/x-on/x-ref/_pickerInstance f-string into
a <year-picker> custom element with typed props (YearPickerProps). Behavior
moves to ts/elements/year-picker.ts; ts/year_picker.ts and _YEAR_PICKER_MEDIA
are removed. The builder lives in primitives.py (next to YearPicker) to avoid a
circular import; registration stays in custom_elements.py for codegen.
- Add bindPopupDismiss (ts/utils.ts): shared Escape + outside-click dismiss with
a cleanup return and an extraInside hook for popups mounted on document.body.
Adopted by date-range-picker.ts (1:1) and year-picker.ts (Datepicker popup is
body-mounted, passed as an extra inside root).
Follow-up #49 tracks unifying popup/dismiss/positioning across the remaining
dropdown/search-select/Flowbite cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the four onSwap-based widgets with TypeScript custom elements
following the pattern from PR #16. Each widget gets a class extending
HTMLElement with connectedCallback/disconnectedCallback, typed props via
register_element + gen_element_types codegen, and lives in ts/elements/.
- range-slider: RangeSliderElement; Python uses _RangeSlider builder
- date-range-picker: DateRangePickerElement; Python uses _DateRangePicker builder
- search-select: SearchSelectElement; Python uses _SearchSelect builder;
data-* attrs become plain attrs (data-name -> name, data-search-url -> search-url, etc.)
- filter-bar: FilterBarElement; props carry preset URLs; onclick/onsubmit
attrs replaced with data-filter-bar-* sentinel attrs; all window.* globals removed
Deletes ts/range_slider.ts, ts/search_select.ts, ts/date_range_picker.ts,
ts/filter_bar.ts. Updates all tests and e2e pages to use the new element
selectors and script paths (dist/elements/<tag>.js).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Inputs rendered at different sizes: the SearchSelect was 54px tall with a 16px
font (the wrapper's p-2 and the inner box's forms-plugin padding stacked, and
the wrapper had no text-sm), and the string-criterion filter input had no
text-sm (16px) with p-2. Native inputs are 42px / 14px / px-3 py-2.5.
Align both to the native size:
- SearchSelect wrapper supplies px-3 py-2.5 + text-sm and the inner search box
zeroes its own padding (p-0), so the field is one 42px row like an input.
- String filter input gets text-sm + px-3 py-2.5.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The string-criterion filter input (e.g. Playevent Note) had transition-all, so
on focus its border-color animated from the near-white default to the focus
blue — a visible white "blink". It also lacked an explicit border width and
focus:border-brand/ring-brand, so its focus came from the forms-plugin default.
Drop transition-all (the other inputs snap to focus), add border +
focus:border-brand focus:ring-brand, and use rounded-base — so it focuses
instantly to the same brand border as every other input.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Add ts/range_slider.ts: typed port of the custom range-slider widget. Number
inputs typed as HTMLInputElement; setTargetValue coerces via String(); mouse
handlers typed MouseEvent; var → const/let
- Point the RangeSlider component Media and every e2e/test reference at the
compiled dist/range_slider.js
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add ts/filter_bar.ts: typed port of the filter bar. Criterion / PillEntry /
RangeField / DeselectableRadio interfaces replace the loose objects and the
radio.wasChecked custom property; var → const/let throughout
- Window entry points (applyFilterBar/clearFilterBar/toggleStringFilterInput/
showPresetNameInput/savePreset) declared in ts/globals.d.ts; readSearchSelect
now called as window.readSearchSelect
- Drop the dead selectValue helper; factor the repeated path→mode mapping into
presetMode()
- Point the FilterBar component Media and every e2e/test reference at the
compiled dist/filter_bar.js
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cleanup of hacky leftovers from the node-tree migration (no behaviour
change):
- Return annotations: the component builders return Node subtrees, not
SafeText strings, but ~40 functions still declared `-> SafeText`. Correct
them to `-> Node` across filters / search_select / date_range_picker /
domain. The genuine string returners keep `-> SafeText`: the Alpine
selectors (GameStatusSelector / SessionDeviceSelector, which build f-string
markup) and the script-tag helpers (CsrfInput / ModuleScript /
ExternalScript / StaticScript).
- layout.render_page / layout.Page / AddForm now accept `Node` in their
`content` / `scripts` / `fields` parameters (TYPE_CHECKING import in
layout to avoid the components import cycle), matching what views already
pass.
- session._session_fields builds a `Fragment(*rows, separator="\n")` instead
of `mark_safe("\n".join(str(row) ...))` — keeps the tree intact so media
could bubble, per the Fragment convention.
- Inline SVG icon children use `Safe(...)` nodes instead of `mark_safe(...)`
strings (filters mode-toggle + collapse icons, date_range_picker calendar
icon).
- _filter_field reads the widget's own id from its node `.attributes`
(`_widget_id`) for the label's `for`, dropping the superfluous `for_widget`
argument that always rendered `for="None"`. Removes the two TODOs whose
premise ("the Component function can't expose the id") the class/node
refactor retired, plus RangeSlider's dead commented-out Label block.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The *FilterBar family (FilterBar / SessionFilterBar / PurchaseFilterBar /
DeviceFilterBar / PlatformFilterBar / PlayEventFilterBar) previously shared
the collapsible chrome through a free `_filter_bar(fields, ...)` helper that
each function called at the end. Replace that with a `_FilterBarBase`
BaseComponent: it owns the chrome render() and declares `media =
_FILTER_BAR_MEDIA`, and each bar is now a subclass implementing
`build_fields()`.
The per-entity field-building bodies move verbatim into module-level
`_<entity>_fields(existing, ...)` functions that each subclass delegates to,
so the large bodies are untouched (no reindentation) and the diff stays
reviewable. Media still bubbles: BaseComponent.collect_media() merges the
bar's own filter_bar.js with the search_select.js / range_slider.js /
date_range_picker.js declared by the contained widgets.
Call sites are unchanged — `FilterBar(filter_json=..., preset_list_url=...)`
now instantiates a Node instead of calling a function, and both `str(bar)`
and `collect_media(bar)` behave as before.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The JS-bearing widgets now declare their script dependencies, so a view
no longer needs to know which scripts a component requires:
- SearchSelect / FilterSelect → search_select.js
- RangeSlider → range_slider.js
- DateRangePicker → date_range_picker.js
- YearPicker → datepicker.umd.js (external, from Phase 2)
- FilterBar chrome → filter_bar.js
Because the filter-bar internals now build a node tree (the legacy
Component() string-builder calls became Element/Div), each bar's
collect_media() returns its own filter_bar.js merged with the scripts
that bubble up from the FilterSelect / RangeSlider / DateRangePicker
widgets it contains — exactly the set the views thread by hand today.
Adds Node.with_media() so a function-built node can declare media
without a full BaseComponent subclass, and tests proving the bubbling.
Note: the six *FilterBar functions still share the _filter_bar chrome
helper rather than a BaseComponent class hierarchy; folding them into
one is a follow-up that does not affect media collection (Phase 4).
https://claude.ai/code/session_01BKurBhE3Qj25p7Bfsg7EeK
Port FastHTML's proc_htmx as onSwap(selector, initializeElement) in
utils.js, built on htmx.onLoad: it runs an initializer once per matching
element, on initial page load and inside every htmx-swapped fragment.
Migrate search_select.js, range_slider.js, filter_bar.js and
add_purchase.js to it, removing the hand-rolled DOMContentLoaded +
htmx:afterSwap listeners and per-element guard flags. This also fixes a
latent bug: both events passed the Event object as range_slider's
"force" parameter, so every htmx swap force-re-initialized all sliders
and stacked duplicate listeners. The collapse button's
window.initRangeSliders() call was a no-op (handles are positioned in
percentages, so hidden-init is safe) and is removed with the global.
Add e2e/test_widgets_e2e.py covering the onSwap lifecycle (initial-load
init, htmx-swap init, single-fire toggles) plus FilterSelect pills and
the add-purchase type toggle. The synthetic page in
test_search_select_e2e.py now loads htmx and search_select.js as a
module, matching the new initialization path.
https://claude.ai/code/session_01BKurBhE3Qj25p7Bfsg7EeK
Implements the DateRangePicker design: a DateRangeField that looks like a
single input but splits each date into DD/MM/YYYY part inputs (ordered by
the new common.time.dateformat_hyphenated), and a DateRangeCalendar popup
with a preset column (today, yesterday, last 7/30 days, this/last month,
this year), anchor-style range picking with an outlined/filled/muted range
track, and a Cancel / Clear / Select footer.
Typing fills each part's placeholder from the right (YYYY -> YY19 -> 1987),
auto-advances between parts, and Backspace/Delete reverts the active part.
The committed value lives in hidden ISO {prefix}-min/{prefix}-max inputs --
the same contract as DateRangeFilter, so filter_bar.js needs no changes.
As a tryout, the Purchased filter in PurchaseFilterBar now uses the
DateRangePicker; Refunded keeps the native-date DateRangeFilter, and the
native-path e2e tests were repointed at it.
Includes unit tests for the component family and the filter-bar
integration, plus Playwright e2e tests for segment entry, calendar
picking, presets, and footer actions.
https://claude.ai/code/session_017b75KJAu4kNNpZPu9NAPBM
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
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
Rename abbreviated identifiers in the PR's code to full words: tpl→template,
e→event, el→element, removeBtn→removeButton, and single-letter loop variables
(o→option, g/d/p→game/device/platform, v→value/modifier_value). Add a
'name variables with complete words' convention to CLAUDE.md.
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
Replace the bespoke SelectableFilter in all three bars with FilterSelect: enum
fields (status, type, ownership) pre-render their fixed options; model-backed
fields (game(s), platform, device) use the search endpoints with prefetch and
resolve only the selected ids to pill labels — dropping the per-page queries that
fetched every game/platform/device. filter_bar.js now reads filter-mode
SearchSelect widgets via readSearchSelect (data-included/excluded/modifier),
preserving the {value, excludes, modifier} JSON and id Number() coercion; the
redundant session game/device blocks are gone. Drop FilterBar's now-unused
platform_options param. Rebuild base.css for the inline filter-pill utilities and
update the bar tests to the new markup.
https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS