Commit Graph

912 Commits

Author SHA1 Message Date
lukas b7d667a07f Merge pull request #60 from KucharczykL/fix/issue-39-dropdown-clipped
Django CI/CD / test (push) Failing after 6m30s
Django CI/CD / build-and-push (push) Has been skipped
fix(dropdown): flip-up menu collapses off-screen (follow-up to #59)
2026-06-20 23:50:32 +02:00
lukas 184749bf6d fix(dropdown): flip-up menu must override top-[105%] class
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>
2026-06-20 23:47:46 +02:00
lukas 4c1d7a9a93 Merge pull request #59 from KucharczykL/fix/issue-39-dropdown-clipped
fix(dropdown): stop table wrapper clipping the device dropdown (#39)
2026-06-20 23:16:29 +02:00
lukas 3bd14e8c89 fix(dropdown): position menu fixed so it isn't clipped by table wrapper
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>
2026-06-20 23:14:06 +02:00
lukas 5f3271b3da Merge pull request #58 from KucharczykL/chore/untrack-base-css
chore: untrack generated base.css, build it in test/CI
2026-06-20 23:06:18 +02:00
lukas ccc3db81c7 chore: untrack generated base.css, build it in test/CI paths
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>
2026-06-20 23:03:50 +02:00
lukas fdbd0cdea8 Merge pull request #57 from KucharczykL/feat/issue-53-session-row-fragment
fix(session): rebuild session row fragment via shared builder (#53)
2026-06-20 22:56:51 +02:00
lukas b6f6da309f fix(popover): remove hidden popover from layout to kill phantom scrollbar
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>
2026-06-20 22:39:49 +02:00
lukas 8637c547e4 refactor(session): address review minors for issue #53
- SessionRowData.cell_data: list[Node | str] (date cells are str)
- strengthen test_session_row_fragment_via_htmx to assert OOB navbar

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 21:37:44 +02:00
lukas ec6423cba5 style(session): apply ruff format to issue #53 changes
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 21:33:25 +02:00
lukas 93252350bb test(e2e): in-place session-row finish swap
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 21:31:23 +02:00
lukas 4a3e40ef29 feat(session): in-place row swap for finish/reset with OOB navbar
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>
2026-06-20 21:27:03 +02:00
lukas 7d10884db7 feat(layout): extract NavbarPlaytime as OOB-swappable component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 21:21:14 +02:00
lukas ba1849e80e refactor(session): extract canonical session_row_data builder
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 21:17:21 +02:00
lukas 796753e3c9 docs: implementation plan for issue #53
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 21:14:39 +02:00
lukas 7a3b275d2f docs: return Node not SafeText in issue #53 spec
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 21:14:39 +02:00
lukas 644b9944da docs: design spec for issue #53 rebuild session row fragment
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 21:14:39 +02:00
lukas bda0657a6b Merge pull request #54 from KucharczykL/feat/issue-33-reset-session-start
feat(session): reset running session start time to now (#33)
2026-06-20 20:48:19 +02:00
lukas 2a1585831f feat(session): reset running session start time to now from list
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>
2026-06-20 20:38:48 +02:00
lukas bf60a2a06b docs: design spec for issue #33 reset session start to now
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 20:37:52 +02:00
lukas 7d17986e68 Merge pull request #52 from KucharczykL/feat/issue-31-submit-create-session
feat(game): add Submit & Create Session button to add-game form
2026-06-20 20:10:02 +02:00
lukas 3fbb0996e5 test(e2e): add Submit & Create Session redirect test for add-game form 2026-06-20 20:04:54 +02:00
lukas 8005e97f01 fix(session): use get_object_or_404 for game lookup in add_session 2026-06-20 19:59:17 +02:00
lukas e7a0eb7dca feat(game): add Submit & Create Session button to add-game form
Closes #31
2026-06-20 19:49:00 +02:00
lukas fe3e070669 Merge pull request #51 from KucharczykL/feat/custom-elements-issue-18
fix(search-select): move field id to inner search input (issue #30)
2026-06-20 19:15:43 +02:00
lukas c25ebec484 test(e2e): remove what-comment from searchselect border test
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>
2026-06-20 19:03:56 +02:00
lukas b229f34b8d test(search-select): assert id lands on inner input not wrapper 2026-06-20 19:01:30 +02:00
lukas b816c68cb8 fix(search-select): move field id to inner search input (issue #30)
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
2026-06-20 18:34:07 +02:00
lukas 12b426e64a Merge pull request #50 from KucharczykL/feat/custom-elements-issue-18
Custom-element widgets + remove inline-handler → window.* contract (#18, #28)
2026-06-20 16:08:05 +02:00
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 0ec564f920 Merge pull request #38 from KucharczykL/feat/custom-elements-issue-18
Convert onSwap widgets to custom elements (issue #18)
2026-06-20 15:19:23 +02:00
lukas 6603a07b85 Merge pull request #41 from KucharczykL/claude/sweet-ritchie-tnt1g6
Fix staging deployment to use single machine and prevent session loss
2026-06-20 15:01:25 +02:00
Claude 5259b1e553 Run staging on a single Fly machine
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
2026-06-20 12:54:12 +00: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 0e142bc8bf Merge pull request #36 from KucharczykL/refactor/form-control-styling
Own all form styling in components; remove form CSS from input.css
2026-06-20 08:26:31 +02:00
lukas 4652f1ff55 Unify input font size and padding (text-sm, px-3 py-2.5)
Inputs rendered at different sizes: the SearchSelect was 54px tall with a 16px
font (the wrapper's p-2 and the inner box's forms-plugin padding stacked, and
the wrapper had no text-sm), and the string-criterion filter input had no
text-sm (16px) with p-2. Native inputs are 42px / 14px / px-3 py-2.5.

Align both to the native size:
- SearchSelect wrapper supplies px-3 py-2.5 + text-sm and the inner search box
  zeroes its own padding (p-0), so the field is one 42px row like an input.
- String filter input gets text-sm + px-3 py-2.5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 08:22:40 +02:00
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 af42229d80 Merge pull request #29 from KucharczykL/fix/add-form-js-bugs
Fix add-form JS: name→sort-name sync and related-game disable
2026-06-20 07:42:41 +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 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 2e2dcdc3e7 Merge pull request #27 from KucharczykL/claude/typescript-utils-issue-17
Conver remaining JavaScript to TypeScript/components
2026-06-19 17:48:45 +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