* fix(make): run migrate before loadplatforms in init target
make init loaded platform fixtures without first creating the database
schema, failing with 'no such table: games_platform' on a clean repo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(add-game): sync original year from year released until dirty (#35)
Mirror year_released into original_year_released live as the user types,
stopping once original is edited directly — same sync-until-dirty pattern
already used for name -> sort_name. Reorder the two year fields so
year_released renders first, otherwise the user would fill original first
and negate the sync.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The played-row "Played N times" dropdown regressed when it was migrated
from Alpine to a custom element (commit 1258c52): the hover highlight,
the row-filling click target and a consistent pointer cursor were lost
because the interactive <a>/<button> shrank to its text while the <li>
rows stopped carrying hover/click behaviour. Clicking the row's padding
hit the handler-less <li> and was silently swallowed.
Make each menu item the interactive element itself (block w-full + own
padding + hover highlight + pointer cursor), mirroring the status
selector's _SELECTOR_OPTION_CLASS, so the control fills the whole row.
Also refresh the Play Events section in place: the play-event-row now
dispatches a "play-added" event after recording a play, and
#playevents-container re-fetches itself on it (mirroring the history
section's status-changed refresh), so the table and count badge update
without a full reload.
Add e2e regression tests covering hover highlight, full-row pointer
cursor, the row-wide +1 click target, and the in-place table refresh.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Arrow Left/Right called target.select(), which painted the segment with
the browser's default text-selection color instead of the brand focus
background used everywhere else — so a part looked pink when reached by
arrow keys but blue when clicked or tabbed into. The select() was
redundant (the digit handler already restarts a full part on the next
keypress), so removing it makes the focus highlight consistent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Typed digits in the segmented date field were unvalidated — you could
enter 60 for a day or 30 for a month. Now each digit is clamped to its
part's range and auto-advances:
- A digit that cannot validly extend the current part commits as a
zero-padded value and moves to the next part (month 9 → 09▶, day 6 →
06▶).
- An ambiguous digit that could still take a second stays pending
(month 1 → 01; then 2 → 12▶, or 9 → 09▶ dropping the overflowed 1).
- Day/month show a pending single digit zero-padded; the year part keeps
its existing right-fill placeholder display and 4-digit advance.
Logic lives in a pure applyDigit() helper; completion is normalized to a
full-width buffer so syncHiddenFromSegments commits it. Adds 10 e2e tests
covering clamping, auto-advance, overflow-drop, zero-pad display, the
single-digit commit invariant, and restart-on-full.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes#64. The segmented date-range field now responds to arrow keys:
- Left/Right move focus between DD/MM/YYYY parts, crossing the min→max
separator; focus clamps at the first/last part (no wrap).
- Up/Down increment/decrement the focused part, clamped to its valid
range (day 1-31, month 1-12, year 1-9999). An empty part seeds to 01
for day/month and the current year for year on the first press.
Arrows with modifiers (Ctrl/Alt/Meta) still fall through to native
behavior. Adds e2e coverage for focus walking, boundary clamping, value
stepping, hidden-ISO commit, and modifier passthrough.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
The flip-up branch cleared inline `top` to "", which let the menu's
`top-[105%]` utility class reassert top:105% on the now-fixed element —
collapsing the menu to a 2px sliver below the viewport, so toggles near the
viewport bottom appeared not to open. Set the unused anchor to "auto" so the
inline value wins over the class. Add an e2e regression for the flip-up path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The device/status dropdown menu is absolutely positioned inside the session
list's overflow-x-auto wrapper. Because overflow-x:auto forces overflow-y:auto,
a menu taller than a short table was clipped (issue #39). Open the menu with
position:fixed anchored to its toggle so it escapes the clipping ancestor,
bound it to the viewport with an internal scroll, flip it up when there is more
room above, and reposition on scroll/resize while open.
Fixes#39.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The id (e.g. id_related_game) sat on the <search-select> wrapper, a
non-labelable custom element. Consequences:
- <label for="id_X"> focused nothing (a11y gap)
- .disabled / .focus() on #id_X silently no-oped
- add_purchase.ts needed a [data-search-select-search] descendant
workaround to gate related_game on the type field
id is now on the [data-search-select-search] <input>, making it a real
labelable, disableable control. add_purchase.ts drops the workaround
and gates via #id_related_game directly. E2e tests updated; new test
asserts label-click focuses the search box.
Closes#30
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>
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>
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>
- 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>
- 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>
- 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>
- 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>
- 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>
- 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>
- 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>
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>
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>