A multi-game Purchase is now treated as an *unsplittable* bundle (one
price, whole-purchase refund). Independently-refundable multi-item orders
(e.g. a Steam cart) are instead recorded as N separate single-game
purchases, so per-game pricing and per-game refunds work with the
existing single-purchase machinery — no through-model needed.
Add-purchase form (single form, single endpoint):
- 1 game: unchanged.
- 2+ games: a "Separate price per game" toggle appears (default off =
one bundle price). On, the bundle Price hides and one price input per
game appears; the view creates one single-game Purchase each from
price_for_game_<id>. `price` is now optional so combined mode still
validates.
Split action:
- A Split button on multi-game purchase rows opens a confirmation modal
that replaces the bundle with one single-game purchase per game (price
split evenly, needs_price_update set), then HX-Redirects to the list.
New general-purpose `selection-fields` custom element renders one synced
form field per selected item of a source SearchSelect (consuming the
existing search-select:change contract); it knows nothing about prices,
so it is reusable. Behavior in ts/elements/selection-fields.ts.
Adds the bundle-vs-separate-purchases convention to CLAUDE.md, a split
icon, and unit + Playwright e2e coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add-on purchases (DLC, Season Pass, Battle Pass) previously linked to a
parent *purchase* via the `related_purchase` self-FK. When the base game
was bought inside a multi-game purchase (e.g. a bundle), there was no
per-game purchase to point at — only the whole bundle.
Replace it with a `related_game` FK (Game -> Game): an add-on belongs to
a *game*, which is unambiguous regardless of how the base game was bought.
- models: drop `related_purchase`; add `related_game`
(SET_NULL, related_name="addon_purchases"); require it for non-GAME
types in `save()`.
- forms: replace the parent-purchase picker with a flat `related_game`
game search (reusing SearchSelectWidget/_game_options); drop the now
unused related_purchase_queryset/RelatedPurchaseChoiceField.
- views/urls: remove the obsolete related_purchase_by_game endpoint.
- add_purchase.js: drop the parent-dropdown refetch; keep platform
auto-fill; retarget the type toggle to #id_related_game.
- migration 0020: add -> backfill (related_game = parent's first game by
sort_name) -> remove related_purchase.
- tests: model validation unit tests + an e2e test for the flat picker.
related_game is deliberately game->game so it can later be synced from
IGDB's parent_game without schema changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Selector menu options were bare <button>s with no padding, so the open
dropdown items were cramped. Add a shared option class (block w-full
text-left px-4 py-2 + hover), matching the original <a> list items.
- The played-row's relative menu wrapper was a block div, so in the inline-flex
button group the chevron toggle sat lower than the count button. Make the
wrapper inline-flex and the group items-stretch so the two buttons align into
one rounded group again.
- Rebuild base.css for the newly-used utilities.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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
Serve alpinejs 3.15.12, @alpinejs/mask 3.15.12, flowbite 2.4.1 and
flowbite-datepicker 2.0.0 from games/static/js/ instead of jsdelivr, so
pages (and browser tests) work without network access. Adds the
StaticScript primitive for vendored UMD bundles, which cannot be loaded
as ES modules.
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
- 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
Spell out the abbreviated data-ss-* hook attributes (data-search-select-option,
-label, -mode, -template, -action, -type, -modifier, -modifier-option, -pills,
-search, -options, -no-results) and the JS expando properties (_searchSelectInit,
_searchSelectLabel, _searchSelectDirty, _searchSelectOption) across components,
JS, and tests — no abbreviations left in the widget's hooks.
https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
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
Eliminate the Python/JS class-string duplication: the server renders hidden
<template> prototypes (row, pill, include/exclude/modifier pills) using the same
component functions, and search_select.js clones them, filling only the
[data-ss-label] slot, value, and data-* attrs. All Tailwind class strings and DOM
structure now live solely in the Python components — the JS no longer hardcodes
any class. Pill gains an opt-in label_slot; the shell takes a templates list.
Companion issue #8 tracks the further HTMX-idiomatic step of returning rendered
row HTML from the search endpoint.
https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
- Wire the long-defined-but-unused _FIELD_CLASS into the container so pills and
the search input form a single padded flex row; the flex-1 input now fills the
widget instead of looking unclickable inside a larger box (affects both
SearchSelect and FilterSelect via the shared shell).
- Filter option labels get text-body so they're readable on dark backgrounds.
- Filter +/- buttons get text-body (readable at rest) and hover:border-brand-strong
so the border stays visible against the brand hover fill.
- Mirror the filter class changes in search_select.js and rebuild base.css.
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
Dispatch on data-ss-mode: in filter mode, value rows (server-rendered or fetched
via buildRow) carry +/- buttons that add include/exclude pills, and pinned
modifier pseudo-options set a lone, mutually-exclusive modifier pill. Pill removal
handles the modifier flag; filter pills carry no hidden inputs. Extend
readSearchSelect to serialise filter widgets into data-included / data-excluded /
data-modifier (the shape the filter bar consumes), leaving form widgets'
data-values path unchanged. JS class strings mirror the FilterSelect constants.
https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
Introduce a general 'prefetch' option (rows to load on first open, default 0 =
unchanged) carried as data-prefetch. Rework the JS search so a search_url widget
filters its loaded window instantly on every keystroke while issuing a debounced
server request for the rest, with an AbortController so a slower earlier response
can never overwrite a newer one. No-results stays hidden until the server
response decides it, avoiding a flash over an incomplete window. On first focus a
prefetch-enabled widget seeds its window immediately. Rename single-letter locals
to full words while reworking these functions.
https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS