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>
RangeSlider does not render its own label — the field label is emitted by
the _filter_field wrapper. The PlayEvent filter bar added the Days to Finish
slider bare, so it showed no label. Wrap it in _filter_field like every
other slider (GameFilterBar/PurchaseFilterBar).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Change PlayEventFilter.started/ended from StringCriterion to DateCriterion
so they support GREATER_THAN / LESS_THAN / BETWEEN, enabling
"finished in year Y" to be expressed through the filter system.
PlayEvent.started/ended are DateField columns, so the criteria apply with
bare field names (no __date lookup, unlike SessionFilter.timestamp_start
which is a datetime). This mirrors the existing PurchaseFilter DateField
precedent. Deserialization auto-switches via the field annotation and the
serialized JSON shape is unchanged, so the type change is backward-compatible.
Prerequisite for #65 Tier-2 stats-page filtered links. Part of #61.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The staging deploy runs on push to a non-main branch and tries to comment
the staging URL on the branch's PR. When the branch is pushed before the PR
exists (the common case), the comment is skipped and never reappears once the
PR is opened.
Add a pull_request [opened, reopened] trigger and move the comment into its
own job that runs both after a successful push-deploy and on PR open/reopen.
The branch is taken from github.head_ref on PR events and github.ref_name on
push; the existing dedupe-by-body keeps the two paths from double-posting.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RF5L4HtbcykTfY9YUYGds3
Add filter_url(), a reverse()-style helper that builds a URL to a filtered
list view from a filter object (target inferred from the filter type).
Add OperatorFilter.where(**lookups), a Django-.filter()-style ergonomic
constructor that resolves each field's criterion class from its annotation
(shared with from_json via _criterion_class_for, removing duplication).
Make SessionFilter.timestamp_start/timestamp_end DateCriterion applied via
the __date lookup, so date ranges over the timestamp columns are expressible.
Wire the navbar 'today' / 'last 7 days' totals as links to the matching
filtered session lists, and align the 'last 7 days' total to the same
calendar-day window so the number matches the list it links to.
Stats-table and game-detail links remain a follow-up (see spec).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RF5L4HtbcykTfY9YUYGds3
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>
base.css is a Tailwind build artifact already listed in .gitignore, but it
had been tracked since it was first committed (gitignore can't untrack an
already-tracked file), so it kept getting re-committed against intent. Untrack
it to match js/dist (the TS artifact, also gitignored + untracked).
Because nothing in the test path rebuilt it, e2e/static serving relied on the
committed copy. Add 'css' (and 'ts') as prerequisites of the test/test-e2e
make targets and a Build CSS step in CI so the stylesheet is generated before
tests run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Flowbite re-initialises popovers on every htmx swap. A popover hidden via
Tailwind `invisible` (visibility:hidden) still occupies layout, so once
Popper parks it with a transform offset it expands the table's
overflow-x-auto wrapper and a spurious scrollbar appears (horizontal here,
vertical in #40). Add `[&.invisible]:hidden` so the popover is removed from
layout while hidden; Flowbite drops `invisible` on show, restoring display.
Relates to #40. e2e regression covers no-overflow-after-swap plus
popover-still-shows-on-hover.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Delete stale _session_row_fragment; end_session and reset_session_start
return the canonical row plus an OOB navbar-playtime fragment. Clone keeps
HX-Refresh since it changes row count. Fixes#53.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a confirm-gated button on running sessions in the session list that
sets timestamp_start to now (issue #33). The htmx path returns HX-Refresh;
ButtonGroup gains optional hx_confirm/hx_swap keys.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The comment described what the code does (find wrapper by name attr),
not why. The locator is self-explanatory.
Co-Authored-By: Claude Sonnet 4.6 <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>
Staging stores sessions in machine-local SQLite with no shared volume.
Fly's default deploy provisions two machines (HA), so requests after
login could land on the machine that never wrote the session row,
bouncing logged-in users straight back to the login page.
Deploy with --ha=false and scale count 1 so each per-branch staging app
runs on exactly one machine.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWAvjVEAibhwbeVgbYmg94
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>