TestCase wraps each test in a savepoint — when scrub_staging deletes
all django_session rows inside that savepoint, the rollback restores
any sessions committed by earlier tests (e.g. force_login in
test_paths_return_200). Those restored rows then leaked into the e2e
live-server tests, causing intermittent Session.MultipleObjectsReturned
errors.
TransactionTestCase flushes the DB before each test instead of using
savepoints, giving scrub_staging a clean slate and removing the leakage.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Address issue #20 and the CI divergence between Gitea and GitHub.
Issue #20 (staging seeded from a prod snapshot):
- Read SECRET_KEY from the environment with the insecure dev key as
fallback, so each deployment can have its own key.
- Add a `scrub_staging` management command that clears django_session and
the django-q schedule/queue/results, removing copied prod sessions and
the inherited convert_prices() schedule.
- Run the scrub from entrypoint.sh when STAGING=true, and wire STAGING plus
a per-branch SECRET_KEY into the Gitea staging deploy.
CI parity (both systems kept, independent):
- Add the Node/pnpm/TypeScript build steps to the Gitea build workflow to
match the GitHub test job.
- Add a GitHub staging workflow that deploys per-branch ephemeral instances
to Fly.io (*.fly.dev) with a fresh database seeded from sample fixtures
and its own SECRET_KEY, never production data. Tears the app down on
branch delete and comments the URL on the open PR via github-script.
- Add fly.staging.toml and a LOAD_SAMPLE_DATA entrypoint hook for the
fresh-database public staging.
https://claude.ai/code/session_01KYjUcNjLfZ8Hq1GAC8J4oZ
The Game status dropdown is now a <game-status-selector> light-DOM custom
element: the Python builder emits the tag + kebab attrs htpy-style, behavior
lives in ts/elements/{dropdown,game-status-selector}.ts wired by the native
connectedCallback, and GameStatusSelectorProps is the codegen'd contract. The
~70-line inline-Alpine f-string is gone.
Also fix SimpleTable to collect and re-attach the media of its row/header
nodes: it stringifies cells into the table markup, which silently dropped each
cell component's declared Media — so a <game-status-selector> in a cell never
got its <script> emitted. Now Page() emits it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tightens the child model so the type is honest end to end. Previously a
``SafeText``/``mark_safe`` string passed as a child rendered unescaped — a
trusted-HTML-as-string backdoor that ``Child = Node | str`` couldn't express
(every ``SafeText`` is a ``str``). Now ``_child_key`` escapes *every* string
child; the only way to put trusted pre-rendered HTML into the tree is a
``Safe`` node. So a ``str`` child is always untrusted text — which is exactly
what the renderer escapes.
Converted the trusted-HTML children that relied on the old passthrough:
- ``CsrfInput`` and the Alpine selectors (``GameStatusSelector`` /
``SessionDeviceSelector``) now return ``Safe`` nodes instead of ``mark_safe``
strings — they are always tree children.
- ``popover_content`` is now a ``Child`` (it is rendered as a child); the one
HTML caller (``LinkedPurchase``) passes ``Safe(...)``.
- View-side children that were ``mark_safe`` strings → ``Safe(...)``:
``_played_row`` (game detail), the stat SVGs and `` `` spacer (game),
the login table (auth), the manual session-form field/label markup
(session), and ``_purchase_name`` (stats).
- ``SimpleTable.header_action`` typed ``Child``.
The script-tag string helpers (``ModuleScript`` / ``StaticScript`` /
``ExternalScript``) stay ``SafeText`` strings: they are only ever joined into
the ``scripts=`` string, never used as tree children.
``Children`` regains a bare ``Node`` member (a single node child is valid);
the one ``*children`` site (``Popover``) normalises via ``as_children`` first.
Tests that asserted the old SafeText-passthrough now assert the new rule
(mark_safe child escaped; ``Safe`` node passes through). Full suite green
(445; +2 new escaping tests).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The legacy back-compat ``Component(tag_name=...)`` function (a thin
string-returning wrapper over ``Element``) was the last piece of the
pre-node-tree API. Migrate its ~18 call sites across the views to the node
builders and remove it:
- stats_content.py: the table helpers now use the whitelisted ``Td`` / ``Th``
/ ``Tr`` builders and ``Element`` for table/tbody/thead/h1; helper return
types are ``Node``.
- auth.py / statuschange.py / game.py / purchase.py: the hand-built
``<form>`` / ``<button>`` / ``<h1>`` / ``<h2>`` / ``<table>`` markup now uses
``Element("tag", ...)``.
- core.py: drop the ``Component()`` function and its back-compat note;
``common/components/__init__`` no longer exports it.
- Tests that exercised the shim now target ``Element`` directly
(test_components cache/escaping/edge-case classes; test_node_tree drops the
legacy-parity and legacy-bridge cases, which ``Element`` coverage subsumes).
- CLAUDE.md: drop the "legacy Component retained for back-compat" notes.
Full suite green (443; one obsolete legacy-bridge test removed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The component tests rendered lazy nodes to HTML through two competing pieces
of scaffolding: a magic ``_RenderingComponents.__getattr__`` proxy that
auto-str()'d any capitalized builder, plus separate ``str()`` wrapper
functions for Checkbox / Radio (test_components) and SearchSelect /
FilterSelect / Pill (test_search_select).
Replace both with one explicit convention: import the real components and
wrap node-returning calls in ``str(...)`` at the call site. ``Node.__str__``
returns a ``SafeText``, so the ``assertIsInstance(..., SafeText)`` checks stay
meaningful and every string assertion is unchanged. Non-node helpers
(``randomid``, ``_resolve_name_with_icon``, ``_render_element``, the legacy
string ``Component()``) are called directly.
No production code touched; 141 component/search-select tests and the full
444-test suite pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Page() now calls collect_media(content) and emits the ModuleScript /
StaticScript tags itself, so views no longer thread scripts= for
component-owned JS. The list views (game/session/purchase/device/
platform/playevent) compose with Fragment(filter_bar, content) instead of
mark_safe(str(filter_bar) + str(content)) — keeping the node tree intact
so the filter bar's media (filter_bar.js + search_select.js +
range_slider.js, and date_range_picker.js on purchases) reaches Page().
The stats views drop _STATS_SCRIPTS; YearPicker's datepicker.umd.js is
collected from its declared media.
The scripts= argument remains for page-specific glue not owned by a
component (the add-form helpers add_game.js / add_purchase.js /
add_session.js, alongside search_select.js for their form widgets).
Adds regression tests asserting the list and stats pages auto-load their
widget scripts with no scripts= in the view, and documents the node/
media model in CLAUDE.md.
https://claude.ai/code/session_01BKurBhE3Qj25p7Bfsg7EeK
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
Generic leaf builders (Div, Span, Td, Tr, Th, Ul, Li, Strong, Label,
Template, P) are now generated from one _html_element factory over the
single Element class — the tag name is data, not a per-tag body. Only
elements that add classes/behaviour (Button, Pill, Checkbox, Radio,
Input, A, SearchField, H1, Modal, AddForm, tables) stay hand-written.
All primitives now return Node objects; string-built widgets (Icon,
SimpleTable, YearPicker) return Safe, and YearPicker declares its
datepicker media. Raw concatenation (_popover_html, Popover slot) uses
Fragment.
Node.__str__/__html__ now return a SafeString: a node's rendered output
is safe HTML by construction, so str(node) stays safe when fed back into
a child list or template (matching the old SafeText behaviour and
preventing double-escaping).
Consumers adapted: the form widgets (SearchSelectWidget,
PrimitiveCheckboxWidget) return render(component) so Django gets a safe
string; the session form's manual field markup joins via str(row).
Component tests render nodes to HTML before asserting.
https://claude.ai/code/session_01BKurBhE3Qj25p7Bfsg7EeK
Introduce a FastHTML-style component model alongside the existing
function-based one, purely additive:
- Node: base renderable; __html__/__str__ render lazily so str()/f-string
composition keeps working during migration.
- Element: the single class for any HTML element (tag + attrs + children),
rendering via the existing memoized _render_element.
- Safe: wraps pre-rendered HTML (migration bridge for f-string components).
- Fragment: ordered children with no wrapper tag (replaces str(a)+str(b)).
- BaseComponent: base for higher-level components; render() returns a
subtree, media declared via a Media attribute.
- Media: declarative JS deps with order-preserving dedup merge.
- collect_media()/render() helpers walk the tree.
The legacy Component() function now builds an Element and is Node-aware in
its child handling, so a tree mixing string- and node-returning components
renders correctly with byte-identical output. No call sites changed yet.
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
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
- 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
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
Add a Template() primitive for the standard <template> tag and export it. Replace
inline Component(tag_name="div"/"span"/"input"/"template") in search_select.py
and Pill with Div/Span/Input/Template; drop the private _template helper in favour
of Template at the call sites. Bare custom-styled <button>s stay on Component
(the opinionated Button() would inject unwanted classes). Document the
prefer-primitives convention in CLAUDE.md.
https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
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
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
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
FilterSelect renders value rows with +/- (include/exclude) buttons, check/cross
pills for the included/excluded sets, and an optional set of pinned modifier
pseudo-options (e.g. (Any)/(None)) that stay visible above the value rows. A
selected modifier is mutually exclusive with value pills. It delegates assembly
to _combobox_shell and supports both pre-rendered options (complete set) and
search_url + prefetch (windowed); included/excluded are passed as resolved
value+label so pills show labels even outside the fetched window. Styling is
inline (ported from the old SelectableFilter CSS) so nothing lives in input.css.
https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
Pull the domain-agnostic combobox skeleton (pills region, search box, options
panel with its no-results node, outer container) into a private _combobox_shell
helper. SearchSelect now builds its form-specific pills and option rows and
delegates assembly to the shell. Rendered markup is byte-identical; a structural
test guards the fixed region order so future builders (e.g. a filter variant)
can share the shell without drift.
https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS