Commit Graph

85 Commits

Author SHA1 Message Date
lukas 84925d4406 Fix string filter input focus "blink"; match other inputs
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>
2026-06-20 08:15:49 +02:00
lukas 9c25f02ddb Give SearchSelect the same border + focus as native inputs
The SearchSelect wrapper had no border and no focus styling, so it looked unlike
a native input (which has border-default-medium → border-brand on focus). Add
border-default-medium + focus-within:border-brand/ring to the shared container
class — focus-within because the focusable element is the inner search box. This
also makes filter-bar comboboxes consistent with the other filter inputs, which
already have borders.

e2e asserts the wrapper border matches a native input's at rest and turns brand
on focus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 08:03:08 +02:00
lukas 29ba3e66e9 Unify disabled appearance across all form controls
Disabled controls looked inconsistent: the SearchSelect faded (opacity-50)
while native inputs used a solid strong surface. Standardize on the faded look
(opacity-50) the user preferred, via shared constants so every form element
matches:

- DISABLED_CONTROL_CLASS (disabled:opacity-50 disabled:cursor-not-allowed) on
  the control — native inputs/select/textarea via PrimitiveWidgetsMixin, plus
  the Checkbox component (previously had no disabled style).
- DISABLED_WITHIN_CLASS (has-[:disabled]: wrapper variant) for composite
  controls like SearchSelect whose disabled state lives on an inner element.

e2e asserts a disabled SearchSelect and the Name input fade identically
(opacity 0.5) and return to 1 when enabled. CLAUDE.md documents the shared
disabled constants.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 07:52:44 +02:00
lukas 02798f8858 Own all form styling in components; remove form CSS from input.css
Form controls were styled "at a distance": Django renders bare
<input>/<select>/<textarea>/<label>, so input.css reached in with ID-scoped
#add-form descendant rules plus a global form *:disabled rule and .errorlist.
The #add-form ID specificity forced state rules to climb, needed
:not([data-search-select-search]) carve-outs, and broke on markup changes — it
surfaced as the add_purchase Name/related_game fields not reading as disabled.

Components now own all form styling via utilities on the elements themselves:

- PrimitiveWidgetsMixin stamps INPUT/SELECT/TEXTAREA_CLASS (incl. disabled:
  variants) onto native widgets by type, skipping SearchSelect (self-styled)
  and checkboxes.
- New FormFields(form, *, extras=...) renders label + control + errors + row
  layout with their own classes (replaces form.as_div()); the <form> owns its
  flex layout. extras appends a node into a named field's row (session
  timestamp buttons).
- AddForm/purchase/session render via FormFields; login too — a new
  LoginForm(PrimitiveWidgetsMixin, AuthenticationForm) styles its inputs and
  auth.py renders it via FormFields + a StyledButton (was as_table).
- input.css loses the entire #add-form block, the global :disabled rule, and
  .errorlist. State (disabled:) now lives on the element — no specificity wars,
  no carve-outs, robust to markup edits.

Tests: error rendering uses the component class (not .errorlist); add-form
labels/inputs carry their own classes; e2e login fixtures click the Login
button by text (submit is now a <button>); Name disabled cursor asserted.
CLAUDE.md documents the no-styling-at-a-distance + FormFields conventions.

513 passed; lint/format/ts-check clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 07:31:53 +02:00
lukas b13cc3c324 Keep not-allowed cursor on disabled SearchSelect input
Excluding the inner search box from the global disabled rule also dropped its
cursor: not-allowed, so the pointer flickered between not-allowed (wrapper) and
the text I-beam (input) when moving across the disabled widget. Add
disabled:cursor-not-allowed to the search input so the cursor stays consistent.

e2e: assert the disabled inner input computes cursor: not-allowed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 18:42:52 +02:00
lukas b49b5f1cc3 Make disabled SearchSelect read as one element
The disabled widget showed two clashing surfaces in dark mode: the wrapper
(faded via has-[:disabled]) plus the inner search input, which picked up the
global disabled-input fill from common/input.css
(`form input:disabled { background: neutral-secondary-strong }`). That rule is
unlayered, so it beat any utility override on the input.

Exclude the SearchSelect's inner search box from that global rule
(`:not([data-search-select-search])`) so it stays transparent — the wrapper is
then the single faded surface. Standalone inputs (e.g. the Name field) keep
their distinct disabled surface, unchanged.

e2e: assert the disabled inner input computes transparent background (one
element), alongside the existing wrapper-opacity check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 18:35:28 +02:00
lukas 885e92b775 Honor UntilChanged in sync; let SearchSelect own its disabled look
Follow-ups on the add-form fixes:

- syncSelectInputUntilChanged now actually stops mirroring once the user edits
  the target (the "UntilChanged" contract). The old focus-based stop was a
  no-op (wrong removeEventListener reference), so live sync kept clobbering a
  manually-edited Sort name. Track dirty targets in a Set keyed by syncData
  index; programmatic writes don't fire "input", so only real user edits mark a
  target dirty. Drops the dead focus listener.

- SearchSelect now greys itself when disabled, via has-[:disabled]: utilities on
  its container class — the visible "box" is the wrapper <div>, so disabling the
  transparent inner input alone left it looking active. The component owns its
  disabled appearance; callers only toggle the inner control's `disabled`.

- Document the composite-widget disabling approach in CLAUDE.md and the
  SearchSelect docstring.

Extends the e2e tests: sync drops after a manual Sort name edit; disabled
related-game wrapper computes opacity 0.5 (and 1 when re-enabled).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 18:24:13 +02:00
lukas e72d44b9be Convert year_picker to TypeScript; drop inline f-string script (issue #17)
- Add ts/year_picker.ts: typed onSwap port of the year-picker glue. Datepicker
  declared as an ambient global (vendored UMD); PickerElement types the
  _pickerInstance prop the Alpine toggle button reaches
- Remove the duplicate inline <script> from the YearPicker component (was a JS
  blob in a Python f-string — the CLAUDE.md anti-pattern) and the orphaned
  games/static/js/year_picker.js that nothing loaded; the component now declares
  dist/year_picker.js as media alongside the datepicker UMD bundle
- Module defer semantics keep the classic UMD bundle running before the
  deferred year_picker module, so Datepicker is defined in time

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 17:35:28 +02:00
lukas 19e9fd1419 Convert date_range_picker.js to TypeScript (issue #17)
- Add ts/date_range_picker.ts: typed port. CalendarState interface (with the
  dynamically-assigned refreshFromField) and an Anchor union replace the loose
  state object; date helpers and DOM queries fully typed; var → const/let
- Replace the DOMContentLoaded + per-element guard-flag + window global with
  onSwap("[data-date-range-picker]", ...), the documented init pattern — so the
  picker now also initializes inside htmx-swapped fragments. Drops the dead
  window.initDateRangePickers export
- Point the DateRangePicker component Media at dist/date_range_picker.js and load
  it as an ES module in the e2e page (was a deferred classic script)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 13:52:48 +02:00
lukas c809341064 Convert range_slider.js to TypeScript (issue #17)
- 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>
2026-06-19 13:48:36 +02:00
lukas cd17053860 Convert filter_bar.js to TypeScript (issue #17)
- 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>
2026-06-19 13:38:20 +02:00
lukas 541fb550ab Convert search_select.js to TypeScript (issue #17)
- Add ts/search_select.ts: typed port of the SearchSelect/FilterSelect widget.
  Exports SearchSelectOption / SearchSelectChangeDetail as the single source of
  truth for the "search-select:change" event contract
- add_purchase.ts now imports those types via `import type` (no runtime
  coupling), instead of redefining them locally
- Declare window.readSearchSelect in ts/globals.d.ts
- Point the SearchSelect component Media and every view/e2e/test reference at
  the compiled dist/search_select.js
- Update doc comments in common/components/search_select.py to name the TS source

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 13:34:06 +02:00
lukas 733da3419b Model refundable orders as separate purchases; add split action
Django CI/CD / test (push) Successful in 3m41s
Staging deployment / deploy (push) Successful in 1m7s
Staging deployment / comment (push) Has been skipped
Staging deployment / teardown (push) Has been skipped
Django CI/CD / build-and-push (push) Failing after 12m17s
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>
2026-06-19 11:36:47 +02:00
lukas f693f8280f Fix pre-existing lint and format issues in domain.py and layout.py
Remove two unused `*Props` imports flagged by ruff (F401) and apply
`ruff format` line-wrapping. Pure cleanup, no behavior change — unblocks
`make check` independently of the purchase changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 11:36:47 +02:00
lukas f036a246a8 Rename Button to StyledButton, simplify A 2026-06-14 10:47:23 +02:00
lukas 5f411b8ae9 Try unifying 3 different element interfaces 2026-06-14 01:34:44 +02:00
lukas 138136e285 Fix dropdown item spacing + played-row button alignment
- 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>
2026-06-13 22:14:47 +02:00
lukas 2364d868fa Fix ported-component styling regressions
Two visual regressions from the custom-element port:

1. The played-row nested its dropdown menu (which contains <button> options)
   inside the toggle <button>. A <button> may not contain another <button>;
   the HTML parser force-closes the toggle on the nested button, and the
   source's explicit </div> tags then close ancestors early — ejecting the
   Purchases/Sessions/etc. sections out of the centered max-w container
   (they rendered full-width). Make the menu a sibling of the toggle, wrapped
   in a relative div so it still anchors under the toggle.

2. Both selector toggles dropped the original
   `flex flex-row gap-4 justify-between items-center` wrapper around their
   content, so the chevron stacked under the label (the GameStatus label is a
   display:flex block). Restore the wrapper — chevron sits on the right with
   proper spacing again.

Verified by screenshot: sections back inside the centered container; both
dropdowns render correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:10:26 +02:00
lukas c7af814364 Clear pre-existing ruff lint + format debt (make check now green) 2026-06-13 21:27:46 +02:00
lukas 1258c529d2 played-row: custom element; delete @@TOKEN@@ template + Alpine 2026-06-13 21:15:49 +02:00
lukas 48644037f6 SessionDeviceSelector: custom element; delete Alpine dropdown helper 2026-06-13 21:12:46 +02:00
lukas 04552aa8f6 GameStatusSelector: custom element + typed contract (retire Alpine)
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>
2026-06-13 21:09:52 +02:00
lukas 0f0dfc48fb Custom-element registry, builder, and TS codegen 2026-06-13 21:05:49 +02:00
lukas 763c00c50e htpy-style sugar on Element: kwargs attributes + [] children 2026-06-13 21:03:57 +02:00
lukas 0c6c536d07 Ban SafeText-as-child: only Safe nodes render unescaped
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 ``&nbsp;`` 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>
2026-06-13 18:35:43 +02:00
lukas 544da26a9d Type component attributes with a covariant Attributes alias
Twin of the children fix: builders annotated ``attributes`` as
``list[HTMLAttribute] | None``, and ``list`` is invariant, so passing the
``list[tuple[str, str]]`` a caller naturally writes was a type error.

Add ``Attributes = Sequence[HTMLAttribute]`` (covariant) and use it for the
``attributes`` parameter of every builder. Locals that get appended/concatenated
stay a concrete ``list[HTMLAttribute]`` via the new ``as_attributes()``
normaliser, mirroring ``as_children()`` — builders call it once up front so
``attributes + [...]`` keeps working on a real list.

Pyright on common/components drops 45 → 42; the remaining errors are all
pre-existing and unrelated (django-stubs model access, the ``mark_safe``
``_Wrapped`` return type, and the separate ``FilterSelect`` options-list
invariance). Full suite green (443).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 18:20:57 +02:00
lukas 7104605c06 Type component children with a covariant Children alias
The builders annotated their ``children`` parameter as
``list[HTMLTag] | HTMLTag | None`` where ``HTMLTag = str``. ``list[str]`` is
invariant, so passing ``list[Element]`` / ``list[Node]`` — the normal case —
was a type error everywhere a component nested children.

Introduce a proper child type in core:

    Child    = Node | str
    Children = Sequence[Child] | str | None

``Sequence`` is covariant, so ``list[Element]`` / ``list[Node]`` are accepted;
``Child`` includes ``Node`` so node children are no longer rejected. ``Element``
itself also accepts a bare ``Node`` (it wraps one), typed ``Children | Node``.

Replace the ``list[HTMLTag] | HTMLTag | None`` annotations across primitives /
domain with ``Children``, and add ``as_children()`` to normalise a ``children``
argument to a ``list[Child]`` — retiring the repeated
``children if isinstance(children, list) else [children]`` dance that defeated
type narrowing. Inline ``mark_safe(...)`` SVG/markup children become ``Safe(...)``
nodes (a ``Node`` child instead of a stub-typed string).

Pyright on the component package drops from 43 to 22 errors; the remaining 22
are pre-existing and unrelated (django-stubs model access, the ``mark_safe``
``_Wrapped`` return type, and ``list[HTMLAttribute]`` attribute invariance).
Full suite green (443).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 18:14:09 +02:00
lukas 9c42d85f52 Migrate remaining Component() callers to Element; delete the shim
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>
2026-06-13 16:51:27 +02:00
lukas 022d43a5a5 Make component return types honest; drop str/mark_safe leftovers
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>
2026-06-13 15:12:52 +02:00
lukas 1c5bff8651 Fold the six filter bars into a BaseComponent hierarchy
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>
2026-06-13 14:46:16 +02:00
Claude 0819ddb87d Phase 3: declare component media that bubbles through the node tree
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
2026-06-13 07:24:29 +00:00
Claude 4031657bb5 Phase 2: convert primitives to nodes via a whitelist element factory
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
2026-06-13 07:16:59 +00:00
Claude f673f3ac80 Phase 1: add lazy node tree (Node/Element/Safe/Fragment/Media)
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
2026-06-13 06:56:37 +00:00
Claude b68a131bae Initialize widget JS via onSwap helper
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
2026-06-12 21:41:55 +00:00
Claude 88cf374f33 Vendor Alpine, Flowbite and Datepicker bundles locally
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
2026-06-12 21:40:35 +00:00
Claude 0fa860c237 Add DateRangePicker component with segmented entry and calendar popup
Django CI/CD / test (push) Successful in 2m33s
Django CI/CD / build-and-push (push) Successful in 1m17s
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
2026-06-12 22:45:25 +02:00
lukas 15a97dee9a Use hours instead of minutes for playtime filters 2026-06-12 22:45:25 +02:00
lukas 1822ea8b51 Fix RangeSlider visual bugs 2026-06-12 22:45:25 +02:00
lukas 19f1cdd197 feat: migrate playevent_note to StringCriterion and add note string filter to SessionFilterBar 2026-06-12 22:45:25 +02:00
lukas 9cb911401a feat: integrate StringFilter into PlatformFilterBar and PurchaseFilterBar 2026-06-12 22:45:25 +02:00
lukas 0c109cf2a1 feat: implement StringFilter composite component with 4x2 grid radios 2026-06-12 22:45:25 +02:00
lukas 58b274a452 refactor: allow Checkbox and Radio primitives to render headlessly without labels 2026-06-12 22:45:25 +02:00
lukas 99af73781b feat: replace single boolean checkboxes with radio groups in all FilterBars 2026-06-12 22:45:25 +02:00
lukas 79d1be2852 feat: implement _parse_bool_nullable and _filter_boolean_radio helper 2026-06-12 22:45:25 +02:00
lukas ebfc9aebfc refactor: generalize Checkbox and add Radio primitive component 2026-06-12 22:45:25 +02:00
lukas 03adcf99a7 Implement date filters in purchase list 2026-06-12 22:45:25 +02:00
lukas b1a4da2704 Improve the layout of the purchase filter bar 2026-06-12 22:45:25 +02:00
lukas 3ce6da708f Improve the layout of the game filter bar 2026-06-12 22:45:25 +02:00
lukas ab079cb447 Use adhoc Component() less 2026-06-12 22:45:25 +02:00
lukas c2996fd91b Add more filters 2026-06-12 22:45:25 +02:00