Commit Graph

19 Commits

Author SHA1 Message Date
lukas b3fa7fac96 Remove inline-handler → window.* contract (issue #28)
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>
2026-06-20 15:54:00 +02:00
lukas 82416e149d Convert onSwap widgets to custom elements (issue #18)
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>
2026-06-20 14:22:59 +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 5bb8b92c05 Fix add-form JS: name→sort-name sync and related-game disable
Two bugs in the add forms, both root-caused via the e2e harness:

1. add_game Name → Sort name never synced. syncSelectInputUntilChanged was
   scoped to "form", but the first <form> on every page is the navbar logout
   form — the add-form fields live in a later form, so the delegated listener
   never heard their events. Scope to "#add-form" (the add-form wrapper). Also
   switch the sync from the "change" event to "input" so Sort name mirrors Name
   live as you type, not only on blur.

2. add_purchase Related game not disabled when Type == Game.
   disableElementsWhenTrue set `.disabled` on #id_related_game, which is the
   SearchSelect wrapper <div> (a <div> ignores `disabled`). Target the inner
   [data-search-select-search] input instead, so the widget is actually disabled.

Adds two e2e regression tests (live sync; type-game disables the related-game
search input and re-enables it for other types).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 18:11:49 +02:00
lukas 893cb22bf8 Convert htmx-redirect-toast.js to TS; remove dead legacy utils.js (issue #17)
- Add ts/htmx-redirect-toast.ts: typed port of the hx-redirect-toast htmx
  extension. Stays a classic (non-module) script — only touches the global
  htmx and registers an extension; layout.py now serves dist/htmx-redirect-toast.js
- Delete games/static/js/utils.js: the legacy hand-written copy is dead — every
  compiled module imports dist/utils.js (from ts/utils.ts); nothing references
  the old path

With this, the only first-party JS served is compiled from ts/; the sole
remaining hand-written .js in static is the vendored datepicker.umd.js bundle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 17:45:09 +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 1decf588c1 Convert toast.js to TypeScript (issue #17)
- Add ts/toast.ts: typed port of the Alpine toast store + window.toast +
  window.fetchWithHtmxTriggers. Toast / ToastStore / ToastMessage interfaces
  type the store and the show-toast CustomEvent detail; Alpine declared as a
  type-only ambient global
- Declare window.toast in ts/globals.d.ts
- Stays a classic (non-module) script — no import/export — so it keeps defining
  globals; layout.py now serves dist/toast.js

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 13:58:43 +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 daae9b8944 Convert add_purchase.js to TypeScript; drop dead add_edition.js (issue #17)
- Add ts/add_purchase.ts: typed port of add_purchase.js. Replaces getEl with
  document.querySelector; types the search-select:change CustomEvent detail
  (SearchSelectChangeDetail / SearchSelectOption)
- Point add_purchase / edit_purchase views at compiled dist/add_purchase.js
- Delete add_edition.js: no Edition model/view/url/template references it
  (feature was removed; the script was dead)
- Delete the now-superseded add_game.js / add_purchase.js source files
- Tighten test_rendered_pages assertions to the dist/ script paths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 13:27:05 +02:00
lukas 4f535be270 Convert add_game.js to TypeScript (issue #17)
- Add ts/utils.ts: fully-typed port of utils.js (onSwap, toISOUTCString,
  syncSelectInputUntilChanged, conditionalElementHandler, disableElementsWhen*,
  getValueFromProperty). Uses document.querySelector throughout; fixes
  removeEventListener bug (was passing function reference instead of named
  listener); uses boolean disabled property instead of string "disabled"/""
- Add ts/add_game.ts: TypeScript conversion of add_game.js, imports
  syncSelectInputUntilChanged from ts/utils.ts (resolves to dist/utils.js
  at runtime)
- Update add_game view to serve compiled dist/add_game.js

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 13:11:14 +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 5f411b8ae9 Try unifying 3 different element interfaces 2026-06-14 01:34:44 +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 5fd82c78d4 Add TypeScript toolchain (tsc per-module, build-only) 2026-06-13 21:01:26 +02:00