Compare commits

...

162 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
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 b03d9241fc Fix staging slug trailing dash after cut
Branch names that hit the 30-char cut boundary can end with a dash,
which Fly.io rejects. Strip trailing dashes after cut in both deploy
and teardown jobs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 13:11:14 +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 ce5e5fb729 Run gen_element_types before tsc --watch in make server/dev
Staging deployment / deploy (push) Successful in 10s
Staging deployment / comment (push) Has been skipped
Staging deployment / teardown (push) Has been skipped
Django CI/CD / build-and-push (push) Successful in 3m32s
Django CI/CD / test (push) Successful in 3m45s
make server and make dev were starting tsc --watch cold, so new element
registrations never landed in ts/generated/props.ts until make ts was run
manually. Adding gen-element-types as a dependency ensures props.ts is
always fresh before the watcher starts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 12:50:39 +02:00
lukas 846151d373 Fix converted price rounding (0 decimal places, not 2)
Django CI/CD / test (push) Failing after 5m49s
Staging deployment / deploy (push) Successful in 1m10s
Staging deployment / comment (push) Has been skipped
Staging deployment / teardown (push) Has been skipped
Django CI/CD / build-and-push (push) Has been skipped
round(..., 0) matches the original floatformat(..., 0) intent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 12:44:27 +02:00
lukas 1fcef255a6 Fix convert_prices storing exchange rate as string
Django CI/CD / test (push) Successful in 4m4s
Staging deployment / deploy (push) Failing after 31s
Staging deployment / comment (push) Has been skipped
Staging deployment / teardown (push) Has been skipped
Django CI/CD / build-and-push (push) Has been skipped
floatformat() returns a str; saving that to ExchangeRate.rate (FloatField)
via create() leaves the Python instance attribute as a str. Reading it back
on the same instance (rate = exchange_rate.rate) then caused
`purchase.price * rate` to fail with "can't multiply sequence by non-int
of type 'float'".

Fix: pass the raw float from the API directly to ExchangeRate.objects.create.
Also replace floatformat(..., 0) on the converted price with round(..., 2)
to keep a numeric type throughout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 12:43:46 +02:00
lukas 97fff21b28 Ignore more SQLite database files 2026-06-19 12:37:25 +02:00
lukas c6aa3d25cc Update uv.lock 2026-06-19 12:36:40 +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 dfccfbff51 Anchor DLC purchases to a base game instead of a parent purchase
Add-on purchases (DLC, Season Pass, Battle Pass) previously linked to a
parent *purchase* via the `related_purchase` self-FK. When the base game
was bought inside a multi-game purchase (e.g. a bundle), there was no
per-game purchase to point at — only the whole bundle.

Replace it with a `related_game` FK (Game -> Game): an add-on belongs to
a *game*, which is unambiguous regardless of how the base game was bought.

- models: drop `related_purchase`; add `related_game`
  (SET_NULL, related_name="addon_purchases"); require it for non-GAME
  types in `save()`.
- forms: replace the parent-purchase picker with a flat `related_game`
  game search (reusing SearchSelectWidget/_game_options); drop the now
  unused related_purchase_queryset/RelatedPurchaseChoiceField.
- views/urls: remove the obsolete related_purchase_by_game endpoint.
- add_purchase.js: drop the parent-dropdown refetch; keep platform
  auto-fill; retarget the type toggle to #id_related_game.
- migration 0020: add -> backfill (related_game = parent's first game by
  sort_name) -> remove related_purchase.
- tests: model validation unit tests + an e2e test for the flat picker.

related_game is deliberately game->game so it can later be synced from
IGDB's parent_game without schema changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 11:36:47 +02:00
lukas 62f0c6c261 Add tests for multiple APP_URLS
Django CI/CD / test (push) Successful in 3m20s
Django CI/CD / build-and-push (push) Successful in 3m48s
2026-06-19 11:28:16 +02:00
lukas d0d6b3f999 Make APP_URLS accept list 2026-06-19 11:28:16 +02:00
lukas 6f58eb3fde Surface the staging URL reliably
Echo the staging URL into the deploy log (not just the step summary),
and comment it when a PR is opened for an already-deployed branch
instead of waiting for the next push.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-19 11:28:16 +02:00
lukas d45ae357c4 Seed staging databases from a prod snapshot on first deploy
When a branch's staging volume doesn't exist yet, take a WAL-safe
online snapshot of the prod SQLite database (sqlite3.backup() in a
throwaway container, prod is only read) into the new volume. Later
pushes keep the staging data; deleting the branch (or the volume)
causes a fresh seed next time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-19 11:28:16 +02:00
lukas ae7fa5bae7 Add CSS-less dev mode
Django CI/CD / test (push) Successful in 5m54s
Django CI/CD / build-and-push (push) Successful in 3m27s
2026-06-19 11:26:01 +02:00
lukas be95c32e7b Change the default APP_URL in docker-compose.yml 2026-06-19 11:26:01 +02:00
lukas 32588226de Fix docker-compose not forwarding SECRET_KEY into container
SECRET_KEY, APP_URL, and DEBUG were hardcoded/missing in the compose
environment block, so passing SECRET_KEY from the host env had no effect
and the container always raised ImproperlyConfigured in production mode.

All three are now forwarded via ${VAR} substitution, consistent with
the other configurable values.
2026-06-19 11:26:01 +02:00
Claude 2ae01bfecf Add unified config system (issue #24)
Introduce timetracker/config.py with a single config() helper that resolves
settings from a fixed priority chain: NAME__FILE (opt-in secret) -> env var
-> .env -> settings.ini -> in-code default. Supports type casting
(bool/list/int/Path), file-based secrets with .strip(), and required_in_prod
validation.

Migrate settings.py off the previous ad-hoc idioms:
- DEBUG via config() (PROD kept as deprecated alias)
- SECRET_KEY required in prod, supports SECRET_KEY__FILE
- APP_URL derives ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS (kept separate,
  each independently overridable); ALLOWED_HOSTS is now configurable
- TZ and DATA_DIR via config()

Fix DATA_DIR inconsistency: entrypoint.sh now reads DATA_DIR (was hardcoded)
so the bash bootstrap and Django agree on the database directory.

Document the container/entrypoint-only flags (PUID/PGID/
CREATE_DEFAULT_SUPERUSER/STAGING/LOAD_SAMPLE_DATA) as bash concerns.

Update deployment configs to set APP_URL (and DEBUG), add docs/configuration.md,
settings.ini.example, regrouped .env.example, CLAUDE.md, and tests.

https://claude.ai/code/session_01FFn8BiGrQpEJarC8xGse8s
2026-06-19 11:26:01 +02:00
Claude 6a3f66b1a9 Provision pnpm via Corepack in CI to honor packageManager pin
CI installed pnpm with 'npm install -g pnpm', which pulls the latest
release and ignores the pnpm@10.33.0 pin in package.json's
packageManager field. This let CI drift to a different pnpm major than
the Docker image and local dev, the exact drift Corepack prevents.

Switch both the GitHub and Gitea build workflows to 'corepack enable',
matching the Dockerfile assets stage and the documented model where
local, CI, and Docker all follow the packageManager field.

https://claude.ai/code/session_01VWXYQxUPWdhoV4otwr6Cyk
2026-06-14 16:03:11 +00:00
lukas 1b0cccacf8 Merge pull request #23 from KucharczykL/claude/optimistic-volta-dx6xhd
Django CI/CD / test (push) Successful in 4m35s
Django CI/CD / build-and-push (push) Successful in 2m39s
Harden staging and bring GitHub/Gitea CI to parity
2026-06-14 16:46:54 +02:00
lukas 2b450c6d47 Fix sample.yaml fixture for current schema
Three issues from when the fixture was created before schema evolved:
- Game and Platform lacked created_at (auto_now_add bypassed by loaddata)
- Purchase lacked created_at/updated_at
- Purchase used 'game' FK that no longer exists; field is now the M2M
  'games', serialized as a list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 16:36:39 +02:00
lukas 9d02121c5b Grant pull-requests: write to staging deploy job
The github-script PR comment step needs this permission; without it
the GITHUB_TOKEN gets Resource not accessible by integration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 16:27:58 +02:00
lukas d2bf6efdb4 Fix Docker assets stage missing pnpm-workspace.yaml
The tar override lives in pnpm-workspace.yaml, which pnpm-lock.yaml
records. Copying only package.json + pnpm-lock.yaml left pnpm without
the overrides config, causing ERR_PNPM_LOCKFILE_CONFIG_MISMATCH on
frozen install.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 16:25:44 +02:00
lukas 227b1f674d Fix scrub_staging test isolation: use TransactionTestCase
TestCase wraps each test in a savepoint — when scrub_staging deletes
all django_session rows inside that savepoint, the rollback restores
any sessions committed by earlier tests (e.g. force_login in
test_paths_return_200). Those restored rows then leaked into the e2e
live-server tests, causing intermittent Session.MultipleObjectsReturned
errors.

TransactionTestCase flushes the DB before each test instead of using
savepoints, giving scrub_staging a clean slate and removing the leakage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 16:21:46 +02:00
Claude 017e3a61a8 Harden staging and bring GitHub/Gitea CI to parity
Address issue #20 and the CI divergence between Gitea and GitHub.

Issue #20 (staging seeded from a prod snapshot):
- Read SECRET_KEY from the environment with the insecure dev key as
  fallback, so each deployment can have its own key.
- Add a `scrub_staging` management command that clears django_session and
  the django-q schedule/queue/results, removing copied prod sessions and
  the inherited convert_prices() schedule.
- Run the scrub from entrypoint.sh when STAGING=true, and wire STAGING plus
  a per-branch SECRET_KEY into the Gitea staging deploy.

CI parity (both systems kept, independent):
- Add the Node/pnpm/TypeScript build steps to the Gitea build workflow to
  match the GitHub test job.
- Add a GitHub staging workflow that deploys per-branch ephemeral instances
  to Fly.io (*.fly.dev) with a fresh database seeded from sample fixtures
  and its own SECRET_KEY, never production data. Tears the app down on
  branch delete and comments the URL on the open PR via github-script.
- Add fly.staging.toml and a LOAD_SAMPLE_DATA entrypoint hook for the
  fresh-database public staging.

https://claude.ai/code/session_01KYjUcNjLfZ8Hq1GAC8J4oZ
2026-06-14 13:15:19 +00:00
lukas 2c699eb976 Merge pull request #22 from KucharczykL/claude/pnpm-npm-v12-compat-9xyk8e
Provision pnpm via Corepack and pin the version
2026-06-14 14:52:19 +02:00
Claude f19d24ee98 Document Corepack-based pnpm provisioning in CLAUDE.md 2026-06-14 12:49:28 +00:00
Claude 263299ca52 Bootstrap pnpm via Corepack and pin packageManager version
Replace the npm-based pnpm bootstrap in the Docker assets stage with
Corepack (ships with Node, no npm needed) and pin the pnpm version via
package.json's packageManager field for reproducible builds.
2026-06-14 12:48:30 +00:00
lukas 0b7ddc260f Merge pull request #21 from KucharczykL/claude/upgrade-tar-dependency-9fvn4d 2026-06-14 14:25:34 +02:00
Claude d9a8835696 Move tar override to pnpm-workspace.yaml
pnpm v11 (installed in CI via `npm install -g pnpm`) no longer reads the
`pnpm.overrides` field from package.json, which caused
ERR_PNPM_LOCKFILE_CONFIG_MISMATCH during the frozen install. Move the
override to pnpm-workspace.yaml, the new home for the setting, so CI's
pnpm reads it and matches the lockfile.

https://claude.ai/code/session_01NPQ9AiNNnapeoTQFAR1ShY
2026-06-14 12:22:22 +00:00
Claude 029c65da79 Update tar to 7.5.11+ to fix Dependabot alert
tar@6.2.1 was pulled in transitively via npm-check-updates' toolchain
(cacache, node-gyp, pacote). Add a pnpm override forcing tar >=7.5.11
to resolve the security advisory. Now resolves to tar@7.5.16.

https://claude.ai/code/session_01NPQ9AiNNnapeoTQFAR1ShY
2026-06-14 12:19:52 +00:00
lukas 008d92d433 Merge pull request #16 from KucharczykL/claude/custom-elements 2026-06-14 13:26:27 +02:00
lukas 9e17b94516 Add migration for FilterPreset.mode devices/platforms choices
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 13:09:15 +02:00
lukas 507353bb48 Commit pnpm lockfile for reproducible CI builds
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 13:03:49 +02:00
lukas a9e148701d Add TS build step to CI so e2e custom element tests have compiled JS
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 13:03:49 +02:00
lukas c3de90e805 Merge pull request #19 from KucharczykL/claude/custom-elements-experimental-unity
Try unifying 3 different element interfaces
2026-06-14 11:50:46 +02:00
lukas 1d2dfd23af Delete pnpm-workspace.yaml 2026-06-14 11:49:04 +02:00
lukas 395f6e8dea Fix logging out 2026-06-14 11:48:43 +02:00
lukas abfdd03c6e Fix Dockerfile build 2026-06-14 11:48:39 +02:00
lukas e15b197623 Remove base.css 2026-06-14 11:48:33 +02:00
lukas e12c667572 Add CREATE_DEFAULT_SUPERUSER env 2026-06-14 11:48:02 +02:00
lukas 874d3e236e Fix Button tests 2026-06-14 10:48:34 +02:00
lukas f036a246a8 Rename Button to StyledButton, simplify A 2026-06-14 10:47:23 +02:00
lukas 7751c29529 add custom element api proposal doc 2026-06-14 01:40:03 +02:00
lukas 5f411b8ae9 Try unifying 3 different element interfaces 2026-06-14 01:34:44 +02:00
lukas 3fb9aa9f84 Fix session-count script rendered as visible text
_GET_SESSION_COUNT_SCRIPT was a mark_safe string used as a child of the
view_game content tree. Under the "only Safe nodes render unescaped" rule, a
mark_safe *string* child is escaped — so the <script> showed as literal text
on the page. Make it a Safe node (and drop the now-unused mark_safe import).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:17:10 +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 ce976e8f2e Build TS in Docker (Node assets stage); document custom-element pattern 2026-06-13 21:28:20 +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 5fd82c78d4 Add TypeScript toolchain (tsc per-module, build-only) 2026-06-13 21:01:26 +02:00
lukas 58008d6f2c Merge pull request #15 from KucharczykL/claude/kind-gauss-vj2wyp
Django CI/CD / test (push) Successful in 3m9s
Django CI/CD / build-and-push (push) Successful in 1m44s
Lazy node-tree component system + onSwap widget lifecycle
2026-06-13 20:58:02 +02:00
lukas 3ff3eed164 Implementation plan: typed custom-element + htpy-style authoring
Bite-sized TDD plan for the design spec: TS toolchain scaffold, htpy-style
Element sugar, custom-element registry + codegen, then the three exemplar
conversions (GameStatusSelector, SessionDeviceSelector, played-row) retiring
their inline Alpine/@@TOKEN@@ f-strings, plus CI/Docker/docs wiring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 20:51:08 +02:00
lukas 7d46cc24b9 Design spec: typed custom-element + htpy-style HTML authoring
Brainstormed design for replacing the trusted HTML/JS f-strings (Alpine
selectors, @@TOKEN@@ played-row) with three composing layers:

- htpy-style sugar on the existing Element (kwargs attrs + [] children),
  additive, keeps Media/collect_media — no build step.
- Custom Elements (light DOM, TypeScript) for behavior, with the native
  connectedCallback lifecycle replacing the onSwap shim.
- A typed contract: one Python Props type per component, codegen'd into a TS
  interface + attribute reader, so server↔client drift fails `tsc`.

Toolchain: tsc per-module (no bundler, preserves per-component Media),
build-only/gitignored output, wired into make + Docker. Exemplars:
GameStatusSelector, SessionDeviceSelector, played-row. Alpine retired for
those three; existing .js migrated later.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 20:43:35 +02:00
lukas 3f95692746 Navbar returns a Safe node; drop redundant filter_presets mark_safe
Navbar is static chrome (a few reverse() URLs in otherwise-fixed markup), so
it now returns a single Safe node wrapping that markup instead of a mark_safe
string — consistent with "trusted HTML is a Safe node," and a full element
tree would be ~80 lines of nesting for no gain (it owns no component JS).
Page() interpolates it via str() exactly as before.

filter_presets.list_presets returned HttpResponse(mark_safe(...)); HttpResponse
never escapes its body, so the mark_safe was pure noise — dropped.

The mark_safe calls that remain are all load-bearing and not tree children:
the node engine itself (core: how a node emits its SafeString), the
script-tag / scripts= string helpers, and Page()'s final document string.

Full suite green (445).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:42:07 +02:00
lukas 0527412265 Update CSS 2026-06-13 19:37:10 +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 bec7a1074c Render nodes explicitly in component tests; drop the proxy/shims
The component tests rendered lazy nodes to HTML through two competing pieces
of scaffolding: a magic ``_RenderingComponents.__getattr__`` proxy that
auto-str()'d any capitalized builder, plus separate ``str()`` wrapper
functions for Checkbox / Radio (test_components) and SearchSelect /
FilterSelect / Pill (test_search_select).

Replace both with one explicit convention: import the real components and
wrap node-returning calls in ``str(...)`` at the call site. ``Node.__str__``
returns a ``SafeText``, so the ``assertIsInstance(..., SafeText)`` checks stay
meaningful and every string assertion is unchanged. Non-node helpers
(``randomid``, ``_resolve_name_with_icon``, ``_render_element``, the legacy
string ``Component()``) are called directly.

No production code touched; 141 component/search-select tests and the full
444-test suite pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 15:17:50 +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
lukas 925cf007f4 Load filter-bar e2e pages with htmx + module scripts
The onSwap migration turned filter_bar.js, range_slider.js, and
search_select.js into ES modules that register via htmx.onLoad. The five
filter synthetic e2e pages still loaded them as classic `<script defer>`
with no htmx present, so the `import { onSwap }` line was a SyntaxError and
no widget ever initialized — 18 failing tests.

Load htmx.min.js first (classic) and the three widgets as `type="module"`,
mirroring how Page() serves them in the real app. date_range_picker.js
stays a classic defer script (it is an IIFE, not a module).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 14:33:39 +02:00
Claude 2d3ae4e04f Phase 4: Page() collects component media; drop manual scripts= threading
Page() now calls collect_media(content) and emits the ModuleScript /
StaticScript tags itself, so views no longer thread scripts= for
component-owned JS. The list views (game/session/purchase/device/
platform/playevent) compose with Fragment(filter_bar, content) instead of
mark_safe(str(filter_bar) + str(content)) — keeping the node tree intact
so the filter bar's media (filter_bar.js + search_select.js +
range_slider.js, and date_range_picker.js on purchases) reaches Page().
The stats views drop _STATS_SCRIPTS; YearPicker's datepicker.umd.js is
collected from its declared media.

The scripts= argument remains for page-specific glue not owned by a
component (the add-form helpers add_game.js / add_purchase.js /
add_session.js, alongside search_select.js for their form widgets).

Adds regression tests asserting the list and stats pages auto-load their
widget scripts with no scripts= in the view, and documents the node/
media model in CLAUDE.md.

https://claude.ai/code/session_01BKurBhE3Qj25p7Bfsg7EeK
2026-06-13 07:32:35 +00: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 e7db7eb0e8 Provide libstdc++ to manylinux wheels in the Nix dev shell
Django CI/CD / test (push) Failing after 8m50s
Staging deployment / deploy (push) Successful in 24s
Staging deployment / teardown (push) Has been skipped
Django CI/CD / build-and-push (push) Has been skipped
greenlet (pulled in by pytest-playwright) ships a manylinux wheel whose
C extension links against libstdc++.so.6, which the nixpkgs Python
cannot resolve, breaking pytest at plugin-load time. Expose it via an
LD_LIBRARY_PATH scoped to the dev shell.

https://claude.ai/code/session_01BKurBhE3Qj25p7Bfsg7EeK
2026-06-12 21:41:55 +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 be919c992d Document pytest-playwright browser testing in CLAUDE.md
https://claude.ai/code/session_01BKurBhE3Qj25p7Bfsg7EeK
2026-06-12 21:39:54 +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 f32a88b47d add make server 2026-06-12 22:45:25 +02:00
lukas 6dfd6c83c9 fix: ensure deselecting presence modifier re-enables string input 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 b5546ed828 test: implement E2E Playwright tests for string multi-mode filters 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 2190b9d590 feat: add client-side toggle logic and multi-mode serialization for string filters 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 e8a49df2cf test: add comprehensive unit tests for all 8 string criterion modifiers 2026-06-12 22:45:25 +02:00
lukas 3c7ccbdd2b docs: add implementation plan for unifying form checkboxes 2026-06-12 22:45:25 +02:00
lukas 1322e6e71c feat: replace all form BooleanFields with PrimitiveCheckboxWidget via mixin 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 e309ff1b30 docs: add design spec and implementation plan for boolean filters improvement 2026-06-12 22:45:25 +02:00
lukas 35d314768f test: add explicit radio group and True/False choice checks for boolean fields 2026-06-12 22:45:25 +02:00
lukas f9032eef9e feat: add click-to-deselect behavior and update checked-radio serialization in JS 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
lukas 22c688bd9a Fix filter bars 2026-06-12 22:45:25 +02:00
lukas 4e77934d06 feat: implement frontend filter bars and integration across all list views 2026-06-12 22:45:25 +02:00
lukas b8d807d302 feat: implement comprehensive filters and cross-entity queries 2026-06-12 22:45:25 +02:00
lukas 67b40255ed Comment staging URL on the open PR after deployment
Looks up the open PR for the pushed branch via the Gitea API using the
workflow token and posts the staging URL once (skips if the same
comment already exists).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 22:45:25 +02:00
lukas eda9d39cdc Install Playwright browsers in CI test jobs
The e2e tests launch chromium, but uv sync only installs the playwright
package, not the browser binaries, so CI failed with "Executable
doesn't exist" for the headless shell.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 22:45:25 +02:00
lukas 3a5b6e2d51 Add Gitea Actions workflows for staging deployments
Django CI/CD / test (push) Failing after 1m1s
Staging deployment / deploy (push) Successful in 59s
Staging deployment / teardown (push) Has been skipped
Django CI/CD / build-and-push (push) Has been skipped
Branch pushes deploy a per-branch staging instance at
tracker-<slug>.home.arpa via the NAS act_runner; branch deletion tears
it down. build.yml ports the GitHub CI workflow so prod image builds
keep running on Gitea now that .gitea/workflows/ exists (Gitea ignores
.github/workflows/ when it does).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 17:08:53 +02:00
132 changed files with 23264 additions and 9132 deletions
+44 -14
View File
@@ -1,21 +1,51 @@
# Docker registry URL (used in docker-compose.yml) # =============================================================================
REGISTRY_URL=registry.kucharczyk.xyz # Django application settings (read by timetracker/config.py)
#
# Resolution priority, highest first:
# SECRET_KEY__FILE -> env var -> .env -> settings.ini -> built-in default
# See docs/configuration.md for the full reference.
# =============================================================================
# Container timezone # Turn DEBUG off in production. Defaults on for local development.
# (The old PROD=1 variable still works but is deprecated; prefer DEBUG.)
DEBUG=false
# Secret key. Required in production; an insecure default is used in DEBUG.
# For Docker/K8s secrets, point SECRET_KEY__FILE at a mounted file instead.
SECRET_KEY=change-me-to-a-long-random-string
# SECRET_KEY__FILE=/run/secrets/timetracker_secret_key
# Public URL(s) of the site — one URL or comma-separated list of full URLs.
# Derives ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS from all listed URLs.
APP_URL=https://tracker.kucharczyk.xyz
# APP_URL=https://tracker.kucharczyk.xyz,https://www.tracker.kucharczyk.xyz
# Override ALLOWED_HOSTS directly for edge cases (e.g. behind a reverse proxy).
# ALLOWED_HOSTS=*
# Container timezone.
TZ=Europe/Prague TZ=Europe/Prague
# User/group IDs for container (used in entrypoint.sh) # Directory holding the SQLite database (defaults to the project root).
DATA_DIR=/home/timetracker/app/data
# =============================================================================
# Container / entrypoint-only settings (read by entrypoint.sh, NOT by Django)
# =============================================================================
# User/group IDs the container process runs as.
PUID=1000 PUID=1000
PGID=100 PGID=100
# External port mapping # Create an admin/admin superuser on startup (for initial setup only).
CREATE_DEFAULT_SUPERUSER=false
# =============================================================================
# docker-compose-only settings (compose file substitution, not the app)
# =============================================================================
# Docker registry URL (used in docker-compose.yml).
REGISTRY_URL=registry.kucharczyk.xyz
# External port mapping.
TIMETRACKER_EXTERNAL_PORT=8000 TIMETRACKER_EXTERNAL_PORT=8000
# Django production mode (set to "1" for production)
PROD=1
# Database directory (defaults to project root)
DATA_DIR=/home/timetracker/app/data
# CSRF trusted origins
CSRF_TRUSTED_ORIGINS=https://tracker.kucharczyk.xyz
+62
View File
@@ -0,0 +1,62 @@
name: Django CI/CD
on:
push:
paths-ignore: [ 'README.md' ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: false
python-version: "3.14"
- name: Install dependencies
run: uv sync --frozen
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install pnpm and JS dependencies
run: corepack enable && pnpm install --frozen-lockfile --ignore-scripts
- name: Build TypeScript
run: make ts
- name: Install Playwright browsers
run: uv run playwright install --with-deps chromium
- name: Run Migrations
run: uv run python manage.py migrate
- name: Run Tests
run: uv run --with pytest-django pytest
build-and-push:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Set Version
run: echo "VERSION_NUMBER=1.7.0" >> $GITHUB_ENV
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
registry.kucharczyk.xyz/timetracker:latest
registry.kucharczyk.xyz/timetracker:${{ env.VERSION_NUMBER }}
+140
View File
@@ -0,0 +1,140 @@
name: Staging deployment
on:
push:
branches-ignore: [main]
delete:
pull_request:
types: [opened]
jobs:
deploy:
if: github.event_name == 'push'
runs-on: ubuntu-latest
env:
BRANCH: ${{ github.ref_name }}
steps:
- uses: actions/checkout@v4
- name: Compute staging name
run: |
SLUG=$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g; s/-+/-/g; s/^-//; s/-$//' | cut -c1-40)
echo "SLUG=${SLUG}" >> "$GITHUB_ENV"
echo "HOST=tracker-${SLUG}.home.arpa" >> "$GITHUB_ENV"
# Per-staging secret so each instance has its own key, decoupling it
# from prod even though the database is seeded from a prod snapshot.
echo "STAGING_SECRET_KEY=staging-${SLUG}-$(head -c16 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9')" >> "$GITHUB_ENV"
- name: Build image
run: docker build -t "timetracker:staging-${SLUG}" .
- name: Seed database from prod (first deploy of this branch only)
run: |
if docker volume inspect "timetracker-staging-${SLUG}" >/dev/null 2>&1; then
echo "Volume exists, keeping current staging data"
exit 0
fi
docker volume create "timetracker-staging-${SLUG}"
# sqlite3.backup() takes a consistent online snapshot (WAL-safe);
# prod is only read, never written.
docker run --rm \
-v /docker/timetracker/data:/prod \
-v "timetracker-staging-${SLUG}:/dest" \
python:3.14-slim-bookworm sh -c "
python -c \"
import sqlite3
source = sqlite3.connect('file:/prod/db.sqlite3?mode=ro', uri=True)
destination = sqlite3.connect('/dest/db.sqlite3')
source.backup(destination)
games = destination.execute('select count(*) from games_game').fetchone()[0]
sessions = destination.execute('select count(*) from games_session').fetchone()[0]
print(f'Seeded staging database: {games} games, {sessions} sessions')
destination.close()
source.close()
\" && chown 1000:100 /dest/db.sqlite3"
- name: Deploy staging container
run: |
docker rm -f "timetracker-staging-${SLUG}" 2>/dev/null || true
docker run -d --name "timetracker-staging-${SLUG}" \
--network docker-compose-templates_public \
-e TZ=Europe/Prague \
-e PUID=1000 \
-e PGID=100 \
-e DATA_DIR=/home/timetracker/app/data \
-e STAGING=true \
-e "SECRET_KEY=${STAGING_SECRET_KEY}" \
-e "APP_URL=https://${HOST}" \
-v "timetracker-staging-${SLUG}:/home/timetracker/app/data" \
-l "caddy=${HOST}" \
-l 'caddy.reverse_proxy={{ upstreams 8000 }}' \
-l xyz.kucharczyk.staging=timetracker \
-l "xyz.kucharczyk.staging.branch=${BRANCH}" \
--restart unless-stopped \
"timetracker:staging-${SLUG}"
- name: Summary
run: |
echo "Deployed to https://${HOST}"
echo "Deployed to https://${HOST}" >> "$GITHUB_STEP_SUMMARY"
- name: Comment staging URL on PR
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
auth="Authorization: token ${GITHUB_TOKEN}"
api="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}"
pr=$(curl -fsS -H "$auth" "${api}/pulls?state=open&limit=50" \
| jq -r --arg branch "$BRANCH" '.[] | select(.head.ref == $branch) | .number' | head -n1)
if [ -z "$pr" ]; then
echo "No open PR for branch '${BRANCH}', skipping comment"
exit 0
fi
body="Staging deployment: https://${HOST}"
if curl -fsS -H "$auth" "${api}/issues/${pr}/comments" \
| jq -e --arg body "$body" 'any(.[]; .body == $body)' >/dev/null; then
echo "Staging URL already commented on PR #${pr}"
exit 0
fi
curl -fsS -X POST -H "$auth" -H 'Content-Type: application/json' \
-d "$(jq -n --arg body "$body" '{body: $body}')" \
"${api}/issues/${pr}/comments" >/dev/null
echo "Commented staging URL on PR #${pr}"
comment:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
env:
BRANCH: ${{ github.event.pull_request.head.ref }}
PR: ${{ github.event.pull_request.number }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Comment staging URL on the new PR
run: |
SLUG=$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g; s/-+/-/g; s/^-//; s/-$//' | cut -c1-40)
HOST="tracker-${SLUG}.home.arpa"
auth="Authorization: token ${GITHUB_TOKEN}"
api="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}"
body="Staging deployment: https://${HOST}"
if curl -fsS -H "$auth" "${api}/issues/${PR}/comments" \
| jq -e --arg body "$body" 'any(.[]; .body == $body)' >/dev/null; then
echo "Staging URL already commented on PR #${PR}"
exit 0
fi
curl -fsS -X POST -H "$auth" -H 'Content-Type: application/json' \
-d "$(jq -n --arg body "$body" '{body: $body}')" \
"${api}/issues/${PR}/comments" >/dev/null
echo "Commented staging URL on PR #${PR}"
teardown:
if: github.event_name == 'delete' && github.event.ref_type == 'branch'
runs-on: ubuntu-latest
env:
BRANCH: ${{ github.event.ref }}
steps:
- name: Remove staging container, volume, and image
run: |
SLUG=$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g; s/-+/-/g; s/^-//; s/-$//' | cut -c1-40)
docker rm -f "timetracker-staging-${SLUG}" 2>/dev/null || true
docker volume rm "timetracker-staging-${SLUG}" 2>/dev/null || true
docker rmi "timetracker:staging-${SLUG}" 2>/dev/null || true
+17
View File
@@ -19,6 +19,23 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: uv sync --frozen run: uv sync --frozen
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install pnpm and JS dependencies
run: corepack enable && pnpm install --frozen-lockfile --ignore-scripts
- name: Build TypeScript
run: make ts
- name: Build CSS
run: make css
- name: Install Playwright browsers
run: uv run playwright install --with-deps chromium
- name: Run Migrations - name: Run Migrations
run: uv run python manage.py migrate run: uv run python manage.py migrate
+105
View File
@@ -0,0 +1,105 @@
name: Staging deployment
on:
push:
branches-ignore: [main]
delete:
concurrency:
group: staging-${{ github.event.ref }}
cancel-in-progress: true
jobs:
deploy:
if: github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
pull-requests: write
env:
BRANCH: ${{ github.ref_name }}
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
steps:
- uses: actions/checkout@v4
- name: Compute staging name
run: |
SLUG=$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g; s/-+/-/g; s/^-//; s/-$//' | cut -c1-30 | sed 's/-*$//')
APP="timetracker-staging-${SLUG}"
echo "SLUG=${SLUG}" >> "$GITHUB_ENV"
echo "APP=${APP}" >> "$GITHUB_ENV"
echo "HOST=${APP}.fly.dev" >> "$GITHUB_ENV"
- name: Set up flyctl
uses: superfly/flyctl-actions/setup-flyctl@master
- name: Create app if missing
run: |
if ! flyctl status --app "$APP" >/dev/null 2>&1; then
flyctl apps create "$APP" --org personal
fi
- name: Set staging secrets
run: |
# Per-app SECRET_KEY so each staging instance is independent and no
# session cookie is shared across instances or with production.
SECRET_KEY="staging-${SLUG}-$(head -c16 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9')"
# APP_URL derives both ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS.
flyctl secrets set --app "$APP" --stage \
"SECRET_KEY=${SECRET_KEY}" \
"APP_URL=https://${HOST}"
- name: Deploy
# --ha=false so Fly provisions a single machine. Staging stores sessions
# in machine-local SQLite (no shared volume), so a second machine would
# serve requests it has no session for, bouncing logged-in users back to
# the login page. scale count 1 fixes apps already created with two.
run: |
flyctl deploy --app "$APP" --config fly.staging.toml --remote-only --yes --ha=false
flyctl scale count 1 --app "$APP" --yes
- name: Summary
run: echo "Deployed to https://${HOST}" >> "$GITHUB_STEP_SUMMARY"
- name: Comment staging URL on PR
uses: actions/github-script@v7
with:
script: |
const host = process.env.HOST;
const branch = process.env.BRANCH;
const body = `Staging deployment: https://${host}`;
const { owner, repo } = context.repo;
const pulls = await github.rest.pulls.list({
owner, repo, state: "open", head: `${owner}:${branch}`,
});
const pr = pulls.data[0];
if (!pr) {
core.info(`No open PR for branch '${branch}', skipping comment`);
return;
}
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: pr.number,
});
if (comments.some((comment) => comment.body === body)) {
core.info(`Staging URL already commented on PR #${pr.number}`);
return;
}
await github.rest.issues.createComment({
owner, repo, issue_number: pr.number, body,
});
core.info(`Commented staging URL on PR #${pr.number}`);
teardown:
if: github.event_name == 'delete' && github.event.ref_type == 'branch'
runs-on: ubuntu-latest
env:
BRANCH: ${{ github.event.ref }}
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
steps:
- name: Set up flyctl
uses: superfly/flyctl-actions/setup-flyctl@master
- name: Destroy staging app
run: |
SLUG=$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g; s/-+/-/g; s/^-//; s/-$//' | cut -c1-30 | sed 's/-*$//')
APP="timetracker-staging-${SLUG}"
flyctl apps destroy "$APP" --yes 2>/dev/null || true
+11 -1
View File
@@ -4,12 +4,22 @@ __pycache__
.venv/ .venv/
node_modules node_modules
package-lock.json package-lock.json
pnpm-lock.yaml
db.sqlite3 db.sqlite3
db.sqlite3-shm
db.sqlite3-wal
data/ data/
/static/ /static/
dist/ dist/
.DS_Store .DS_Store
.python-version .python-version
# Local configuration (may contain secrets); examples are committed instead
.env
/settings.ini
.direnv .direnv
.hermes/ .hermes/
# Build artifacts: generated in CI/Docker assets stage, not committed
/games/static/base.css
/games/static/js/dist/
/ts/generated/
+44 -17
View File
@@ -35,6 +35,7 @@ games/ — Django app: models, views, templates, forms, signals, tasks,
common/ — Shared utilities: time formatting, component system, criteria, layout, icons common/ — Shared utilities: time formatting, component system, criteria, layout, icons
timetracker/ — Django project: settings, URL root, ASGI/WSGI timetracker/ — Django project: settings, URL root, ASGI/WSGI
tests/ — Pytest tests tests/ — Pytest tests
e2e/ — Playwright browser tests (run via `make test-e2e`)
contrib/ — One-off scripts (exchange rate import) contrib/ — One-off scripts (exchange rate import)
docs/ — Additional documentation docs/ — Additional documentation
``` ```
@@ -43,7 +44,7 @@ docs/ — Additional documentation
- **Game** — `name`, `platform` (FK), `status` (u/p/f/r/a), `mastered`, `playtime` (DurationField updated via signal), `year_released`, `sort_name`, `wikidata` - **Game** — `name`, `platform` (FK), `status` (u/p/f/r/a), `mastered`, `playtime` (DurationField updated via signal), `year_released`, `sort_name`, `wikidata`
- **Platform** — `name`, `group`, `icon` (slug, auto-generated from name) - **Platform** — `name`, `group`, `icon` (slug, auto-generated from name)
- **Purchase** — ownership type, prices, currency conversion (`converted_price`, `price_per_game` is a `GeneratedField`), links to Game via M2M. `num_purchases` counts linked games. DLC/SeasonPass/BattlePass must have a `related_purchase` - **Purchase** — ownership type, prices, currency conversion (`converted_price`, `price_per_game` is a `GeneratedField`), links to Game via M2M. `num_purchases` counts linked games. DLC/SeasonPass/BattlePass must have a `related_game` (the base Game the add-on belongs to; reverse accessor `game.addon_purchases`). **A multi-game Purchase is an *unsplittable* bundle** (one price, whole-purchase refund — e.g. a Humble Bundle). Independently-refundable multi-item orders (e.g. a Steam cart) are modeled as **separate single-game purchases**, not one bundle: the add-purchase form's "separate price per game" mode (≥2 games) creates them, and the row's **Split** action breaks an existing bundle into per-game purchases (price split evenly as a starting point). This is why per-game refund/price need no through-model — each refundable unit is its own Purchase.
- **Session** — `timestamp_start`/`timestamp_end`, `duration_manual`, `device` (FK), `note`, `emulated`. `duration_calculated` and `duration_total` are `GeneratedField`s (cannot be written directly) - **Session** — `timestamp_start`/`timestamp_end`, `duration_manual`, `device` (FK), `note`, `emulated`. `duration_calculated` and `duration_total` are `GeneratedField`s (cannot be written directly)
- **Device** — `name`, `type` (PC/Console/Handheld/Mobile/SBC/Unknown) - **Device** — `name`, `type` (PC/Console/Handheld/Mobile/SBC/Unknown)
- **PlayEvent** — marks when a game was started/finished (separate from Sessions), `days_to_finish` is a `GeneratedField` - **PlayEvent** — marks when a game was started/finished (separate from Sessions), `days_to_finish` is a `GeneratedField`
@@ -57,12 +58,12 @@ docs/ — Additional documentation
### Key patterns ### Key patterns
**Layout system** (`common/layout.py`): Views call `render_page(request, content, title=...)` instead of Django's `render()`. This assembles a full HTML document via `Page()` — analogous to FastHTML's `fast_app()`. `Page()` handles the `<head>`, navbar, toast container, JS includes, and FOUC-prevention script. The navbar shows today's playtime and last-7-days playtime from the `model_counts` context processor. **Layout system** (`common/layout.py`): Views call `render_page(request, content, title=...)` instead of Django's `render()`. This assembles a full HTML document via `Page()` — analogous to FastHTML's `fast_app()`. `Page()` handles the `<head>`, navbar, toast container, FOUC-prevention script, and **JS includes**: it calls `collect_media(content)` to gather every component's declared `Media` and emits the `<script>` tags automatically — so views do **not** pass `scripts=` for component-owned JS. The `scripts=` argument remains only for page-specific glue not owned by a reusable component (e.g. the add-form helper `add_*.js`). The navbar shows today's/last-7-days playtime from the `model_counts` context processor.
**Component system** (`common/components/`): Pure-Python HTML builders, split into four submodules re-exported via `common/components/__init__.py`: **Component system** (`common/components/`): a FastHTML-style **lazy node tree**. Components are `Node` objects that render to HTML only when asked (`str(node)` / `Page()`), so `Page()` can walk a finished tree and collect each component's JS. Split into submodules re-exported via `common/components/__init__.py`:
- **`core.py`** — `Component(tag_name, attributes, children)`: the fundamental builder. `_render_element()` is `@lru_cache`-memoized (4096 entries, always active). Attribute values are always HTML-escaped; children are escaped unless they are `SafeText`. `randomid()` generates stable hash-based IDs. - **`core.py`** — the node layer. `Node` (base; `__html__`/`__str__` return a `SafeString`), `Element` (the single class for *any* HTML element), `Safe` (wraps pre-rendered/trusted HTML), `Fragment` (ordered children, no wrapper tag — use instead of `str(a)+str(b)`), `BaseComponent` (base for higher-level components: implement `render()`, declare `media`), and `Media` (declarative JS deps with order-preserving dedup merge; `collect_media()` sums them over a tree, `node.with_media(...)` attaches them). `_render_element()` is `@lru_cache`-memoized (4096). Attribute values are always escaped. **Children: every string child is escaped — `SafeText`/`mark_safe` included; only `Node` children (so `Safe`) render unescaped.** Trusted pre-rendered HTML must be wrapped in `Safe(...)`, never passed as a safe string. `randomid()` generates stable hash-based IDs.
- **`primitives.py`** — Generic HTML: `A()`, `Button()` (with color/size/icon params), `ButtonGroup()`, `Div()`, `Span()`, `Label()`, `Input()`, `Icon()`, `Popover()`, `PopoverTruncated()`, `SearchField()`, `H1()`, `Modal()`, `SimpleTable()`, `TableRow()`, `TableTd()`, `TableHeader()`, `paginated_table_content()`, `AddForm()`, `Pill()`, `CsrfInput()`, `ModuleScript()` - **`primitives.py`** — Generic HTML. Plain leaf builders (`Div`, `Span`, `P`, `Ul`, `Li`, `Strong`, `Label`, `Template`, `Td`, `Tr`, `Th`) are **generated from a whitelist** via the `_html_element(tag)` factory over `Element` — not hand-written per tag. Builders that add classes/behaviour are written out: `A()`, `Button()`, `ButtonGroup()`, `Input()`, `Checkbox()`, `Radio()`, `Pill()`, `Icon()`, `Popover()`, `PopoverTruncated()`, `SearchField()`, `H1()`, `Modal()`, `SimpleTable()`, `TableRow()`, `TableTd()`, `TableHeader()`, `paginated_table_content()`, `AddForm()`, `YearPicker()` (declares datepicker media), `CsrfInput()`/`ModuleScript()`/`StaticScript()` (script-tag string helpers used by `Page()`).
- **`domain.py`** — Domain-specific: `GameLink()`, `GameStatus()` (colored dot + label), `GameStatusSelector()` (Alpine.js PATCH dropdown), `SessionDeviceSelector()` (Alpine.js PATCH dropdown), `LinkedPurchase()`, `NameWithIcon()`, `PriceConverted()`, `PurchasePrice()` - **`domain.py`** — Domain-specific: `GameLink()`, `GameStatus()` (colored dot + label), `GameStatusSelector()` (Alpine.js PATCH dropdown), `SessionDeviceSelector()` (Alpine.js PATCH dropdown), `LinkedPurchase()`, `NameWithIcon()`, `PriceConverted()`, `PurchasePrice()`
- **`filters.py`** — Filter UI: `FilterBar()`, `SessionFilterBar()`, `PurchaseFilterBar()` (built from `FilterSelect` widgets) - **`filters.py`** — Filter UI: `FilterBar()`, `SessionFilterBar()`, `PurchaseFilterBar()` (built from `FilterSelect` widgets)
- **`search_select.py`** — `SearchSelect()` (form combobox) + `FilterSelect()` (include/exclude filter combobox with pinned Any/None modifiers) + `SearchSelectOption`, all built on a shared `_combobox_shell`; wired by `games/static/js/search_select.js` - **`search_select.py`** — `SearchSelect()` (form combobox) + `FilterSelect()` (include/exclude filter combobox with pinned Any/None modifiers) + `SearchSelectOption`, all built on a shared `_combobox_shell`; wired by `games/static/js/search_select.js`
@@ -113,29 +114,47 @@ Only a small number of HTML templates remain (platform icon snippets and partial
### Frontend stack ### Frontend stack
- **HTMX** (`games/static/js/htmx.min.js`) — partial page updates - **HTMX** (`games/static/js/htmx.min.js`) — partial page updates
- **Alpine.js** (CDN) — reactive dropdowns (`GameStatusSelector`, `SessionDeviceSelector`), toast store - **Alpine.js** (vendored: `alpine.min.js`, `alpine-mask.min.js`) — reactive dropdowns (`GameStatusSelector`, `SessionDeviceSelector`), toast store
- **Flowbite** (CDN) — navbar collapse, dropdown toggles - **Flowbite** (vendored: `flowbite.min.js`; `datepicker.umd.js` for the stats YearPicker) — navbar collapse, dropdown toggles
- **Tailwind CSS** — utility classes, compiled from `common/input.css``games/static/base.css` - **Tailwind CSS** — utility classes, compiled from `common/input.css``games/static/base.css`
- All third-party JS is served locally from `games/static/js/` (no CDNs), so pages and browser tests work offline
- **Custom JS** in `games/static/js/`: - **Custom JS** in `games/static/js/`:
- `toast.js` — Alpine.js toast store (listens for `show-toast` HTMX event) - `toast.js` — Alpine.js toast store (listens for `show-toast` HTMX event); also defines `window.fetchWithHtmxTriggers`
- `search_select.js` — SearchSelect/FilterSelect widgets (search-as-you-type, pills, include/exclude filter mode) - `search_select.js` — SearchSelect/FilterSelect widgets (search-as-you-type, pills, include/exclude filter mode)
- `utils.js` — shared helpers (e.g., `fetchWithHtmxTriggers`) - `utils.js` — shared ES-module helpers (`onSwap`, `toISOUTCString`, …)
- **Widget initialization**: widget JS registers with `onSwap(selector, initializeElement)` from `utils.js` — a port of FastHTML's `proc_htmx` built on `htmx.onLoad`. It runs the initializer once per matching element, on initial page load and inside every htmx-swapped fragment. Never hand-roll `DOMContentLoaded`/`htmx:afterSwap` listeners with per-element guard flags.
### Interactive components: custom elements + TypeScript
New interactive components are **custom elements**, not inline JS in Python. A component that needs behavior emits a semantic tag via `custom_element("tag", Props(...))` (light DOM, server-rendered inner markup built with the htpy-style node builders). Behavior lives in `ts/elements/<tag>.ts` (TypeScript, vanilla DOM, `customElements.define`); the native `connectedCallback` replaces `onSwap` (it fires on parse *and* htmx swap). The server↔client contract is one Python `TypedDict` per element registered with `register_element(...)` in `common/components/custom_elements.py`; `manage.py gen_element_types` codegens `ts/generated/props.ts` (interface + attribute reader) so renaming a prop fails `tsc`.
- **Build:** `tsc` per-module (`tsconfig.json`) compiles `ts/``games/static/js/dist/` (build-only, gitignored). `make ts` = codegen + compile; `make ts-check` (in `make check`) = codegen + `tsc --noEmit`; `make dev` runs `tsc --watch`. The Docker image builds CSS + TS in a Node stage. Run `make ts` after editing any `.ts` so e2e/local serving sees fresh output.
- **htpy-style markup:** generic builders take kwargs attributes and `[]` children — `Div(class_="x", hx_get="/y")[child1, child2]` (`class_``class`, `hx_get``hx-get`, `True`→bare attr, `False`/`None`→omitted). Still a walkable `Element` tree, so `Media` bubbles.
- **Do NOT** author HTML/JS as Python f-strings or add new inline Alpine `x-data` blobs. Alpine remains only for trivial pre-existing toggles (toast store, etc.).
- **Tables collect cell media:** `SimpleTable` stringifies cells, so it explicitly `collect_media`s its rows/header and re-attaches it — a custom element in a table cell still gets its `<script>` emitted by `Page()`.
### Deployment ### Deployment
Docker-based: multi-stage Dockerfile (uv builder → slim runtime), Caddy as reverse proxy on port 8000, Gunicorn with UvicornWorker (ASGI), Supervisor to manage Caddy + Gunicorn + django-q2. `make dev-prod` mimics production locally. CI/CD via GitHub Actions (`.github/workflows/build-docker.yml`): builds Docker image; Drone CI (`.drone.yml`) also present for deployments via Portainer webhook. Docker-based: multi-stage Dockerfile (uv builder → Node assets stage → slim runtime), Caddy as reverse proxy on port 8000, Gunicorn with UvicornWorker (ASGI), Supervisor to manage Caddy + Gunicorn + django-q2. `make dev-prod` mimics production locally. CI/CD via GitHub Actions (`.github/workflows/build-docker.yml`): builds Docker image; Drone CI (`.drone.yml`) also present for deployments via Portainer webhook.
**Package manager (pnpm):** front-end deps use **pnpm**, not npm. The pnpm version is pinned in `package.json`'s `packageManager` field and provisioned via **Corepack** (bundled with Node) — the Docker assets stage runs `corepack enable` rather than `npm install -g pnpm`. To bump pnpm, update the `packageManager` field; local, CI, and Docker all follow it. pnpm disables dependency lifecycle scripts by default (opt in via `pnpm.onlyBuiltDependencies`), so the project is unaffected by npm v12's install-script changes.
### Database ### Database
SQLite with WAL journal mode. Connection timeout 20s. The `DATA_DIR` env var controls the database file location. Migrations live in `games/migrations/`. There are `GeneratedField`s on the models — these are computed by the database engine and cannot be written from application code. SQLite with WAL journal mode. Connection timeout 20s. The `DATA_DIR` setting controls the database file location and is read consistently by both `settings.py` and `entrypoint.sh` (same env var + matching default). Migrations live in `games/migrations/`. There are `GeneratedField`s on the models — these are computed by the database engine and cannot be written from application code.
### Configuration ### Configuration
- `DEBUG` is `True` unless `PROD` env var is set All configurable Django settings are read through `config()` in `timetracker/config.py`, never via bare `os.environ` in `settings.py`. Full reference: `docs/configuration.md`.
- `TIME_ZONE` defaults to `Europe/Prague` in debug, otherwise reads `TZ` env var (default `UTC`)
- Django Admin, Debug Toolbar, and `django_extensions` are only available in `DEBUG` mode - **Resolution priority** (highest first): `NAME__FILE` (opt-in file secret) → `NAME` env var → `.env``settings.ini` (`[timetracker]` section) → in-code default. Missing + no default = `ImproperlyConfigured`.
- `CSRF_TRUSTED_ORIGINS` is parsed from a comma-separated env var - `config(name, *, default, cast, allow_file, required_in_prod)`: `cast` handles `bool`/`list`/`int`/`Path`/callable; `allow_file=True` honors `NAME__FILE` (contents `.strip()`-ed); `required_in_prod=True` hard-fails when missing and DEBUG is off.
- `DATA_DIR` env var sets the SQLite database location (defaults to `BASE_DIR`) - `DEBUG` defaults `True` (dev), turned off with `DEBUG=false`. `PROD` is a **deprecated alias** kept for one release.
- `SECRET_KEY` is required in production (insecure default only in DEBUG); supports `SECRET_KEY__FILE`.
- `APP_URL` accepts one full URL or a comma-separated list of full URLs; `ALLOWED_HOSTS` and `CSRF_TRUSTED_ORIGINS` are derived from all listed URLs. `ALLOWED_HOSTS` can still be overridden directly (e.g. `ALLOWED_HOSTS=*` behind a reverse proxy); `CSRF_TRUSTED_ORIGINS` is always derived from `APP_URL`.
- `TIME_ZONE` reads `TZ` (defaults `Europe/Prague` in debug, `UTC` in prod).
- Django Admin, Debug Toolbar, and `django_extensions` are only available in `DEBUG` mode.
- **Container/entrypoint-only** flags (`PUID`, `PGID`, `CREATE_DEFAULT_SUPERUSER`, `STAGING`, `LOAD_SAMPLE_DATA`) live in `entrypoint.sh`, not the Python config — they are bootstrap concerns, not Django settings.
- django-q2 cluster: 1 worker, 60s timeout, 120s retry, ORM broker - django-q2 cluster: 1 worker, 60s timeout, 120s retry, ORM broker
### Testing ### Testing
@@ -155,16 +174,24 @@ Tests live in `tests/`. Run with `make test` or `uv run --with pytest-django pyt
Pytest settings are in `pyproject.toml` under `[tool.pytest.ini_options]` (`DJANGO_SETTINGS_MODULE = "timetracker.settings"`). Pytest settings are in `pyproject.toml` under `[tool.pytest.ini_options]` (`DJANGO_SETTINGS_MODULE = "timetracker.settings"`).
**Browser/E2E tests** live in `e2e/` and run with `make test-e2e` (`pytest-playwright` driving a real Chromium against pytest-django's `live_server`). `e2e/conftest.py` sets `DJANGO_ALLOW_ASYNC_UNSAFE` and prefers a system Chrome/Chromium; otherwise install browsers once via `uv run playwright install chromium`. All JS (including Alpine/Flowbite) is vendored in `games/static/js/`, so the tests run fully offline. Note that a bare `pytest` (`make test`) collects `e2e/` too, so it needs a browser as well. Key files: `test_widgets_e2e.py` (onSwap initialization lifecycle, FilterSelect/RangeSlider/add-purchase behavior), `test_search_select_e2e.py` (single-select edge cases on a synthetic page).
## Conventions for AI assistants ## Conventions for AI assistants
- **Never write to `GeneratedField`s** (`duration_calculated`, `duration_total`, `price_per_game`, `days_to_finish`). They are computed by the database. - **Never write to `GeneratedField`s** (`duration_calculated`, `duration_total`, `price_per_game`, `days_to_finish`). They are computed by the database.
- **Name variables with complete words** — readable, unabbreviated identifiers in both Python and JavaScript (e.g. `template` not `tpl`, `event` not `e`, `element` not `el`, `removeButton` not `removeBtn`, `option`/`value` not single letters in loops). This applies to new code and to code you touch. - **Name variables with complete words** — readable, unabbreviated identifiers in both Python and JavaScript (e.g. `template` not `tpl`, `event` not `e`, `element` not `el`, `removeButton` not `removeBtn`, `option`/`value` not single letters in loops). This applies to new code and to code you touch.
- **Use `render_page()` not `render()`** for all full-page HTTP responses. Import from `common.layout`. - **Use `render_page()` not `render()`** for all full-page HTTP responses. Import from `common.layout`.
- **Build UI with Python components** from `common.components`, not raw HTML strings or Django templates. `SafeText` children pass through unescaped; plain strings are auto-escaped. - **Build UI with Python components** from `common.components`, not raw HTML strings or Django templates. `SafeText` children pass through unescaped; plain strings are auto-escaped.
- **Prefer the named element primitives over raw `Component(tag_name=…)`** — use `Div()`, `Span()`, `Input()`, `Label()`, `Template()`, etc. from `common.components` instead of `Component(tag_name="div")`. Reach for `Component` directly only when no primitive fits (e.g. a bare, custom-styled `<button>` where the opinionated `Button()` would inject unwanted classes). Add a new primitive rather than repeating an inline `Component` for a standard tag. - **Components are nodes; use the named builders** — build with `Div()`, `Span()`, `Element("tag", ...)`, etc., which return `Node` objects. For a tag with no builder, add it to the whitelist in `primitives.py` (one line) or use `Element("tag", attrs, children)`. Use `Fragment(a, b, ...)` to group siblings (never `str(a)+str(b)`, which flattens the tree and drops media). Wrap trusted pre-rendered HTML in `Safe(html)` (the `mark_safe` analogue).
- **JS-bearing components declare `Media`, they don't rely on the view** — give a component `class Media: js = (...)` (a `BaseComponent`) or `return node.with_media(Media(js=...))`. `Page()` collects and emits it. Never re-add `scripts=ModuleScript(...)` threading in a view for a component that can declare its own dependency.
- **Filter views** accept `?filter=<JSON>` (structured) and fall back to `?search_string=` (free-text). New filter criteria go in `games/filters.py`; new criterion types go in `common/criteria.py`. - **Filter views** accept `?filter=<JSON>` (structured) and fall back to `?search_string=` (free-text). New filter criteria go in `games/filters.py`; new criterion types go in `common/criteria.py`.
- **Read settings via `config()`** — new Django settings go through `config()` from `timetracker/config.py`, never bare `os.environ.get` in `settings.py`. Declare `cast`/`allow_file`/`required_in_prod` explicitly. Container-bootstrap flags belong in `entrypoint.sh`, not the Python config. See `docs/configuration.md`.
- **Signals handle side-effects** — do not manually recalculate `Game.playtime` or `Purchase.num_purchases`; the signals in `games/signals.py` do this on save/delete. - **Signals handle side-effects** — do not manually recalculate `Game.playtime` or `Purchase.num_purchases`; the signals in `games/signals.py` do this on save/delete.
- **Button colors**: `blue` (primary action), `red` (destructive), `gray` (secondary), `green` (positive). Icon buttons use `icon=True`. - **Button colors**: `blue` (primary action), `red` (destructive), `gray` (secondary), `green` (positive). Icon buttons use `icon=True`.
- **Inline Alpine.js** is used for client-side reactivity in domain components (`GameStatusSelector`, `SessionDeviceSelector`). The pattern is `x-data="{...}"` with `fetchWithHtmxTriggers()` for PATCH API calls. - **Inline Alpine.js** is used for client-side reactivity in domain components (`GameStatusSelector`, `SessionDeviceSelector`). The pattern is `x-data="{...}"` with `fetchWithHtmxTriggers()` for PATCH API calls.
- **No styling-at-a-distance; elements carry their own classes**: `input.css` is document bootstrapping only (Tailwind import, theme, fonts, resets) — it contains **no form/component styling and no selectors that reach across the DOM** (`#id descendant`, `form input:disabled`, etc.) to style something a component owns. An element's appearance, **including state** (`disabled:`, `has-[:disabled]:`, `focus:`), comes from utility classes on that element, emitted by its component. This keeps state composable (no specificity wars) and robust to markup edits.
- **Forms render via `FormFields`/`AddForm`, never `form.as_div()`**: `FormFields(form, *, extras=...)` (in `primitives.py`) renders label + control + errors + row layout with their own classes; native controls get their classes from `PrimitiveWidgetsMixin` (`games/forms.py`, which stamps `INPUT/SELECT/TEXTAREA_CLASS` incl. `disabled:` variants by widget type, skipping SearchSelect + checkbox). Every form is on this path, including login (`LoginForm(PrimitiveWidgetsMixin, AuthenticationForm)`). `extras` appends a node into a named field's row (e.g. the session timestamp buttons).
- **Disabled form controls share one look**: every form element fades the same way when disabled, via the shared constants in `primitives.py``DISABLED_CONTROL_CLASS` (`disabled:opacity-50 disabled:cursor-not-allowed`, put on the control: native inputs via the mixin, `Checkbox`, etc.) and `DISABLED_WITHIN_CLASS` (the `has-[:disabled]:` wrapper variant, for composite controls like `SearchSelect` whose disabled state lives on an inner element). Reuse these constants; don't hand-roll a different disabled style per control.
- **Disabling composite widgets**: a composite widget (e.g. `SearchSelect`) carries its `id` on a wrapper `<div>`, which has no `disabled` state — setting `.disabled` on it is a no-op. Disable the inner control (for `SearchSelect`, the `[data-search-select-search]` input); the wrapper fades itself via `DISABLED_WITHIN_CLASS`, so callers toggle only the control's `disabled`, never styles.
- **Platform icons** are SVG snippets in `games/templates/icons/<slug>.html`. Add new ones there and reference them by slug in `Platform.icon`. - **Platform icons** are SVG snippets in `games/templates/icons/<slug>.html`. Add new ones there and reference them by slug in `Platform.icon`.
- **Name compound types explicitly** — if a `tuple`, `dict`, or other compound value is passed between functions or appears in multiple signatures, give it a named type (`TypedDict`, `NamedTuple`, or a `type` alias) rather than repeating the structural annotation. This applies even to small types used in only a few places; the name carries intent that the structure cannot. Examples: `LabeledOption = tuple[str, str]` instead of repeating `tuple[str, str]` for (value, label) pairs; `RangeValues(min, max)` instead of `tuple[str, str]` for range bounds. - **Name compound types explicitly** — if a `tuple`, `dict`, or other compound value is passed between functions or appears in multiple signatures, give it a named type (`TypedDict`, `NamedTuple`, or a `type` alias) rather than repeating the structural annotation. This applies even to small types used in only a few places; the name carries intent that the structure cannot. Examples: `LabeledOption = tuple[str, str]` instead of repeating `tuple[str, str]` for (value, label) pairs; `RangeValues(min, max)` instead of `tuple[str, str]` for range bounds.
+23
View File
@@ -15,6 +15,25 @@ COPY . .
RUN --mount=type=cache,target=/root/.cache/uv \ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev uv sync --frozen --no-dev
# Codegen the TypeScript prop contracts (needs Django); tsc compiles them in
# the assets stage below.
RUN uv run python manage.py gen_element_types
# Front-end assets: Tailwind CSS + the TypeScript custom elements. Built here so
# the compiled output ships in the image (dist/ is build-only, not committed).
FROM node:22-bookworm-slim AS assets
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
# Corepack ships with Node and activates the pnpm version pinned in
# package.json's "packageManager" field — no npm bootstrap needed.
RUN corepack enable && pnpm install --frozen-lockfile --ignore-scripts
COPY . .
COPY --from=builder /home/timetracker/app/ts/generated ./ts/generated
RUN pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css \
&& pnpm exec tsc
FROM python:3.14-slim-bookworm FROM python:3.14-slim-bookworm
@@ -44,6 +63,10 @@ WORKDIR /home/timetracker/app
COPY --from=builder --chown=timetracker:timetracker /home/timetracker/app /home/timetracker/app COPY --from=builder --chown=timetracker:timetracker /home/timetracker/app /home/timetracker/app
# Built front-end assets from the Node stage (Tailwind CSS + compiled TS).
COPY --from=assets --chown=timetracker:timetracker /app/games/static/base.css /home/timetracker/app/games/static/base.css
COPY --from=assets --chown=timetracker:timetracker /app/games/static/js/dist /home/timetracker/app/games/static/js/dist
COPY --chown=timetracker:timetracker Caddyfile /etc/caddy/Caddyfile COPY --chown=timetracker:timetracker Caddyfile /etc/caddy/Caddyfile
COPY --chown=timetracker:timetracker supervisor.conf /etc/supervisor/conf.d/supervisor.conf COPY --chown=timetracker:timetracker supervisor.conf /etc/supervisor/conf.d/supervisor.conf
COPY --chown=timetracker:timetracker entrypoint.sh / COPY --chown=timetracker:timetracker entrypoint.sh /
+25 -6
View File
@@ -22,12 +22,29 @@ init:
pnpm install pnpm install
$(MAKE) loadplatforms $(MAKE) loadplatforms
dev: server: gen-element-types
@pnpm concurrently \ @pnpm concurrently \
--names "Django,Tailwind" \ --names "Django,TS" \
--prefix-colors "blue,green" \ --prefix-colors "blue,green" \
"uv run python -Wa manage.py runserver" \ "uv run python -Wa manage.py runserver" \
"pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css --watch" "pnpm exec tsc --watch"
gen-element-types:
uv run python manage.py gen_element_types
ts: gen-element-types
pnpm exec tsc
ts-check: gen-element-types
pnpm exec tsc --noEmit
dev: gen-element-types
@pnpm concurrently \
--names "Django,Tailwind,TS" \
--prefix-colors "blue,green,magenta" \
"uv run python -Wa manage.py runserver" \
"pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css --watch" \
"pnpm exec tsc --watch"
caddy: caddy:
@@ -64,10 +81,12 @@ collectstatic:
uv.lock: pyproject.toml uv.lock: pyproject.toml
uv sync uv sync
test: uv.lock # base.css (Tailwind) and js/dist (TS) are build artifacts, gitignored and not
# tracked — build both before tests so e2e/static serving has fresh assets.
test: uv.lock css ts
uv run --with pytest-django pytest uv run --with pytest-django pytest
test-e2e: uv.lock test-e2e: uv.lock css ts
uv run pytest e2e/ uv run pytest e2e/
lint: lint:
@@ -82,7 +101,7 @@ format:
format-check: format-check:
uv run ruff format --check uv run ruff format --check
check: lint format-check test check: lint format-check ts-check test
date: date:
uv run python -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))' uv run python -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))'
+99 -38
View File
@@ -4,49 +4,29 @@ Split into core / primitives / domain / filters submodules; this package
re-exports the public API so ``from common.components import X`` keeps working. re-exports the public API so ``from common.components import X`` keeps working.
""" """
from common.utils import truncate
from common.components.core import ( from common.components.core import (
Component, BaseComponent,
Element,
Fragment,
HTMLAttribute, HTMLAttribute,
HTMLTag, HTMLTag,
Media,
Node,
Safe,
_render_element, _render_element,
collect_media,
randomid, randomid,
render,
) )
from common.components.primitives import ( from common.components.custom_elements import (
A, SelectionFields,
AddForm, SessionTimestampButtons,
Button, register_element,
ButtonGroup,
CsrfInput,
Div,
ExternalScript,
H1,
Icon,
Input,
Modal,
ModuleScript,
Pill,
Popover,
PopoverTruncated,
SearchField,
SimpleTable,
Span,
Label,
TableHeader,
TableRow,
TableTd,
Template,
YearPicker,
paginated_table_content,
) )
from common.components.search_select import ( from common.components.date_range_picker import (
DEFAULT_PREFETCH, DateRangeCalendar,
FilterSelect, DateRangeField,
LabeledOption, DateRangePicker,
SearchSelect,
SearchSelectOption,
searchselect_selected,
) )
from common.components.domain import ( from common.components.domain import (
GameLink, GameLink,
@@ -60,22 +40,89 @@ from common.components.domain import (
_resolve_name_with_icon, _resolve_name_with_icon,
) )
from common.components.filters import ( from common.components.filters import (
DeviceFilterBar,
FilterBar, FilterBar,
PlatformFilterBar,
PlayEventFilterBar,
PurchaseFilterBar, PurchaseFilterBar,
SessionFilterBar, SessionFilterBar,
StringFilter,
) )
from common.components.primitives import (
H1,
A,
AddForm,
ButtonGroup,
DISABLED_CONTROL_CLASS,
DISABLED_WITHIN_CLASS,
FormFields,
Checkbox,
CsrfInput,
Div,
ExternalScript,
Icon,
Input,
Label,
Li,
Modal,
ModuleScript,
Pill,
Popover,
PopoverTruncated,
Radio,
SearchField,
SimpleTable,
Span,
StaticScript,
StyledButton,
TableHeader,
TableRow,
TableTd,
Td,
Template,
Th,
Tr,
Ul,
YearPicker,
custom_element_builder,
paginated_table_content,
)
from common.components.search_select import (
DEFAULT_PREFETCH,
FilterSelect,
LabeledOption,
SearchSelect,
SearchSelectOption,
searchselect_selected,
)
from common.utils import truncate
__all__ = [ __all__ = [
"truncate", "truncate",
"Component", "BaseComponent",
"register_element",
"SelectionFields",
"SessionTimestampButtons",
"custom_element_builder",
"Element",
"Fragment",
"Media",
"Node",
"Safe",
"collect_media",
"render",
"HTMLAttribute", "HTMLAttribute",
"HTMLTag", "HTMLTag",
"_render_element", "_render_element",
"randomid", "randomid",
"A", "A",
"AddForm", "AddForm",
"Button", "DISABLED_CONTROL_CLASS",
"DISABLED_WITHIN_CLASS",
"FormFields",
"StyledButton",
"ButtonGroup", "ButtonGroup",
"Checkbox",
"CsrfInput", "CsrfInput",
"Div", "Div",
"ExternalScript", "ExternalScript",
@@ -87,6 +134,7 @@ __all__ = [
"Pill", "Pill",
"Popover", "Popover",
"PopoverTruncated", "PopoverTruncated",
"Radio",
"SearchField", "SearchField",
"DEFAULT_PREFETCH", "DEFAULT_PREFETCH",
"FilterSelect", "FilterSelect",
@@ -96,7 +144,13 @@ __all__ = [
"searchselect_selected", "searchselect_selected",
"SimpleTable", "SimpleTable",
"Span", "Span",
"StaticScript",
"Label", "Label",
"Li",
"Td",
"Th",
"Tr",
"Ul",
"TableHeader", "TableHeader",
"TableRow", "TableRow",
"TableTd", "TableTd",
@@ -112,7 +166,14 @@ __all__ = [
"PurchasePrice", "PurchasePrice",
"SessionDeviceSelector", "SessionDeviceSelector",
"_resolve_name_with_icon", "_resolve_name_with_icon",
"DateRangeCalendar",
"DateRangeField",
"DateRangePicker",
"FilterBar", "FilterBar",
"PurchaseFilterBar", "PurchaseFilterBar",
"SessionFilterBar", "SessionFilterBar",
"DeviceFilterBar",
"PlatformFilterBar",
"PlayEventFilterBar",
"StringFilter",
] ]
+306 -27
View File
@@ -1,6 +1,20 @@
"""Escaping core: the Component builder and its memoised renderer.""" """Node layer: the lazy component tree, its renderer, and media collection.
A FastHTML-style model. Everything renderable is a :class:`Node`. The single
:class:`Element` class represents *any* HTML element (tag + attrs + children);
named builders like ``Div`` / ``Span`` are generated from a whitelist rather
than hand-written per tag (see ``primitives.py``). Higher-level, behaviour- or
media-bearing components subclass :class:`BaseComponent` and implement
``render()`` returning a node subtree.
Nodes are *lazy*: they hold structure and render to HTML only when asked
(``str(node)`` / ``node.__html__()`` / :func:`render`). This is what lets
``Page()`` walk a finished tree and collect every component's declared JS
(:class:`Media`) instead of each view threading ``scripts=`` by hand.
"""
import hashlib import hashlib
from collections.abc import Sequence
from functools import lru_cache from functools import lru_cache
from django.utils.html import escape from django.utils.html import escape
@@ -10,24 +24,181 @@ from django.utils.safestring import SafeText, mark_safe
HTMLAttribute = tuple[str, str | int | bool] HTMLAttribute = tuple[str, str | int | bool]
# Type for a builder's ``attributes`` parameter. Covariant ``Sequence`` so a
# caller's ``list[tuple[str, str]]`` is accepted (a plain ``list[HTMLAttribute]``
# would be invariant and reject it). Locals that get ``.append()``-ed should
# stay a concrete ``list[HTMLAttribute]``.
Attributes = Sequence[HTMLAttribute]
HTMLTag = str HTMLTag = str
# ── Media: declarative JS dependencies ──────────────────────────────────────
def _dedup(*sequences: tuple[str, ...]) -> tuple[str, ...]:
"""First-seen dedup that preserves declaration order across sequences."""
seen: dict[str, None] = {}
for sequence in sequences:
for item in sequence:
seen.setdefault(item, None)
return tuple(seen)
class Media:
"""A component's JS dependencies, modelled on ``django.forms.Media``.
``js`` are static ES-module filenames (rendered as ``ModuleScript``);
``js_external`` are vendored UMD / classic bundles (rendered as
``StaticScript``). Addition merges with first-seen, order-preserving dedup,
so a page that uses a component many times emits each script once.
"""
__slots__ = ("js", "js_external")
def __init__(
self,
js: tuple[str, ...] | list[str] = (),
js_external: tuple[str, ...] | list[str] = (),
) -> None:
self.js = tuple(js)
self.js_external = tuple(js_external)
def __add__(self, other: "Media | None") -> "Media":
if not other:
return self
return Media(
_dedup(self.js, other.js),
_dedup(self.js_external, other.js_external),
)
def __radd__(self, other: "Media | None") -> "Media":
# Supports ``sum(medias, Media())`` and ``0 + media``.
if not other or other == 0:
return self
return other.__add__(self)
def __bool__(self) -> bool:
return bool(self.js or self.js_external)
def __eq__(self, other: object) -> bool:
return (
isinstance(other, Media)
and self.js == other.js
and self.js_external == other.js_external
)
def __hash__(self) -> int:
return hash((self.js, self.js_external))
def __repr__(self) -> str:
return f"Media(js={self.js!r}, js_external={self.js_external!r})"
# ── Node tree ────────────────────────────────────────────────────────────────
class Node:
"""Base class for everything renderable to HTML."""
# Declared dependencies. Class-level default is shared and empty; concrete
# components override with their own ``Media(...)``.
media: Media = Media()
def _render(self) -> str:
raise NotImplementedError
def collect_media(self) -> Media:
"""Total media of this node and its subtree."""
return self.media
def with_media(self, media: Media) -> "Node":
"""Attach JS dependencies to this node and return it (for fluent use).
Lets a function-built node declare its media without becoming a full
``BaseComponent`` subclass: ``return Div(...).with_media(Media(js=...))``.
"""
self.media = self.media + media
return self
# A node's rendered output is always safe HTML by construction (Element
# escapes unsafe children; Safe wraps trusted markup; Fragment escapes plain
# strings). So both `__html__` (Django's conditional_escape hook) and
# `__str__` return a SafeString — this is what keeps ``str(node)`` safe when
# fed back into a child list or template, matching the old SafeText shims.
def __html__(self) -> SafeText:
return mark_safe(self._render())
def __str__(self) -> SafeText:
return mark_safe(self._render())
# A renderable child is a node or a string. Strings are ALWAYS escaped (a string
# is untrusted text — ``SafeText``/``mark_safe`` is escaped too); trusted
# pre-rendered HTML must be a ``Safe`` node. ``Children`` is the type for a
# builder's ``children``
# parameter: a sequence of child nodes/strings, a bare string, or nothing. The
# sequence is a covariant ``Sequence`` so ``list[Element]`` / ``list[Node]`` are
# accepted (a plain ``list[str]`` would be invariant and reject them). A single
# bare ``Node`` is accepted only by ``Element`` itself (which wraps it); the
# higher-level builders take ``Children``.
Child = Node | str
Children = Sequence[Child] | Node | str | None
def as_children(children: Children) -> list[Child]:
"""Normalise a builder's ``children`` argument to a flat list.
Accepts ``None`` (→ empty), a single node/string (→ one-element list), or a
sequence of them. Lets builders drop the ``children if isinstance(children,
list) else [children]`` dance and get a properly typed ``list[Child]``.
"""
if children is None:
return []
if isinstance(children, (str, Node)):
return [children]
return list(children)
def as_attributes(attributes: "Attributes | None") -> list[HTMLAttribute]:
"""Normalise an ``attributes`` argument to a mutable ``list[HTMLAttribute]``.
Builders take a covariant ``Attributes`` (so callers can pass a
``list[tuple[str, str]]``) but often append to or concatenate the value;
this turns it into a concrete list they can mutate.
"""
return list(attributes) if attributes else []
def _child_key(child: object) -> tuple[str, bool]:
"""Normalise a child to a ``(text, is_safe)`` pair.
Only :class:`Node` children render unescaped — that includes :class:`Safe`,
the one sanctioned way to put trusted pre-rendered HTML into the tree. Every
*string* child is escaped, ``SafeText``/``mark_safe`` included: a string is
always treated as untrusted text, so trusted markup must be wrapped in
``Safe(...)`` rather than smuggled in as a safe string. ``is_safe`` is part
of the render cache key so a safe ``"<b>"`` and an unsafe ``"<b>"`` never
collide.
"""
if isinstance(child, Node):
return (child._render(), True)
if isinstance(child, str):
return (child, False)
return (str(child), False)
@lru_cache(maxsize=4096) @lru_cache(maxsize=4096)
def _render_element( def _render_element(
tag_name: str, tag_name: str,
attrs_key: tuple[tuple[str, str], ...], attrs_key: tuple[tuple[str, str], ...],
children_key: tuple[tuple[str, bool], ...], children_key: tuple[tuple[str, bool], ...],
) -> str: ) -> str:
"""Pure, memoized HTML builder behind `Component`. """Pure, memoized HTML builder. Identical (tag, attrs, children) render once.
Inputs are fully hashable and fully determine the output, so identical ``attrs_key`` is (name, stringified value) pairs (values always escaped);
elements are rendered once. `attrs_key` is (name, stringified value) pairs ``children_key`` is (text, is_safe) pairs (safe passes through, else escaped).
(attribute values are always escaped). `children_key` is (child, is_safe)
pairs: SafeText children pass through, plain strings are escaped. The
`is_safe` flag is part of the key on purpose — otherwise a safe ``"<b>"``
and an unsafe ``"<b>"`` (equal as strings) would collide and one would
render with the wrong escaping.
""" """
children_blob = "\n".join( children_blob = "\n".join(
child if is_safe else escape(child) for child, is_safe in children_key child if is_safe else escape(child) for child, is_safe in children_key
@@ -41,24 +212,132 @@ def _render_element(
return f"<{tag_name}{attributes_blob}>{children_blob}</{tag_name}>" return f"<{tag_name}{attributes_blob}>{children_blob}</{tag_name}>"
def Component( class Element(Node):
attributes: list[HTMLAttribute] | None = None, """Any HTML element: a tag name, attributes and children.
children: list[HTMLTag] | HTMLTag | None = None,
tag_name: str = "", Children may be other nodes, ``SafeText``, or plain strings (escaped).
) -> SafeText: Rendering goes through the memoized :func:`_render_element`.
"""Render an HTML element. Attribute values are always escaped; children are """
escaped unless they are `SafeText` (so nested components pass through),
preventing accidental HTML injection. Rendering is memoized via def __init__(
`_render_element`.""" self,
attributes = attributes or [] tag_name: str,
children = children or [] attributes: Attributes | None = None,
if not tag_name: children: "Children | Node" = None,
raise ValueError("tag_name is required.") ) -> None:
if isinstance(children, str): if not tag_name:
children = [children] raise ValueError("tag_name is required.")
attrs_key = tuple((name, str(value)) for name, value in attributes) self.tag_name = tag_name
children_key = tuple((child, isinstance(child, SafeText)) for child in children) self.attributes = attributes or []
return mark_safe(_render_element(tag_name, attrs_key, children_key)) if children is None:
children = []
elif isinstance(children, (str, Node)):
children = [children]
self.children = children
def __getitem__(self, children: "Children | Node") -> "Element":
"""htpy-style children: ``Div(class_="x")[child1, child2]``.
Returns an Element with the same tag/attributes/media and these
children, so the tree stays walkable (Media still bubbles)."""
items = children if isinstance(children, tuple) else (children,)
clone = Element(self.tag_name, self.attributes, list(items))
clone.media = self.media
return clone
def collect_media(self) -> Media:
media = self.media
for child in self.children:
if isinstance(child, Node):
media = media + child.collect_media()
return media
def _render(self) -> str:
attrs_key = tuple((name, str(value)) for name, value in self.attributes)
children_key = tuple(_child_key(child) for child in self.children)
return _render_element(self.tag_name, attrs_key, children_key)
class Safe(Node):
"""A node wrapping pre-rendered, trusted HTML (the ``mark_safe`` analogue).
Used as the migration bridge for components still built from f-strings:
they return ``Safe(html)`` and declare their ``media`` explicitly rather
than atomising their markup into a node tree up front.
"""
def __init__(self, html: object, media: Media | None = None) -> None:
self._html = str(html)
if media is not None:
self.media = media
def _render(self) -> str:
return self._html
class Fragment(Node):
"""An ordered group of children with no wrapping tag.
Replaces ``mark_safe(str(a) + str(b))`` / ``"\\n".join(...)`` composition,
so media still bubbles up from the grouped children.
"""
def __init__(self, *children: object, separator: str = "") -> None:
self.children = [c for c in children if c is not None and c != ""]
self.separator = separator
def collect_media(self) -> Media:
media = Media()
for child in self.children:
if isinstance(child, Node):
media = media + child.collect_media()
return media
def _render(self) -> str:
parts = []
for child in self.children:
text, is_safe = _child_key(child)
parts.append(text if is_safe else escape(text))
return self.separator.join(parts)
class BaseComponent(Node):
"""Base for higher-level components: implement ``render()`` returning a node
subtree and declare ``media`` (a :class:`Media`).
``render()`` is called once and memoized; ``collect_media()`` returns this
component's own media merged with the rendered subtree's.
"""
def render(self) -> Node:
raise NotImplementedError
def _tree(self) -> Node:
cached = getattr(self, "_tree_cache", None)
if cached is None:
cached = self.render()
self._tree_cache = cached
return cached
def _render(self) -> str:
return self._tree()._render()
def collect_media(self) -> Media:
return self.media + self._tree().collect_media()
def render(node: "Node | str") -> SafeText:
"""Render a node (or pass a string through) to safe HTML."""
if isinstance(node, Node):
return mark_safe(node._render())
return mark_safe(str(node))
def collect_media(node: "Node | str") -> Media:
"""Collect the media of a node tree (empty for a bare string)."""
if isinstance(node, Node):
return node.collect_media()
return Media()
def randomid(seed: str = "", content: str = "", length: int = 10) -> str: def randomid(seed: str = "", content: str = "", length: int = 10) -> str:
+236
View File
@@ -0,0 +1,236 @@
"""Custom-element builder, registry, and TypeScript codegen.
A custom element is a light-DOM Web Component: the Python builder emits a
semantic tag whose typed props become kebab-case attributes and whose behavior
lives in a compiled TS module (loaded via Media). One ``TypedDict`` per element
is the single source of truth for the server<->client contract;
``gen_element_types`` turns each registered spec into a TS interface + attribute
reader so drift fails ``tsc``.
"""
from dataclasses import dataclass
from typing import TypedDict, get_type_hints
from common.components.core import Node
from common.components.primitives import (
Div,
Input,
Label,
Template,
custom_element_builder,
)
@dataclass(frozen=True)
class ElementSpec:
tag: str # e.g. "game-status-selector"
ts_name: str # e.g. "GameStatusSelector"
props: type # a TypedDict subclass
ELEMENT_REGISTRY: list[ElementSpec] = []
def register_element(tag: str, ts_name: str, props: type) -> None:
"""Register an element so codegen can emit its TS contract."""
ELEMENT_REGISTRY.append(ElementSpec(tag, ts_name, props))
def _kebab(name: str) -> str:
return name.replace("_", "-")
# ── Codegen ──────────────────────────────────────────────────────────────────
_TYPE_MAP = {int: "number", float: "number", str: "string", bool: "boolean"}
def _camel(name: str) -> str:
head, *tail = name.split("_")
return head + "".join(part.title() for part in tail)
def _reader_expr(name: str, python_type: type) -> str:
attr = _kebab(name)
if python_type in (int, float):
return f'Number(el.getAttribute("{attr}"))'
if python_type is bool:
return f'el.getAttribute("{attr}") === "true"'
return f'el.getAttribute("{attr}") ?? ""'
def _ts_for_spec(spec: ElementSpec) -> str:
hints = get_type_hints(spec.props)
interface_lines = "\n".join(
f" {_camel(name)}: {_TYPE_MAP[python_type]};"
for name, python_type in hints.items()
)
reader_lines = "\n".join(
f" {_camel(name)}: {_reader_expr(name, python_type)},"
for name, python_type in hints.items()
)
return (
f"export interface {spec.ts_name}Props {{\n{interface_lines}\n}}\n\n"
f"export function read{spec.ts_name}Props(el: HTMLElement): "
f"{spec.ts_name}Props {{\n return {{\n{reader_lines}\n }};\n}}"
)
def render_props_module() -> str:
"""The full ``ts/generated/props.ts`` content for every registered element."""
header = "// GENERATED by `manage.py gen_element_types` — do not edit.\n"
blocks = [_ts_for_spec(spec) for spec in ELEMENT_REGISTRY]
return header + "\n" + "\n\n".join(blocks) + "\n"
# ── Element prop schemas (registered at import time) ─────────────────────────
class GameStatusSelectorProps(TypedDict):
game_id: int
status: str
csrf: str
register_element("game-status-selector", "GameStatusSelector", GameStatusSelectorProps)
class SessionDeviceSelectorProps(TypedDict):
session_id: int
csrf: str
register_element(
"session-device-selector", "SessionDeviceSelector", SessionDeviceSelectorProps
)
class PlayEventRowProps(TypedDict):
game_id: int
csrf: str
api_create_url: str
register_element("play-event-row", "PlayEventRow", PlayEventRowProps)
class SessionTimestampButtonsProps(TypedDict):
pass
register_element(
"session-timestamp-buttons", "SessionTimestampButtons", SessionTimestampButtonsProps
)
# ── Named tag builders (consistent htpy-style with Div/Span) ─────────────────
# Underscore-prefixed: used internally by domain wrappers.
# Public ones (no domain wrapper): exported directly.
_GameStatusSelector = custom_element_builder("game-status-selector")
_SessionDeviceSelector = custom_element_builder("session-device-selector")
_PlayEventRow = custom_element_builder("play-event-row")
SessionTimestampButtons = custom_element_builder("session-timestamp-buttons")
class SelectionFieldsProps(TypedDict):
source: str # data-name of the source SearchSelect to mirror
name_prefix: str # each rendered input is named f"{name_prefix}{item_id}"
field_type: str # input type, e.g. "number"
min_items: int # render nothing until at least this many items are selected
active: bool # when false, render nothing (but preserve typed values)
register_element("selection-fields", "SelectionFields", SelectionFieldsProps)
_SelectionFields = custom_element_builder("selection-fields")
class RangeSliderProps(TypedDict):
min: int
max: int
step: int
mode: str # "range" | "point"
register_element("range-slider", "RangeSlider", RangeSliderProps)
_RangeSlider = custom_element_builder("range-slider")
class DateRangePickerProps(TypedDict):
pass
register_element("date-range-picker", "DateRangePicker", DateRangePickerProps)
_DateRangePicker = custom_element_builder("date-range-picker")
class SearchSelectProps(TypedDict):
name: str
search_url: str
multi: bool
filter_mode: bool
free_text: bool
always_visible: bool
prefetch: int
sync_url: bool
register_element("search-select", "SearchSelect", SearchSelectProps)
_SearchSelect = custom_element_builder("search-select")
class FilterBarProps(TypedDict):
preset_list_url: str
preset_save_url: str
register_element("filter-bar", "FilterBar", FilterBarProps)
_FilterBarElement = custom_element_builder("filter-bar")
class YearPickerProps(TypedDict):
selected_year: str # "" for the all-time/empty state
available_years: str # csv, e.g. "2019,2020"
url_template: str # contains the literal __year__ placeholder
# The <year-picker> builder lives in primitives.py (next to YearPicker, which
# uses it) because custom_elements imports from primitives — registering here
# would be a circular import. Registration is codegen-only, so it belongs here.
register_element("year-picker", "YearPicker", YearPickerProps)
def SelectionFields(
*,
source: str,
name_prefix: str,
field_type: str = "text",
min_items: int = 1,
active: bool = False,
input_attributes: list[tuple[str, str]] | None = None,
) -> Node:
"""Render one synced form field per selected item of a source SearchSelect.
General-purpose: it mirrors the SearchSelect named ``source`` and emits an
input named ``f"{name_prefix}{item_id}"`` per selected item. Behavior lives
in ``ts/elements/selection-fields.ts``; this is just the server-rendered
light DOM (an empty rows container + a row ``<template>``). Inputs inherit
the global ``#add-form`` styling, so the markup stays minimal.
"""
row_template = Template(attributes=[("data-selection-fields-row", "")])[
Div(attributes=[("data-selection-fields-row-item", "")])[
Label(attributes=[("data-selection-fields-label", "")]),
Input(type=field_type, attributes=list(input_attributes or [])),
]
]
return _SelectionFields(
source=source,
name_prefix=name_prefix,
field_type=field_type,
min_items=min_items,
active="true" if active else "false",
)[
Div(attributes=[("data-selection-fields-rows", "")]),
row_template,
]
+344
View File
@@ -0,0 +1,344 @@
"""DateRangePicker: a segmented date-range input with a calendar popup.
``DateRangePicker`` composes two parts:
- ``DateRangeField`` — the visible widget, styled as a single input. Each
date is split into per-part segments (``DD``/``MM``/``YYYY``, ordered by
``common.time.dateformat_hyphenated``) that the user fills digit by digit,
plus a calendar icon that opens the popup.
- ``DateRangeCalendar`` — the popup: a preset column (today, yesterday,
last 7 days, …), a month grid rendered client-side, and a
Cancel / Clear / Select footer.
The committed value lives in two hidden ISO-date inputs named
``{input_name_prefix}-min`` / ``{input_name_prefix}-max`` — the same contract
as the older ``DateRangeFilter``, so ``filter_bar.ts`` serializes either
widget into a ``DateCriterion`` unchanged. All behaviour is wired by
``ts/date_range_picker.ts`` (compiled to ``dist/date_range_picker.js``).
"""
from common.components.core import Element, Node, Safe
from common.components.custom_elements import _DateRangePicker
from common.components.primitives import Div, Input, Span
from common.time import DatePartSpec, date_parts
_FIELD_CONTAINER_CLASS = (
"flex items-center gap-0.5 w-full rounded-base border border-default-medium "
"bg-neutral-secondary-medium text-sm text-heading p-1.5 cursor-text "
"focus-within:ring-1 focus-within:ring-brand focus-within:border-brand"
)
# The segments must not stand out from the container: transparent background,
# no border, and only a subtle highlight when active (focused).
_SEGMENT_INPUT_CLASS = (
"bg-transparent border-0 p-0 text-center text-sm text-heading "
"placeholder:text-body rounded-xs focus:outline-none focus:ring-0 "
"focus:bg-brand/30 caret-transparent"
)
_SEGMENT_WIDTH_CLASSES = {2: "w-[2.5ch]", 4: "w-[4.5ch]"}
_CALENDAR_ICON_SVG = (
'<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" '
'stroke="currentColor" aria-hidden="true">'
'<path stroke-linecap="round" stroke-linejoin="round" '
'd="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5'
"A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5"
"A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5"
'A2.25 2.25 0 0 1 21 11.25v7.5"/>'
"</svg>"
)
_PRESET_OPTIONS: list[tuple[str, str]] = [
("today", "Today"),
("yesterday", "Yesterday"),
("last_7_days", "Last 7 days"),
("last_30_days", "Last 30 days"),
("this_month", "This month"),
("last_month", "Last month"),
("this_year", "This year"),
]
_PRESET_BUTTON_CLASS = (
"px-3 py-1.5 text-sm text-start text-body hover:text-heading "
"hover:bg-neutral-tertiary-medium rounded-base cursor-pointer whitespace-nowrap"
)
_NAV_BUTTON_CLASS = (
"p-1.5 text-body hover:text-heading hover:bg-neutral-tertiary-medium "
"rounded-base cursor-pointer"
)
_FOOTER_BUTTON_CLASS = (
"px-3 py-1.5 text-sm font-medium rounded-base cursor-pointer "
"text-heading bg-neutral-secondary-medium border border-default-medium "
"hover:bg-neutral-tertiary-medium"
)
_FOOTER_SELECT_BUTTON_CLASS = (
"px-3 py-1.5 text-sm font-medium rounded-base cursor-pointer "
"text-white bg-brand border border-transparent hover:bg-brand-strong"
)
def _iso_part_values(iso_value: str, parts: list[DatePartSpec]) -> dict[str, str]:
"""Split an ISO ``YYYY-MM-DD`` string into per-part initial values.
Returns an empty mapping for empty/malformed input so a bad stored filter
renders as empty segments instead of crashing."""
if not iso_value:
return {}
pieces = iso_value.split("-")
if len(pieces) != 3:
return {}
year, month, day = pieces
values = {"year": year, "month": month, "day": day}
if any(not values[part.name].isdigit() for part in parts):
return {}
return values
def _segment_input(*, part: DatePartSpec, side: str, label: str, value: str) -> Node:
side_label = "from" if side == "min" else "to"
return Input(
attributes=[
("inputmode", "numeric"),
("autocomplete", "off"),
("maxlength", str(part.length)),
("placeholder", part.placeholder),
("value", value),
("data-date-part", part.name),
("data-date-side", side),
("aria-label", f"{label} {side_label} {part.name}"),
(
"class",
f"{_SEGMENT_INPUT_CLASS} "
f"{_SEGMENT_WIDTH_CLASSES.get(part.length, 'w-[4.5ch]')}",
),
],
)
def _segment_group(*, side: str, label: str, iso_value: str) -> Node:
"""One date's worth of segments (``DD - MM - YYYY``) for a range side."""
parts = date_parts()
initial_values = _iso_part_values(iso_value, parts)
children: list[Node] = []
for index, part in enumerate(parts):
if index > 0:
children.append(
Span(
attributes=[("class", "text-body select-none")],
children=["-"],
)
)
children.append(
_segment_input(
part=part,
side=side,
label=label,
value=initial_values.get(part.name, ""),
)
)
return Span(
attributes=[
("class", "flex items-center gap-0.5"),
("data-date-range-side", side),
],
children=children,
)
def DateRangeField(
*,
label: str,
input_name_prefix: str,
min_value: str = "",
max_value: str = "",
) -> Node:
"""The visible half of the DateRangePicker: a single-input-looking
container holding two segmented dates, a calendar toggle, and the two
hidden ISO inputs (``{prefix}-min`` / ``{prefix}-max``) that carry the
committed value to ``filter_bar.js``."""
min_input_id = f"{input_name_prefix}-min"
max_input_id = f"{input_name_prefix}-max"
return Div(
attributes=[
("class", _FIELD_CONTAINER_CLASS),
("data-date-range-field", ""),
],
children=[
Input(
type="hidden",
attributes=[
("name", min_input_id),
("id", min_input_id),
("value", min_value),
("data-date-range-hidden", "min"),
],
),
Input(
type="hidden",
attributes=[
("name", max_input_id),
("id", max_input_id),
("value", max_value),
("data-date-range-hidden", "max"),
],
),
_segment_group(side="min", label=label, iso_value=min_value),
Span(
attributes=[("class", "text-body select-none px-0.5")],
children=[""],
),
_segment_group(side="max", label=label, iso_value=max_value),
Element(
"button",
attributes=[
("type", "button"),
("data-date-range-calendar-toggle", ""),
("aria-label", f"Open {label} calendar"),
(
"class",
"ms-auto p-1 text-body hover:text-heading rounded "
"cursor-pointer shrink-0",
),
],
children=[Safe(_CALENDAR_ICON_SVG)],
),
],
)
def _calendar_nav_button(direction: str, arrow: str, label: str) -> Node:
return Element(
"button",
attributes=[
("type", "button"),
(f"data-date-range-{direction}", ""),
("aria-label", label),
("class", _NAV_BUTTON_CLASS),
],
children=[arrow],
)
def _footer_button(action: str, label: str, button_class: str) -> Node:
return Element(
"button",
attributes=[
("type", "button"),
(f"data-date-range-{action}", ""),
("class", button_class),
],
children=[label],
)
def DateRangeCalendar(*, input_name_prefix: str) -> Node:
"""The popup half of the DateRangePicker: preset column, month grid
(filled client-side into ``[data-date-range-grid]``), and the
Cancel / Clear / Select footer. Hidden until the calendar toggle opens it."""
preset_buttons = [
Element(
"button",
attributes=[
("type", "button"),
("data-date-range-preset", preset_value),
("class", _PRESET_BUTTON_CLASS),
],
children=[preset_label],
)
for preset_value, preset_label in _PRESET_OPTIONS
]
return Div(
attributes=[
(
"class",
"hidden absolute z-20 top-full start-0 mt-1 flex "
"rounded-base border border-default-medium "
"bg-neutral-secondary-medium shadow-lg",
),
("data-date-range-calendar", ""),
("data-input-name-prefix", input_name_prefix),
],
children=[
Div(
attributes=[
(
"class",
"flex flex-col gap-0.5 p-2 border-e border-default-medium",
),
("data-date-range-presets", ""),
],
children=preset_buttons,
),
Div(
attributes=[("class", "p-2")],
children=[
Div(
attributes=[
("class", "flex items-center justify-between gap-2"),
],
children=[
_calendar_nav_button("prev", "", "Previous month"),
Span(
attributes=[
("class", "text-sm font-medium text-heading"),
("data-date-range-month-label", ""),
],
),
_calendar_nav_button("next", "", "Next month"),
],
),
Div(
attributes=[
("class", "grid grid-cols-7 gap-y-0.5 mt-1"),
("data-date-range-grid", ""),
],
),
Div(
attributes=[
(
"class",
"flex justify-end gap-2 mt-2 pt-2 border-t "
"border-default-medium",
),
],
children=[
_footer_button("cancel", "Cancel", _FOOTER_BUTTON_CLASS),
_footer_button("clear", "Clear", _FOOTER_BUTTON_CLASS),
_footer_button(
"select", "Select", _FOOTER_SELECT_BUTTON_CLASS
),
],
),
],
),
],
)
def DateRangePicker(
*,
label: str,
input_name_prefix: str,
min_value: str = "",
max_value: str = "",
) -> Node:
"""A date-range widget: segmented manual entry plus a calendar popup.
Drop-in replacement for ``DateRangeFilter`` — exposes the same hidden
``{prefix}-min`` / ``{prefix}-max`` ISO inputs, so the filter-bar
serializer needs no changes. ``min_value`` / ``max_value`` are ISO
``YYYY-MM-DD`` strings used to prefill both the segments and the hidden
inputs."""
return _DateRangePicker(class_="relative")[
DateRangeField(
label=label,
input_name_prefix=input_name_prefix,
min_value=min_value,
max_value=max_value,
),
DateRangeCalendar(input_name_prefix=input_name_prefix),
]
+111 -145
View File
@@ -4,9 +4,8 @@ from typing import Any
from django.template.defaultfilters import floatformat from django.template.defaultfilters import floatformat
from django.urls import reverse from django.urls import reverse
from django.utils.safestring import SafeText, mark_safe
from common.components.core import Component, HTMLTag from common.components.core import Children, Node, Safe, as_children
from common.components.primitives import ( from common.components.primitives import (
A, A,
Div, Div,
@@ -21,25 +20,23 @@ from games.models import Game, Purchase, Session
def GameLink( def GameLink(
game_id: int, game_id: int,
name: str = "", name: str = "",
children: list[HTMLTag] | HTMLTag | None = None, children: Children = None,
) -> SafeText: ) -> Node:
"""Link to a game's detail page. Uses children (slot) if provided, otherwise name.""" """Link to a game's detail page. Uses children (slot) if provided, otherwise name."""
from django.urls import reverse from django.urls import reverse
children = children or [] display = as_children(children) or [name]
display = children if children else [name]
link = reverse("games:view_game", args=[game_id]) link = reverse("games:view_game", args=[game_id])
return Span( return Span(
attributes=[("class", "truncate-container")], attributes=[("class", "truncate-container")],
children=[ children=[
Component( A(
tag_name="a", href=link,
attributes=[ attributes=[
("href", link),
("class", "underline decoration-slate-500 sm:decoration-2"), ("class", "underline decoration-slate-500 sm:decoration-2"),
], ],
children=display if isinstance(display, list) else [display], children=display,
), ),
], ],
) )
@@ -55,11 +52,11 @@ _STATUS_COLORS = {
def GameStatus( def GameStatus(
children: list[HTMLTag] | HTMLTag | None = None, children: Children = None,
status: str = "u", status: str = "u",
display: str = "", display: str = "",
class_: str = "", class_: str = "",
) -> SafeText: ) -> Node:
"""Colored status dot with label. Status codes: u/p/f/a/r.""" """Colored status dot with label. Status codes: u/p/f/a/r."""
children = children or [] children = children or []
outer_class = ( outer_class = (
@@ -77,13 +74,13 @@ def GameStatus(
return Span( return Span(
attributes=[("class", outer_class)], attributes=[("class", outer_class)],
children=[dot] + (children if isinstance(children, list) else [children]), children=[dot] + as_children(children),
) )
def PriceConverted( def PriceConverted(
children: list[HTMLTag] | HTMLTag | None = None, children: Children = None,
) -> SafeText: ) -> Node:
"""Wrap content in a span that indicates the price was converted.""" """Wrap content in a span that indicates the price was converted."""
children = children or [] children = children or []
return Span( return Span(
@@ -91,11 +88,11 @@ def PriceConverted(
("title", "Price is a result of conversion and rounding."), ("title", "Price is a result of conversion and rounding."),
("class", "decoration-dotted underline"), ("class", "decoration-dotted underline"),
], ],
children=children if isinstance(children, list) else [children], children=as_children(children),
) )
def LinkedPurchase(purchase: Purchase) -> SafeText: def LinkedPurchase(purchase: Purchase) -> Node:
link = reverse("games:view_purchase", args=[int(purchase.id)]) link = reverse("games:view_purchase", args=[int(purchase.id)])
link_content = "" link_content = ""
popover_content = "" popover_content = ""
@@ -132,7 +129,7 @@ def LinkedPurchase(purchase: Purchase) -> SafeText:
), ),
PopoverTruncated( PopoverTruncated(
input_string=link_content, input_string=link_content,
popover_content=mark_safe(popover_content), popover_content=Safe(popover_content),
popover_if_not_truncated=popover_if_not_truncated, popover_if_not_truncated=popover_if_not_truncated,
), ),
], ],
@@ -146,7 +143,7 @@ def NameWithIcon(
session: Session | None = None, session: Session | None = None,
linkify: bool = True, linkify: bool = True,
emulated: bool = False, emulated: bool = False,
) -> SafeText: ) -> Node:
_name, platform, final_emulated, create_link, link = _resolve_name_with_icon( _name, platform, final_emulated, create_link, link = _resolve_name_with_icon(
name, game, session, linkify name, game, session, linkify
) )
@@ -204,7 +201,7 @@ def _resolve_name_with_icon(
return _name, platform, final_emulated, create_link, link return _name, platform, final_emulated, create_link, link
def PurchasePrice(purchase) -> SafeText: def PurchasePrice(purchase) -> Node:
return Popover( return Popover(
popover_content=f"{floatformat(purchase.price)} {purchase.price_currency}", popover_content=f"{floatformat(purchase.price)} {purchase.price_currency}",
wrapped_content=f"{floatformat(purchase.converted_price)} {purchase.converted_currency}", wrapped_content=f"{floatformat(purchase.converted_price)} {purchase.converted_currency}",
@@ -212,131 +209,100 @@ def PurchasePrice(purchase) -> SafeText:
) )
def GameStatusSelector(game, game_statuses, csrf_token: str) -> SafeText: _SELECTOR_MENU_CLASS = (
"""Alpine.js dropdown to change a game's status.""" "absolute top-[105%] left-0 w-full whitespace-nowrap z-10 text-sm "
options_html = "\n".join( "font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-t-none "
f"<template x-if=\"status == '{value}'\">" "border border-gray-200 dark:border-gray-700"
f"{GameStatus(status=value, children=[label], display='flex')}" )
f"</template>" _SELECTOR_TOGGLE_CLASS = (
"relative px-4 py-2 text-sm font-medium bg-white border border-gray-200 "
"rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 "
"dark:hover:text-white dark:hover:bg-gray-700 hover:cursor-pointer"
)
_SELECTOR_OPTION_CLASS = (
"block w-full text-left px-4 py-2 rounded-sm cursor-pointer "
"hover:bg-gray-700 hover:text-white dark:hover:bg-gray-700 "
"dark:hover:text-white border-0"
)
def GameStatusSelector(game, game_statuses, csrf_token: str) -> Node:
"""Light-DOM custom element; behavior in ts/elements/game-status-selector.ts."""
from common.components.core import Element
from common.components.custom_elements import _GameStatusSelector
from common.components.primitives import Li, Ul
options = [
Li()[
Element(
"button",
[
("type", "button"),
("data-option", ""),
("data-value", str(value)),
("class", _SELECTOR_OPTION_CLASS),
],
GameStatus(status=value, children=[label], display="flex"),
)
]
for value, label in game_statuses for value, label in game_statuses
) ]
list_items = "\n".join( current_label = Span(data_label="")[
f"<li><a href=\"#\" @click.prevent.stop=\"setStatus('{value}', '{label}'); open = false;\" " GameStatus(
f'class="block px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 ' status=game.status,
f'dark:focus:ring-blue-500 dark:focus:text-white rounded-sm no-underline! border-0!" ' children=[game.get_status_display()],
f":class=\"{{'font-bold': status === '{value}'}}\">" display="flex",
f"{GameStatus(status=value, children=[label], display='flex', class_='text-slate-300')}"
f"</a></li>"
for value, label in game_statuses
)
return mark_safe(f"""
<div class="flex gap-2 items-center"
x-data="{{
status: '{game.status}',
status_display: '{game.get_status_display()}',
open: false,
saving: false,
setStatus(newStatus, newStatusDisplay) {{
this.status = newStatus;
this.status_display = newStatusDisplay;
this.saving = true;
fetchWithHtmxTriggers(`/api/games/{game.id}/status`, {{
method: 'PATCH',
headers: {{
'Content-Type': 'application/json',
'X-CSRFToken': '{csrf_token}'
}},
body: JSON.stringify({{ status: newStatus }})
}})
.then(() => {{
document.body.dispatchEvent(new CustomEvent('status-changed'));
}})
.catch(() => {{
console.error('Failed to update status');
}})
.finally(() => this.saving = false);
}}
}}">
{_dropdown_button_html(options_html, list_items)}
</div>
""")
def SessionDeviceSelector(session, session_devices, csrf_token: str) -> SafeText:
"""Alpine.js dropdown to change a session's device."""
device_id = session.device_id or "null"
device_name = (session.device.name if session.device else "Unknown").replace(
"'", "\\'"
)
list_items = "\n".join(
f'<li><a href="#" @click.prevent.stop="setDevice({d.id}, \'{d.name.replace(chr(39), chr(92) + chr(39))}\'); open = false;" '
f'class="block px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 '
f'dark:focus:ring-blue-500 dark:focus:text-white rounded-sm no-underline! border-0!" '
f":class=\"{{'font-bold': deviceId === {d.id}}}\">{d.name}</a></li>"
for d in session_devices
)
return mark_safe(f"""
<div class="flex gap-2 items-center"
x-data="{{
originalDeviceId: {device_id},
originalDeviceName: '{device_name}',
deviceId: {device_id},
deviceName: '{device_name}',
open: false,
saving: false,
setDevice(newDeviceId, newDeviceName) {{
this.deviceId = newDeviceId;
this.deviceName = newDeviceName;
this.saving = true;
fetchWithHtmxTriggers(`/api/session/{session.id}/device`, {{
method: 'PATCH',
headers: {{
'Content-Type': 'application/json',
'X-CSRFToken': '{csrf_token}'
}},
body: JSON.stringify({{ device_id: newDeviceId }})
}})
.then((res) => {{
document.body.dispatchEvent(new CustomEvent('device-changed'));
}})
.catch(() => {{
this.deviceName = this.originalDeviceName;
this.deviceId = this.originalDeviceId;
console.error('Failed to update device');
}})
.finally(() => this.saving = false);
}}
}}">
{
_dropdown_button_html(
'<span x-text="deviceName"></span>' + str(Icon("arrowdown")), list_items
) )
} ]
</div> toggle = Element(
""") "button",
[("type", "button"), ("data-toggle", ""), ("class", _SELECTOR_TOGGLE_CLASS)],
Span(class_="flex flex-row gap-4 justify-between items-center")[
def _dropdown_button_html(button_content: str, list_items: str) -> str: current_label, Icon("arrowdown")
"""Shared dropdown button + list structure for Alpine.js selectors.""" ],
return (
'<div class="inline-flex rounded-md shadow-2xs" role="group" @click.outside="open = false">'
'<button type="button" @click="open = !open" '
'class="relative px-4 py-2 text-sm font-medium bg-white border border-gray-200 '
"rounded-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 "
"focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 "
"dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 "
'dark:focus:text-white align-middle hover:cursor-pointer">'
f'<span class="flex flex-row gap-4 justify-between items-center">{button_content}</span>'
'<div class="absolute top-[105%] left-0 w-full whitespace-nowrap z-10 text-sm '
"font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-t-none border "
'border-gray-200 dark:border-gray-700" x-show="open" style="display: none;">'
'<ul class="[&_li:first-of-type_a]:rounded-none [&_li:last-of-type_a]:rounded-t-none">'
f"{list_items}"
"</ul>"
"</div>"
"</button>"
"</div>"
) )
menu = Div(data_menu="", hidden=True, class_=_SELECTOR_MENU_CLASS)[Ul()[*options]]
dropdown = Div(
data_dropdown="", class_="inline-flex rounded-md shadow-2xs relative"
)[toggle, menu]
return _GameStatusSelector(game_id=game.id, status=game.status, csrf=csrf_token)[
Div(class_="flex gap-2 items-center")[dropdown]
]
def SessionDeviceSelector(session, session_devices, csrf_token: str) -> Node:
"""Light-DOM custom element; behavior in ts/elements/session-device-selector.ts."""
from common.components.core import Element
from common.components.custom_elements import _SessionDeviceSelector
from common.components.primitives import Li, Ul
current_name = session.device.name if session.device else "Unknown"
options = [
Li()[
Element(
"button",
[
("type", "button"),
("data-option", ""),
("data-value", str(device.id)),
("class", _SELECTOR_OPTION_CLASS),
],
children=[device.name],
)
]
for device in session_devices
]
toggle = Element(
"button",
[("type", "button"), ("data-toggle", ""), ("class", _SELECTOR_TOGGLE_CLASS)],
Span(class_="flex flex-row gap-4 justify-between items-center")[
Span(data_label="")[current_name], Icon("arrowdown")
],
)
menu = Div(data_menu="", hidden=True, class_=_SELECTOR_MENU_CLASS)[Ul()[*options]]
dropdown = Div(
data_dropdown="", class_="inline-flex rounded-md shadow-2xs relative"
)[toggle, menu]
return _SessionDeviceSelector(session_id=session.id, csrf=csrf_token)[
Div(class_="flex gap-2 items-center")[dropdown]
]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+113 -86
View File
@@ -6,7 +6,20 @@ hidden ``<input>`` so an existing ``ModelMultipleChoiceField`` keeps validating.
This module imports only from ``common.components`` — it has no Django-forms or This module imports only from ``common.components`` — it has no Django-forms or
``games`` knowledge. Styling is inline Tailwind utilities; behavioural hooks are ``games`` knowledge. Styling is inline Tailwind utilities; behavioural hooks are
``data-*`` attributes wired up by ``games/static/js/search_select.js``. ``data-*`` attributes wired up by ``ts/search_select.ts`` (compiled to
``games/static/js/dist/search_select.js``).
**Field id / label association**: when ``SearchSelect`` is used as a Django form
widget, the field ``id`` (e.g. ``id_related_game``) is placed on the inner
search ``<input>`` (``[data-search-select-search]``), making it a real labelable
control. ``<label for="id_X">`` therefore focuses the search box, and
``document.querySelector('#id_X').disabled`` behaves as for a native input.
**Disabling**: set ``disabled`` directly on the field id (or on the inner
``[data-search-select-search]`` input). The wrapper greys itself via the
``has-[:disabled]:`` utilities in ``_CONTAINER_CLASS``. The inner input stays
transparent — the widget reads as one faded element, not a nested box. Callers
toggle only the control's ``disabled`` — never styles.
Option sourcing follows two axes. *Population*: options are either rendered Option sourcing follows two axes. *Population*: options are either rendered
inline up front (``options=``, no ``search_url``) or fetched from ``search_url``. inline up front (``options=``, no ``search_url``) or fetched from ``search_url``.
@@ -21,10 +34,17 @@ user types.
from collections.abc import Callable, Iterable from collections.abc import Callable, Iterable
from typing import TypedDict from typing import TypedDict
from django.utils.safestring import SafeText
from common.components.core import Component, HTMLAttribute from common.components.core import Attributes, Element, HTMLAttribute, Node
from common.components.primitives import Div, Input, Pill, Span, Template from common.components.custom_elements import _SearchSelect
from common.components.primitives import (
DISABLED_WITHIN_CLASS,
Div,
Input,
Pill,
Span,
Template,
)
class SearchSelectOption(TypedDict): class SearchSelectOption(TypedDict):
@@ -43,15 +63,33 @@ LabeledOption = tuple[str, str]
# widget reads as a single clickable field; the pills wrapper uses `contents` # widget reads as a single clickable field; the pills wrapper uses `contents`
# so its pills/hidden inputs flow as direct participants of that row, inline # so its pills/hidden inputs flow as direct participants of that row, inline
# with the search input. The options panel is absolute, so it sits outside the # with the search input. The options panel is absolute, so it sits outside the
# flex flow. (border omitted intentionally — see if it's needed later.) # flex flow.
# Border + focus styling mirror a native input (INPUT_CLASS): border-default-medium
# normally, brand border + ring on focus. The search input is the focusable
# element, so the focus state is expressed on the wrapper with focus-within: (and
# the inner input suppresses its own ring — see _SEARCH_CLASS).
# The widget owns its disabled appearance: when any control inside it is
# :disabled (e.g. add_purchase.ts disabling the search input), the wrapper fades
# via :has() — the same opacity-50 a disabled native input uses (see
# _DISABLED_CONTROL in games/forms.py), so the two look identical. Callers only
# toggle the control's `disabled`, never styles.
# px-3 py-2.5 text-sm match a native input (INPUT_CLASS); the wrapper supplies
# the field padding, and the inner search box zeroes its own (p-0) so the two
# don't stack into a too-tall field.
_CONTAINER_CLASS = ( _CONTAINER_CLASS = (
"relative flex flex-wrap items-center gap-1 p-2 " "relative flex flex-wrap items-center gap-1 px-3 py-2.5 rounded-base text-sm "
"rounded-base bg-neutral-secondary-medium" "bg-neutral-secondary-medium border border-default-medium "
"focus-within:border-brand focus-within:ring-1 focus-within:ring-brand "
f"{DISABLED_WITHIN_CLASS}"
) )
_PILLS_CLASS = "contents" _PILLS_CLASS = "contents"
# disabled:cursor-not-allowed matches the wrapper's cursor so hovering across
# the whole widget stays consistent (the wrapper handles the faded look via
# has-[:disabled]:opacity-50).
_SEARCH_CLASS = ( _SEARCH_CLASS = (
"flex-1 min-w-[8rem] border-0 bg-transparent text-sm text-heading " "flex-1 min-w-[8rem] border-0 p-0 bg-transparent text-sm text-heading "
"focus:ring-0 focus:outline-hidden placeholder:text-body" "focus:ring-0 focus:outline-hidden placeholder:text-body "
"disabled:cursor-not-allowed"
) )
# top-full anchors the panel to the container's bottom edge: as an absolutely # top-full anchors the panel to the container's bottom edge: as an absolutely
# positioned child of the flex field, its static position would otherwise be # positioned child of the flex field, its static position would otherwise be
@@ -79,7 +117,7 @@ DEFAULT_PREFETCH = 20
# Inline class strings (ported verbatim from the retired SelectableFilter CSS) # Inline class strings (ported verbatim from the retired SelectableFilter CSS)
# so the filter combobox is fully self-styled — nothing in input.css. JS-added # so the filter combobox is fully self-styled — nothing in input.css. JS-added
# rows/pills are cloned from server-rendered <template>s, so these strings live # rows/pills are cloned from server-rendered <template>s, so these strings live
# only here — never duplicated in search_select.js. The keyboard-highlighted # only here — never duplicated in ts/search_select.ts. The keyboard-highlighted
# state is expressed via Tailwind `data-[search-select-highlighted]` and # state is expressed via Tailwind `data-[search-select-highlighted]` and
# `group-data-[search-select-highlighted]` variants on the row/label/button # `group-data-[search-select-highlighted]` variants on the row/label/button
# classes below; the JS only toggles the data attribute on the row. # classes below; the JS only toggles the data attribute on the row.
@@ -141,11 +179,11 @@ def _data_attributes(data: dict[str, str]) -> list[HTMLAttribute]:
return [(f"data-{key}", str(value)) for key, value in data.items()] return [(f"data-{key}", str(value)) for key, value in data.items()]
def _hidden_input(name: str, value) -> SafeText: def _hidden_input(name: str, value) -> Node:
return Input(type="hidden", attributes=[("name", name), ("value", str(value))]) return Input(type="hidden", attributes=[("name", name), ("value", str(value))])
def _label_slot(text: str, *, extra_class: str = "") -> SafeText: def _label_slot(text: str, *, extra_class: str = "") -> Node:
"""A ``<span data-search-select-label>`` holding a row/pill's visible label. JS fills this """A ``<span data-search-select-label>`` holding a row/pill's visible label. JS fills this
one node when cloning the shape from a ``<template>``, so labels are the only one node when cloning the shape from a ``<template>``, so labels are the only
thing the JS sets — all classes and structure stay server-side.""" thing the JS sets — all classes and structure stay server-side."""
@@ -159,7 +197,7 @@ def _label_slot(text: str, *, extra_class: str = "") -> SafeText:
_BLANK_OPTION: SearchSelectOption = {"value": "", "label": "", "data": {}} _BLANK_OPTION: SearchSelectOption = {"value": "", "label": "", "data": {}}
def _option_row(option: SearchSelectOption) -> SafeText: def _option_row(option: SearchSelectOption) -> Node:
return Div( return Div(
attributes=[ attributes=[
("data-search-select-option", ""), ("data-search-select-option", ""),
@@ -172,27 +210,20 @@ def _option_row(option: SearchSelectOption) -> SafeText:
) )
def _combobox_shell( def _combobox_children(
*, *,
container_attributes: list[HTMLAttribute], pills: Node,
pills: SafeText, search_attributes: Attributes,
search_attributes: list[HTMLAttribute], options_children: list[Node],
options_children: list[SafeText],
always_visible: bool, always_visible: bool,
items_visible: int, items_visible: int,
templates: list[SafeText] | None = None, templates: list[Node] | None = None,
) -> SafeText: ) -> list[Node]:
"""Assemble the shared, domain-agnostic combobox skeleton. """Build and return the shared combobox interior nodes.
Every combobox built on top of this shell has the same three regions in the Returns the three content regions (pills, search box, options panel) plus
same order: the ``pills`` region, the search box, and the options panel (which any templates — ready to be placed as children of the caller's container
always carries a trailing no-results node). Callers supply the already-built element. The shell knows nothing about how individual rows or pills look.
``pills`` region, the ``search_attributes`` for the text box, the
``options_children`` (value rows plus any pinned pseudo-options), the
``container_attributes`` that carry the widget's identity and behaviour flags,
and any ``templates`` (inert ``<template>`` prototypes the JS clones for
dynamically-added rows/pills). The shell knows nothing about how individual
rows or pills look.
""" """
search = Input(attributes=search_attributes) search = Input(attributes=search_attributes)
@@ -213,8 +244,7 @@ def _combobox_shell(
children=[*options_children, no_results], children=[*options_children, no_results],
) )
children: list[SafeText] = [pills, search, options_panel, *(templates or [])] return [pills, search, options_panel, *(templates or [])]
return Div(attributes=container_attributes, children=children)
def SearchSelect( def SearchSelect(
@@ -232,7 +262,7 @@ def SearchSelect(
id: str = "", id: str = "",
sync_url: bool = False, sync_url: bool = False,
autofocus: bool = False, autofocus: bool = False,
) -> SafeText: ) -> Node:
"""Render the search-select widget. See module docstring for the contract.""" """Render the search-select widget. See module docstring for the contract."""
selected = [_normalize_option(option) for option in (selected or [])] selected = [_normalize_option(option) for option in (selected or [])]
options = [_normalize_option(option) for option in (options or [])] options = [_normalize_option(option) for option in (options or [])]
@@ -242,7 +272,7 @@ def SearchSelect(
# pill — the committed label shows inside the search box instead, with a # pill — the committed label shows inside the search box instead, with a
# lone hidden input carrying the value. Both keep the hidden input(s) inside # lone hidden input carrying the value. Both keep the hidden input(s) inside
# `[data-search-select-pills]` so the JS reads/writes values uniformly. # `[data-search-select-pills]` so the JS reads/writes values uniformly.
pills_children: list[SafeText] = [] pills_children: list[Node] = []
search_value = "" search_value = ""
if multi_select: if multi_select:
for option in selected: for option in selected:
@@ -273,6 +303,8 @@ def SearchSelect(
("autocomplete", "off"), ("autocomplete", "off"),
("class", _SEARCH_CLASS), ("class", _SEARCH_CLASS),
] ]
if id:
search_attrs.append(("id", id))
if autofocus: if autofocus:
search_attrs.append(("autofocus", "")) search_attrs.append(("autofocus", ""))
if search_value: if search_value:
@@ -283,7 +315,7 @@ def SearchSelect(
# ── Templates the JS clones: a row when results are fetched, a pill when # ── Templates the JS clones: a row when results are fetched, a pill when
# multi-select adds chosen items. ── # multi-select adds chosen items. ──
templates: list[SafeText] = [] templates: list[Node] = []
if search_url: if search_url:
templates.append( templates.append(
Template( Template(
@@ -299,23 +331,7 @@ def SearchSelect(
) )
) )
container_attributes: list[HTMLAttribute] = [ children = _combobox_children(
("data-search-select", ""),
("data-name", name),
("data-search-url", search_url),
("data-multi", "true" if multi_select else "false"),
("data-always-visible", "true" if always_visible else "false"),
("data-items-visible", str(items_visible)),
("data-items-scroll", str(items_scroll)),
("data-prefetch", str(prefetch)),
("data-sync-url", "true" if sync_url else "false"),
("class", _CONTAINER_CLASS),
]
if id:
container_attributes.append(("id", id))
return _combobox_shell(
container_attributes=container_attributes,
pills=pills, pills=pills,
search_attributes=search_attrs, search_attributes=search_attrs,
options_children=option_rows, options_children=option_rows,
@@ -323,11 +339,22 @@ def SearchSelect(
items_visible=items_visible, items_visible=items_visible,
templates=templates, templates=templates,
) )
return _SearchSelect(
name=name,
search_url=search_url,
multi="true" if multi_select else "false",
filter_mode="false",
free_text="false",
always_visible="true" if always_visible else "false",
prefetch=prefetch,
sync_url="true" if sync_url else "false",
class_=_CONTAINER_CLASS,
)[*children]
def _filter_remove_button() -> SafeText: def _filter_remove_button() -> Node:
return Component( return Element(
tag_name="button", "button",
attributes=[ attributes=[
("type", "button"), ("type", "button"),
("data-pill-remove", ""), ("data-pill-remove", ""),
@@ -338,7 +365,7 @@ def _filter_remove_button() -> SafeText:
) )
def _filter_value_pill(option: SearchSelectOption, kind: str) -> SafeText: def _filter_value_pill(option: SearchSelectOption, kind: str) -> Node:
"""An include (✓) or exclude (✗) value pill. ``kind`` is "include"/"exclude".""" """An include (✓) or exclude (✗) value pill. ``kind`` is "include"/"exclude"."""
symbol = "" if kind == "include" else "" symbol = "" if kind == "include" else ""
css = ( css = (
@@ -357,7 +384,7 @@ def _filter_value_pill(option: SearchSelectOption, kind: str) -> SafeText:
) )
def _filter_modifier_pill(modifier_value: str, label: str) -> SafeText: def _filter_modifier_pill(modifier_value: str, label: str) -> Node:
"""The lone, sticky modifier pill (e.g. "(Any)"/"(None)").""" """The lone, sticky modifier pill (e.g. "(Any)"/"(None)")."""
return Span( return Span(
attributes=[ attributes=[
@@ -369,9 +396,9 @@ def _filter_modifier_pill(modifier_value: str, label: str) -> SafeText:
) )
def _filter_action_button(action: str, symbol: str, title: str) -> SafeText: def _filter_action_button(action: str, symbol: str, title: str) -> Node:
return Component( return Element(
tag_name="button", "button",
attributes=[ attributes=[
("type", "button"), ("type", "button"),
("data-search-select-action", action), ("data-search-select-action", action),
@@ -382,7 +409,7 @@ def _filter_action_button(action: str, symbol: str, title: str) -> SafeText:
) )
def _filter_option_row(value: str | int, label: str) -> SafeText: def _filter_option_row(value: str | int, label: str) -> Node:
"""A value row with include (+) and exclude () buttons.""" """A value row with include (+) and exclude () buttons."""
return Div( return Div(
attributes=[ attributes=[
@@ -404,7 +431,7 @@ def _filter_option_row(value: str | int, label: str) -> SafeText:
) )
def _filter_modifier_row(modifier_value: str, label: str) -> SafeText: def _filter_modifier_row(modifier_value: str, label: str) -> Node:
"""A pinned pseudo-option row. It carries no ``data-search-select-option`` so the text """A pinned pseudo-option row. It carries no ``data-search-select-option`` so the text
filter never hides it — modifiers stay visible at the top of the panel.""" filter never hides it — modifiers stay visible at the top of the panel."""
return Div( return Div(
@@ -431,7 +458,8 @@ def FilterSelect(
items_scroll: int = 10, items_scroll: int = 10,
placeholder: str = "Search…", placeholder: str = "Search…",
id: str = "", id: str = "",
) -> SafeText: free_text: bool = False,
) -> Node:
"""Include/exclude filter combobox built on the shared ``_combobox_shell``. """Include/exclude filter combobox built on the shared ``_combobox_shell``.
Like ``SearchSelect`` but each value row carries +/ buttons that add an Like ``SearchSelect`` but each value row carries +/ buttons that add an
@@ -447,6 +475,11 @@ def FilterSelect(
``included``/``excluded`` are resolved options (value + label) so pills show ``included``/``excluded`` are resolved options (value + label) so pills show
labels even when the value rows come from ``search_url``. ``options`` labels even when the value rows come from ``search_url``. ``options``
pre-renders the value rows for the complete-set (no ``search_url``) case. pre-renders the value rows for the complete-set (no ``search_url``) case.
``free_text`` turns the widget into a typed-pill input: there is no backing
option list, the JS builds an ephemeral option row from whatever the user
types so the +/ buttons (and Enter) commit the typed string itself as an
include / exclude pill.
""" """
options = [_normalize_option(option) for option in (options or [])] options = [_normalize_option(option) for option in (options or [])]
included = [_normalize_option(option) for option in (included or [])] included = [_normalize_option(option) for option in (included or [])]
@@ -464,7 +497,7 @@ def FilterSelect(
# pills — but the stored state guarantees they never coexist, so we render # pills — but the stored state guarantees they never coexist, so we render
# both channels unconditionally. Non-presence modifiers (INCLUDES_ALL / # both channels unconditionally. Non-presence modifiers (INCLUDES_ALL /
# INCLUDES_ONLY) coexist with value pills and render side by side. # INCLUDES_ONLY) coexist with value pills and render side by side.
pills_children: list[SafeText] = [] pills_children: list[Node] = []
if active_modifier_label: if active_modifier_label:
pills_children.append(_filter_modifier_pill(modifier, active_modifier_label)) pills_children.append(_filter_modifier_pill(modifier, active_modifier_label))
for option in included: for option in included:
@@ -498,7 +531,7 @@ def FilterSelect(
# ── Templates the JS clones: include/exclude pills (added on click), the # ── Templates the JS clones: include/exclude pills (added on click), the
# modifier pill (when modifiers exist), and a value row (when fetched). ── # modifier pill (when modifiers exist), and a value row (when fetched). ──
templates: list[SafeText] = [ templates: list[Node] = [
Template( Template(
attributes=[("data-search-select-template", "pill-include")], attributes=[("data-search-select-template", "pill-include")],
children=[_filter_value_pill(_BLANK_OPTION, "include")], children=[_filter_value_pill(_BLANK_OPTION, "include")],
@@ -515,7 +548,7 @@ def FilterSelect(
children=[_filter_modifier_pill("", "")], children=[_filter_modifier_pill("", "")],
) )
) )
if search_url: if search_url or free_text:
templates.append( templates.append(
Template( Template(
attributes=[("data-search-select-template", "row")], attributes=[("data-search-select-template", "row")],
@@ -523,26 +556,7 @@ def FilterSelect(
) )
) )
container_attributes: list[HTMLAttribute] = [ children = _combobox_children(
("data-search-select", ""),
("data-search-select-mode", "filter"),
("data-name", field_name),
("data-search-url", search_url),
("data-multi", "true"),
("data-always-visible", "false"),
("data-items-visible", str(items_visible)),
("data-items-scroll", str(items_scroll)),
("data-prefetch", str(prefetch)),
("data-sync-url", "false"),
("class", _CONTAINER_CLASS),
]
if modifier:
container_attributes.append(("data-modifier", modifier))
if id:
container_attributes.append(("id", id))
return _combobox_shell(
container_attributes=container_attributes,
pills=pills, pills=pills,
search_attributes=search_attributes, search_attributes=search_attributes,
options_children=[*modifier_rows, *value_rows], options_children=[*modifier_rows, *value_rows],
@@ -550,6 +564,19 @@ def FilterSelect(
items_visible=items_visible, items_visible=items_visible,
templates=templates, templates=templates,
) )
return _SearchSelect(
name=field_name,
search_url=search_url,
multi="true",
filter_mode="true",
free_text="true" if free_text else "false",
always_visible="false",
prefetch=prefetch,
sync_url="false",
class_=_CONTAINER_CLASS,
id_=id or None,
data_modifier=modifier or None,
)[*children]
def searchselect_selected( def searchselect_selected(
+3 -58
View File
@@ -127,16 +127,9 @@
} }
} }
form input:disabled, /* Form controls (incl. disabled state) and form-field markup (labels, errors,
select:disabled, rows) are styled by utilities on the elements themselves — see
textarea:disabled { PrimitiveWidgetsMixin and FormFields. No form styling lives here. */
@apply cursor-not-allowed bg-neutral-secondary-strong text-fg-disabled;
}
.errorlist {
@apply mt-4 mb-1 pl-3 py-2 bg-red-600 text-slate-200 w-[300px];
}
#button-container button { #button-container button {
@apply mx-1; @apply mx-1;
@@ -178,54 +171,6 @@ textarea:disabled {
padding-left: 1em; padding-left: 1em;
} }
#add-form {
label + select, input, textarea {
@apply mt-1;
}
form {
@apply flex flex-col gap-3;
}
.form-row-button-group {
display: flex;
flex-direction: row;
@apply gap-0 p-0;
button {
@apply mr-0;
&:first-child {
@apply rounded-e-none;
}
&:nth-child(2) {
@apply rounded-none;
}
&:last-child {
@apply rounded-s-none;
}
}
}
label {
@apply mb-2.5 text-sm font-medium text-heading;
}
input:not([type="checkbox"]):not([data-search-select-search]) {
@apply mb-3 bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand block w-full px-3 py-2.5 shadow-xs placeholder:text-body;
}
input[type="checkbox"] {
@apply w-4 h-4 border border-default-medium rounded-xs bg-neutral-secondary-medium focus:ring-2 focus:ring-brand-soft;
}
select {
@apply w-full px-3 py-2.5 bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand shadow-xs placeholder:text-body;
}
textarea {
@apply bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand block w-full p-3.5 shadow-xs placeholder:text-body;
}
:has(> label + input[type="checkbox"]) {
@apply mt-3; /* needed because compared to all other form elements checkbox and its label are on the same row */
display: flex;
flex-direction: row;
justify-content: space-between;
}
}
@layer utilities { @layer utilities {
.toast-container { .toast-container {
@apply fixed z-50 flex flex-col items-end bottom-0 right-0 p-4; @apply fixed z-50 flex flex-col items-end bottom-0 right-0 p-4;
+69 -19
View File
@@ -8,9 +8,11 @@ it hoists shared `<head>` content (the `_HEADERS` block, analogous to
""" """
import json import json
from typing import TYPE_CHECKING
from django.contrib.messages import get_messages from django.contrib.messages import get_messages
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.middleware.csrf import get_token
from django.templatetags.static import static from django.templatetags.static import static
from django.urls import reverse from django.urls import reverse
from django.utils.html import conditional_escape from django.utils.html import conditional_escape
@@ -19,6 +21,9 @@ from django_htmx.jinja import django_htmx_script
from games.templatetags.version import version, version_date from games.templatetags.version import version, version_date
if TYPE_CHECKING:
from common.components import Node
# Static head script that sets the dark/light class before paint (avoids FOUC). # Static head script that sets the dark/light class before paint (avoids FOUC).
_THEME_FOUC_SCRIPT = """<script> _THEME_FOUC_SCRIPT = """<script>
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) { if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
@@ -182,10 +187,37 @@ def _main_script(mastered: bool) -> str:
return _MAIN_SCRIPT_A + ("true" if mastered else "false") + _MAIN_SCRIPT_B return _MAIN_SCRIPT_A + ("true" if mastered else "false") + _MAIN_SCRIPT_B
def Navbar(*, today_played: str, last_7_played: str, current_year: int) -> SafeText: def NavbarPlaytime(
"""Top navigation bar.""" today_played: str, last_7_played: str, *, oob: bool = False
) -> "Node":
"""The navbar 'Today · Last 7 days' totals. Carries a stable id so
htmx endpoints can refresh it out-of-band after a session change."""
from common.components import Safe
oob_attr = ' hx-swap-oob="true"' if oob else ""
return Safe(
f'<li id="navbar-playtime"{oob_attr} '
'class="dark:text-white flex flex-col items-center text-xs">'
'<span class="flex uppercase gap-1">Today'
'<span class="dark:text-gray-400">·</span>Last 7 days</span>'
'<span class="flex items-center gap-1">'
f'{today_played}<span class="dark:text-gray-400">·</span>'
f"{last_7_played}</span></li>"
)
def Navbar(
*, today_played: str, last_7_played: str, current_year: int, csrf_token: str
) -> "Node":
"""Top navigation bar.
Static chrome, so it's a single ``Safe`` node wrapping its markup rather
than a hand-built element tree — trusted HTML belongs in a ``Safe`` node,
not a ``mark_safe`` string."""
from common.components import Safe
logo = static("icons/schedule.png") logo = static("icons/schedule.png")
return mark_safe(f"""<nav class="bg-neutral-primary-soft border-b border-default"> return Safe(f"""<nav class="bg-neutral-primary-soft border-b border-default">
<div class="max-w-(--breakpoint-xl) flex flex-wrap items-center justify-between mx-auto p-4"> <div class="max-w-(--breakpoint-xl) flex flex-wrap items-center justify-between mx-auto p-4">
<a href="{reverse("games:index")}" <a href="{reverse("games:index")}"
class="flex items-center space-x-3 rtl:space-x-reverse"> class="flex items-center space-x-3 rtl:space-x-reverse">
@@ -212,10 +244,7 @@ def Navbar(*, today_played: str, last_7_played: str, current_year: int) -> SafeT
</svg> </svg>
</button> </button>
</li> </li>
<li class="dark:text-white flex flex-col items-center text-xs"> {NavbarPlaytime(today_played, last_7_played)}
<span class="flex uppercase gap-1">Today<span class="dark:text-gray-400">·</span>Last 7 days</span>
<span class="flex items-center gap-1">{today_played}<span class="dark:text-gray-400">·</span>{last_7_played}</span>
</li>
<li> <li>
<a href="#" class="block py-2 px-3 text-white bg-blue-700 rounded-sm md:bg-transparent md:text-blue-700 md:p-0 md:dark:text-blue-500 dark:bg-blue-600 md:dark:bg-transparent" aria-current="page">Home</a> <a href="#" class="block py-2 px-3 text-white bg-blue-700 rounded-sm md:bg-transparent md:text-blue-700 md:p-0 md:dark:text-blue-500 dark:bg-blue-600 md:dark:bg-transparent" aria-current="page">Home</a>
</li> </li>
@@ -260,7 +289,10 @@ def Navbar(*, today_played: str, last_7_played: str, current_year: int) -> SafeT
<a href="{reverse("games:stats_by_year", args=[current_year])}" class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Stats</a> <a href="{reverse("games:stats_by_year", args=[current_year])}" class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Stats</a>
</li> </li>
<li> <li>
<a href="{reverse("logout")}" class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Log out</a> <form method="post" action="{reverse("logout")}">
<input type="hidden" name="csrfmiddlewaretoken" value="{csrf_token}">
<button type="submit" class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Log out</button>
</form>
</li> </li>
</ul> </ul>
</div> </div>
@@ -269,22 +301,37 @@ def Navbar(*, today_played: str, last_7_played: str, current_year: int) -> SafeT
def Page( def Page(
content: SafeText | str, content: "Node | SafeText | str",
*, *,
request: HttpRequest, request: HttpRequest,
title: str = "", title: str = "",
scripts: SafeText | str = "", scripts: "Node | SafeText | str" = "",
mastered: bool = False, mastered: bool = False,
) -> SafeText: ) -> SafeText:
"""Assemble a full HTML document around `content` (the fast_app equivalent).""" """Assemble a full HTML document around `content` (the fast_app equivalent).
Scripts are collected from `content`'s component tree: every component
declares its JS via `Media`, and `collect_media` gathers (deduped) the union
for the whole page. The `scripts` argument remains for page-specific glue
that isn't owned by a reusable component (e.g. the add-form helpers).
"""
from common.components import ModuleScript, StaticScript, collect_media
from games.views.general import global_current_year, model_counts from games.views.general import global_current_year, model_counts
media = collect_media(content)
collected_scripts = "".join(
[str(ModuleScript(name)) for name in media.js]
+ [str(StaticScript(name)) for name in media.js_external]
)
all_scripts = collected_scripts + (str(scripts) if scripts else "")
counts = model_counts(request) counts = model_counts(request)
year = global_current_year(request)["global_current_year"] year = global_current_year(request)["global_current_year"]
navbar = Navbar( navbar = Navbar(
today_played=counts["today_played"], today_played=counts["today_played"],
last_7_played=counts["last_7_played"], last_7_played=counts["last_7_played"],
current_year=year, current_year=year,
csrf_token=get_token(request),
) )
messages = [ messages = [
@@ -306,12 +353,15 @@ def Page(
" htmx.config.scrollBehavior = 'smooth';\n" " htmx.config.scrollBehavior = 'smooth';\n"
" htmx.config.selfRequestsOnly = false;\n" " htmx.config.selfRequestsOnly = false;\n"
" </script>\n" " </script>\n"
f' <script src="{static("js/htmx-redirect-toast.js")}"></script>\n' f' <script src="{static("js/dist/htmx-redirect-toast.js")}"></script>\n'
f" {django_htmx_script(nonce=None)}\n" f" {django_htmx_script(nonce=None)}\n"
f' <link rel="stylesheet" href="{static("base.css")}" />\n' f' <link rel="stylesheet" href="{static("base.css")}" />\n'
' <script src="https://cdn.jsdelivr.net/npm/flowbite@2.4.1/dist/flowbite.min.js"></script>\n' # Vendored bundles (flowbite 2.4.1, alpinejs/@alpinejs/mask 3.15.12) —
' <script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/mask@3.x.x/dist/cdn.min.js"></script>\n' # served locally so pages work offline (and in browser tests). The mask
' <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>\n' # plugin must load before Alpine core; both stay deferred.
f' <script src="{static("js/flowbite.min.js")}"></script>\n'
f' <script defer src="{static("js/alpine-mask.min.js")}"></script>\n'
f' <script defer src="{static("js/alpine.min.js")}"></script>\n'
f" {_THEME_FOUC_SCRIPT}\n" f" {_THEME_FOUC_SCRIPT}\n"
" </head>\n" " </head>\n"
) )
@@ -325,12 +375,12 @@ def Page(
f' <div id="main-container" class="flex flex-1 flex-col pt-8 pb-16">{content}</div>\n' f' <div id="main-container" class="flex flex-1 flex-col pt-8 pb-16">{content}</div>\n'
f' <span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{version()} ({version_date()})</span>\n' f' <span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{version()} ({version_date()})</span>\n'
" </div>\n" " </div>\n"
f" {scripts}\n" f" {all_scripts}\n"
f" {_main_script(mastered)}\n" f" {_main_script(mastered)}\n"
" <!-- hx-swap-oob makes sure the modal gets removed upon any HTMX response -->\n" " <!-- hx-swap-oob makes sure the modal gets removed upon any HTMX response -->\n"
' <div id="global-modal-container" hx-swap-oob="true"></div>\n' ' <div id="global-modal-container" hx-swap-oob="true"></div>\n'
f" {_TOAST_CONTAINER}\n" f" {_TOAST_CONTAINER}\n"
f' <script src="{static("js/toast.js")}"></script>\n' f' <script src="{static("js/dist/toast.js")}"></script>\n'
" </body>\n</html>\n" " </body>\n</html>\n"
) )
@@ -339,10 +389,10 @@ def Page(
def render_page( def render_page(
request: HttpRequest, request: HttpRequest,
content: SafeText | str, content: "Node | SafeText | str",
*, *,
title: str = "", title: str = "",
scripts: SafeText | str = "", scripts: "Node | SafeText | str" = "",
mastered: bool = False, mastered: bool = False,
status: int = 200, status: int = 200,
) -> HttpResponse: ) -> HttpResponse:
+26
View File
@@ -1,17 +1,43 @@
import re import re
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from typing import NamedTuple
from django.utils import timezone from django.utils import timezone
from common.utils import generate_split_ranges from common.utils import generate_split_ranges
dateformat: str = "%d/%m/%Y" dateformat: str = "%d/%m/%Y"
dateformat_hyphenated: str = "%d-%m-%Y"
datetimeformat: str = "%d/%m/%Y %H:%M" datetimeformat: str = "%d/%m/%Y %H:%M"
timeformat: str = "%H:%M" timeformat: str = "%H:%M"
durationformat: str = "%2.1H hours" durationformat: str = "%2.1H hours"
durationformat_manual: str = "%H hours" durationformat_manual: str = "%H hours"
class DatePartSpec(NamedTuple):
"""One date part (day/month/year) of a hyphenated date format."""
name: str
placeholder: str
length: int
_DATE_PART_SPECS: dict[str, DatePartSpec] = {
"%d": DatePartSpec("day", "DD", 2),
"%m": DatePartSpec("month", "MM", 2),
"%Y": DatePartSpec("year", "YYYY", 4),
}
def date_parts(format_string: str = dateformat_hyphenated) -> list[DatePartSpec]:
"""Split a hyphenated strftime date format into its ordered parts.
``"%d-%m-%Y"`` becomes ``[day, month, year]`` specs, each carrying the
placeholder text (``DD``/``MM``/``YYYY``) and digit length shown by the
DateRangeField segments."""
return [_DATE_PART_SPECS[directive] for directive in format_string.split("-")]
def _safe_timedelta(duration: timedelta | int | None): def _safe_timedelta(duration: timedelta | int | None):
if duration is None: if duration is None:
return timedelta(0) return timedelta(0)
+4 -1
View File
@@ -7,8 +7,11 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: timetracker container_name: timetracker
environment: environment:
- DEBUG=false
- TZ=Europe/Prague - TZ=Europe/Prague
- CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz" # APP_URL drives ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS unless overridden.
# Behind your own reverse proxy you may also set ALLOWED_HOSTS=* directly.
- APP_URL=https://tracker.kucharczyk.xyz
user: "1000" user: "1000"
# volumes: # volumes:
# - "db:/home/timetracker/app/src/timetracker/db.sqlite3" # - "db:/home/timetracker/app/src/timetracker/db.sqlite3"
+6 -2
View File
@@ -1,17 +1,21 @@
--- ---
services: services:
timetracker: timetracker:
image: ${REGISTRY_URL:-registry.kucharczyk.xyz}/timetracker:1.7.0 image: ${REGISTRY_URL:-registry.kucharczyk.xyz}/timetracker:latest
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: timetracker container_name: timetracker
environment: environment:
- DEBUG=${DEBUG:-false}
- SECRET_KEY=${SECRET_KEY}
- TZ=${TZ:-Europe/Prague} - TZ=${TZ:-Europe/Prague}
- CSRF_TRUSTED_ORIGINS=https://tracker.kucharczyk.xyz # APP_URL drives ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS unless overridden.
- APP_URL=${APP_URL:-http://localhost:8000}
- PUID=${PUID:-1000} - PUID=${PUID:-1000}
- PGID=${PGID:-100} - PGID=${PGID:-100}
- DATA_DIR=${DATA_DIR:-/home/timetracker/app/data} - DATA_DIR=${DATA_DIR:-/home/timetracker/app/data}
- CREATE_DEFAULT_SUPERUSER=${CREATE_DEFAULT_SUPERUSER:-false}
ports: ports:
- "${TIMETRACKER_EXTERNAL_PORT:-8000}:8000" - "${TIMETRACKER_EXTERNAL_PORT:-8000}:8000"
volumes: volumes:
+133
View File
@@ -0,0 +1,133 @@
# Configuration
All configurable Django settings are read through a single helper,
`config()` in [`timetracker/config.py`](../timetracker/config.py). It resolves
each value from a fixed chain of sources so the same setting can come from an
environment variable, a `.env` file, an `.ini` file, or a built-in default —
without any per-setting special-casing in `settings.py`.
## Resolution priority
For a setting named `NAME`, the first source that provides a value wins:
| Priority | Source | Notes |
|---------:|--------|-------|
| 1 | `NAME__FILE` env var | Path to a file; its *stripped* contents are the value. Opt-in per setting (`allow_file=True`). For Docker/Kubernetes secrets. |
| 2 | `NAME` env var | A real process environment variable. |
| 3 | `.env` file | `KEY=value` lines (see [.env syntax](#env-syntax)). |
| 4 | `settings.ini` file | The `[timetracker]` section, parsed with `configparser`. |
| 5 | `default` | The in-code fallback in `settings.py`. |
If no source supplies a value and no `default` is defined, startup fails with
`ImproperlyConfigured` rather than silently using an empty value.
**Worked example.** With `VALUE` set in the environment *and* in `.env` *and*
in `settings.ini`, the environment variable wins. Remove it and `.env` wins;
remove that and `settings.ini` wins; remove that and the code default applies.
## Settings reference
| Setting | Cast | Default | `__FILE`? | Description |
|---------|------|---------|:---------:|-------------|
| `SECRET_KEY` | str | insecure dev key | yes | Django secret key. **Required in production** (DEBUG off) — a missing value is a hard error, not a silent insecure fallback. |
| `DEBUG` | bool | `true` (dev) | no | Debug mode. Turn **off** in production. Defaults on for local development. |
| `APP_URL` | str (or comma-separated URLs) | `http://localhost:8000` | no | Public URL(s) of the site. One full URL or a comma-separated list. Derives `ALLOWED_HOSTS` and `CSRF_TRUSTED_ORIGINS` from all listed URLs. |
| `ALLOWED_HOSTS` | list | derived from `APP_URL` | no | Comma-separated hostnames. Overrides the `APP_URL` derivation (useful for `ALLOWED_HOSTS=*` behind a reverse proxy). |
| `TZ` | str | `Europe/Prague` (dev) / `UTC` (prod) | no | Time zone. |
| `DATA_DIR` | path | project root | no | Directory holding the SQLite database. Also read by `entrypoint.sh`. |
`cast` understands `bool` (`true/1/yes/on``True`), `list` (comma-separated,
whitespace-trimmed, empty items dropped), `int`, `Path`, or any callable.
## APP_URL, ALLOWED_HOSTS and CSRF
`APP_URL` accepts one full URL or a comma-separated list of full URLs. Both
`ALLOWED_HOSTS` and `CSRF_TRUSTED_ORIGINS` are derived from all listed URLs —
no need to repeat the same information in separate variables.
Single domain (common case):
```
APP_URL=https://tracker.example.com
# -> ALLOWED_HOSTS = ["tracker.example.com"]
# -> CSRF_TRUSTED_ORIGINS = ["https://tracker.example.com"]
```
Multiple domains:
```
APP_URL=https://tracker.example.com,https://www.tracker.example.com
# -> ALLOWED_HOSTS = ["tracker.example.com", "www.tracker.example.com"]
# -> CSRF_TRUSTED_ORIGINS = ["https://tracker.example.com", "https://www.tracker.example.com"]
```
`ALLOWED_HOSTS` can still be overridden directly for edge cases. A typical
reverse-proxy setup where the proxy validates the host:
```
ALLOWED_HOSTS=*
```
## Secrets and `__FILE`
Secret managers (Docker secrets, Kubernetes) mount secrets as files. For any
setting that opts in (currently `SECRET_KEY`), point a `*__FILE` variable at
the mounted path:
```
SECRET_KEY__FILE=/run/secrets/timetracker_secret_key
```
The file contents are read and `.strip()`-ed. The strip matters: editors and
`echo` often append a trailing newline, and a stray `\n` inside `SECRET_KEY`
would silently invalidate every signed cookie/token when the file is recreated
without it.
## .env syntax
```dotenv
# full-line comment
KEY=value
export KEY=value # optional leading "export"
QUOTED="value with spaces" # surrounding quotes are stripped
SINGLE='also fine'
WITH_HASH="a # b" # '#' inside quotes is literal
INLINE=value # trailing comment after an unquoted value is dropped
```
Deliberately **not** supported (documented limits, not bugs):
- variable interpolation (`${OTHER}`)
- multiline values
File locations default to `.env` and `settings.ini` at the project root and
can be moved with the `ENV_FILE` / `INI_FILE` environment variables. Missing
files are ignored, so env-only deployments need neither. A `.env` file used by
`docker-compose` for `${VAR}` substitution is the same file Django reads in
local development; inside the container, real environment variables apply.
See [`.env.example`](../.env.example) and
[`settings.ini.example`](../settings.ini.example) for starting points.
## Container / entrypoint-only variables
These are consumed by [`entrypoint.sh`](../entrypoint.sh) during container
bootstrap, **not** by Django. They are intentionally not part of the Python
config — moving them there would buy nothing and force a bash↔Python bridge.
| Variable | Default | Purpose |
|----------|---------|---------|
| `PUID` / `PGID` | `1000` / `100` | uid/gid the container process runs as. |
| `DATA_DIR` | `/home/timetracker/app/data` | Database directory. Shared with Django via the same env var + matching default. |
| `CREATE_DEFAULT_SUPERUSER` | `false` | Create an `admin`/`admin` superuser on first start. |
| `STAGING` | `false` | Scrub copied sessions / django-q schedule on staging. |
| `LOAD_SAMPLE_DATA` | `false` | Seed sample fixtures when the database is empty. |
## Migrating from the old config
- `PROD=1``DEBUG=false`. `PROD` still works as a **deprecated alias** for
one release and emits a `DeprecationWarning`.
- `ALLOWED_HOSTS` is now configurable (it was previously hard-coded to `*`).
After upgrading, set `APP_URL` (or `ALLOWED_HOSTS` explicitly) or the host
will be rejected. Reverse-proxy deployments that relied on `*` should set
`ALLOWED_HOSTS=*`.
+51
View File
@@ -0,0 +1,51 @@
# Custom Element API: Two patterns, one goal
## Pattern 1: Named builder (current, preferred)
A tag builder with auto-attached `Media`, created via `custom_element_builder()`:
```python
# definition (custom_elements.py)
SessionTimestampButtons = custom_element_builder("session-timestamp-buttons")
# usage (session.py)
SessionTimestampButtons(class_="form-row-button-group", hx_boost="false")[
Button(data_target="timestamp_start", data_type="now", size="xs")["Set to now"],
Button(data_target="timestamp_start", data_type="toggle", size="xs")["Toggle text"],
]
```
**Pros:** explicit dependency, visible import, fails loudly if builder deleted
**Cons:** one line of ceremony per element
## Pattern 2: Element + registry (proposed, not implemented)
A global `CUSTOM_ELEMENT_MEDIA` dict in `core.py` that maps tag names to their `Media`. `register_element()` populates it automatically at import time, so `Element("session-timestamp-buttons")` silently picks up its JS dependency:
```python
# definition (custom_elements.py)
register_element("session-timestamp-buttons", "SessionTimestampButtons", EmptyProps)
# CUSTOM_ELEMENT_MEDIA["session-timestamp-buttons"] = Media(js=("dist/elements/...",))
# usage (session.py) — no builder import needed
Element("session-timestamp-buttons",
[("class", "form-row-button-group"), ("hx-boost", "false")],
children=[...],
)
```
**Pros:** one universal API — `Div(...)`, `Button(...)`, `Element("custom-tag")` all same pattern
**Cons:** implicit dependency — deleting a `register_element()` call produces no error, just broken JS at runtime
## Recommendation
Start with Pattern 1 (named builders) — safe by default. Add Pattern 2 later if the ceremony becomes annoying. The two are **not mutually exclusive**: a named builder is just a thin wrapper around an `Element`; the registry can be added without changing any call sites.
## Quick reference
| Want | Write |
|------|-------|
| Plain HTML tag | `Div(class_="flex")["text"]` |
| Custom element (builder) | `SessionTimestampButtons(class_="...")[child]` |
| Raw element | `Element("custom-tag", attributes_list, children=[...])` |
| Builder from scratch | `custom_element_builder("tag-name")` |
@@ -0,0 +1,485 @@
# Boolean Filters Overhaul Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Overhaul the boolean criterion filters from a single checkbox (representing True/Not set) to a 2-radio-button UI representing True, False, and Unset states across all filter bars.
**Architecture:**
1. Generalize `_filter_checkbox` into a filter-agnostic `Checkbox` component and introduce a `Radio` component in `common/components/primitives.py`.
2. Implement a nullable boolean filter JSON parsing helper `_parse_bool_nullable` and a component helper `_filter_boolean_radio` in `common/components/filters.py`.
3. Update `GameFilterBar`, `SessionFilterBar`, and `PurchaseFilterBar` in `common/components/filters.py` to leverage these new helpers.
4. Enhance `games/static/js/filter_bar.js` with deselectable radio toggling behavior and updated checked-radio state serialization.
**Tech Stack:** Python, Django, vanilla JavaScript, HTML.
---
### Task 1: Generalize Checkbox and Introduce Radio in Primitives
**Files:**
- Modify: `common/components/primitives.py`
- [ ] **Step 1: Write the failing test for the new Checkbox and Radio primitives**
Create a new test class `ComponentPrimitivesTest` in `tests/test_components.py` (or verify where to append) to check the output of `Checkbox` and `Radio`.
Add the following code to `tests/test_components.py`:
```python
from common.components.primitives import Checkbox, Radio
class ComponentPrimitivesTest(SimpleTestCase):
def test_checkbox_primitive(self):
html = Checkbox(name="test-check", label="Accept Terms", checked=True, value="yes")
self.assertIn('type="checkbox"', html)
self.assertIn('name="test-check"', html)
self.assertIn('value="yes"', html)
self.assertIn('checked="true"', html)
self.assertIn("Accept Terms", html)
def test_radio_primitive(self):
html = Radio(name="test-radio", label="Option A", checked=False, value="A")
self.assertIn('type="radio"', html)
self.assertIn('name="test-radio"', html)
self.assertIn('value="A"', html)
self.assertNotIn('checked="true"', html)
self.assertIn("Option A", html)
```
- [ ] **Step 2: Run test to verify it fails**
Run: `pytest tests/test_components.py -k ComponentPrimitivesTest`
Expected output: Failures/errors due to `Checkbox` and `Radio` not being defined/imported.
- [ ] **Step 3: Implement Checkbox and Radio in `common/components/primitives.py`**
Open `common/components/primitives.py` and find the other basic primitives (e.g. `Input`, `Label`). Add the following implementations and ensure they are exported / added to imports/exports:
```python
def Checkbox(
name: str,
label: str,
checked: bool = False,
value: str = "1",
attributes: list[HTMLAttribute] | None = None,
) -> SafeText:
"""A filter-agnostic Checkbox component."""
attributes = attributes or []
input_attrs = [
("name", name),
("value", value),
("class", "rounded border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand"),
] + attributes
if checked:
input_attrs.append(("checked", "true"))
return Label(
attributes=[("class", "flex items-center gap-2 text-sm text-heading cursor-pointer")],
children=[
Input(type="checkbox", attributes=input_attrs),
label,
],
)
def Radio(
name: str,
label: str,
checked: bool = False,
value: str = "",
attributes: list[HTMLAttribute] | None = None,
) -> SafeText:
"""A filter-agnostic Radio component."""
attributes = attributes or []
input_attrs = [
("name", name),
("value", value),
("class", "rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand"),
] + attributes
if checked:
input_attrs.append(("checked", "true"))
return Label(
attributes=[("class", "flex items-center gap-1.5 text-sm text-heading cursor-pointer")],
children=[
Input(type="radio", attributes=input_attrs),
label,
],
)
```
- [ ] **Step 4: Run test to verify it passes**
Run: `pytest tests/test_components.py -k ComponentPrimitivesTest`
Expected output: `2 passed`
- [ ] **Step 5: Commit**
Run:
```bash
git add common/components/primitives.py tests/test_components.py
git commit -m "refactor: generalize Checkbox and add Radio primitive component"
```
---
### Task 2: Implement Filter Parsers & Helpers in filters.py
**Files:**
- Modify: `common/components/filters.py`
- Modify: `tests/test_filter_helpers.py`
- [ ] **Step 1: Write failing unit tests for `_parse_bool_nullable` in `tests/test_filter_helpers.py`**
Add a new test class `ParseBoolNullableTest` to `tests/test_filter_helpers.py`:
```python
from common.components.filters import _parse_bool_nullable
class ParseBoolNullableTest(SimpleTestCase):
def test_missing_key(self):
self.assertIsNone(_parse_bool_nullable({}, "field"))
def test_null_value(self):
self.assertIsNone(_parse_bool_nullable({"field": None}, "field"))
self.assertIsNone(_parse_bool_nullable({"field": {}}, "field"))
def test_boolean_values(self):
self.assertTrue(_parse_bool_nullable({"field": {"value": True}}, "field"))
self.assertFalse(_parse_bool_nullable({"field": {"value": False}}, "field"))
def test_string_values(self):
self.assertTrue(_parse_bool_nullable({"field": {"value": "true"}}, "field"))
self.assertTrue(_parse_bool_nullable({"field": {"value": "1"}}, "field"))
self.assertFalse(_parse_bool_nullable({"field": {"value": "false"}}, "field"))
self.assertFalse(_parse_bool_nullable({"field": {"value": "0"}}, "field"))
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `pytest tests/test_filter_helpers.py -k ParseBoolNullableTest`
Expected output: Failures/errors due to `_parse_bool_nullable` not found.
- [ ] **Step 3: Implement `_parse_bool_nullable` and `_filter_boolean_radio` in `common/components/filters.py`**
1. Import `Checkbox` and `Radio` from `common.components.primitives` at the top of `common/components/filters.py`.
2. Define `_FILTER_RADIO_CLASS` and add `_parse_bool_nullable`.
3. Create `_filter_boolean_radio`.
4. Refactor `_filter_checkbox` to use `Checkbox` instead of raw `Label` and `Input`.
Code to implement:
```python
_FILTER_RADIO_CLASS = (
"rounded-full border-default-medium bg-neutral-secondary-medium "
"text-brand focus:ring-brand"
)
def _parse_bool_nullable(existing: dict, key: str) -> bool | None:
"""Extract a nullable boolean value from a filter criterion."""
if key not in existing:
return None
field = existing[key]
if not isinstance(field, dict):
return None
val = field.get("value")
if val is None:
return None
if isinstance(val, str):
if val.lower() in ("true", "1", "yes"):
return True
if val.lower() in ("false", "0", "no"):
return False
return bool(val)
def _filter_checkbox(name: str, label: str, checked: bool) -> SafeText:
"""Thin adapter mapping legacy checkbox filters to the generalized Checkbox primitive."""
return Checkbox(name=name, label=label, checked=checked)
def _filter_boolean_radio(name: str, label: str, value: bool | None) -> SafeText:
"""Renders a filter-specific boolean radio button group with 'True' and 'False' options."""
return Div(
attributes=[("class", "flex flex-col gap-1")],
children=[
Span(
attributes=[("class", _FILTER_LABEL_CLASS)],
children=[label],
),
Div(
attributes=[("class", "flex items-center gap-4 h-9")],
children=[
Radio(name=name, label="True", checked=value is True, value="true"),
Radio(name=name, label="False", checked=value is False, value="false"),
],
),
],
)
```
- [ ] **Step 4: Run unit tests to verify they pass**
Run: `pytest tests/test_filter_helpers.py`
Expected output: All helper tests passed (including `ParseBoolNullableTest`).
- [ ] **Step 5: Commit**
Run:
```bash
git add common/components/filters.py tests/test_filter_helpers.py
git commit -m "feat: implement _parse_bool_nullable and _filter_boolean_radio helper"
```
---
### Task 3: Replace Single Checkboxes with Radio Groups in Filter Bars
**Files:**
- Modify: `common/components/filters.py`
- [ ] **Step 1: Update GameFilterBar**
In `common/components/filters.py` inside `GameFilterBar`:
1. Parse using `_parse_bool_nullable` instead of `_parse_bool` for:
- `mastered_value`
- `purchase_refunded_value`
- `purchase_infinite_value`
- `session_emulated_value`
2. Update the fields list to replace `_filter_checkbox` with `_filter_boolean_radio`, changing the wrapper div to have `gap-6` for better horizontal radio button spacing.
Code snippet modification:
```python
# Parsing:
mastered_value = _parse_bool_nullable(existing, "mastered")
# ...
purchase_refunded_value = _parse_bool_nullable(existing, "purchase_refunded")
purchase_infinite_value = _parse_bool_nullable(existing, "purchase_infinite")
session_emulated_value = _parse_bool_nullable(existing, "session_emulated")
# Rendering (in fields):
Div(
attributes=[("class", "flex items-end gap-6 mb-4 flex-wrap")],
children=[
_filter_boolean_radio("filter-mastered", "Mastered", mastered_value),
_filter_boolean_radio(
"filter-purchase-refunded", "Refunded", purchase_refunded_value
),
_filter_boolean_radio(
"filter-purchase-infinite", "Infinite", purchase_infinite_value
),
_filter_boolean_radio(
"filter-session-emulated", "Emulated", session_emulated_value
),
],
),
```
- [ ] **Step 2: Update SessionFilterBar**
In `common/components/filters.py` inside `SessionFilterBar`:
1. Parse using `_parse_bool_nullable` for:
- `emulated_value`
- `is_active_value`
2. Update the fields to replace `_filter_checkbox` with `_filter_boolean_radio`.
Code snippet modification:
```python
# Parsing:
emulated_value = _parse_bool_nullable(existing, "emulated")
is_active_value = _parse_bool_nullable(existing, "is_active")
# Rendering (in fields):
Div(
attributes=[("class", "flex gap-6 mb-4")],
children=[
_filter_boolean_radio("filter-emulated", "Emulated", emulated_value),
_filter_boolean_radio("filter-active", "Active", is_active_value),
],
),
```
- [ ] **Step 3: Update PurchaseFilterBar**
In `common/components/filters.py` inside `PurchaseFilterBar`:
1. Parse using `_parse_bool_nullable` for:
- `is_refunded_value`
- `infinite_value`
- `needs_price_update_value`
2. Update the fields to replace `_filter_checkbox` with `_filter_boolean_radio`.
Code snippet modification:
```python
# Parsing:
is_refunded_value = _parse_bool_nullable(existing, "is_refunded")
infinite_value = _parse_bool_nullable(existing, "infinite")
needs_price_update_value = _parse_bool_nullable(existing, "needs_price_update")
# Rendering (in fields):
Div(
attributes=[("class", "flex flex-col items-start gap-4 mb-4")],
children=[
_filter_boolean_radio(
"filter-refunded", "Refunded", is_refunded_value
),
_filter_boolean_radio("filter-infinite", "Infinite", infinite_value),
_filter_boolean_radio(
"filter-needs-price-update",
"Needs Price Update",
needs_price_update_value,
),
],
),
```
- [ ] **Step 4: Run component tests to verify output**
Run: `pytest tests/test_filter_bars.py`
Expected output: Since we only changed the internal input type from checkbox to radio but kept the `name="..."` attribute intact, the tests asserting name occurrences should still pass!
- [ ] **Step 5: Commit**
Run:
```bash
git add common/components/filters.py
git commit -m "feat: replace single boolean checkboxes with radio groups in all FilterBars"
```
---
### Task 4: Frontend Behavior and Serialization in JS
**Files:**
- Modify: `games/static/js/filter_bar.js`
- [ ] **Step 1: Update Radio Serialization in `buildFilterJSON`**
In `games/static/js/filter_bar.js`, locate the `// 2. Boolean Fields (Checkboxes)` section.
Update the loop to check for `:checked` radio options:
```javascript
// 2. Boolean Fields (Radio Button Groups)
var booleanFields = [
{ name: "filter-mastered", key: "mastered" },
{ name: "filter-emulated", key: "emulated" },
{ name: "filter-active", key: "is_active" },
{ name: "filter-refunded", key: "is_refunded" },
{ name: "filter-infinite", key: "infinite" },
{ name: "filter-needs-price-update", key: "needs_price_update" },
{ name: "filter-purchase-refunded", key: "purchase_refunded" },
{ name: "filter-purchase-infinite", key: "purchase_infinite" },
{ name: "filter-session-emulated", key: "session_emulated" }
];
booleanFields.forEach(function (bf) {
var el = form.querySelector('[name="' + bf.name + '"]:checked');
if (el) {
var val = el.value === "true";
filter[bf.key] = criterion(val, null, "EQUALS");
}
});
```
- [ ] **Step 2: Add click-to-deselect functionality for radios**
In `games/static/js/filter_bar.js`, add `setupDeselectableRadios` and call it inside `DOMContentLoaded`:
```javascript
/**
* Enable deselect-on-click behavior for filter radio buttons.
*/
function setupDeselectableRadios() {
document.querySelectorAll('input[type="radio"]').forEach(function (radio) {
radio.addEventListener('click', function (e) {
if (this.wasChecked) {
this.checked = false;
this.wasChecked = false;
this.dispatchEvent(new Event('change', { bubbles: true }));
} else {
var name = this.getAttribute('name');
if (name) {
document.querySelectorAll('input[type="radio"][name="' + name + '"]').forEach(function (r) {
r.wasChecked = false;
});
}
this.wasChecked = true;
}
});
if (radio.checked) {
radio.wasChecked = true;
}
});
}
```
Locate the `document.addEventListener("DOMContentLoaded", ...)` callback at the bottom of the file and update it:
```javascript
document.addEventListener("DOMContentLoaded", function () {
injectSearchInputs();
setupDeselectableRadios();
loadPresets();
});
```
- [ ] **Step 3: Run existing frontend / component tests to verify no syntax errors or simple breaks**
Run: `pytest tests/test_filter_bars.py`
Expected output: PASS
- [ ] **Step 4: Commit**
Run:
```bash
git add games/static/js/filter_bar.js
git commit -m "feat: add click-to-deselect behavior and update checked-radio serialization in JS"
```
---
### Task 5: Add Comprehensive Test Coverage & Verification
**Files:**
- Modify: `tests/test_filter_bars.py`
- [ ] **Step 1: Write explicit tests for boolean radio elements in filter bars**
Add a test case checking that the filter bars output `type="radio"` and contain `value="true"` and `value="false"` for boolean fields:
In `tests/test_filter_bars.py`, add the following test method:
```python
def test_boolean_fields_render_as_radio_groups(self):
"""Boolean fields must render as radio groups with True/False choices."""
from common.components import FilterBar, SessionFilterBar, PurchaseFilterBar
# 1. Games Filter Bar
games_html = str(FilterBar(filter_json=""))
self.assertIn('type="radio"', games_html)
self.assertIn('name="filter-mastered"', games_html)
self.assertIn('value="true"', games_html)
self.assertIn('value="false"', games_html)
# 2. Session Filter Bar
session_html = str(SessionFilterBar(filter_json=""))
self.assertIn('type="radio"', session_html)
self.assertIn('name="filter-emulated"', session_html)
self.assertIn('value="true"', session_html)
self.assertIn('value="false"', session_html)
# 3. Purchase Filter Bar
purchase_html = str(PurchaseFilterBar(filter_json=""))
self.assertIn('type="radio"', purchase_html)
self.assertIn('name="filter-refunded"', purchase_html)
self.assertIn('value="true"', purchase_html)
self.assertIn('value="false"', purchase_html)
```
- [ ] **Step 2: Run pytest to verify all tests (including new ones) pass**
Run: `pytest`
Expected output: `356 passed` (including the new test case).
- [ ] **Step 3: Commit final tests**
Run:
```bash
git add tests/test_filter_bars.py
git commit -m "test: add explicit radio group and True/False choice checks for boolean fields"
```
@@ -0,0 +1,662 @@
# Comprehensive Filters Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement a comprehensive suite of backend filter classes and filter field expansions across all 6 main models (Game, Session, Purchase, Device, Platform, PlayEvent) using a subquery-based cross-entity approach.
**Architecture:** We will implement missing filter classes (`DeviceFilter`, `PlatformFilter`, `PlayEventFilter`) in `games/filters.py`. We will extend all filters to support powerful, deeply linked "cross-entity" subqueries (e.g. `GameFilter.session_filter` or `PlatformFilter.game_filter`) which builds robust `Q` objects without causing duplicate join rows in list queries.
**Tech Stack:** Django, Python dataclasses, Pytest.
---
### Task 1: Implement New Filter Classes (Device, Platform, PlayEvent)
**Files:**
- Modify: `games/filters.py`
- Test: `tests/test_filters.py`
- [ ] **Step 1: Implement DeviceFilter, PlatformFilter, and PlayEventFilter**
Add the three new operator filters to `games/filters.py`. Ensure we import all necessary criterion types and add the `parse_device_filter`, `parse_platform_filter`, and `parse_playevent_filter` helper functions at the end of the file.
```python
# Insert new filter imports and classes in games/filters.py
@dataclass
class DeviceFilter(OperatorFilter):
"""Filter for the Device model."""
AND: DeviceFilter | None = None
OR: DeviceFilter | None = None
NOT: DeviceFilter | None = None
name: StringCriterion | None = None
type: ChoiceCriterion | None = None
created_at: StringCriterion | None = None
# Free-text search
search: StringCriterion | None = None
# Cross-entity: Devices that have sessions matching these criteria
session_filter: SessionFilter | None = None
def to_q(self) -> Q:
q = Q()
if self.name is not None:
q &= self.name.to_q("name")
if self.type is not None:
q &= self.type.to_q("type")
if self.created_at is not None:
q &= self.created_at.to_q("created_at")
# Free-text search
if self.search is not None and self.search.value:
search_q = (
Q(name__icontains=self.search.value)
| Q(type__icontains=self.search.value)
)
if self.search.modifier == Modifier.EXCLUDES:
search_q = ~search_q
q &= search_q
# Cross-entity filter: session_filter
if self.session_filter is not None:
from games.models import Session
session_q = self.session_filter.to_q()
matching_ids = Session.objects.filter(session_q).values_list("device_id", flat=True)
q &= Q(id__in=matching_ids)
sub = self.sub_filter()
if sub is not None:
if self.AND is not None:
q &= sub.to_q()
elif self.OR is not None:
q |= sub.to_q()
elif self.NOT is not None:
q &= ~sub.to_q()
return q
@dataclass
class PlatformFilter(OperatorFilter):
"""Filter for the Platform model."""
AND: PlatformFilter | None = None
OR: PlatformFilter | None = None
NOT: PlatformFilter | None = None
name: StringCriterion | None = None
group: StringCriterion | None = None
icon: StringCriterion | None = None
created_at: StringCriterion | None = None
# Free-text search
search: StringCriterion | None = None
# Cross-entity
game_filter: GameFilter | None = None
purchase_filter: PurchaseFilter | None = None
def to_q(self) -> Q:
q = Q()
if self.name is not None:
q &= self.name.to_q("name")
if self.group is not None:
q &= self.group.to_q("group")
if self.icon is not None:
q &= self.icon.to_q("icon")
if self.created_at is not None:
q &= self.created_at.to_q("created_at")
# Free-text search
if self.search is not None and self.search.value:
search_q = (
Q(name__icontains=self.search.value)
| Q(group__icontains=self.search.value)
)
if self.search.modifier == Modifier.EXCLUDES:
search_q = ~search_q
q &= search_q
# Cross-entity filter: game_filter
if self.game_filter is not None:
from games.models import Game
game_q = self.game_filter.to_q()
matching_ids = Game.objects.filter(game_q).values_list("platform_id", flat=True)
q &= Q(id__in=matching_ids)
# Cross-entity filter: purchase_filter
if self.purchase_filter is not None:
from games.models import Purchase
purchase_q = self.purchase_filter.to_q()
matching_ids = Purchase.objects.filter(purchase_q).values_list("platform_id", flat=True)
q &= Q(id__in=matching_ids)
sub = self.sub_filter()
if sub is not None:
if self.AND is not None:
q &= sub.to_q()
elif self.OR is not None:
q |= sub.to_q()
elif self.NOT is not None:
q &= ~sub.to_q()
return q
@dataclass
class PlayEventFilter(OperatorFilter):
"""Filter for the PlayEvent model."""
AND: PlayEventFilter | None = None
OR: PlayEventFilter | None = None
NOT: PlayEventFilter | None = None
game: MultiCriterion | None = None # filters on game_id
started: StringCriterion | None = None # date string
ended: StringCriterion | None = None # date string
days_to_finish: IntCriterion | None = None
note: StringCriterion | None = None
created_at: StringCriterion | None = None
# Free-text search
search: StringCriterion | None = None
# Cross-entity: PlayEvents for games matching these criteria
game_filter: GameFilter | None = None
def to_q(self) -> Q:
q = Q()
if self.game is not None:
q &= self.game.to_q("game_id")
if self.started is not None:
q &= self.started.to_q("started")
if self.ended is not None:
q &= self.ended.to_q("ended")
if self.days_to_finish is not None:
q &= self.days_to_finish.to_q("days_to_finish")
if self.note is not None:
q &= self.note.to_q("note")
if self.created_at is not None:
q &= self.created_at.to_q("created_at")
# Free-text search
if self.search is not None and self.search.value:
search_q = (
Q(game__name__icontains=self.search.value)
| Q(note__icontains=self.search.value)
)
if self.search.modifier == Modifier.EXCLUDES:
search_q = ~search_q
q &= search_q
# Cross-entity filter: game_filter
if self.game_filter is not None:
from games.models import Game
game_q = self.game_filter.to_q()
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
q &= Q(game_id__in=matching_ids)
sub = self.sub_filter()
if sub is not None:
if self.AND is not None:
q &= sub.to_q()
elif self.OR is not None:
q |= sub.to_q()
elif self.NOT is not None:
q &= ~sub.to_q()
return q
# Add to convenience helpers section:
def parse_device_filter(json_str: str) -> DeviceFilter | None:
return filter_from_json(DeviceFilter, json_str)
def parse_platform_filter(json_str: str) -> PlatformFilter | None:
return filter_from_json(PlatformFilter, json_str)
def parse_playevent_filter(json_str: str) -> PlayEventFilter | None:
return filter_from_json(PlayEventFilter, json_str)
```
- [ ] **Step 2: Run existing tests to verify everything compiles**
Run: `pytest tests/test_filters.py -v`
Expected: All existing tests PASS without issues.
---
### Task 2: Expand SessionFilter (Duration Fields + Cross-Entity)
**Files:**
- Modify: `games/filters.py:SessionFilter`
- Test: `tests/test_filters.py`
- [ ] **Step 1: Refactor SessionFilter and add new duration fields & device_filter**
Modify `SessionFilter` to replace `duration_minutes: IntCriterion` with `duration_total_minutes`, `duration_manual_minutes`, and `duration_calculated_minutes`. Add `device_filter: DeviceFilter`.
Update `to_q()` inside `SessionFilter` to map duration fields correctly to their respective GeneratedFields (`duration_total`, `duration_calculated`) or manual field (`duration_manual`). Use standard Python `timedelta` logic.
```python
# Inside SessionFilter class:
duration_total_minutes: IntCriterion | None = None
duration_manual_minutes: IntCriterion | None = None
duration_calculated_minutes: IntCriterion | None = None
# Cross-entity: sessions for devices matching these criteria
device_filter: DeviceFilter | None = None
```
```python
# Helper inside SessionFilter or refactored:
def _duration_to_q(self, c: IntCriterion, field: str) -> Q:
from datetime import timedelta
q = Q()
td_val = timedelta(minutes=c.value)
m = c.modifier
if m == Modifier.EQUALS:
q &= Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(minutes=c.value + 1),
}
)
elif m == Modifier.NOT_EQUALS:
q &= ~Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(minutes=c.value + 1),
}
)
elif m == Modifier.GREATER_THAN:
q &= Q(**{f"{field}__gt": td_val})
elif m == Modifier.LESS_THAN:
q &= Q(**{f"{field}__lt": td_val})
elif m == Modifier.BETWEEN and c.value2 is not None:
lo = timedelta(minutes=min(c.value, c.value2))
hi = timedelta(minutes=max(c.value, c.value2))
q &= Q(**{f"{field}__gte": lo, f"{f_field}__lte": hi})
elif m == Modifier.NOT_BETWEEN and c.value2 is not None:
lo = timedelta(minutes=min(c.value, c.value2))
hi = timedelta(minutes=max(c.value, c.value2))
q &= Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
elif m == Modifier.IS_NULL:
q &= Q(**{f"{field}": timedelta(0)})
elif m == Modifier.NOT_NULL:
q &= ~Q(**{f"{field}": timedelta(0)})
return q
```
Then in `to_q()` inside `SessionFilter`:
```python
if self.duration_total_minutes is not None:
q &= self._duration_to_q(self.duration_total_minutes, "duration_total")
if self.duration_manual_minutes is not None:
q &= self._duration_to_q(self.duration_manual_minutes, "duration_manual")
if self.duration_calculated_minutes is not None:
q &= self._duration_to_q(self.duration_calculated_minutes, "duration_calculated")
# Cross-entity filter: device_filter
if self.device_filter is not None:
from games.models import Device
device_q = self.device_filter.to_q()
matching_ids = Device.objects.filter(device_q).values_list("id", flat=True)
q &= Q(device_id__in=matching_ids)
```
- [ ] **Step 2: Run tests to verify compiles correctly**
Run: `pytest tests/test_filters.py -v`
Expected: PASS (existing tests may need updating if they referenced `duration_minutes`).
---
### Task 3: Expand PurchaseFilter (Original Currency, Infinite, Needs Price Update, Converted Currency)
**Files:**
- Modify: `games/filters.py:PurchaseFilter`
- Test: `tests/test_filters.py`
- [ ] **Step 1: Add new fields to PurchaseFilter and platform_filter**
Expand `PurchaseFilter` with `infinite: BoolCriterion`, `needs_price_update: BoolCriterion`, `converted_currency: StringCriterion`, and `platform_filter: PlatformFilter`.
```python
# Inside PurchaseFilter class:
infinite: BoolCriterion | None = None
needs_price_update: BoolCriterion | None = None
converted_currency: StringCriterion | None = None
# Cross-entity
platform_filter: PlatformFilter | None = None
```
Update `to_q()` inside `PurchaseFilter`:
```python
if self.infinite is not None:
q &= self.infinite.to_q("infinite")
if self.needs_price_update is not None:
q &= self.needs_price_update.to_q("needs_price_update")
if self.converted_currency is not None:
q &= self.converted_currency.to_q("converted_currency")
# Cross-entity filter: platform_filter
if self.platform_filter is not None:
from games.models import Platform
platform_q = self.platform_filter.to_q()
matching_ids = Platform.objects.filter(platform_q).values_list("id", flat=True)
q &= Q(platform_id__in=matching_ids)
```
- [ ] **Step 2: Verify test suite continues to pass**
Run: `pytest tests/test_filters.py -v`
Expected: PASS
---
### Task 4: Expand GameFilter (Has Purchases, Has PlayEvents, Session Stats, Cross-Entity)
**Files:**
- Modify: `games/filters.py:GameFilter`
- Test: `tests/test_filters.py`
- [ ] **Step 1: Expand GameFilter with session stats, purchase/playevent existence, and cross-entity filters**
Add fields and cross-entity filters to `GameFilter`:
```python
# Inside GameFilter class:
has_purchases: BoolCriterion | None = None
has_playevents: BoolCriterion | None = None
session_count: IntCriterion | None = None
session_average: IntCriterion | None = None # average in minutes
# Cross-entity filters
session_filter: SessionFilter | None = None
purchase_filter: PurchaseFilter | None = None
playevent_filter: PlayEventFilter | None = None
platform_filter: PlatformFilter | None = None
```
Update `to_q()` inside `GameFilter`.
For existence and session stats filters, we use Subqueries to avoid complex inline annotations during the generic filter generation (which is much cleaner and less bug-prone):
```python
if self.has_purchases is not None:
from games.models import Purchase
purchased_ids = Purchase.objects.values_list("games__id", flat=True).distinct()
if self.has_purchases.value:
q &= Q(id__in=purchased_ids)
else:
q &= ~Q(id__in=purchased_ids)
if self.has_playevents is not None:
from games.models import PlayEvent
played_ids = PlayEvent.objects.values_list("game_id", flat=True).distinct()
if self.has_playevents.value:
q &= Q(id__in=played_ids)
else:
q &= ~Q(id__in=played_ids)
if self.session_count is not None:
from games.models import Game
from django.db.models import Count
matching_ids = Game.objects.annotate(s_count=Count("sessions")).filter(self.session_count.to_q("s_count")).values_list("id", flat=True)
q &= Q(id__in=matching_ids)
if self.session_average is not None:
from games.models import Game, Session
from django.db.models import Avg, F, ExpressionWrapper, DurationField
# Compute average session total duration.
# Avg returns an interval/duration type, so we can convert it to minutes in Python or do duration comparisons directly.
# To match the criterion easily, we can filter Game objects using Avg:
matching_ids = Game.objects.annotate(s_avg=Avg("sessions__duration_total")).filter(self._playtime_to_q_for_field(self.session_average, "s_avg")).values_list("id", flat=True)
q &= Q(id__in=matching_ids)
# Cross-entity filters
if self.session_filter is not None:
from games.models import Session
session_q = self.session_filter.to_q()
matching_ids = Session.objects.filter(session_q).values_list("game_id", flat=True)
q &= Q(id__in=matching_ids)
if self.purchase_filter is not None:
from games.models import Purchase
purchase_q = self.purchase_filter.to_q()
matching_ids = Purchase.objects.filter(purchase_q).values_list("games__id", flat=True)
q &= Q(id__in=matching_ids)
if self.playevent_filter is not None:
from games.models import PlayEvent
playevent_q = self.playevent_filter.to_q()
matching_ids = PlayEvent.objects.filter(playevent_q).values_list("game_id", flat=True)
q &= Q(id__in=matching_ids)
if self.platform_filter is not None:
from games.models import Platform
platform_q = self.platform_filter.to_q()
matching_ids = Platform.objects.filter(platform_q).values_list("id", flat=True)
q &= Q(platform_id__in=matching_ids)
```
Add a helper `_playtime_to_q_for_field` in `GameFilter` that works exactly like `_playtime_to_q` but accepts a customized field name (e.g. `s_avg`):
```python
@staticmethod
def _playtime_to_q_for_field(c: IntCriterion, field: str) -> Q:
from datetime import timedelta
m = c.modifier
td_val = timedelta(minutes=c.value)
if m == Modifier.EQUALS:
return Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(minutes=c.value + 1),
}
)
if m == Modifier.NOT_EQUALS:
return ~Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(minutes=c.value + 1),
}
)
if m == Modifier.GREATER_THAN:
return Q(**{f"{field}__gt": td_val})
if m == Modifier.LESS_THAN:
return Q(**{f"{field}__lt": td_val})
if m == Modifier.BETWEEN and c.value2 is not None:
lo = timedelta(minutes=min(c.value, c.value2))
hi = timedelta(minutes=max(c.value, c.value2))
return Q(**{f"{field}__gte": lo, f"{field}__lte": hi})
if m == Modifier.NOT_BETWEEN and c.value2 is not None:
lo = timedelta(minutes=min(c.value, c.value2))
hi = timedelta(minutes=max(c.value, c.value2))
return Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
if m == Modifier.IS_NULL:
return Q(**{f"{field}": timedelta(0)})
if m == Modifier.NOT_NULL:
return ~Q(**{f"{field}": timedelta(0)})
return Q()
```
- [ ] **Step 2: Update existing `_playtime_to_q` to delegate to `_playtime_to_q_for_field`**
```python
@staticmethod
def _playtime_to_q(c: IntCriterion) -> Q:
return GameFilter._playtime_to_q_for_field(c, "playtime")
```
---
### Task 5: Add Exhaustive DB Tests for the Expanded and New Filters
**Files:**
- Modify: `tests/test_filters.py`
- [ ] **Step 1: Write DB-backed unit tests for the new filter behaviors**
Add comprehensive test cases inside `tests/test_filters.py` covering:
- New cross-entity filters (e.g. Platform -> Game -> Session -> Device chain).
- Session total vs manual vs calculated duration filters.
- Game session stats (`session_count`, `session_average`) and presence flags (`has_purchases`, `has_playevents`).
- Device, Platform, and PlayEvent specific filters.
```python
# Add test class at the end of tests/test_filters.py:
@pytest.mark.django_db
class TestExpandedFiltersAgainstDB:
def _setup_entities(self):
from games.models import Game, Platform, Device, Session, Purchase, PlayEvent
import datetime
from datetime import timedelta
# 1. Platform & Game
plat, _ = Platform.objects.get_or_create(name="Retro Console", group="Nintendo", icon="retro")
game, _ = Game.objects.get_or_create(name="Super Mario World", defaults={"platform": plat, "status": "f"})
game2, _ = Game.objects.get_or_create(name="Zelda", defaults={"platform": plat, "status": "u"})
# 2. Device & Session
dev, _ = Device.objects.get_or_create(name="Super Famicom", type="Console")
# Session 1: total 40 minutes (30 calc, 10 manual)
s1 = Session.objects.create(
game=game,
device=dev,
timestamp_start=datetime.datetime(2026, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc),
timestamp_end=datetime.datetime(2026, 6, 1, 12, 30, 0, tzinfo=datetime.timezone.utc),
duration_manual=timedelta(minutes=10)
)
# 3. Purchase
pur = Purchase.objects.create(
platform=plat,
date_purchased=datetime.date(2026, 1, 1),
infinite=True,
price=49.99,
price_currency="JPY",
converted_price=45.00,
converted_currency="USD",
needs_price_update=False
)
pur.games.add(game)
# 4. PlayEvent
pe = PlayEvent.objects.create(
game=game,
started=datetime.date(2026, 6, 1),
ended=datetime.date(2026, 6, 2),
note="Completed 100%"
)
return {
"plat": plat,
"game": game,
"game2": game2,
"dev": dev,
"s1": s1,
"pur": pur,
"pe": pe
}
def test_device_filter_and_cross_entity(self):
from games.filters import DeviceFilter, SessionFilter
from games.models import Device
data = self._setup_entities()
# Find devices that have sessions on "Super Mario World"
df = DeviceFilter.from_json({
"session_filter": {
"game_filter": {
"name": {"value": "Super Mario World", "modifier": "EQUALS"}
}
}
})
results = list(Device.objects.filter(df.to_q()))
assert data["dev"] in results
def test_platform_filter_and_cross_entity(self):
from games.filters import PlatformFilter, GameFilter
from games.models import Platform
data = self._setup_entities()
# Find platforms with games that are finished
pf = PlatformFilter.from_json({
"game_filter": {
"status": {"value": ["f"], "modifier": "INCLUDES"}
}
})
results = list(Platform.objects.filter(pf.to_q()))
assert data["plat"] in results
def test_session_filter_duration_splits(self):
from games.filters import SessionFilter
from games.models import Session
data = self._setup_entities()
# Test duration_total_minutes equals 40
sf_tot = SessionFilter.from_json({
"duration_total_minutes": {"value": 40, "modifier": "EQUALS"}
})
assert Session.objects.filter(sf_tot.to_q()).count() == 1
# Test duration_manual_minutes equals 10
sf_man = SessionFilter.from_json({
"duration_manual_minutes": {"value": 10, "modifier": "EQUALS"}
})
assert Session.objects.filter(sf_man.to_q()).count() == 1
# Test duration_calculated_minutes equals 30
sf_calc = SessionFilter.from_json({
"duration_calculated_minutes": {"value": 30, "modifier": "EQUALS"}
})
assert Session.objects.filter(sf_calc.to_q()).count() == 1
def test_purchase_filter_new_fields(self):
from games.filters import PurchaseFilter
from games.models import Purchase
data = self._setup_entities()
pf = PurchaseFilter.from_json({
"infinite": {"value": True, "modifier": "EQUALS"},
"needs_price_update": {"value": False, "modifier": "EQUALS"},
"converted_currency": {"value": "USD", "modifier": "EQUALS"}
})
assert Purchase.objects.filter(pf.to_q()).count() == 1
def test_game_filter_stats_and_existence(self):
from games.filters import GameFilter
from games.models import Game
data = self._setup_entities()
# has_purchases = True
gf_pur = GameFilter.from_json({
"has_purchases": {"value": True, "modifier": "EQUALS"}
})
assert data["game"] in list(Game.objects.filter(gf_pur.to_q()))
assert data["game2"] not in list(Game.objects.filter(gf_pur.to_q()))
# session_count = 1
gf_cnt = GameFilter.from_json({
"session_count": {"value": 1, "modifier": "EQUALS"}
})
assert data["game"] in list(Game.objects.filter(gf_cnt.to_q()))
```
- [ ] **Step 2: Run all unit tests to confirm success**
Run: `pytest tests/test_filters.py -v`
Expected: ALL tests pass perfectly.
@@ -0,0 +1,577 @@
# Frontend Filters Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement a comprehensive frontend filter bar interface for all 6 list views (Games, Sessions, Purchases, Devices, Platforms, PlayEvents) with specific field controls, simple cross-entity toggles, and full JSON preset support.
**Architecture:** We will extend existing components in `common/components/filters.py` and implement new filter bars (`DeviceFilterBar`, `PlatformFilterBar`, `PlayEventFilterBar`). We will update the views in `games/views/` to parse standard filter JSON from `request.GET.get('filter')`, apply them to querysets, render the filter bars, and export them in `common/components/__init__.py`.
**Tech Stack:** Django, Python dataclasses, Pytest.
---
### Task 1: Update existing FilterBars in `common/components/filters.py`
**Files:**
- Modify: `common/components/filters.py`
- [ ] **Step 1: Add new fields to GameFilterBar**
Add checkboxes for `has_purchases`, `has_playevents` and RangeSliders for `session_count`, `session_average`.
```python
# Inside common/components/filters.py: FilterBar()
# Parse new values
has_purchases_value = _parse_bool(existing, "has_purchases")
has_playevents_value = _parse_bool(existing, "has_playevents")
session_count_min, session_count_max = _parse_range(existing, "session_count")
session_avg_min, session_avg_max = _parse_range(existing, "session_average")
# Add components to fields:
# 1. Under status and platform, add the checkboxes for purchases/playevents
# 2. Add RangeSliders for session count and average
```
Code change to apply in `FilterBar`:
```python
fields = [
Component(
tag_name="div",
attributes=[("class", _FILTER_GRID_CLASS)],
children=[
_filter_field(
"Status",
_enum_filter(
"status",
status_options,
status_choice,
nullable=not Game._meta.get_field("status").has_default(),
),
),
_filter_field(
"Platform",
_model_filter(
"platform",
platform_choice,
search_url="/api/platforms/search",
nullable=Game._meta.get_field("platform").null,
),
),
],
),
RangeSlider(
label="Year",
input_name_prefix="filter-year",
min_value=year_min,
max_value=year_max,
range_min=year_range_min,
range_max=year_range_max,
min_placeholder="e.g. 2020",
max_placeholder="e.g. 2024",
),
Component(
tag_name="div",
attributes=[("class", "flex items-end gap-4 mb-4")],
children=[
_filter_checkbox("filter-mastered", "Mastered", mastered_value),
_filter_checkbox("filter-has-purchases", "Has Purchases", has_purchases_value),
_filter_checkbox("filter-has-playevents", "Has Play Events", has_playevents_value),
],
),
RangeSlider(
label="Playtime",
input_name_prefix="filter-playtime",
min_value=playtime_min,
max_value=playtime_max,
range_min=0,
range_max=playtime_range_max,
step="1",
min_placeholder="e.g. 1",
max_placeholder="e.g. 100",
),
RangeSlider(
label="Session Count",
input_name_prefix="filter-session-count",
min_value=session_count_min,
max_value=session_count_max,
range_min=0,
range_max=100,
step="1",
min_placeholder="e.g. 1",
max_placeholder="e.g. 50",
),
RangeSlider(
label="Average Session Duration (mins)",
input_name_prefix="filter-session-average",
min_value=session_avg_min,
max_value=session_avg_max,
range_min=0,
range_max=240,
step="1",
min_placeholder="e.g. 10",
max_placeholder="e.g. 120",
),
]
```
- [ ] **Step 2: Update SessionFilterBar to support split duration fields**
Replace old `duration_minutes` RangeSlider with split total, manual, and calculated duration RangeSliders.
```python
# Inside common/components/filters.py: SessionFilterBar()
dur_tot_min, dur_tot_max = _parse_range(existing, "duration_total_minutes")
dur_man_min, dur_man_max = _parse_range(existing, "duration_manual_minutes")
dur_calc_min, dur_calc_max = _parse_range(existing, "duration_calculated_minutes")
# Inside fields array, replace RangeSlider "Duration" with:
RangeSlider(
label="Total Duration (mins)",
input_name_prefix="filter-duration-total-minutes",
min_value=dur_tot_min,
max_value=dur_tot_max,
range_min=0,
range_max=duration_range_max * 60, # Range sliders use minutes now
step="1",
min_placeholder="e.g. 30",
max_placeholder="e.g. 180",
),
RangeSlider(
label="Manual Duration (mins)",
input_name_prefix="filter-duration-manual-minutes",
min_value=dur_man_min,
max_value=dur_man_max,
range_min=0,
range_max=240,
step="1",
min_placeholder="e.g. 10",
max_placeholder="e.g. 120",
),
RangeSlider(
label="Calculated Duration (mins)",
input_name_prefix="filter-duration-calculated-minutes",
min_value=dur_calc_min,
max_value=dur_calc_max,
range_min=0,
range_max=duration_range_max * 60,
step="1",
min_placeholder="e.g. 30",
max_placeholder="e.g. 180",
),
```
- [ ] **Step 3: Update PurchaseFilterBar to support original and converted currencies and infinite flag**
Add Checkboxes `infinite`, `needs_price_update` and currency StringCriterion text field / Choice options.
```python
# Inside common/components/filters.py: PurchaseFilterBar()
infinite_value = _parse_bool(existing, "infinite")
needs_price_update_value = _parse_bool(existing, "needs_price_update")
price_currency_value = existing.get("price_currency", {}).get("value", "")
converted_currency_value = existing.get("converted_currency", {}).get("value", "")
# Expand fields component array with:
Component(
tag_name="div",
attributes=[("class", "flex gap-4 mb-4")],
children=[
_filter_checkbox("filter-refunded", "Refunded", is_refunded_value),
_filter_checkbox("filter-infinite", "Infinite", infinite_value),
_filter_checkbox("filter-needs-price-update", "Needs Price Update", needs_price_update_value),
],
),
```
Add currency text filters (as primitive `Input` controls for string criteria):
```python
Component(
tag_name="div",
attributes=[("class", _FILTER_GRID_CLASS)],
children=[
_filter_field(
"Original Currency",
Component(
tag_name="input",
attributes=[
("type", "text"),
("name", "filter-price_currency"),
("value", price_currency_value),
("placeholder", "e.g. USD, EUR"),
("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"),
],
),
),
_filter_field(
"Converted Currency",
Component(
tag_name="input",
attributes=[
("type", "text"),
("name", "filter-converted_currency"),
("value", converted_currency_value),
("placeholder", "e.g. USD, EUR"),
("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"),
],
),
),
],
),
```
---
### Task 2: Create New FilterBars in `common/components/filters.py`
**Files:**
- Modify: `common/components/filters.py`
- [ ] **Step 1: Implement DeviceFilterBar, PlatformFilterBar, and PlayEventFilterBar**
Append these three new filter bar components to `common/components/filters.py`:
```python
def DeviceFilterBar(
filter_json="", preset_list_url="", preset_save_url=""
) -> SafeText:
"""Collapsible filter bar for the Device list."""
from games.models import Device
existing = _filter_parse(filter_json)
type_options = Device.DEVICE_TYPES
type_choice = _filter_get_choice(existing, "type")
fields = [
Component(
tag_name="div",
attributes=[("class", _FILTER_GRID_CLASS)],
children=[
_filter_field(
"Device Type",
_enum_filter(
"type",
type_options,
type_choice,
nullable=True,
),
),
],
),
]
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
def PlatformFilterBar(
filter_json="", preset_list_url="", preset_save_url=""
) -> SafeText:
"""Collapsible filter bar for the Platform list."""
existing = _filter_parse(filter_json)
name_value = existing.get("name", {}).get("value", "")
group_value = existing.get("group", {}).get("value", "")
fields = [
Component(
tag_name="div",
attributes=[("class", _FILTER_GRID_CLASS)],
children=[
_filter_field(
"Platform Name",
Component(
tag_name="input",
attributes=[
("type", "text"),
("name", "filter-name"),
("value", name_value),
("placeholder", "e.g. Nintendo Switch"),
("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"),
],
),
),
_filter_field(
"Platform Group",
Component(
tag_name="input",
attributes=[
("type", "text"),
("name", "filter-group"),
("value", group_value),
("placeholder", "e.g. Nintendo"),
("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"),
],
),
),
],
),
]
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
def PlayEventFilterBar(
filter_json="", preset_list_url="", preset_save_url=""
) -> SafeText:
"""Collapsible filter bar for the PlayEvent list."""
from games.models import PlayEvent
existing = _filter_parse(filter_json)
game_choice = _filter_get_choice(existing, "game")
days_min, days_max = _parse_range(existing, "days_to_finish")
fields = [
Component(
tag_name="div",
attributes=[("class", _FILTER_GRID_CLASS)],
children=[
_filter_field(
"Game",
_model_filter(
"game",
game_choice,
search_url="/api/games/search",
nullable=False,
),
),
],
),
RangeSlider(
label="Days to Finish",
input_name_prefix="filter-days-to-finish",
min_value=days_min,
max_value=days_max,
range_min=0,
range_max=365,
step="1",
min_placeholder="e.g. 1",
max_placeholder="e.g. 30",
),
]
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
```
- [ ] **Step 2: Export new FilterBars in `common/components/__init__.py`**
Modify: `common/components/__init__.py` to import and expose `DeviceFilterBar`, `PlatformFilterBar`, and `PlayEventFilterBar`.
```python
# Import section:
from common.components.filters import (
FilterBar,
PurchaseFilterBar,
SessionFilterBar,
DeviceFilterBar,
PlatformFilterBar,
PlayEventFilterBar,
)
# In __all__:
"FilterBar",
"PurchaseFilterBar",
"SessionFilterBar",
"DeviceFilterBar",
"PlatformFilterBar",
"PlayEventFilterBar",
```
---
### Task 3: Integrate FilterBars into `Device`, `Platform`, and `PlayEvent` views
**Files:**
- Modify: `games/views/device.py`
- Modify: `games/views/platform.py`
- Modify: `games/views/playevent.py`
- [ ] **Step 1: Integrate FilterBar in `list_devices` in `games/views/device.py`**
Import and parse the filter, apply to queryset, instantiate `DeviceFilterBar`, prepend it to the output page content.
```python
# At top of games/views/device.py:
from django.utils.safestring import mark_safe
from common.components import DeviceFilterBar, ModuleScript
from games.filters import parse_device_filter
# Inside list_devices(request):
devices = Device.objects.order_by("-created_at")
filter_json = request.GET.get("filter", "")
if filter_json:
device_filter = parse_device_filter(filter_json)
if device_filter is not None:
devices = devices.filter(device_filter.to_q())
devices, page_obj, elided_page_range = paginate(request, devices)
# ... create data dict ...
# Prepend the filter bar above table:
filter_bar = DeviceFilterBar(
filter_json=filter_json,
preset_list_url=reverse("games:list_presets") + "?mode=devices",
preset_save_url=reverse("games:save_preset") + "?mode=devices",
)
content = mark_safe(str(filter_bar) + str(content))
return render_page(
request,
content,
title="Manage devices",
scripts=ModuleScript("range_slider.js")
+ ModuleScript("search_select.js")
+ ModuleScript("filter_bar.js"),
)
```
- [ ] **Step 2: Integrate FilterBar in `list_platforms` in `games/views/platform.py`**
Import and parse the filter, apply to platform queryset, instantiate platform filter bar, prepend to page content.
```python
# At top of games/views/platform.py:
from django.utils.safestring import mark_safe
from common.components import PlatformFilterBar, ModuleScript
from games.filters import parse_platform_filter
# Inside list_platforms(request):
platforms = Platform.objects.order_by("name")
filter_json = request.GET.get("filter", "")
if filter_json:
platform_filter = parse_platform_filter(filter_json)
if platform_filter is not None:
platforms = platforms.filter(platform_filter.to_q())
platforms, page_obj, elided_page_range = paginate(request, platforms)
# ... create data dict ...
filter_bar = PlatformFilterBar(
filter_json=filter_json,
preset_list_url=reverse("games:list_presets") + "?mode=platforms",
preset_save_url=reverse("games:save_preset") + "?mode=platforms",
)
content = mark_safe(str(filter_bar) + str(content))
return render_page(
request,
content,
title="Manage platforms",
scripts=ModuleScript("range_slider.js")
+ ModuleScript("search_select.js")
+ ModuleScript("filter_bar.js"),
)
```
- [ ] **Step 3: Integrate FilterBar in `list_playevents` in `games/views/playevent.py`**
Import and parse the filter, apply to playevent queryset, instantiate playevent filter bar, prepend to page content.
```python
# At top of games/views/playevent.py:
from django.utils.safestring import mark_safe
from common.components import PlayEventFilterBar
from games.filters import parse_playevent_filter
# Inside list_playevents(request):
playevents = PlayEvent.objects.order_by("-created_at")
filter_json = request.GET.get("filter", "")
if filter_json:
playevent_filter = parse_playevent_filter(filter_json)
if playevent_filter is not None:
playevents = playevents.filter(playevent_filter.to_q())
playevents, page_obj, elided_page_range = paginate(request, playevents)
# ... create data ...
filter_bar = PlayEventFilterBar(
filter_json=filter_json,
preset_list_url=reverse("games:list_presets") + "?mode=playevents",
preset_save_url=reverse("games:save_preset") + "?mode=playevents",
)
content = mark_safe(str(filter_bar) + str(content))
return render_page(
request,
content,
title="Manage play events",
scripts=ModuleScript("range_slider.js")
+ ModuleScript("search_select.js")
+ ModuleScript("filter_bar.js"),
)
```
---
### Task 4: Support new preset modes in Preset View/Model
Ensure FilterPreset allows `devices` and `platforms` modes.
**Files:**
- Modify: `games/models.py`
- Modify: `games/views/filter_presets.py`
- [ ] **Step 1: Expand FilterPreset mode choices**
Verify or expand `MODE_CHOICES` inside `FilterPreset` model in `games/models.py`.
```python
# Inside FilterPreset class:
MODE_CHOICES = [
("games", "Games"),
("sessions", "Sessions"),
("purchases", "Purchases"),
("playevents", "Play Events"),
("devices", "Devices"),
("platforms", "Platforms"),
]
```
---
### Task 5: Add Render Tests for new FilterBars
**Files:**
- Modify: `tests/test_filter_bars.py`
- [ ] **Step 1: Write tests to verify new FilterBars render correctly**
Add test cases in `tests/test_filter_bars.py`:
```python
def test_device_filter_bar(self):
from common.components import DeviceFilterBar
html = str(
DeviceFilterBar(
filter_json="",
preset_list_url="/presets/devices/list",
preset_save_url="/presets/devices/save",
)
)
self._assert_shell(html, "/presets/devices/list", "/presets/devices/save")
def test_platform_filter_bar(self):
from common.components import PlatformFilterBar
html = str(
PlatformFilterBar(
filter_json="",
preset_list_url="/presets/platforms/list",
preset_save_url="/presets/platforms/save",
)
)
self._assert_shell(html, "/presets/platforms/list", "/presets/platforms/save")
def test_playevent_filter_bar(self):
from common.components import PlayEventFilterBar
html = str(
PlayEventFilterBar(
filter_json="",
preset_list_url="/presets/playevents/list",
preset_save_url="/presets/playevents/save",
)
)
self._assert_shell(html, "/presets/playevents/list", "/presets/playevents/save")
```
- [ ] **Step 2: Run all test suites to confirm complete success**
Run: `pytest tests/test_filter_bars.py -v`
Expected: ALL filter bar render tests pass.
@@ -0,0 +1,177 @@
# Unify Form Checkboxes Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Unify all Django form checkboxes across the codebase by routing them through our new Python `Checkbox` primitive.
**Architecture:**
1. Modify `Checkbox` and `Radio` primitives in `common/components/primitives.py` to support headless (label-less) rendering when `label` is `None`, so they can be injected into Django's native `form.as_div()` rendering without duplicating labels.
2. Create a `PrimitiveCheckboxWidget` in `games/forms.py` that extends `forms.CheckboxInput` but renders using our `Checkbox` Python component.
3. Create a `PrimitiveWidgetsMixin` in `games/forms.py` that automatically applies the `PrimitiveCheckboxWidget` to all `forms.BooleanField` instances in a form. Add this mixin to all ModelForms.
**Tech Stack:** Python, Django Forms, HTML.
---
### Task 1: Update Primitives for Headless Rendering
**Files:**
- Modify: `common/components/primitives.py`
- Modify: `tests/test_components.py`
- [ ] **Step 1: Write a failing test for headless rendering**
In `tests/test_components.py`, add a test to `ComponentPrimitivesTest`:
```python
def test_checkbox_headless(self):
html = Checkbox(name="test-headless", label=None, checked=True)
self.assertNotIn('<label', html)
self.assertIn('<input', html)
self.assertIn('type="checkbox"', html)
self.assertIn('name="test-headless"', html)
```
- [ ] **Step 2: Run test to verify it fails**
Run: `pytest tests/test_components.py -k test_checkbox_headless`
Expected: Fail because `Checkbox` currently requires `label` as a `str` and always renders a `Label` wrapper.
- [ ] **Step 3: Update `Checkbox` and `Radio` in `common/components/primitives.py`**
Update the function signatures to accept `label: str | None = None` and selectively return only the `Input` if `label` is missing.
```python
def Checkbox(
name: str,
label: str | None = None,
checked: bool = False,
value: str = "1",
attributes: list[HTMLAttribute] | None = None,
) -> SafeText:
"""A filter-agnostic Checkbox component."""
attributes = attributes or []
input_attrs = [
("name", name),
("value", value),
("class", "rounded border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand"),
] + attributes
if checked:
input_attrs.append(("checked", "true"))
input_el = Input(type="checkbox", attributes=input_attrs)
if label is None:
return input_el
return Label(
attributes=[("class", "flex items-center gap-2 text-sm text-heading cursor-pointer")],
children=[input_el, label],
)
def Radio(
name: str,
label: str | None = None,
checked: bool = False,
value: str = "",
attributes: list[HTMLAttribute] | None = None,
) -> SafeText:
"""A filter-agnostic Radio component."""
attributes = attributes or []
input_attrs = [
("name", name),
("value", value),
("class", "rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand"),
] + attributes
if checked:
input_attrs.append(("checked", "true"))
input_el = Input(type="radio", attributes=input_attrs)
if label is None:
return input_el
return Label(
attributes=[("class", "flex items-center gap-1.5 text-sm text-heading cursor-pointer")],
children=[input_el, label],
)
```
- [ ] **Step 4: Run test to verify it passes**
Run: `pytest tests/test_components.py -k ComponentPrimitivesTest`
Expected: PASS
- [ ] **Step 5: Commit**
Run:
```bash
git add common/components/primitives.py tests/test_components.py
git commit -m "refactor: allow Checkbox and Radio primitives to render headlessly without labels"
```
---
### Task 2: Create Django Widget Adapter and Mixin
**Files:**
- Modify: `games/forms.py`
- [ ] **Step 1: Write the Widget and Mixin implementations**
At the top of `games/forms.py`, import `Checkbox` and implement `PrimitiveCheckboxWidget` and `PrimitiveWidgetsMixin`.
```python
from common.components.primitives import Checkbox
class PrimitiveCheckboxWidget(forms.CheckboxInput):
"""Adapts Django's CheckboxInput to use our Checkbox component."""
def render(self, name, value, attrs=None, renderer=None):
final_attrs = self.build_attrs(self.attrs, attrs)
checked = self.check_test(value)
attributes = [(k, str(v)) for k, v in final_attrs.items() if k not in ("type", "name", "value", "checked")]
# Django uses boolean values differently for checkboxes, we omit value if empty
return str(Checkbox(
name=name,
label=None,
checked=checked,
value=str(value) if value else "1",
attributes=attributes
))
class PrimitiveWidgetsMixin:
"""Automatically applies primitive custom widgets to native Django form fields."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field_name, field in self.fields.items():
if isinstance(field, forms.BooleanField):
field.widget = PrimitiveCheckboxWidget()
# Maintain the field's explicit required status (usually False for booleans)
```
- [ ] **Step 2: Apply the Mixin to all Forms**
In `games/forms.py`, update all the ModelForm classes to inherit from `PrimitiveWidgetsMixin` as the **first** base class (before `forms.ModelForm`).
Example:
```python
class SessionForm(PrimitiveWidgetsMixin, forms.ModelForm):
# ...
class PurchaseForm(PrimitiveWidgetsMixin, forms.ModelForm):
# ...
class GameForm(PrimitiveWidgetsMixin, forms.ModelForm):
# ...
class PlatformForm(PrimitiveWidgetsMixin, forms.ModelForm):
# ...
class DeviceForm(PrimitiveWidgetsMixin, forms.ModelForm):
# ...
class PlayEventForm(PrimitiveWidgetsMixin, forms.ModelForm):
# ...
class GameStatusChangeForm(PrimitiveWidgetsMixin, forms.ModelForm):
# ...
```
- [ ] **Step 3: Test Django Form Rendering**
Run the full test suite to ensure forms still validate properly and render without error.
Run: `pytest`
Expected: PASS
- [ ] **Step 4: Commit**
Run:
```bash
git add games/forms.py
git commit -m "feat: replace all form BooleanFields with PrimitiveCheckboxWidget via mixin"
```
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,595 @@
# Issue #53 — Rebuild session row fragment via shared builder — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the htmx session-row fragment reuse the same row builder as the list table, and give the finish/reset actions a real in-place row swap with the navbar playtime totals kept correct via an out-of-band swap.
**Architecture:** Extract the session row's content into one `session_row_data()` dict builder used by both `list_sessions` and a thin `session_row()` Node wrapper (`TableRow(session_row_data(...))`). The navbar's playtime `<li>` becomes a `NavbarPlaytime` component with a stable id so endpoints can return it `hx-swap-oob`. `end_session`/`reset_session_start` return `Fragment(row, NavbarPlaytime(oob=True))`; clone keeps `HX-Refresh`.
**Tech Stack:** Django 6, the in-house Python component system (`common/components`), HTMX, pytest / pytest-django, Playwright (e2e).
## Global Constraints
- Build UI with Python components from `common.components`; never raw HTML strings or Django templates. Builders return `Node`; stringify only at the `HttpResponse` boundary (Django str-encodes content). Do **not** return `SafeText`/`mark_safe` from row builders.
- Never write to `GeneratedField`s (`duration_calculated`, `duration_total`, `days_to_finish`).
- Name variables with complete words (`device_list`, `csrf_token`, `session`, not abbreviations).
- Name compound types explicitly: the row dict is a `TypedDict` (`SessionRowData`).
- Signals handle playtime recalculation — do not recompute `Game.playtime` by hand.
- Run tests with `uv run --with pytest-django pytest`. A bare `pytest` also collects `e2e/` (needs a browser); scope unit/view runs to `tests/...` paths.
- Spec: `docs/superpowers/specs/2026-06-20-issue-53-session-row-fragment-rebuild-design.md`.
---
## File Structure
- `games/views/session.py` — add `SessionRowData` (TypedDict), `session_row_data()`, `session_row()`; refactor `list_sessions` to use them; delete `_session_row_fragment()`; rewire `end_session`, `reset_session_start`, `new_session_from_existing_session`.
- `common/layout.py` — add `NavbarPlaytime()`; embed it inside `Navbar()`.
- `tests/test_session_row.py` — new: unit tests for `session_row_data` / `session_row`.
- `tests/test_navbar_playtime.py` — new: unit tests for `NavbarPlaytime`.
- `tests/test_session_endpoints.py` — new: view tests for the three rewired endpoints.
- `e2e/test_session_inplace_swap_e2e.py` — new: in-place finish swap + navbar update.
---
### Task 1: Extract `session_row_data` + `session_row` (canonical row builder)
**Files:**
- Modify: `games/views/session.py` (the `data["rows"]` comprehension at ~line 126-190, and the `list_sessions` body)
- Test: `tests/test_session_row.py` (create)
**Interfaces:**
- Produces:
- `class SessionRowData(TypedDict)` with keys `row_id: str`, `hx_trigger: str`, `hx_get: str`, `hx_select: str`, `hx_swap: str`, `cell_data: list[Node]`.
- `session_row_data(session: Session, device_list, csrf_token: str) -> SessionRowData` — the 6-cell row dict (Name, Date, Duration, Device, Created, Actions) with `row_id="session-row-{pk}"` and the device-change self-refresh hx attrs. For a running session (`timestamp_end is None`) the Actions `ButtonGroup` includes Finish and Reset buttons wired for htmx row swap (`hx_get` to the end/reset URL, `hx_target=f"#session-row-{pk}"`, `hx_swap="outerHTML"`).
- `session_row(session: Session, device_list, csrf_token: str) -> Node``TableRow(session_row_data(...))`.
- [ ] **Step 1: Write the failing test**
```python
# tests/test_session_row.py
import pytest
from django.utils import timezone
from games.models import Device, Game, Platform, Session
from games.views.session import session_row, session_row_data
@pytest.fixture
def running_session(db):
platform = Platform.objects.create(name="PC")
game = Game.objects.create(name="Celeste", platform=platform)
device = Device.objects.create(name="Desktop")
return Session.objects.create(
game=game, device=device, timestamp_start=timezone.now()
)
def test_session_row_data_shape(running_session):
device_list = Device.objects.order_by("name")
data = session_row_data(running_session, device_list, "tok")
assert data["row_id"] == f"session-row-{running_session.pk}"
assert len(data["cell_data"]) == 6
assert data["hx_select"] == f"#session-row-{running_session.pk}"
def test_session_row_renders_id_and_six_cells(running_session):
device_list = Device.objects.order_by("name")
html = str(session_row(running_session, device_list, "tok"))
assert f'id="session-row-{running_session.pk}"' in html
assert html.count("<td") + html.count("<th") == 6
def test_running_session_finish_button_targets_row(running_session):
device_list = Device.objects.order_by("name")
html = str(session_row(running_session, device_list, "tok"))
assert f'hx-target="#session-row-{running_session.pk}"' in html
assert 'hx-swap="outerHTML"' in html
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run --with pytest-django pytest tests/test_session_row.py -v`
Expected: FAIL with `ImportError: cannot import name 'session_row'` (and `session_row_data`).
- [ ] **Step 3: Write minimal implementation**
In `games/views/session.py`, add `TypedDict` to the `typing` import (`from typing import Any, TypedDict`) and add `from common.components import Fragment` is already present. Add the type + builders above `list_sessions`:
```python
class SessionRowData(TypedDict):
row_id: str
hx_trigger: str
hx_get: str
hx_select: str
hx_swap: str
cell_data: list[Node]
def session_row_data(
session: Session, device_list, csrf_token: str
) -> SessionRowData:
"""Canonical session-list row. Single source of truth shared by
list_sessions and the htmx finish/reset fragments."""
row_selector = f"#session-row-{session.pk}"
end_url = reverse("games:list_sessions_end_session", args=[session.pk])
reset_url = reverse(
"games:list_sessions_reset_session_start", args=[session.pk]
)
actions = ButtonGroup(
[
{
"href": end_url,
"hx_get": end_url,
"hx_target": row_selector,
"hx_swap": "outerHTML",
"slot": Icon("end"),
"title": "Finish session now",
"color": "green",
}
if session.timestamp_end is None
else {},
{
"href": reset_url,
"hx_get": reset_url,
"hx_target": row_selector,
"hx_swap": "outerHTML",
"hx_confirm": "Reset this session's start time to now?",
"slot": Icon("reset"),
"title": "Reset start to now",
"color": "gray",
}
if session.timestamp_end is None
else {},
{
"href": reverse("games:edit_session", args=[session.pk]),
"slot": Icon("edit"),
"title": "Edit",
},
{
"href": reverse("games:delete_session", args=[session.pk]),
"slot": Icon("delete"),
"title": "Delete",
"color": "red",
},
]
)
return SessionRowData(
row_id=f"session-row-{session.pk}",
hx_trigger="device-changed from:body",
hx_get="",
hx_select=row_selector,
hx_swap="outerHTML",
cell_data=[
NameWithIcon(session=session),
f"{local_strftime(session.timestamp_start)}"
f"{f'{local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
session.duration_formatted_with_mark(),
SessionDeviceSelector(session, device_list, csrf_token),
session.created_at.strftime(dateformat),
actions,
],
)
def session_row(session: Session, device_list, csrf_token: str) -> Node:
"""The single-session <tr> node, rendered through the same TableRow
path the list table uses."""
return TableRow(session_row_data(session, device_list, csrf_token))
```
Add `TableRow` to the `from common.components import (...)` block (it currently imports `paginated_table_content` but not `TableRow`).
- [ ] **Step 4: Run test to verify it passes**
Run: `uv run --with pytest-django pytest tests/test_session_row.py -v`
Expected: PASS (3 tests).
- [ ] **Step 5: Refactor `list_sessions` to consume the builder**
Replace the inline `"rows": [ {...} for session in sessions]` in the `data` dict with the builder call. First compute the token once near the top of `list_sessions` (after `device_list`): add `csrf_token = get_token(request)`. Then:
```python
"rows": [
session_row_data(session, device_list, csrf_token)
for session in sessions
],
```
Delete the now-removed inline row dict (the whole `{ "row_id": ..., ... }` comprehension body, ~line 127-189). Leave `header_action`/`columns` untouched.
- [ ] **Step 6: Run the broader suite to confirm no regression**
Run: `uv run --with pytest-django pytest tests/test_session_row.py tests/test_paths_return_200.py tests/test_rendered_pages.py -v`
Expected: PASS.
- [ ] **Step 7: Commit**
```bash
git add games/views/session.py tests/test_session_row.py
git commit -m "refactor(session): extract canonical session_row_data builder
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
### Task 2: `NavbarPlaytime` component (OOB-swappable)
**Files:**
- Modify: `common/layout.py` (`Navbar()` at ~line 190-231)
- Test: `tests/test_navbar_playtime.py` (create)
**Interfaces:**
- Produces: `NavbarPlaytime(today_played: str, last_7_played: str, *, oob: bool = False) -> Node` — an `<li id="navbar-playtime">` with the "Today · Last 7 days" label and values; when `oob=True` it carries `hx-swap-oob="true"`. `Navbar()` embeds `NavbarPlaytime(today_played, last_7_played)` in place of the inline `<li>`.
- [ ] **Step 1: Write the failing test**
```python
# tests/test_navbar_playtime.py
from common.layout import NavbarPlaytime
def test_navbar_playtime_has_stable_id_and_values():
html = str(NavbarPlaytime("1 h 00 m", "7 h 00 m"))
assert 'id="navbar-playtime"' in html
assert "1 h 00 m" in html
assert "7 h 00 m" in html
assert "hx-swap-oob" not in html
def test_navbar_playtime_oob_flag():
html = str(NavbarPlaytime("1 h 00 m", "7 h 00 m", oob=True))
assert 'id="navbar-playtime"' in html
assert 'hx-swap-oob="true"' in html
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run --with pytest-django pytest tests/test_navbar_playtime.py -v`
Expected: FAIL with `ImportError: cannot import name 'NavbarPlaytime'`.
- [ ] **Step 3: Write minimal implementation**
In `common/layout.py`, add above `Navbar()`:
```python
def NavbarPlaytime(
today_played: str, last_7_played: str, *, oob: bool = False
) -> "Node":
"""The navbar 'Today · Last 7 days' totals. Carries a stable id so
htmx endpoints can refresh it out-of-band after a session change."""
from common.components import Safe
oob_attr = ' hx-swap-oob="true"' if oob else ""
return Safe(
f'<li id="navbar-playtime"{oob_attr} '
'class="dark:text-white flex flex-col items-center text-xs">'
'<span class="flex uppercase gap-1">Today'
'<span class="dark:text-gray-400">·</span>Last 7 days</span>'
'<span class="flex items-center gap-1">'
f'{today_played}<span class="dark:text-gray-400">·</span>'
f"{last_7_played}</span></li>"
)
```
- [ ] **Step 4: Run test to verify it passes**
Run: `uv run --with pytest-django pytest tests/test_navbar_playtime.py -v`
Expected: PASS (2 tests).
- [ ] **Step 5: Embed it inside `Navbar()`**
In the `Navbar()` `Safe(f"""...""")` markup, replace the inline `<li>` block:
```html
<li class="dark:text-white flex flex-col items-center text-xs">
<span class="flex uppercase gap-1">Today<span class="dark:text-gray-400">·</span>Last 7 days</span>
<span class="flex items-center gap-1">{today_played}<span class="dark:text-gray-400">·</span>{last_7_played}</span>
</li>
```
with:
```python
{NavbarPlaytime(today_played, last_7_played)}
```
(The surrounding string is already an f-string, so the `{NavbarPlaytime(...)}` call interpolates its rendered HTML.)
- [ ] **Step 6: Run pages tests to confirm navbar still renders**
Run: `uv run --with pytest-django pytest tests/test_navbar_playtime.py tests/test_rendered_pages.py tests/test_paths_return_200.py -v`
Expected: PASS. The navbar still shows the totals (now via the component).
- [ ] **Step 7: Commit**
```bash
git add common/layout.py tests/test_navbar_playtime.py
git commit -m "feat(layout): extract NavbarPlaytime as OOB-swappable component
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
### Task 3: Rewire endpoints (in-place swap for end/reset, HX-Refresh for clone)
**Files:**
- Modify: `games/views/session.py` (`_session_row_fragment` delete; `end_session`, `reset_session_start`, `new_session_from_existing_session`)
- Test: `tests/test_session_endpoints.py` (create)
**Interfaces:**
- Consumes: `session_row` (Task 1), `NavbarPlaytime` (Task 2), `model_counts` (`games/views/general.py`).
- Produces: rewired views. `end_session`/`reset_session_start` htmx → `HttpResponse(str(Fragment(session_row(...), NavbarPlaytime(..., oob=True))))`; `new_session_from_existing_session` htmx → `HttpResponse(status=204)` with `HX-Refresh: true`.
- [ ] **Step 1: Write the failing test**
```python
# tests/test_session_endpoints.py
import pytest
from django.urls import reverse
from django.utils import timezone
from games.models import Device, Game, Platform, Session
@pytest.fixture
def auth_client(client, django_user_model):
user = django_user_model.objects.create_user(username="u", password="p")
client.force_login(user)
return client
@pytest.fixture
def running_session(db):
platform = Platform.objects.create(name="PC")
game = Game.objects.create(name="Hades", platform=platform)
device = Device.objects.create(name="Deck")
return Session.objects.create(
game=game, device=device, timestamp_start=timezone.now()
)
def test_end_session_htmx_returns_row_and_oob_navbar(auth_client, running_session):
url = reverse("games:list_sessions_end_session", args=[running_session.pk])
response = auth_client.get(url, HTTP_HX_REQUEST="true")
body = response.content.decode()
assert response.status_code == 200
assert f'id="session-row-{running_session.pk}"' in body
assert 'id="navbar-playtime"' in body
assert 'hx-swap-oob="true"' in body
running_session.refresh_from_db()
assert running_session.timestamp_end is not None
def test_reset_session_start_htmx_returns_row_no_refresh_header(
auth_client, running_session
):
original_start = running_session.timestamp_start
url = reverse(
"games:list_sessions_reset_session_start", args=[running_session.pk]
)
response = auth_client.get(url, HTTP_HX_REQUEST="true")
body = response.content.decode()
assert response.status_code == 200
assert f'id="session-row-{running_session.pk}"' in body
assert 'id="navbar-playtime"' in body
assert "HX-Refresh" not in response.headers
running_session.refresh_from_db()
assert running_session.timestamp_start > original_start
def test_clone_htmx_returns_hx_refresh(auth_client, running_session):
url = reverse(
"games:list_sessions_start_session_from_session",
args=[running_session.pk],
)
before = Session.objects.count()
response = auth_client.get(url, HTTP_HX_REQUEST="true")
assert response.status_code == 204
assert response.headers.get("HX-Refresh") == "true"
assert Session.objects.count() == before + 1
def test_end_session_non_htmx_redirects(auth_client, running_session):
url = reverse("games:list_sessions_end_session", args=[running_session.pk])
response = auth_client.get(url)
assert response.status_code == 302
assert response.url == reverse("games:list_sessions")
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run --with pytest-django pytest tests/test_session_endpoints.py -v`
Expected: FAIL — `end`/`reset` currently return the old fragment / `204+HX-Refresh`; clone returns the old fragment (200, not 204).
- [ ] **Step 3: Delete `_session_row_fragment` and rewire the views**
In `games/views/session.py`:
1. Delete the entire `_session_row_fragment(session)` function (the hand-built 4-column `Tr`).
2. Add imports: at top, `from games.views.general import model_counts`. Ensure `Fragment` and `Node` are imported from `common.components` (they already are). Add `from common.layout import NavbarPlaytime` (the file already imports `render_page` from `common.layout`).
3. Add a small helper near the endpoints:
```python
def _row_with_navbar(request: HttpRequest, session: Session) -> HttpResponse:
device_list = Device.objects.order_by("name")
counts = model_counts(request)
fragment = Fragment(
session_row(session, device_list, get_token(request)),
NavbarPlaytime(
counts["today_played"], counts["last_7_played"], oob=True
),
)
return HttpResponse(str(fragment))
```
4. Rewrite the endpoints:
```python
@login_required
def new_session_from_existing_session(
request: HttpRequest, session_id: int
) -> HttpResponse:
clone_session_by_id(session_id)
if request.htmx:
# Clone adds a new row whose position depends on sort + pagination,
# which a single-row swap cannot place — refresh the list instead.
response = HttpResponse(status=204)
response["HX-Refresh"] = "true"
return response
return redirect("games:list_sessions")
@login_required
def end_session(request: HttpRequest, session_id: int) -> HttpResponse:
session = get_object_or_404(Session, id=session_id)
session.timestamp_end = timezone.now()
session.save()
if request.htmx:
return _row_with_navbar(request, session)
return redirect("games:list_sessions")
@login_required
def reset_session_start(request: HttpRequest, session_id: int) -> HttpResponse:
session = get_object_or_404(Session, id=session_id)
session.timestamp_start = timezone.now()
session.save()
if request.htmx:
return _row_with_navbar(request, session)
return redirect("games:list_sessions")
```
Note: `clone_session_by_id` already returns the clone; we drop the unused local. Check for an import cycle when adding `from games.views.general import model_counts` at module top — if `general.py` imports from `session.py` it will cycle; in that case import `model_counts` lazily inside `_row_with_navbar` instead.
- [ ] **Step 4: Run test to verify it passes**
Run: `uv run --with pytest-django pytest tests/test_session_endpoints.py -v`
Expected: PASS (4 tests).
- [ ] **Step 5: Switch the game-detail "Finish" button off the htmx path it never used**
Confirm `games/views/game.py` `_sessions_section` still uses plain `href` for its end button (it does, and it stays full-nav per spec / #55). No change needed — just verify by reading; if it has any `hx_get` to `view_game_end_session`, leave it, since `end_session` still redirects for non-list contexts. (The game-detail button is `href`-only, so it triggers the non-htmx redirect branch.)
- [ ] **Step 6: Run the full unit/view suite**
Run: `uv run --with pytest-django pytest tests/ -v`
Expected: PASS (no regressions; old fragment tests, if any, are gone with the function).
- [ ] **Step 7: Commit**
```bash
git add games/views/session.py tests/test_session_endpoints.py
git commit -m "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 Opus 4.8 <noreply@anthropic.com>"
```
---
### Task 4: E2E — in-place finish swap + navbar update
**Files:**
- Test: `e2e/test_session_inplace_swap_e2e.py` (create)
**Interfaces:**
- Consumes: the rewired list UI (Tasks 1-3). No production code changes.
- [ ] **Step 1: Write the test**
Follow the existing `e2e/` patterns (`live_server`, login helper, Playwright `page`). Inspect `e2e/conftest.py` and an existing test (e.g. `e2e/test_widgets_e2e.py`) for the project's login fixture and `page.goto(live_server.url + ...)` style, and mirror them.
```python
# e2e/test_session_inplace_swap_e2e.py
from django.urls import reverse
from django.utils import timezone
from games.models import Device, Game, Platform, Session
def test_finish_session_swaps_row_in_place(live_server, page, logged_in):
platform = Platform.objects.create(name="PC")
game = Game.objects.create(name="Tunic", platform=platform)
device = Device.objects.create(name="Desktop")
session = Session.objects.create(
game=game, device=device, timestamp_start=timezone.now()
)
page.goto(live_server.url + reverse("games:list_sessions"))
row = page.locator(f"#session-row-{session.pk}")
row.get_by_title("Finish session now").click()
# Row updates in place (still present, now shows an end time → em dash).
page.wait_for_selector(f"#session-row-{session.pk}")
assert "" in page.locator(f"#session-row-{session.pk}").inner_text()
session.refresh_from_db()
assert session.timestamp_end is not None
```
If the repo has no shared `logged_in` fixture, replicate the login step used by the other e2e tests inline (they all authenticate the same way — copy that fixture/usage).
- [ ] **Step 2: Build TS assets (custom elements served fresh) and run the test**
Run:
```bash
make ts
uv run --with pytest-django --with pytest-playwright pytest e2e/test_session_inplace_swap_e2e.py -v
```
Expected: PASS. (Requires a Chromium; `e2e/conftest.py` prefers a system Chrome, else run `uv run playwright install chromium` once.)
- [ ] **Step 3: Commit**
```bash
git add e2e/test_session_inplace_swap_e2e.py
git commit -m "test(e2e): in-place session-row finish swap
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
### Task 5: Full check + cleanup
**Files:** none (verification).
- [ ] **Step 1: Lint + format + tests aggregate**
Run: `make check`
Expected: PASS (ruff lint, format check, ts-check, tests). Fix any unused imports left in `session.py` — particularly `SafeText`, `mark_safe`, `date_filter`, `Span`, `Tr`, `Td` if the deleted `_session_row_fragment` was their only user. Verify with `make lint` and remove the dead imports.
- [ ] **Step 2: Manual smoke (optional but recommended)**
Run `make dev`, open the session list, finish a running session: the row should update in place (end time appears, duration fills) and the navbar "Today · Last 7 days" totals change, with no full-page reload. Reset start on a running session: start time jumps to now, duration resets, navbar updates. Clone ("play" button): list reloads.
- [ ] **Step 3: Commit any cleanup**
```bash
git add -A
git commit -m "chore(session): drop imports orphaned by fragment removal
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Self-Review
**Spec coverage:**
- Canonical builder (`session_row_data` + `session_row`, both Node) → Task 1. ✓
- `NavbarPlaytime` OOB component → Task 2. ✓
- end/reset in-place swap + OOB navbar; reset drops 204+HX-Refresh → Task 3. ✓
- clone stays HX-Refresh → Task 3 (with documented reason). ✓
- Return `Node`, stringify at HttpResponse boundary → Tasks 1/3 (`HttpResponse(str(Fragment(...)))`). ✓
- List buttons switch to htmx row swap → Task 1 (Finish/Reset in `session_row_data`). ✓
- Delete dead `_session_row_fragment` + old Tr → Task 3. ✓
- game-detail out of scope (#55) → Task 3 Step 5 (verify, no change). ✓
- Tests: unit (row, navbar), view (endpoints), e2e (in-place swap) → Tasks 1-4. ✓
**Placeholder scan:** No TBD/TODO; all steps carry concrete code or commands. The one judgement call (import-cycle on `model_counts`) is given an explicit fallback (lazy import). ✓
**Type consistency:** `session_row_data(session, device_list, csrf_token) -> SessionRowData` and `session_row(session, device_list, csrf_token) -> Node` used identically in Task 1, Task 3, and tests. `NavbarPlaytime(today_played, last_7_played, *, oob=False)` used consistently in Task 2 and Task 3. `_row_with_navbar(request, session) -> HttpResponse` used in both end/reset. ✓
@@ -0,0 +1,197 @@
# Design Spec: Boolean Filters Overhaul (Approach A with Reusable Primitives)
Expose a two-radio-button UI for all boolean filters to allow selecting "True" (Yes), "False" (No), or leaving the filter "Unset" (Not set).
## 1. Architectural Changes
### 1.1 Backend Primitives & Components
We will extract the `_filter_checkbox` rendering logic from `common/components/filters.py` and generalize it into a reusable, filter-agnostic `Checkbox` component in `common/components/primitives.py`. We will also add a corresponding `Radio` component.
#### In `common/components/primitives.py`:
```python
def Checkbox(
name: str,
label: str,
checked: bool = False,
value: str = "1",
attributes: list[HTMLAttribute] | None = None,
) -> SafeText:
"""A filter-agnostic Checkbox component."""
attributes = attributes or []
input_attrs = [
("name", name),
("value", value),
("class", "rounded border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand"),
] + attributes
if checked:
input_attrs.append(("checked", "true"))
return Label(
attributes=[("class", "flex items-center gap-2 text-sm text-heading cursor-pointer")],
children=[
Input(type="checkbox", attributes=input_attrs),
label,
],
)
def Radio(
name: str,
label: str,
checked: bool = False,
value: str = "",
attributes: list[HTMLAttribute] | None = None,
) -> SafeText:
"""A filter-agnostic Radio component."""
attributes = attributes or []
input_attrs = [
("name", name),
("value", value),
("class", "rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand"),
] + attributes
if checked:
input_attrs.append(("checked", "true"))
return Label(
attributes=[("class", "flex items-center gap-1.5 text-sm text-heading cursor-pointer")],
children=[
Input(type="radio", attributes=input_attrs),
label,
],
)
```
#### In `common/components/filters.py`:
We will import `Checkbox` and `Radio` from `common.components.primitives`. We will redefine `_filter_checkbox` as a thin adapter pointing to our new generalized `Checkbox` component (preserving any backward compatibility), and we will create a new helper `_filter_boolean_radio` using `Radio`:
```python
_FILTER_RADIO_CLASS = (
"rounded-full border-default-medium bg-neutral-secondary-medium "
"text-brand focus:ring-brand"
)
def _filter_checkbox(name: str, label: str, checked: bool) -> SafeText:
"""Thin adapter mapping legacy checkbox filters to the generalized Checkbox primitive."""
return Checkbox(name=name, label=label, checked=checked)
def _filter_boolean_radio(name: str, label: str, value: bool | None) -> SafeText:
"""Renders a filter-specific boolean radio button group with 'True' and 'False' options."""
return Div(
attributes=[("class", "flex flex-col gap-1")],
children=[
Span(
attributes=[("class", _FILTER_LABEL_CLASS)],
children=[label],
),
Div(
attributes=[("class", "flex items-center gap-4 h-9")],
children=[
Radio(name=name, label="True", checked=value is True, value="true"),
Radio(name=name, label="False", checked=value is False, value="false"),
],
),
],
)
```
### 1.2 Parsing Filter JSON (Backend)
We will introduce a robust parsing function in `common/components/filters.py` to distinguish `True`, `False`, and `None` (unset):
```python
def _parse_bool_nullable(existing: dict, key: str) -> bool | None:
"""Extract a nullable boolean value from a filter criterion."""
if key not in existing:
return None
field = existing[key]
if not isinstance(field, dict):
return None
val = field.get("value")
if val is None:
return None
if isinstance(val, str):
if val.lower() in ("true", "1", "yes"):
return True
if val.lower() in ("false", "0", "no"):
return False
return bool(val)
```
### 1.3 UI Overhauls in Filter Bars
We will update the following filter bars to use `_parse_bool_nullable` and `_filter_boolean_radio`:
1. **GameFilterBar:** `mastered`, `purchase_refunded`, `purchase_infinite`, `session_emulated`.
2. **SessionFilterBar:** `emulated`, `is_active`.
3. **PurchaseFilterBar:** `is_refunded`, `infinite`, `needs_price_update`.
---
## 2. Frontend JS Changes (`games/static/js/filter_bar.js`)
### 2.1 Deselectable Radios Behavior
To support resetting filters back to "Unset" without resetting the whole form, we add click behavior that unchecks an already checked radio button when clicked.
```javascript
function setupDeselectableRadios() {
document.querySelectorAll('input[type="radio"]').forEach(function (radio) {
radio.addEventListener('click', function (e) {
if (this.wasChecked) {
this.checked = false;
this.wasChecked = false;
this.dispatchEvent(new Event('change', { bubbles: true }));
} else {
var name = this.getAttribute('name');
if (name) {
document.querySelectorAll('input[type="radio"][name="' + name + '"]').forEach(function (r) {
r.wasChecked = false;
});
}
this.wasChecked = true;
}
});
if (radio.checked) {
radio.wasChecked = true;
}
});
}
```
We will call `setupDeselectableRadios()` during `DOMContentLoaded`.
### 2.2 Serializing Radio States
Update `buildFilterJSON(form)` to collect checked radios from boolean field groups:
```javascript
// 2. Boolean Fields (Radio Button Groups)
var booleanFields = [
{ name: "filter-mastered", key: "mastered" },
{ name: "filter-emulated", key: "emulated" },
{ name: "filter-active", key: "is_active" },
{ name: "filter-refunded", key: "is_refunded" },
{ name: "filter-infinite", key: "infinite" },
{ name: "filter-needs-price-update", key: "needs_price_update" },
{ name: "filter-purchase-refunded", key: "purchase_refunded" },
{ name: "filter-purchase-infinite", key: "purchase_infinite" },
{ name: "filter-session-emulated", key: "session_emulated" }
];
booleanFields.forEach(function (bf) {
var el = form.querySelector('[name="' + bf.name + '"]:checked');
if (el) {
var val = el.value === "true";
filter[bf.key] = criterion(val, null, "EQUALS");
}
});
```
---
## 3. Testing Strategy
1. **Unit Tests (`tests/test_filter_helpers.py`):**
- Add test coverage for `_parse_bool_nullable` covering `None`, `True`, `False`, strings, missing keys, etc.
2. **Component Tests (`tests/test_filter_bars.py`):**
- Update tests where the filters render checkbox elements to assert that radio groups are rendered instead (with "True" and "False" radio buttons).
3. **Integration and End-to-End Tests:**
- Execute the test suite using `pytest` to ensure that all 355 tests continue to pass and reflect the updated UI structure perfectly.
@@ -0,0 +1,157 @@
# HTML + JS component authoring — design
**Date:** 2026-06-13
**Status:** Approved (design); pending implementation plan
**Branch context:** follows the lazy node-tree component system (`Element`/`Safe`/`Fragment`/`Media`) and the `Children`/`Attributes` typing work.
## Problem
Trusted HTML and JavaScript are authored as Python f-strings in several places. Two distinct pains:
- **HTML-as-string** — `Navbar`, `_TOAST_CONTAINER`, the played-row markup skeleton, and the generally verbose `Element("div", attributes=[...], children=[...])` call shape.
- **JS-in-string** — the genuinely ugly ones: `GameStatusSelector` (~70 lines) and `SessionDeviceSelector` (~50 lines) inline an Alpine `x-data="{...}"` blob with `fetchWithHtmxTriggers`, server-value interpolation (`{game.status}`), **and** `{{ }}` brace-doubling throughout; `_PLAYED_ROW_TEMPLATE` dodges the brace collision entirely by switching to `@@TOKEN@@` placeholders + a `.replace()` loop.
You cannot node-tree JavaScript, so the JS pain needs a different answer than the HTML pain. The newer widgets (`search_select`, `range_slider`, `filter_bar`) already moved behavior into real `.js` files wired by `onSwap` + `data-*` attributes; the Alpine selectors are the holdouts that still inline their JS.
## Goal
Establish the *right* way to author interactive, server-rendered components in this codebase, and convert a few exemplars to prove it. North-star principle:
> The server never writes a line of JavaScript. The server↔client boundary is a typed, declarative contract. Behavior lives in real, tooled TypeScript files.
## Decisions (locked during brainstorming)
| Decision | Choice |
| --- | --- |
| HTML authoring | **htpy-*style* sugar on the existing `Element`** (not the htpy library) — keeps `Media`/`collect_media`, no build step |
| JS runtime model | **Custom Elements** (Web Components), light DOM |
| Server↔client contract | **Typed contract + codegen** (one Python `Props` type → generated TS interface + reader) |
| JS language | **TypeScript** (real `.ts`, compiled) |
| Build tool | **`tsc` per-module** (no bundler) — preserves per-component `Media` loading |
| Alpine, for converted components | **Retired** — behavior rewritten as vanilla TS in the element class |
| Exemplars | **`GameStatusSelector` + `SessionDeviceSelector` + played-row** |
| Compiled output | **Build-only, gitignored** (produced by `make` + Docker) |
| Existing hand-written `.js` | **Left as-is**, migrated to TS later |
## Architecture
Three independent layers composing through one typed seam:
```
Python (server) TypeScript (client)
───────────────── ───────────────────
htpy-style Element ──renders──► <game-status-selector ──connectedCallback──► game-status-selector.ts
+ Media (kept) game-id="3" status="f"> (vanilla DOM behavior)
│ ▲
└── GameStatusSelectorProps ─codegen─┘ generated props.ts (interface + typed reader)
(one Python type = the whole server↔client contract)
```
- **Layer 1 — htpy-style HTML** removes HTML-string / verbose-`Element` ugliness, pure Python, no build, `Media` untouched.
- **Layer 2 — Custom Elements (TS)** removes JS-string ugliness; behavior in real typed modules with a native lifecycle.
- **Layer 3 — Typed contract codegen** makes the seam type-safe in both languages from a single Python source.
### Layer 1 — htpy-style sugar on `Element`
Additive only. Existing `Element("div", attributes=[...], children=[...])` and `Div([("class","x")], "hi")` keep working.
- **Attributes as kwargs:** `Div(class_="card", hx_get="/x", disabled=True)`. Translation: trailing `_` stripped (`class_``class`); inner `_``-` (`hx_get``hx-get`, `data_id``data-id`); `True`→bare attribute, `False`/`None`→omitted.
- **Children via `[]`:** `Div(class_="card")[H1["Title"], body]`. `Element.__getitem__` normalizes through the existing `as_children` and returns an `Element` carrying the same attributes and media.
The result is still a walkable `Element` tree, so `collect_media` / `Media` are unaffected. This is the "htpy feel on our own node so the asset system survives" decision.
Example:
```python
Div(class_="flex gap-2 items-center")[
Icon("play"),
Span(class_="label")[name],
]
```
### Layer 2 — Custom Elements (TypeScript, light DOM)
- Python builder emits a **semantic tag**: `Element("game-status-selector", attrs).with_media(Media(js=("dist/elements/game-status-selector.js",)))`.
- **Light DOM** (no shadow root — Tailwind's global classes must apply). The server renders the inner markup (htpy-style); the element enhances it.
- **Native lifecycle replaces `onSwap`:** `connectedCallback()` fires when the browser parses or htmx-swaps the element in; `disconnectedCallback()` provides free teardown. No init registry, no guard flags.
- Behavior is **vanilla TS** — the element class owns its state (dropdown open/closed, PATCH-on-select via `fetchWithHtmxTriggers`). Alpine retired for these three.
- Source `ts/elements/<tag>.ts` → compiled `games/static/js/dist/elements/<tag>.js`, loaded only on pages that use it (via `Media`).
### Layer 3 — Typed contract (one Python type → the whole seam)
Each element declares its props once, in Python:
```python
class GameStatusSelectorProps(TypedDict):
game_id: int
status: str
csrf: str
```
- The **Python builder** takes these typed args and serializes them to kebab-case attributes (`game-id="3"`).
- **Codegen** reads the registered Props types and emits, per component, into `ts/generated/props.ts`:
- an **interface**`GameStatusSelectorProps { gameId: number; status: string; csrf: string }`, and
- a **typed reader**`readGameStatusSelectorProps(el): GameStatusSelectorProps` that pulls and parses attributes (`Number(el.getAttribute("game-id"))`, etc.).
- The element imports the generated reader. The entire server↔client boundary is generated from one Python type: rename `game_id` in Python, regenerate, and `tsc` fails until the element updates. Drift is caught at build time; no hand-written `getAttribute` soup, no silent attr-name drift.
Type map: `int`/`float``number`, `str``string`, `bool``boolean`. Field `game_id` → attr `game-id` → TS prop `gameId`. Reader parsing follows the type (number → `Number(...)`, bool → presence / `=== "true"`, string → `getAttribute(...) ?? ""`).
## Toolchain (`tsc` per-module, build-only)
Layout:
```
ts/
elements/game-status-selector.ts # hand-written element classes
generated/props.ts # codegen output (gitignored)
globals.d.ts # ambient: window.fetchWithHtmxTriggers, htmx
tsconfig.json # strict, ES2022, lib [ES2022, DOM, DOM.Iterable]
# rootDir: ts/ → outDir: games/static/js/dist/
```
- **`games/static/js/dist/` is the only compiled output**, trivially gitignored, never colliding with hand-written `.js`. `Media` references `dist/elements/...`.
- **package.json**: add `typescript` devDep; scripts `build:ts` (`tsc -p tsconfig.json`), `watch:ts` (`tsc -p tsconfig.json --watch`).
- **Makefile**: `make ts` = codegen → `tsc`; `make dev` also runs `tsc --watch` (beside Django runserver + Tailwind watch); `make check` gains `tsc --noEmit` as a drift gate.
- **.gitignore**: `games/static/js/dist/`, `ts/generated/`.
- **Docker**: add a `make ts` step in the image build (npm already present for Tailwind); compiled JS baked into the image. Runtime stays offline.
- **TS lint/format**: deferred — `tsc --strict` is the only gate for now.
### Codegen mechanics
- A registry maps `tag → Props type` (e.g. a decorator `@element("game-status-selector", GameStatusSelectorProps)` on the Python builder, collected into a module-level registry).
- A Django management command (or script) imports the registry and writes `ts/generated/props.ts` (interface + reader per component).
- **Ordering:** codegen runs before `tsc` (the generated file is a `tsc` input). CI runs codegen then `tsc --noEmit`, so Python/TS drift fails the build. No committed generated artifact to diff against — `tsc` failing on drift is the gate.
## Exemplar conversions
1. **`GameStatusSelector``<game-status-selector game-id status csrf>`** — Python builds the light-DOM htpy-style; `game-status-selector.ts` wires the dropdown toggle + click→PATCH `/api/games/{id}/status` via `fetchWithHtmxTriggers` with CSRF, and updates the displayed status. Deletes the ~70-line f-string + brace-doubling.
2. **`SessionDeviceSelector``<session-device-selector>`** — same shape; PATCH `/api/session/{id}/device`.
3. **played-row → `<play-event-row>`** (non-Alpine) — deletes `_PLAYED_ROW_TEMPLATE` and the `@@TOKEN@@` / `.replace()` hack; Python builds markup htpy-style; `play-event-row.ts` owns the dropdown + add-playthrough POST. URLs are server-reversed and passed as attributes. Proves the pattern is not Alpine-only.
## Testing
- **Python**: builders render the correct tag + attributes (extend `test_components` / `test_rendered_pages`); assert no f-string remnants remain.
- **Type-check**: `tsc --noEmit` in `make check` — type errors, including contract drift, fail CI.
- **e2e (Playwright)**: real Chromium upgrades the custom elements natively; port/extend the existing widget-e2e pattern for all three (open dropdown → select → PATCH → DOM updates).
## Risks and mitigations
1. **Element module must be loaded before its tag appears.** Full-page render loads the module via `Media`; htmx row-swaps reuse the already-defined element. Constraint to document: a fragment response that introduces a brand-new element type must include that element's `Media`. (Same limitation class as today's "`onSwap` needs the script present.")
2. **A build step is now required** for `make dev` and Docker. One-time wiring, mitigated by Make/Docker integration.
3. **First TypeScript in the repo** — adds `typescript`, `tsconfig.json`, a Docker build step. Scoped to `ts/`; existing `.js` untouched.
4. **CSRF/PATCH parity** — the vanilla TS must replicate the Alpine version's fetch/CSRF/`HX-Trigger` behavior; it reuses the existing `fetchWithHtmxTriggers`; e2e guards it.
5. **Codegen ↔ build ordering** — codegen must precede `tsc`; encoded in `make ts`.
## Out of scope (YAGNI)
- Migrating the existing hand-written `.js` to TypeScript (later, incrementally).
- Bundling / minification of app JS.
- Shadow DOM / scoped styles.
- A general island / props-blob hydration runtime (custom elements cover these three).
- TS lint/format tooling (prettier/eslint).
## Future on-ramps (not now)
- **More custom elements**: migrate the remaining `onSwap` widgets to custom elements once the pattern is proven.
- **Existing `.js` → TS**: incremental, file by file (`tsc` checks mixed projects).
- The typed contract already positions the boundary for full type-safety as more client code becomes TS.
@@ -0,0 +1,138 @@
# Reset running session start to now (issue #33)
## Problem
Sometimes a session is started but a sizeable amount of time passes before play
actually begins. The current UX to fix this is: edit the session, press "Set to
now", submit. This is three steps across two pages.
## Goal
Add a one-click button in the session list — next to the existing "Finish
session now", "Edit", and "Delete" buttons — that sets a running session's
`timestamp_start` to the current time. A confirmation dialog protects against
accidental clicks (the original start time is overwritten).
## Scope
- **Visibility:** the button shows only on running sessions (`timestamp_end is
None`), exactly like the green "Finish session now" button.
- **Appearance:** gray button, new "reset" icon.
- **Behavior:** confirm dialog before resetting; on confirm, sets
`timestamp_start = timezone.now()`, saves, and refreshes the list via htmx so
the new start time shows.
Out of scope: changing the existing Finish/Edit/Delete buttons; resetting end
time; bulk operations.
## Design
### 1. New icon — `games/templates/icons/reset.html`
A rotate/counterclockwise-arrow SVG signifying "reset". Styled like sibling
icons (`text-black dark:text-white w-4 h-4`). Icons are auto-loaded by file stem
(`common/icons.py`), so `Icon("reset")` resolves once the file exists — no
registration needed.
### 2. New view — `games/views/session.py`
Mirrors the existing `end_session` view, but the htmx path returns an empty
`204` with an `HX-Refresh: true` header instead of a row fragment:
```python
@login_required
def reset_session_start(request: HttpRequest, session_id: int) -> HttpResponse:
session = get_object_or_404(Session, id=session_id)
session.timestamp_start = timezone.now()
session.save()
if request.htmx:
response = HttpResponse(status=204)
response["HX-Refresh"] = "true"
return response
return redirect("games:list_sessions")
```
**Why `HX-Refresh` and not a row swap:** `_session_row_fragment` (used by
`end_session`) renders a legacy 4-column `<tr>` that no longer matches the live
session-list table (6 columns, built inline by `list_sessions`) and carries no
`id="session-row-{pk}"`. Swapping it into the current table would produce a
malformed row. The list table is rebuilt server-side on every request, so a full
htmx refresh is the simplest correct update — and consistent with the existing
Finish button, which also does a full-page navigation.
### 3. New URL — `games/urls.py`
```python
path(
"session/start/reset-to-now/from-list/<int:session_id>",
session.reset_session_start,
name="list_sessions_reset_session_start",
),
```
### 4. Extend `ButtonGroup` — `common/components/primitives.py`
The button-group button dict currently supports `href`, `slot`, `color`,
`title`, `hx_get`, `hx_target`. Add two optional keys threaded through both
`ButtonGroup()` and `_button_group_button()`:
- `hx_confirm` — emitted as `hx-confirm` on the `<a>`; htmx shows a native
`confirm()` dialog before issuing the request.
- `hx_swap` — emitted as `hx-swap` on the `<a>`; needed so the returned row
fragment replaces the row (`outerHTML`) rather than htmx's default.
Both are additive and optional; existing callers are unaffected. Update the
`ButtonGroup` docstring to list the new keys.
### 5. Button in the session list — `games/views/session.py`
Added to the `ButtonGroup` list in `list_sessions`, guarded the same way as the
Finish button:
```python
{
"href": reverse(
"games:list_sessions_reset_session_start", args=[session.pk]
),
"hx_get": reverse(
"games:list_sessions_reset_session_start", args=[session.pk]
),
"hx_confirm": "Reset this session's start time to now?",
"slot": Icon("reset"),
"title": "Reset start to now",
"color": "gray",
}
if session.timestamp_end is None
else {}
```
Placement: directly after the Finish button, before Edit. `href` is a graceful
fallback (the non-htmx view path redirects); `hx_get` + `hx_confirm` drive the
confirm dialog and htmx refresh when JS is active.
## Rationale: htmx confirm
The confirm dialog comes from htmx's built-in `hx-confirm`, which only fires on
htmx-driven requests — so the button must use `hx-get` (not just `href`). No
inline JS is needed, consistent with the project's conventions.
## Testing
### Unit (`tests/`)
- `reset_session_start` sets `timestamp_start` to ~now and saves.
- Returns the row fragment when called via htmx; redirects to `list_sessions`
otherwise.
- Session list renders the reset button only for running sessions
(`timestamp_end is None`), not for finished ones.
### E2E (`e2e/`)
- On the session list with a running session, click the reset button, accept the
confirm dialog (`page.on("dialog", lambda d: d.accept())`), and assert the
row's displayed start time updated to ~now.
## No TypeScript build
`hx-confirm` is built into htmx; no new custom element or `.ts` file, so `make
ts` is not required for this change.
@@ -0,0 +1,223 @@
# Design: Issue #53 — Rebuild `_session_row_fragment` via a shared row builder
**Date:** 2026-06-20
**Issue:** [#53](https://github.com/KucharczykL/timetracker/issues/53)
**Follow-on:** [#55](https://github.com/KucharczykL/timetracker/issues/55) (standardize all session tables on the canonical builder)
## Problem
`_session_row_fragment()` in `games/views/session.py` renders a **4-column** session
`<tr>` (Name, Start, End, Duration) with a hand-built `Tr`, no `id="session-row-{pk}"`.
The live `list_sessions` table is **6 columns** (Name, Date, Duration, Device, Created,
Actions) with a row id and htmx attributes. The fragment cannot be htmx-swapped into the
live table without producing a malformed, un-targetable row.
In practice the fragment is **dead**: every session action button in the UI is a plain
`href` (full-page navigation). The only htmx caller, `reset_session_start`, returns
`204 + HX-Refresh` (the #33 workaround) rather than the fragment. The fragment's htmx
paths in `end_session` and `new_session_from_existing_session` are never exercised, which
is why the drift went unnoticed.
Root cause: the fragment is an independent re-implementation of a session row. Fixed
properly, there must be exactly one source of truth for a session row, reused by both
the table and any htmx fragment.
## Goal
1. One canonical session-row builder shared by `list_sessions` and the htmx fragment — no
duplicated `<tr>` markup, so the two cannot drift.
2. Real in-place htmx row swap for **finish** and **reset-start** actions on the session
list, with the navbar playtime totals kept correct in the same request via an
out-of-band (OOB) swap.
Non-goals (tracked in #55): migrating the game-detail sessions table (4-column, different
shape) onto the canonical builder. It keeps its current full-navigation buttons for now.
## Architecture
### Single source of truth for a session row
`TableRow` (`common/components/primitives.py:894`) is the only place a `<tr>` is built.
The table reaches it through `list_sessions → row dict → paginated_table_content →
SimpleTable → TableRow(data=dict)`. The fix splits the row into two reused units:
- **`session_row_data(session, device_list, csrf_token) -> SessionRowData`** — owns cell
content, `row_id`, and the row's htmx attributes (the dict currently inlined in
`list_sessions`). New function in `games/views/session.py`.
- **`TableRow`** — owns the `<tr>` markup. Unchanged, already shared.
Both consumers go through the same dict builder and the same renderer:
```python
# list_sessions
rows = [session_row_data(s, device_list, csrf_token) for s in sessions]
# → paginated_table_content → SimpleTable → TableRow(data=dict)
# single-row htmx fragment — returns a Node, not a stringified SafeText
def session_row(session, device_list, csrf_token) -> Node:
return TableRow(session_row_data(session, device_list, csrf_token))
```
The fragment is therefore the *same* row the table renders, for a single session. Change
a column once in `session_row_data` and list + fragment move together. The old hand-built
`Tr` (4-column, the `#last-session-start` toggle, the yellow "Finish now?" link) — and the
`_session_row_fragment` helper returning `SafeText` — are deleted entirely.
**Return `Node`, not `SafeText`.** Per the component-system direction, builders return
`Node` objects and stringification happens only at the `HttpResponse` boundary (Django
str-encodes response content automatically — `HttpResponse(node)` already works across the
codebase, e.g. `purchase.py` `HttpResponse(_refund_confirmation_modal(...))`). `TableRow`
already returns an `Element` (a `Node`), so `session_row` returns it directly with no
`str()`/`mark_safe`. The endpoints combine the row and the OOB navbar with `Fragment`
(also a `Node`) and pass that straight to `HttpResponse`.
`session_row_data` reproduces today's `list_sessions` dict exactly:
- `row_id`: `f"session-row-{session.pk}"`
- `hx_trigger`: `"device-changed from:body"`, `hx_get`: `""`, `hx_select`:
`f"#session-row-{session.pk}"`, `hx_swap`: `"outerHTML"` (the existing self-refresh on
device change)
- `cell_data` (6): `NameWithIcon(session=session)`; startend string via `local_strftime`;
`session.duration_formatted_with_mark()`; `SessionDeviceSelector(session, device_list,
csrf_token)`; `session.created_at.strftime(dateformat)`; the `ButtonGroup` of actions.
The action `ButtonGroup` for a running session (`timestamp_end is None`) switches the
**Finish** and **Reset start** buttons from plain `href` to htmx (see below). `ButtonGroup`
already forwards `hx_get`/`hx_target`/`hx_swap`/`hx_confirm` (`primitives.py:367`).
### Named type
```python
class SessionRowData(TypedDict):
row_id: str
hx_trigger: str
hx_get: str
hx_select: str
hx_swap: str
cell_data: list[Node]
```
Defined in `games/views/session.py` (per the project convention to name compound types
passed between functions).
### Navbar playtime as an OOB-swappable component
The navbar's "Today · Last 7 days" totals live inline in the monolithic `Navbar()`
`Safe` f-string (`common/layout.py:228-231`). Finishing or resetting a session changes a
session's duration → game playtime → these totals, so an in-place row swap would leave
them stale.
Extract the `<li>` into a small component with a stable id:
```python
# common/layout.py (or common/components)
def NavbarPlaytime(today_played: str, last_7_played: str, *, oob: bool = False) -> Node:
# <li id="navbar-playtime" [hx-swap-oob="true"]> ...today · last_7... </li>
```
- `Navbar()` embeds `NavbarPlaytime(today_played, last_7_played)` in place of the inline
markup (no visual change).
- htmx endpoints render `NavbarPlaytime(..., oob=True)`, which adds `hx-swap-oob="true"`,
and append it to their response body. htmx applies it to the matching `#navbar-playtime`
regardless of the primary target.
Totals come from the existing `model_counts(request)` (`games/views/general.py:26`), which
already computes `today_played` / `last_7_played`. The endpoints call it after saving.
### Endpoint behavior
All three endpoints keep their non-htmx branch (`redirect("games:list_sessions")`).
| Endpoint | htmx response |
|---|---|
| `end_session` | `HttpResponse(Fragment(session_row(...), NavbarPlaytime(..., oob=True)))` |
| `reset_session_start` | `HttpResponse(Fragment(session_row(...), NavbarPlaytime(..., oob=True)))` |
| `new_session_from_existing_session` (clone) | `204 + HX-Refresh: true` |
- **end / reset** return a `Fragment` Node holding the fresh row plus the OOB navbar in one
response body, passed straight to `HttpResponse` (no manual stringification). The
triggering button targets `#session-row-{pk}` with `hx-swap="outerHTML"`; htmx extracts
the OOB `<li>` and swaps the remainder (the `<tr>`) into the row. `reset_session_start`
drops its current `204 + HX-Refresh` workaround.
- **clone stays on `HX-Refresh`**: it creates a *new* session whose correct position
depends on sort + pagination, which a single-row `outerHTML` swap cannot place. Its htmx
branch returns `204 + HX-Refresh: true` (replacing the dead fragment return). This is a
deliberate, documented exception.
Both `end_session` and `reset_session_start` need `device_list` and a CSRF token to build
the row (for the `SessionDeviceSelector` cell): `Device.objects.order_by("name")` and
`get_token(request)`, mirroring `list_sessions`.
### List buttons → htmx
In `session_row_data`, for a running session:
- **Finish session now**: add `hx_get` = `list_sessions_end_session` URL,
`hx_target` = `f"#session-row-{session.pk}"`, `hx_swap` = `"outerHTML"`. Keep `href` as
a no-JS fallback.
- **Reset start to now**: same `hx_target`/`hx_swap`; keep existing `hx_confirm` and
`href` fallback. (Previously its `hx_get` hit the 204+refresh path; now it swaps the
row.)
Edit, Delete, and the clone/"play" affordances are unchanged.
## Components / files touched
- `games/views/session.py` — add `SessionRowData`, `session_row_data() -> SessionRowData`,
`session_row() -> Node`; delete the old `_session_row_fragment() -> SafeText`; update
`list_sessions` to use the builder; rewire `end_session`, `reset_session_start`,
`new_session_from_existing_session`. Drop the now-unused `SafeText`/`Tr` imports if no
other references remain.
- `common/layout.py` — add `NavbarPlaytime`; use it inside `Navbar()`.
- (If `NavbarPlaytime` is placed in `common/components`, re-export via `__init__.py`.)
## Data flow (finish from the list)
```
click Finish → hx-get end_session (htmx)
→ session.timestamp_end = now; save()
→ model_counts(request) (fresh totals)
→ response body: <tr id=session-row-pk …>(6 cells)</tr>
+ <li id=navbar-playtime hx-swap-oob=true>…</li>
htmx: OOB <li> → #navbar-playtime ; <tr> → #session-row-pk (outerHTML)
→ row shows end time + duration; navbar totals update; no full reload
→ swapped row keeps device-change self-refresh + device selector custom element
```
## Error handling
- Missing session → `get_object_or_404` (unchanged).
- Non-htmx requests → full-page redirect (unchanged), so the feature degrades to the
current behavior without JS.
- `SessionDeviceSelector` custom element re-initializes on swap via its native
`connectedCallback`; its JS module is already loaded by the list page, so no extra
`scripts=` wiring is needed.
## Testing
Unit (`tests/`):
- `session_row_data` returns 6 `cell_data` entries and `row_id == "session-row-{pk}"`,
with the device/created/actions cells present.
- `session_row(...)` is a `Node`; `str(session_row(...))` contains `id="session-row-{pk}"`
and 6 `<td>/<th>` cells (regression against the 4-column drift).
- `NavbarPlaytime(oob=True)` emits `id="navbar-playtime"` and `hx-swap-oob="true"`;
`oob=False` omits the OOB attribute.
View (`tests/`, htmx requests via `HTTP_HX_REQUEST=true`):
- `end_session` (htmx) response body contains `#session-row-{pk}` and an OOB
`#navbar-playtime`; sets `timestamp_end`.
- `reset_session_start` (htmx) likewise; sets `timestamp_start` to ~now; **no**
`HX-Refresh` header.
- `new_session_from_existing_session` (htmx) returns status 204 with `HX-Refresh: true`
and creates a session.
- Non-htmx variants of all three still redirect to the session list.
E2E (`e2e/`):
- From the session list, finish a running session → its row updates in place (end time +
duration) and the navbar "Today · Last 7 days" totals change, with no full page reload.
## Out of scope (→ #55)
`games/views/game.py` `_sessions_section` (4-column game-detail table, different first
column, no Device/Created) keeps its full-navigation `href` buttons. Migrating it onto
`session_row_data` with configurable visible columns is tracked in #55.
@@ -0,0 +1,150 @@
# Convert Remaining onSwap Widgets to Custom Elements
**Date:** 2026-06-20
**Issue:** #18
**Relates to:** #17 (TS migration), spec `2026-06-13-html-js-authoring-design.md`
## Context
PR #16 established the custom-element pattern (TypeScript custom elements, `connectedCallback` lifecycle, codegen'd typed prop contracts) and converted three components. Four interactive widgets still use the old pattern: a hand-written `.ts` file registered with `onSwap(selector, fn)` + `data-*` attributes.
**Goal:** Migrate all four remaining widgets to the custom-element pattern so the whole interactive surface uses one model.
## Widgets and Dependency Order
Convert in this order (least-to-most dependent):
1. `range-slider` — no cross-widget deps
2. `date-range-picker` — no cross-widget deps
3. `search-select` — no deps; exports `readSearchSelect()` consumed by filter-bar
4. `filter-bar` — imports `readSearchSelect`; removes all `window.*` globals
`onSwap` is NOT retired by this issue — `year_picker.ts` and `add_purchase.ts` still use it (see #17).
## Per-Widget Conversion Pattern
Each widget follows the same steps:
### Python side
1. Add `XxxProps(TypedDict)` to `common/components/custom_elements.py`
2. Call `register_element("xxx", "Xxx", XxxProps)` immediately after
3. Create `_Xxx = custom_element_builder("xxx")`
4. Update the Python component (in `filters.py`, `search_select.py`, or `date_range_picker.py`) to use the builder; remove old `_XXX_MEDIA` and `.with_media(...)` calls
### TypeScript side
5. Create `ts/elements/xxx.ts` (move logic from `ts/xxx.ts`)
6. Replace IIFE + `onSwap(selector, fn)` with `class XxxElement extends HTMLElement { connectedCallback() { ... } }`
7. Read typed props via generated `readXxxProps(this)` instead of `el.getAttribute("data-xxx")`
8. Add `disconnectedCallback()` to remove any document-level event listeners
9. End with `customElements.define("xxx", XxxElement)`
### Build
10. `uv run manage.py gen_element_types` — regenerates `ts/generated/props.ts`
11. `make ts` — compiles all TypeScript
12. `make check` — linting + type-check + tests
### E2E
13. Update Playwright locators to match new element tags and attribute names
## Widget Specifics
### `range-slider`
**Props:**
```python
class RangeSliderProps(TypedDict):
min: int
max: int
step: int
mode: str # "range" | "point"
```
**Structural change:** `<range-slider>` replaces the outer `.range-slider-block` wrapper div AND the inner `.range-slider` div. The mode toggle button and the track/handles all become light-DOM children of `<range-slider>`. This eliminates `slider.closest(".range-slider-block")` — the TS can use `this.querySelector(".range-mode-toggle")` directly.
The `data-mode` attribute becomes the typed `mode` prop (attribute `mode` on the element). The JS updates this attribute on toggle: `this.setAttribute("mode", newMode)`.
E2E: `.range-slider-block``range-slider`; `slider[data-mode]``range-slider[mode]`.
### `date-range-picker`
**Props:**
```python
class DateRangePickerProps(TypedDict):
input_name_prefix: str
```
**Structural change:** `<date-range-picker>` replaces the outer `<div data-date-range-picker data-input-name-prefix="...">`. `DateRangeField` and `DateRangeCalendar` remain unchanged as light-DOM children.
The `data-input-name-prefix` attribute on `DateRangeCalendar` can be removed since the prefix is now a typed prop on the element itself, readable as `readDateRangePickerProps(this).inputNamePrefix`.
### `search-select`
**Props:**
```python
class SearchSelectProps(TypedDict):
name: str
search_url: str # empty string when no URL
multi: bool
filter_mode: bool # true for FilterSelect; replaces data-search-select-mode="filter"
free_text: bool
always_visible: bool
prefetch: int
sync_url: bool
```
**Structural change:** `<search-select>` replaces the outer `<div data-search-select ...>`. All internal child elements (`[data-search-select-search]`, `[data-search-select-options]`, etc.) remain unchanged.
**`readSearchSelect` export:** Remove `window.readSearchSelect = ...`. Export as a named module function:
```typescript
export function readSearchSelect(scope: HTMLElement): void { ... }
```
`filter_bar.ts` will import it. Update the function to query `search-select[filter-mode="true"]` instead of `[data-search-select][data-search-select-mode="filter"]`.
E2E: `[data-search-select][data-name="status"]``search-select[name="status"]`.
### `filter-bar`
**Props:**
```python
class FilterBarProps(TypedDict):
preset_list_url: str
preset_save_url: str
```
**Structural change:** `<filter-bar>` wraps the entire filter bar structure (collapse toggle + form + action row). The Python `_FilterBarBase.render()` wraps its output in the builder.
**Window globals removed:** `applyFilterBar`, `clearFilterBar`, `toggleStringFilterInput`, `showPresetNameInput`, `savePreset` are no longer assigned to `window`. `connectedCallback` wires all handlers:
- `this.querySelector("form")``submit` listener (replaces `onsubmit`)
- `this.querySelector("[data-filter-bar-clear]")``click` listener
- `this.querySelector("[data-filter-bar-save]")``click` listener
- `this.querySelector("[data-filter-bar-confirm-save]")``click` listener
- `this.querySelectorAll("[data-string-modifier-radio]")``change` listeners
**Python changes in `filters.py`:**
- Remove `onsubmit="return applyFilterBar(event)"` from form
- Replace `onclick="clearFilterBar(...)"``data-filter-bar-clear`
- Replace `onclick="showPresetNameInput()"``data-filter-bar-save`
- Replace `onclick="savePreset(...)"``data-filter-bar-confirm-save`
- Replace `onclick="toggleStringFilterInput(this)"``data-string-modifier-radio` (already present)
- Move `preset_list_url` from `data-preset-list-url` on `#preset-dropdown` to a typed prop on `<filter-bar>`
- Preset dropdown: `this.querySelector("[data-preset-dropdown]")` (add this attr)
**Import:** `filter-bar.ts` imports `{ readSearchSelect }` from `./search-select.js`.
**`globals.d.ts`:** Remove all entries except `fetchWithHtmxTriggers` and `toast` (which remain as globals).
## Verification
```bash
uv run manage.py gen_element_types # codegen passes
make ts # tsc --noEmit passes
make test # unit tests pass
make test-e2e # e2e tests pass (after locator updates)
make check # full CI gate
```
Manual visual check each widget after conversion (per issue requirement).
+1
View File
@@ -7,6 +7,7 @@ import pytest
# synchronous operations inside the async context safely. # synchronous operations inside the async context safely.
os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true") os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true")
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def browser_type_launch_args(browser_type_launch_args): def browser_type_launch_args(browser_type_launch_args):
# Try to find a system-installed Google Chrome or Chromium to bypass Nix/NixOS shared library issues # Try to find a system-installed Google Chrome or Chromium to bypass Nix/NixOS shared library issues
+112
View File
@@ -0,0 +1,112 @@
"""End-to-end Playwright test for boolean radio filter serialization and deselect behavior.
Covers:
1. Selecting True/False serializes the boolean field as True/False.
2. Unsetting/unchecking a radio button by clicking on it again, which deselects it, omitting the field from JSON.
"""
import json
import urllib.parse
import pytest
from django.http import HttpResponse
from django.test import override_settings
from django.urls import path
from common.components import FilterBar
def _bar_page(filter_json: str = "") -> str:
return f"""<!DOCTYPE html>
<html>
<head>
<title>Boolean filter E2E</title>
<script src="/static/js/htmx.min.js"></script>
<script src="/static/js/dist/elements/range-slider.js" type="module"></script>
<script src="/static/js/dist/elements/search-select.js" type="module"></script>
<script src="/static/js/dist/elements/filter-bar.js" type="module"></script>
</head>
<body>
{FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
</body>
</html>"""
def empty_bar_view(request):
return HttpResponse(_bar_page())
urlpatterns = [
path("test-boolean-filter/", empty_bar_view),
]
def _filter_from_url(url: str) -> dict:
"""Extract and parse the ?filter=... query param from a URL."""
query = urllib.parse.urlparse(url).query
params = urllib.parse.parse_qs(query)
raw = params.get("filter", [""])[0]
return json.loads(raw) if raw else {}
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_boolean_filter_e2e")
def test_no_selection_omits_boolean_filters(live_server, page):
page.goto(live_server.url + "/test-boolean-filter/")
with page.expect_navigation():
page.evaluate(
"document.getElementById('filter-bar-form')"
".dispatchEvent(new Event('submit', {cancelable: true}))"
)
parsed = _filter_from_url(page.url)
assert "mastered" not in parsed
assert "purchase_refunded" not in parsed
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_boolean_filter_e2e")
def test_select_true_and_false_serializes_correctly(live_server, page):
page.goto(live_server.url + "/test-boolean-filter/")
# Select "True" for Mastered
# Under PurchaseFilterBar: "filter-mastered" is the mastered radio name.
# The true radio has value="true", false radio has value="false"
true_radio = page.locator('input[name="filter-mastered"][value="true"]')
true_radio.click()
# Select "False" for Refunded (filter-purchase-refunded)
false_radio = page.locator('input[name="filter-purchase-refunded"][value="false"]')
false_radio.click()
with page.expect_navigation():
page.evaluate(
"document.getElementById('filter-bar-form')"
".dispatchEvent(new Event('submit', {cancelable: true}))"
)
parsed = _filter_from_url(page.url)
assert parsed.get("mastered") == {"value": True, "modifier": "EQUALS"}
assert parsed.get("purchase_refunded") == {"value": False, "modifier": "EQUALS"}
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_boolean_filter_e2e")
def test_click_to_deselect_radio_works(live_server, page):
page.goto(live_server.url + "/test-boolean-filter/")
true_radio = page.locator('input[name="filter-mastered"][value="true"]')
# First click checks it
true_radio.click()
assert true_radio.is_checked()
# Second click deselects it
true_radio.click()
assert not true_radio.is_checked()
with page.expect_navigation():
page.evaluate(
"document.getElementById('filter-bar-form')"
".dispatchEvent(new Event('submit', {cancelable: true}))"
)
parsed = _filter_from_url(page.url)
assert "mastered" not in parsed
+84
View File
@@ -0,0 +1,84 @@
import pytest
from django.urls import reverse
from playwright.sync_api import Page, expect
@pytest.fixture
def authenticated_page(live_server, page: Page, django_user_model) -> Page:
django_user_model.objects.create_user(username="tester", password="secret123")
page.goto(f"{live_server.url}{reverse('login')}")
page.fill('input[name="username"]', "tester")
page.fill('input[name="password"]', "secret123")
page.click('button:has-text("Login")')
page.wait_for_url(f"{live_server.url}/tracker**")
return page
@pytest.mark.django_db
def test_game_status_selector_opens_and_patches(authenticated_page: Page, live_server):
from games.models import Game, Platform
platform = Platform.objects.create(name="PC", icon="pc")
game = Game.objects.create(name="Test Game", platform=platform, status="u")
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:list_games')}")
host = page.locator("game-status-selector").first
expect(host).to_be_attached()
host.locator("[data-toggle]").click()
expect(host.locator("[data-menu]")).to_be_visible()
with page.expect_response(
lambda r: "/status" in r.url and r.request.method == "PATCH"
):
host.locator('[data-option][data-value="f"]').click()
expect(host.locator("[data-menu]")).to_be_hidden()
game.refresh_from_db()
assert game.status == "f"
@pytest.mark.django_db
def test_session_device_selector_patches(authenticated_page: Page, live_server):
from games.models import Device, Game, Platform, Session
platform = Platform.objects.create(name="PC", icon="pc")
game = Game.objects.create(name="Test Game", platform=platform)
desktop = Device.objects.create(name="Desktop")
deck = Device.objects.create(name="Deck")
session = Session.objects.create(
game=game, device=desktop, timestamp_start="2025-01-01 00:00:00+00:00"
)
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:list_sessions')}")
host = page.locator("session-device-selector").first
expect(host).to_be_attached()
host.locator("[data-toggle]").click()
with page.expect_response(
lambda r: "/device" in r.url and r.request.method == "PATCH"
):
host.locator(f'[data-option][data-value="{deck.id}"]').click()
session.refresh_from_db()
assert session.device_id == deck.id
@pytest.mark.django_db
def test_play_event_row_increments(authenticated_page: Page, live_server):
from games.models import Game, Platform
platform = Platform.objects.create(name="PC", icon="pc")
game = Game.objects.create(name="Test Game", platform=platform)
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:view_game', args=[game.id])}")
host = page.locator("play-event-row").first
expect(host).to_be_attached()
host.locator("[data-toggle]").click()
with page.expect_response(
lambda r: "playevent" in r.url.lower() and r.request.method == "POST"
):
host.locator("[data-add-play]").click()
expect(host.locator("[data-count]")).to_have_text("1")
assert game.playevents.count() == 1
+167
View File
@@ -0,0 +1,167 @@
"""End-to-end Playwright test for the date-range filter widget's JS submit path.
Covers the one layer the Django-Client tests in ``test_rendered_pages.py``
cannot reach: ``filter_bar.js`` reading the two ``<input type="date">``
elements, building a ``DateCriterion`` JSON object, and navigating the
browser to ``?filter=<encoded>``.
The native ``<input type="date">`` path is exercised through the Refunded
field — the Purchased field now uses the DateRangePicker component, covered
by ``test_date_range_picker_e2e.py``.
Renders the bar at its own custom URL so the test doesn't need to auth
against the real app — the bar's JS doesn't care what route serves it.
"""
import json
import urllib.parse
import pytest
from django.http import HttpResponse
from django.test import override_settings
from django.urls import path
from common.components import PurchaseFilterBar
def _bar_page(filter_json: str = "") -> str:
return f"""<!DOCTYPE html>
<html>
<head>
<title>Date filter E2E</title>
<script src="/static/js/htmx.min.js"></script>
<script src="/static/js/dist/elements/range-slider.js" type="module"></script>
<script src="/static/js/dist/elements/search-select.js" type="module"></script>
<script src="/static/js/dist/elements/filter-bar.js" type="module"></script>
</head>
<body>
{PurchaseFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
</body>
</html>"""
def empty_bar_view(request):
return HttpResponse(_bar_page())
def prefilled_bar_view(request):
filter_json = json.dumps(
{
"date_refunded": {
"value": "2024-03-15",
"value2": "2024-09-20",
"modifier": "BETWEEN",
}
}
)
return HttpResponse(_bar_page(filter_json))
urlpatterns = [
path("test-date-filter/", empty_bar_view),
path("test-date-filter-prefilled/", prefilled_bar_view),
]
def _filter_from_url(url: str) -> dict:
"""Extract and parse the ?filter=... query param from a URL."""
query = urllib.parse.urlparse(url).query
params = urllib.parse.parse_qs(query)
raw = params.get("filter", [""])[0]
return json.loads(raw) if raw else {}
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e")
def test_both_dates_serializes_as_between(live_server, page):
page.goto(live_server.url + "/test-date-filter/")
page.locator('input[name="filter-date-refunded-min"]').fill("2024-01-01")
page.locator('input[name="filter-date-refunded-max"]').fill("2024-12-31")
with page.expect_navigation():
page.evaluate(
"document.getElementById('filter-bar-form')"
".dispatchEvent(new Event('submit', {cancelable: true}))"
)
parsed = _filter_from_url(page.url)
assert parsed == {
"date_refunded": {
"value": "2024-01-01",
"value2": "2024-12-31",
"modifier": "BETWEEN",
}
}
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e")
def test_min_only_serializes_as_greater_than(live_server, page):
page.goto(live_server.url + "/test-date-filter/")
page.locator('input[name="filter-date-refunded-min"]').fill("2024-06-15")
with page.expect_navigation():
page.evaluate(
"document.getElementById('filter-bar-form')"
".dispatchEvent(new Event('submit', {cancelable: true}))"
)
parsed = _filter_from_url(page.url)
assert parsed == {
"date_refunded": {"value": "2024-06-15", "modifier": "GREATER_THAN"}
}
# value2 must not be present when there's no upper bound.
assert "value2" not in parsed["date_refunded"]
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e")
def test_max_only_serializes_as_less_than(live_server, page):
page.goto(live_server.url + "/test-date-filter/")
page.locator('input[name="filter-date-refunded-max"]').fill("2025-06-30")
with page.expect_navigation():
page.evaluate(
"document.getElementById('filter-bar-form')"
".dispatchEvent(new Event('submit', {cancelable: true}))"
)
parsed = _filter_from_url(page.url)
assert parsed == {"date_refunded": {"value": "2025-06-30", "modifier": "LESS_THAN"}}
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e")
def test_empty_inputs_omit_date_criterion(live_server, page):
"""No date typed → the filter JSON simply has no date_purchased /
date_refunded keys (vs. an empty-string crash)."""
page.goto(live_server.url + "/test-date-filter/")
with page.expect_navigation():
page.evaluate(
"document.getElementById('filter-bar-form')"
".dispatchEvent(new Event('submit', {cancelable: true}))"
)
parsed = _filter_from_url(page.url)
assert "date_purchased" not in parsed
assert "date_refunded" not in parsed
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e")
def test_prefilled_bar_reflects_existing_filter_in_inputs(live_server, page):
"""A bar rendered with a BETWEEN filter_json pre-fills the inputs and
re-submits the same bounds unchanged."""
page.goto(live_server.url + "/test-date-filter-prefilled/")
assert (
page.locator('input[name="filter-date-refunded-min"]').input_value()
== "2024-03-15"
)
assert (
page.locator('input[name="filter-date-refunded-max"]').input_value()
== "2024-09-20"
)
with page.expect_navigation():
page.evaluate(
"document.getElementById('filter-bar-form')"
".dispatchEvent(new Event('submit', {cancelable: true}))"
)
parsed = _filter_from_url(page.url)
assert parsed["date_refunded"] == {
"value": "2024-03-15",
"value2": "2024-09-20",
"modifier": "BETWEEN",
}
+326
View File
@@ -0,0 +1,326 @@
"""End-to-end Playwright tests for the DateRangePicker component.
Exercises the behaviour layers the rendering tests cannot reach
(``date_range_picker.js``): segmented digit entry with right-to-left
placeholder fill and auto-advance, Backspace reverting a part, the calendar
popup's anchor-style range picking, presets, the Cancel / Clear / Select
footer, and the ``filter_bar.js`` serialization of the hidden ISO inputs
into a ``DateCriterion``.
Like the other filter-bar e2e modules, the bar is served from its own
minimal URLconf (no auth, no CSS) — the JS only cares about the DOM.
"""
import datetime
import json
import urllib.parse
import pytest
from django.http import HttpResponse
from django.test import override_settings
from common.components import PurchaseFilterBar
from django.urls import path
def _bar_page(filter_json: str = "") -> str:
return f"""<!DOCTYPE html>
<html>
<head>
<title>Date range picker E2E</title>
<script src="/static/js/htmx.min.js"></script>
<script src="/static/js/dist/elements/range-slider.js" type="module"></script>
<script src="/static/js/dist/elements/search-select.js" type="module"></script>
<script src="/static/js/dist/elements/date-range-picker.js" type="module"></script>
<script src="/static/js/dist/elements/filter-bar.js" type="module"></script>
</head>
<body>
{PurchaseFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
</body>
</html>"""
def empty_bar_view(request):
return HttpResponse(_bar_page())
def prefilled_bar_view(request):
filter_json = json.dumps(
{
"date_purchased": {
"value": "2024-03-15",
"value2": "2024-09-20",
"modifier": "BETWEEN",
}
}
)
return HttpResponse(_bar_page(filter_json))
urlpatterns = [
path("test-date-range-picker/", empty_bar_view),
path("test-date-range-picker-prefilled/", prefilled_bar_view),
]
PICKER = "date-range-picker"
POPUP = PICKER + " [data-date-range-calendar]"
HIDDEN_MIN = 'input[name="filter-date-purchased-min"]'
HIDDEN_MAX = 'input[name="filter-date-purchased-max"]'
def _segment(page, side: str, part: str):
return page.locator(
f'{PICKER} input[data-date-side="{side}"][data-date-part="{part}"]'
)
def _day_cell(page, iso_date: str):
return page.locator(
f'{PICKER} [data-date-range-grid] button[data-date="{iso_date}"]'
)
def _popup_is_open(page) -> bool:
return "hidden" not in (page.locator(POPUP).get_attribute("class") or "")
def _submit_filter_bar(page):
with page.expect_navigation():
page.evaluate(
"document.getElementById('filter-bar-form')"
".dispatchEvent(new Event('submit', {cancelable: true}))"
)
def _filter_from_url(url: str) -> dict:
query = urllib.parse.urlparse(url).query
params = urllib.parse.parse_qs(query)
raw = params.get("filter", [""])[0]
return json.loads(raw) if raw else {}
# ── Segmented manual entry ──────────────────────────────────────────────────
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_typing_fills_parts_and_serializes_between(live_server, page):
"""Digits flow through the parts (DD → MM → YYYY → DD …) with
auto-advance, ending in a BETWEEN criterion on submit."""
page.goto(live_server.url + "/test-date-range-picker/")
_segment(page, "min", "day").click()
page.keyboard.type("1503202420092024")
assert page.locator(HIDDEN_MIN).input_value() == "2024-03-15"
assert page.locator(HIDDEN_MAX).input_value() == "2024-09-20"
_submit_filter_bar(page)
parsed = _filter_from_url(page.url)
assert parsed == {
"date_purchased": {
"value": "2024-03-15",
"value2": "2024-09-20",
"modifier": "BETWEEN",
}
}
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_placeholder_fills_from_the_right(live_server, page):
"""Typing 19 into the YYYY part shows YYY1 then YY19."""
page.goto(live_server.url + "/test-date-range-picker/")
year_segment = _segment(page, "min", "year")
year_segment.click()
page.keyboard.press("1")
assert year_segment.input_value() == "YYY1"
page.keyboard.press("9")
assert year_segment.input_value() == "YY19"
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_min_side_only_serializes_greater_than(live_server, page):
page.goto(live_server.url + "/test-date-range-picker/")
_segment(page, "min", "day").click()
page.keyboard.type("15062024")
_submit_filter_bar(page)
parsed = _filter_from_url(page.url)
assert parsed == {
"date_purchased": {"value": "2024-06-15", "modifier": "GREATER_THAN"}
}
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_backspace_reverts_part_to_placeholder(live_server, page):
page.goto(live_server.url + "/test-date-range-picker/")
_segment(page, "min", "day").click()
page.keyboard.type("15032024")
assert page.locator(HIDDEN_MIN).input_value() == "2024-03-15"
month_segment = _segment(page, "min", "month")
month_segment.click()
page.keyboard.press("Backspace")
assert month_segment.input_value() == ""
# An incomplete date no longer commits to the hidden input.
assert page.locator(HIDDEN_MIN).input_value() == ""
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_only_numbers_can_be_typed(live_server, page):
page.goto(live_server.url + "/test-date-range-picker/")
day_segment = _segment(page, "min", "day")
day_segment.click()
page.keyboard.type("ab-/")
assert day_segment.input_value() == ""
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_invalid_calendar_date_does_not_commit(live_server, page):
"""31-02-2024 fills all parts but is not a real date — no hidden value."""
page.goto(live_server.url + "/test-date-range-picker/")
_segment(page, "min", "day").click()
page.keyboard.type("31022024")
assert page.locator(HIDDEN_MIN).input_value() == ""
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_clicking_container_activates_first_part(live_server, page):
page.goto(live_server.url + "/test-date-range-picker/")
page.locator(PICKER + " [data-date-range-field]").click(position={"x": 5, "y": 5})
focused = page.evaluate(
"document.activeElement.getAttribute('data-date-part') + ':' +"
"document.activeElement.getAttribute('data-date-side')"
)
assert focused == "day:min"
# ── Calendar popup ──────────────────────────────────────────────────────────
def _open_calendar(page):
page.locator(PICKER + " [data-date-range-calendar-toggle]").click()
def _current_month_iso(day_of_month: int) -> str:
today = datetime.date.today()
return today.replace(day=day_of_month).isoformat()
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_calendar_pick_range_then_select(live_server, page):
page.goto(live_server.url + "/test-date-range-picker/")
_open_calendar(page)
assert _popup_is_open(page)
first_pick = _current_month_iso(10)
second_pick = _current_month_iso(20)
_day_cell(page, first_pick).click()
assert page.locator(HIDDEN_MIN).input_value() == first_pick
assert page.locator(HIDDEN_MAX).input_value() == ""
_day_cell(page, second_pick).click()
assert page.locator(HIDDEN_MAX).input_value() == second_pick
page.locator(PICKER + " [data-date-range-select]").click()
assert not _popup_is_open(page)
_submit_filter_bar(page)
parsed = _filter_from_url(page.url)
assert parsed == {
"date_purchased": {
"value": first_pick,
"value2": second_pick,
"modifier": "BETWEEN",
}
}
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_picking_before_start_restarts_the_range(live_server, page):
"""With the StartDate anchored, picking an earlier date clears the range
and the clicked date becomes the new StartDate."""
page.goto(live_server.url + "/test-date-range-picker/")
_open_calendar(page)
_day_cell(page, _current_month_iso(20)).click()
_day_cell(page, _current_month_iso(10)).click()
assert page.locator(HIDDEN_MIN).input_value() == _current_month_iso(10)
assert page.locator(HIDDEN_MAX).input_value() == ""
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_completed_range_anchor_moves_to_end(live_server, page):
"""After both dates are picked the EndDate becomes the anchor, so a
further pick inside the range moves the StartDate."""
page.goto(live_server.url + "/test-date-range-picker/")
_open_calendar(page)
_day_cell(page, _current_month_iso(10)).click()
_day_cell(page, _current_month_iso(20)).click()
_day_cell(page, _current_month_iso(15)).click()
assert page.locator(HIDDEN_MIN).input_value() == _current_month_iso(15)
assert page.locator(HIDDEN_MAX).input_value() == _current_month_iso(20)
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_preset_fills_both_dates(live_server, page):
page.goto(live_server.url + "/test-date-range-picker/")
_open_calendar(page)
page.locator(PICKER + ' [data-date-range-preset="last_7_days"]').click()
today = datetime.date.today()
assert (
page.locator(HIDDEN_MIN).input_value()
== (today - datetime.timedelta(days=6)).isoformat()
)
assert page.locator(HIDDEN_MAX).input_value() == today.isoformat()
# Presets keep the popup open; Select commits and closes.
assert _popup_is_open(page)
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_clear_clears_dates_but_keeps_popup_open(live_server, page):
page.goto(live_server.url + "/test-date-range-picker/")
_open_calendar(page)
_day_cell(page, _current_month_iso(10)).click()
_day_cell(page, _current_month_iso(20)).click()
page.locator(PICKER + " [data-date-range-clear]").click()
assert page.locator(HIDDEN_MIN).input_value() == ""
assert page.locator(HIDDEN_MAX).input_value() == ""
assert _popup_is_open(page)
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_cancel_clears_dates_and_closes_popup(live_server, page):
page.goto(live_server.url + "/test-date-range-picker/")
_open_calendar(page)
_day_cell(page, _current_month_iso(10)).click()
_day_cell(page, _current_month_iso(20)).click()
page.locator(PICKER + " [data-date-range-cancel]").click()
assert page.locator(HIDDEN_MIN).input_value() == ""
assert page.locator(HIDDEN_MAX).input_value() == ""
assert not _popup_is_open(page)
# ── Prefill round-trip ──────────────────────────────────────────────────────
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_prefilled_picker_round_trips_unchanged(live_server, page):
page.goto(live_server.url + "/test-date-range-picker-prefilled/")
assert _segment(page, "min", "day").input_value() == "15"
assert _segment(page, "min", "month").input_value() == "03"
assert _segment(page, "min", "year").input_value() == "2024"
assert _segment(page, "max", "day").input_value() == "20"
assert page.locator(HIDDEN_MIN).input_value() == "2024-03-15"
assert page.locator(HIDDEN_MAX).input_value() == "2024-09-20"
_submit_filter_bar(page)
parsed = _filter_from_url(page.url)
assert parsed["date_purchased"] == {
"value": "2024-03-15",
"value2": "2024-09-20",
"modifier": "BETWEEN",
}
+123
View File
@@ -0,0 +1,123 @@
"""Browser test: the device dropdown is not clipped by the table wrapper (#39).
The session list lives inside an ``overflow-x-auto`` wrapper, which forces
``overflow-y: auto`` and used to clip an absolutely-positioned dropdown menu
that extended past a short table. The menu now opens with ``position: fixed``
so it escapes the clipping ancestor and stays within the viewport.
"""
import pytest
from django.urls import reverse
from django.utils import timezone
from playwright.sync_api import Page
from games.models import Device, Game, Platform, Session
@pytest.fixture
def authenticated_page(live_server, page: Page, django_user_model) -> Page:
django_user_model.objects.create_user(username="tester", password="secret123")
page.goto(f"{live_server.url}{reverse('login')}")
page.fill('input[name="username"]', "tester")
page.fill('input[name="password"]', "secret123")
page.click('button:has-text("Login")')
page.wait_for_url(f"{live_server.url}/tracker**")
return page
def test_device_dropdown_not_clipped_on_short_table(
authenticated_page: Page, live_server
):
page = authenticated_page
page.set_viewport_size({"width": 1280, "height": 800})
platform = Platform.objects.create(name="PC", icon="pc", group="PC")
game = Game.objects.create(name="Tunic")
game.platform = platform
game.save()
# Many devices → a tall menu; a single row → a short table that would clip
# an absolutely-positioned menu.
devices = [Device.objects.create(name=f"Device {i:02d}") for i in range(15)]
session = Session.objects.create(
game=game, device=devices[0], timestamp_start=timezone.now()
)
page.goto(f"{live_server.url}{reverse('games:list_sessions')}")
page.locator(f"#session-row-{session.pk} [data-toggle]").click()
menu = page.locator("[data-menu]:not([hidden])")
menu.wait_for(state="visible")
geometry = page.evaluate(
"""() => {
const menu = document.querySelector('[data-menu]:not([hidden])');
const rect = menu.getBoundingClientRect();
return {
position: getComputedStyle(menu).position,
bottom: rect.bottom,
viewportHeight: window.innerHeight,
};
}"""
)
# Fixed positioning escapes the overflow-x-auto clip...
assert geometry["position"] == "fixed"
# ...and the menu stays inside the viewport (not clipped/cut off).
assert geometry["bottom"] <= geometry["viewportHeight"] + 1, geometry
# A device far down the (previously clipped) list is selectable.
page.locator("[data-option]", has_text="Device 14").click()
page.wait_for_timeout(200)
session.refresh_from_db()
assert session.device == devices[14]
def test_device_dropdown_flips_up_near_viewport_bottom(
authenticated_page: Page, live_server
):
"""A dropdown whose toggle sits near the viewport bottom must open upward
and stay fully visible — not collapse off-screen.
Regression: the menu keeps a ``top-[105%]`` utility class; clearing inline
``top`` to "" in the flip-up branch let that class reassert ``top: 105%``
on the now-``fixed`` menu, collapsing it to a 2px sliver below the viewport.
"""
page = authenticated_page
page.set_viewport_size({"width": 1280, "height": 760})
platform = Platform.objects.create(name="PC", icon="pc", group="PC")
game = Game.objects.create(name="Tunic")
game.platform = platform
game.save()
devices = [Device.objects.create(name=f"Device {i:02d}") for i in range(15)]
sessions = [
Session.objects.create(
game=game, device=devices[0], timestamp_start=timezone.now()
)
for _ in range(10)
]
page.goto(f"{live_server.url}{reverse('games:list_sessions')}")
# Scroll the table so the lower rows sit near the viewport bottom, where the
# menu cannot fit below and must flip up.
page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
page.wait_for_timeout(200)
bottom_row = sessions[-3]
page.locator(f"#session-row-{bottom_row.pk} [data-toggle]").click()
menu = page.locator("[data-menu]:not([hidden])")
menu.wait_for(state="visible")
geometry = page.evaluate(
"""() => {
const menu = document.querySelector('[data-menu]:not([hidden])');
const rect = menu.getBoundingClientRect();
return {
top: rect.top,
bottom: rect.bottom,
height: rect.height,
viewportHeight: window.innerHeight,
};
}"""
)
# The flipped-up menu is a real, fully on-screen box (not a 2px sliver).
assert geometry["height"] > 50, geometry
assert geometry["top"] >= -1, geometry
assert geometry["bottom"] <= geometry["viewportHeight"] + 1, geometry
+178
View File
@@ -0,0 +1,178 @@
"""Browser tests for the purchase pricing UX and the split action.
- A synthetic page isolates the general ``selection-fields`` element (no API,
deterministic option values), mirroring ``test_search_select_e2e.py``.
- The real-app tests drive the actual add-purchase form and the split modal
against pytest-django's ``live_server``.
"""
from datetime import date
import pytest
from django.http import HttpResponse
from django.test import override_settings
from django.urls import path, reverse
from playwright.sync_api import Page, expect
from common.components import SearchSelect, SelectionFields
from games.models import Game, Platform, Purchase
def selection_fields_view(request):
html = f"""
<!DOCTYPE html>
<html>
<head>
<script src="/static/js/htmx.min.js"></script>
<script type="module" src="/static/js/dist/elements/search-select.js"></script>
<script type="module" src="/static/js/dist/elements/selection-fields.js"></script>
</head>
<body>
<div style="padding: 50px;">
{
SearchSelect(
name="games",
selected=[],
options=[
{"value": "7", "label": "Game A", "data": {}},
{"value": "8", "label": "Game B", "data": {}},
],
multi_select=True,
)
}
{
SelectionFields(
source="games",
name_prefix="price_for_game_",
field_type="number",
min_items=2,
active=True,
)
}
</div>
</body>
</html>
"""
return HttpResponse(html)
urlpatterns = [
path("sf-test/", selection_fields_view),
]
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_purchase_e2e")
def test_selection_fields_syncs_with_source(live_server, page: Page):
page.goto(live_server.url + "/sf-test/")
games = page.locator('search-select[name="games"]')
rows = page.locator("selection-fields [data-selection-fields-rows] input")
# Below min_items (2): nothing rendered.
expect(rows).to_have_count(0)
games.locator("[data-search-select-search]").click()
games.locator('[data-search-select-option][data-value="7"]').click()
expect(rows).to_have_count(0) # only one selected, still below min_items
games.locator("[data-search-select-search]").click()
games.locator('[data-search-select-option][data-value="8"]').click()
expect(rows).to_have_count(2)
# One input per item, named by the prefix + item id.
expect(
page.locator('selection-fields input[name="price_for_game_7"]')
).to_have_count(1)
expect(
page.locator('selection-fields input[name="price_for_game_8"]')
).to_have_count(1)
# Typed values survive removing and re-adding another item.
page.locator('selection-fields input[name="price_for_game_7"]').fill("12")
games.locator('[data-pill][data-value="8"] [data-pill-remove]').click()
expect(rows).to_have_count(0)
games.locator("[data-search-select-search]").click()
games.locator('[data-search-select-option][data-value="8"]').click()
expect(rows).to_have_count(2)
expect(
page.locator('selection-fields input[name="price_for_game_7"]')
).to_have_value("12")
@pytest.fixture
def authenticated_page(live_server, page: Page, django_user_model) -> Page:
django_user_model.objects.create_user(username="tester", password="secret123")
page.goto(f"{live_server.url}{reverse('login')}")
page.fill('input[name="username"]', "tester")
page.fill('input[name="password"]', "secret123")
page.click('button:has-text("Login")')
page.wait_for_url(f"{live_server.url}/tracker**")
return page
def _select_two_games(page: Page) -> None:
games = page.locator('search-select[name="games"]')
games.locator("[data-search-select-search]").click()
options = games.locator("[data-search-select-option]")
expect(options).to_have_count(2) # prefetched on focus
options.nth(0).click()
options.nth(1).click()
def test_add_purchase_per_game_toggle_reveals_inputs(
authenticated_page: Page, live_server
):
"""The combined/per-game toggle appears only at 2+ games; turning it on
hides the bundle Price and shows one price input per selected game.
(Server-side creation of N purchases is covered by the unit tests.)"""
page = authenticated_page
platform = Platform.objects.create(name="PC", icon="pc", group="PC")
Game.objects.create(name="Alpha Game", platform=platform)
Game.objects.create(name="Beta Game", platform=platform)
page.goto(f"{live_server.url}{reverse('games:add_purchase')}")
checkbox_row = page.locator("#separate-prices-row")
expect(checkbox_row).to_be_hidden()
_select_two_games(page)
expect(checkbox_row).to_be_visible()
page.locator("#id_separate_prices").check()
expect(page.locator("#id_price")).to_be_hidden()
per_game_inputs = page.locator(
"selection-fields [data-selection-fields-rows] input"
)
expect(per_game_inputs).to_have_count(2)
def test_split_purchase_action(authenticated_page: Page, live_server):
page = authenticated_page
platform = Platform.objects.create(name="PC", icon="pc", group="PC")
game_a = Game.objects.create(name="Alpha Game", platform=platform)
game_b = Game.objects.create(name="Beta Game", platform=platform)
bundle = Purchase.objects.create(
price=30.0,
price_currency="USD",
date_purchased=date(2025, 1, 1),
platform=platform,
ownership_type=Purchase.DIGITAL,
type=Purchase.GAME,
)
bundle.games.set([game_a, game_b])
page.goto(f"{live_server.url}{reverse('games:list_purchases')}")
# Before: one bundle row.
expect(page.locator('[id^="purchase-row-"]')).to_have_count(1)
page.locator('[title="Split into per-game purchases"]').click()
modal = page.locator("#split-confirmation-modal")
expect(modal).to_be_visible()
modal.locator('button[type="submit"]', has_text="Split").click()
page.wait_for_url(f"{live_server.url}{reverse('games:list_purchases')}**")
# After: the bundle row is gone, replaced by two per-game rows. Asserted via
# the UI (not the ORM) to avoid live_server/SQLite write-read contention.
expect(page.locator(f"#purchase-row-{bundle.id}")).to_have_count(0)
expect(page.locator('[id^="purchase-row-"]')).to_have_count(2)
+114
View File
@@ -0,0 +1,114 @@
"""End-to-end Playwright test for the RangeSlider JS synchronization, cross-over, and clamping behavior."""
import pytest
from django.http import HttpResponse
from django.test import override_settings
from django.urls import path
from common.components import FilterBar
def _bar_page(filter_json: str = "") -> str:
return f"""<!DOCTYPE html>
<html>
<head>
<title>Range Slider E2E</title>
<script src="/static/js/htmx.min.js"></script>
<script src="/static/js/dist/elements/range-slider.js" type="module"></script>
<script src="/static/js/dist/elements/search-select.js" type="module"></script>
<script src="/static/js/dist/elements/filter-bar.js" type="module"></script>
</head>
<body>
{FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
</body>
</html>"""
def empty_bar_view(request):
return HttpResponse(_bar_page())
urlpatterns = [
path("test-range-slider/", empty_bar_view),
]
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
def test_range_slider_crossover_min_higher_than_max(live_server, page):
page.goto(live_server.url + "/test-range-slider/")
# 1. Start with known state: Min is empty, Max is empty
min_input = page.locator('input[name="filter-session-count-min"]')
max_input = page.locator('input[name="filter-session-count-max"]')
# 2. Type "20" into max input
max_input.fill("20")
# 3. Type "50" into min input (which is higher than 20)
min_input.fill("50")
# 4. Max input should have automatically synchronized/snapped to 50
assert max_input.input_value() == "50"
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
def test_range_slider_crossover_max_less_than_min(live_server, page):
page.goto(live_server.url + "/test-range-slider/")
min_input = page.locator('input[name="filter-session-count-min"]')
max_input = page.locator('input[name="filter-session-count-max"]')
# 1. Type "50" into min input
min_input.fill("50")
# 2. Type "30" into max input (which is less than 50)
max_input.fill("30")
# 3. Min input should have automatically synchronized/snapped to 30
assert min_input.input_value() == "30"
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
def test_range_slider_strict_bounds_clamping_on_blur(live_server, page):
page.goto(live_server.url + "/test-range-slider/")
min_input = page.locator('input[name="filter-session-count-min"]')
max_input = page.locator('input[name="filter-session-count-max"]')
# 1. Type value higher than dataMax (100 is max, type "150")
max_input.fill("150")
max_input.blur() # triggers "change" event
assert max_input.input_value() == "100"
# 2. Type value lower than dataMin (0 is min, type "-20")
min_input.fill("-20")
min_input.blur() # triggers "change" event
assert min_input.input_value() == "0"
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
def test_range_slider_empty_max_thumb_does_not_jump_to_beginning(live_server, page):
page.goto(live_server.url + "/test-range-slider/")
# Locate handles
max_handle = page.locator(
'.range-handle-max[data-target="filter-session-count-max"]'
)
# Initially, max_input is empty, so handle should sit at 100% (far right)
style = max_handle.get_attribute("style")
assert "left:100%" in style or "left: 100%" in style
# Set min to 50
min_input = page.locator('input[name="filter-session-count-min"]')
min_input.fill("50")
# Max handle should STILL stay at 100% since max input is still empty (defaults to max_value)
style = max_handle.get_attribute("style")
assert "left:100%" in style or "left: 100%" in style
+18 -11
View File
@@ -4,35 +4,42 @@ from django.http import HttpResponse
from django.test import override_settings from django.test import override_settings
from common.components import SearchSelect from common.components import SearchSelect
def e2e_test_view(request): def e2e_test_view(request):
html = f""" html = f"""
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<title>SearchSelect E2E Test</title> <title>SearchSelect E2E Test</title>
<script src="/static/js/search_select.js" defer></script> <!-- search-select is a custom element; htmx must be present for filter_bar. -->
<script src="/static/js/htmx.min.js"></script>
<script type="module" src="/static/js/dist/elements/search-select.js"></script>
</head> </head>
<body> <body>
<div style="padding: 50px;"> <div style="padding: 50px;">
{SearchSelect( {
name="games", SearchSelect(
selected=[{"value": "7", "label": "Game A", "data": {}}], name="games",
options=[ selected=[{"value": "7", "label": "Game A", "data": {}}],
{"value": "7", "label": "Game A", "data": {}}, options=[
{"value": "8", "label": "Game B", "data": {}}, {"value": "7", "label": "Game A", "data": {}},
], {"value": "8", "label": "Game B", "data": {}},
multi_select=False ],
)} multi_select=False,
)
}
</div> </div>
</body> </body>
</html> </html>
""" """
return HttpResponse(html) return HttpResponse(html)
urlpatterns = [ urlpatterns = [
path("test-search-select/", e2e_test_view), path("test-search-select/", e2e_test_view),
] ]
@pytest.mark.django_db @pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_search_select_e2e") @override_settings(ROOT_URLCONF="e2e.test_search_select_e2e")
def test_search_select_backspace_clears_single_select(live_server, page): def test_search_select_backspace_clears_single_select(live_server, page):
@@ -44,7 +51,7 @@ def test_search_select_backspace_clears_single_select(live_server, page):
# Inject our event logger # Inject our event logger
page.evaluate("""() => { page.evaluate("""() => {
const s = document.querySelector('input[data-search-select-search]'); const s = document.querySelector('input[data-search-select-search]');
const c = document.querySelector('[data-search-select]'); const c = document.querySelector('search-select');
s.addEventListener('focus', () => console.log('JS-EVENT: focus, dirty=' + c._searchSelectDirty + ', value="' + s.value + '"')); s.addEventListener('focus', () => console.log('JS-EVENT: focus, dirty=' + c._searchSelectDirty + ', value="' + s.value + '"'));
s.addEventListener('blur', () => console.log('JS-EVENT: blur, dirty=' + c._searchSelectDirty + ', value="' + s.value + '"')); s.addEventListener('blur', () => console.log('JS-EVENT: blur, dirty=' + c._searchSelectDirty + ', value="' + s.value + '"'));
s.addEventListener('input', () => console.log('JS-EVENT: input, dirty=' + c._searchSelectDirty + ', value="' + s.value + '"')); s.addEventListener('input', () => console.log('JS-EVENT: input, dirty=' + c._searchSelectDirty + ', value="' + s.value + '"'));
+103
View File
@@ -0,0 +1,103 @@
"""Browser test for the session-list "Finish session now" in-place row swap (issue #53).
Drives the real session list against pytest-django's ``live_server``: clicks the
finish button on a running session and asserts the row is updated in place via
htmx (the row still exists and now shows an end-time em dash separator).
"""
import pytest
from django.urls import reverse
from django.utils import timezone
from playwright.sync_api import Page, expect
from games.models import Device, Game, Platform, Session
@pytest.fixture
def authenticated_page(live_server, page: Page, django_user_model) -> Page:
django_user_model.objects.create_user(username="tester", password="secret123")
page.goto(f"{live_server.url}{reverse('login')}")
page.fill('input[name="username"]', "tester")
page.fill('input[name="password"]', "secret123")
page.click('button:has-text("Login")')
page.wait_for_url(f"{live_server.url}/tracker**")
return page
def test_finish_session_swaps_row_in_place(authenticated_page: Page, live_server):
page = authenticated_page
platform = Platform.objects.create(name="PC", icon="pc", group="PC")
game = Game.objects.create(name="Tunic", platform=platform)
device = Device.objects.create(name="Desktop")
session = Session.objects.create(
game=game, device=device, timestamp_start=timezone.now()
)
page.goto(f"{live_server.url}{reverse('games:list_sessions')}")
row = page.locator(f"#session-row-{session.pk}")
expect(row).to_be_visible()
row.locator('button[title="Finish session now"]').click()
# htmx swaps the row in place; the row still exists and now shows an end
# time separated by an em dash.
expect(row).to_contain_text("")
session.refresh_from_db()
assert session.timestamp_end is not None
def test_finish_session_swap_does_not_add_scrollbar(
authenticated_page: Page, live_server
):
"""Regression for the phantom horizontal scrollbar (issues #53 / #40).
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 it expands the table's overflow-x-auto
wrapper and a spurious scrollbar appears. The popover must be removed from
layout while hidden.
"""
page = authenticated_page
page.set_viewport_size({"width": 1280, "height": 800})
platform = Platform.objects.create(name="PC", icon="pc", group="PC")
# A long name guarantees a truncated NameWithIcon popover in the row.
game = Game.objects.create(name="A Very Long Game Title That Truncates")
game.platform = platform
game.save()
device = Device.objects.create(name="Desktop")
session = Session.objects.create(
game=game, device=device, timestamp_start=timezone.now()
)
page.goto(f"{live_server.url}{reverse('games:list_sessions')}")
# The fix only removes the popover from layout while it is hidden; it must
# still display on hover. Verify on the freshly-loaded page.
trigger = page.locator(f"#session-row-{session.pk} [data-popover-target]").first
popover_id = trigger.get_attribute("data-popover-target")
trigger.hover()
page.wait_for_timeout(400)
shown_display = page.evaluate(
"""(id) => getComputedStyle(document.querySelector(`[id="${id}"]`)).display""",
popover_id,
)
assert shown_display != "none", "popover stayed display:none on hover"
page.mouse.move(0, 0)
page.locator(f"#session-row-{session.pk}").locator(
'button[title="Finish session now"]'
).click()
expect(page.locator(f"#session-row-{session.pk}")).to_contain_text("")
page.wait_for_timeout(500) # allow Flowbite afterSettle re-init + Popper
# After the swap re-inits popovers, the table wrapper must not become
# horizontally scrollable (the phantom-scrollbar regression).
overflow = page.evaluate(
"""() => {
const w = document.querySelector('.overflow-x-auto');
return w.scrollWidth - w.clientWidth;
}"""
)
assert overflow <= 0, f"table wrapper overflows by {overflow}px after swap"
+46
View File
@@ -0,0 +1,46 @@
"""Browser test for the session-list "Reset start to now" button (issue #33).
Drives the real session list against pytest-django's ``live_server``: clicks the
reset button on a running session, accepts the confirm dialog, and asserts the
row's start time is updated in place via htmx.
"""
import datetime as dt
import pytest
from django.urls import reverse
from playwright.sync_api import Page, expect
from games.models import Game, Platform, Session
@pytest.fixture
def authenticated_page(live_server, page: Page, django_user_model) -> Page:
django_user_model.objects.create_user(username="tester", password="secret123")
page.goto(f"{live_server.url}{reverse('login')}")
page.fill('input[name="username"]', "tester")
page.fill('input[name="password"]', "secret123")
page.click('button:has-text("Login")')
page.wait_for_url(f"{live_server.url}/tracker**")
return page
def test_reset_session_start_to_now(authenticated_page: Page, live_server):
page = authenticated_page
platform = Platform.objects.create(name="PC", icon="pc", group="PC")
game = Game.objects.create(name="Reset Game", platform=platform)
session = Session.objects.create(
game=game,
timestamp_start=dt.datetime(2020, 1, 1, 10, 0, tzinfo=dt.timezone.utc),
)
page.goto(f"{live_server.url}{reverse('games:list_sessions')}")
row = page.locator(f"#session-row-{session.id}")
expect(row).to_contain_text("2020")
page.on("dialog", lambda dialog: dialog.accept())
row.locator('button[title="Reset start to now"]').click()
# htmx swaps the row in place; the old 2020 start time is gone.
expect(row).not_to_contain_text("2020")
+150
View File
@@ -0,0 +1,150 @@
"""End-to-end Playwright test for String multi-mode filter serialization, null-state toggling, and prefill behaviors."""
import json
import urllib.parse
import pytest
from django.http import HttpResponse
from django.test import override_settings
from django.urls import path
from common.components import PlatformFilterBar
def _bar_page(filter_json: str = "") -> str:
return f"""<!DOCTYPE html>
<html>
<head>
<title>String filter E2E</title>
<script src="/static/js/htmx.min.js"></script>
<script src="/static/js/dist/elements/range-slider.js" type="module"></script>
<script src="/static/js/dist/elements/search-select.js" type="module"></script>
<script src="/static/js/dist/elements/filter-bar.js" type="module"></script>
</head>
<body>
{PlatformFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
</body>
</html>"""
def empty_bar_view(request):
return HttpResponse(_bar_page())
def prefilled_bar_view(request):
filter_json = json.dumps(
{
"name": {
"value": "Switch",
"modifier": "INCLUDES",
},
"group": {"modifier": "IS_NULL"},
}
)
return HttpResponse(_bar_page(filter_json=filter_json))
urlpatterns = [
path("test-string-filter-empty/", empty_bar_view),
path("test-string-filter-prefilled/", prefilled_bar_view),
]
def _filter_from_url(url: str) -> dict:
query = urllib.parse.urlparse(url).query
params = urllib.parse.parse_qs(query)
raw = params.get("filter", [""])[0]
return json.loads(raw) if raw else {}
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_string_filter_e2e")
def test_string_filter_defaults_and_toggles(live_server, page):
page.goto(live_server.url + "/test-string-filter-empty/")
# 1. Verify text inputs are active by default and modifier "is" (EQUALS) is checked
name_input = page.locator('input[name="filter-name"]')
assert name_input.is_enabled()
is_radio = page.locator('input[name="filter-name-modifier"][value="EQUALS"]')
assert is_radio.is_checked()
# 2. Enter values, click "includes" (INCLUDES), and submit
name_input.fill("PlayStation")
includes_radio = page.locator(
'input[name="filter-name-modifier"][value="INCLUDES"]'
)
includes_radio.click()
with page.expect_navigation():
page.evaluate(
"document.getElementById('filter-bar-form')"
".dispatchEvent(new Event('submit', {cancelable: true}))"
)
parsed = _filter_from_url(page.url)
assert parsed["name"] == {"value": "PlayStation", "modifier": "INCLUDES"}
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_string_filter_e2e")
def test_string_filter_null_states(live_server, page):
page.goto(live_server.url + "/test-string-filter-empty/")
name_input = page.locator('input[name="filter-name"]')
name_input.fill("Xbox")
# Click "is null"
is_null_radio = page.locator('input[name="filter-name-modifier"][value="IS_NULL"]')
is_null_radio.click()
# Verification of interactive disabling
assert not name_input.is_enabled()
assert name_input.input_value() == ""
with page.expect_navigation():
page.evaluate(
"document.getElementById('filter-bar-form')"
".dispatchEvent(new Event('submit', {cancelable: true}))"
)
parsed = _filter_from_url(page.url)
assert parsed["name"] == {"modifier": "IS_NULL"}
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_string_filter_e2e")
def test_string_filter_prefilled_states(live_server, page):
page.goto(live_server.url + "/test-string-filter-prefilled/")
name_input = page.locator('input[name="filter-name"]')
group_input = page.locator('input[name="filter-group"]')
# Verifies name matches "Switch" and "includes" is checked
assert name_input.input_value() == "Switch"
assert name_input.is_enabled()
assert page.locator(
'input[name="filter-name-modifier"][value="INCLUDES"]'
).is_checked()
# Verifies group is empty, disabled, and "is null" is checked
assert group_input.input_value() == ""
assert not group_input.is_enabled()
assert page.locator(
'input[name="filter-group-modifier"][value="IS_NULL"]'
).is_checked()
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_string_filter_e2e")
def test_string_filter_deselect_re_enables(live_server, page):
page.goto(live_server.url + "/test-string-filter-empty/")
name_input = page.locator('input[name="filter-name"]')
is_null_radio = page.locator('input[name="filter-name-modifier"][value="IS_NULL"]')
# 1. Click "is null" -> disables input
is_null_radio.click()
assert not name_input.is_enabled()
# 2. Click "is null" again to deselect/uncheck -> should re-enable the text input
is_null_radio.click()
assert name_input.is_enabled()
+275
View File
@@ -0,0 +1,275 @@
"""Browser tests for widget JavaScript (search_select.js, range_slider.js,
add_purchase.js) and their onSwap() initialization lifecycle.
These run a real Chromium via pytest-playwright against pytest-django's
``live_server``. All JavaScript under test is served locally from
``games/static/js/`` (htmx, Alpine, Flowbite and the widget files are
vendored), so no network access is needed beyond the live server itself.
Browser binaries must be installed once: ``uv run playwright install chromium``.
"""
import re
import pytest
from django.urls import reverse
from playwright.sync_api import Page, expect
@pytest.fixture
def authenticated_page(live_server, page: Page, django_user_model) -> Page:
django_user_model.objects.create_user(username="tester", password="secret123")
page.goto(f"{live_server.url}{reverse('login')}")
page.fill('input[name="username"]', "tester")
page.fill('input[name="password"]', "secret123")
page.click('button:has-text("Login")')
page.wait_for_url(f"{live_server.url}/tracker**")
return page
def open_filter_bar(page: Page) -> None:
page.click("#filter-bar button:has-text('Filters')")
expect(page.locator("#filter-bar-body")).to_be_visible()
def status_filter_widget(page: Page):
return page.locator('search-select[name="status"]')
def test_search_select_initializes_on_page_load(authenticated_page: Page, live_server):
"""Clicking into a FilterSelect search box opens its options panel —
proof that onSwap ran the widget initializer on the initial page load."""
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:list_games')}")
open_filter_bar(page)
widget = status_filter_widget(page)
widget.locator("[data-search-select-search]").click()
options_panel = widget.locator("[data-search-select-options]")
expect(options_panel).to_be_visible()
# The pinned "(Any)" modifier pseudo-option is rendered server-side and
# only becomes interactable through the initialized panel.
expect(
options_panel.locator("[data-search-select-modifier-option]").first
).to_have_text("(Any)")
def test_search_select_adds_include_pill(authenticated_page: Page, live_server):
"""Clicking an enum option row adds an include pill (full widget wiring)."""
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:list_games')}")
open_filter_bar(page)
widget = status_filter_widget(page)
widget.locator("[data-search-select-search]").click()
widget.locator('[data-search-select-option][data-label="Finished"]').click()
pill = widget.locator("[data-search-select-pills] [data-pill]")
expect(pill).to_have_count(1)
expect(pill).to_contain_text("Finished")
def test_range_slider_mode_toggle_fires_exactly_once(
authenticated_page: Page, live_server
):
"""One click on the mode toggle flips the slider from range to point mode
exactly once. Double-bound listeners (the old force-re-init bug) would
flip it twice, leaving data-mode unchanged."""
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:list_games')}")
open_filter_bar(page)
slider = page.locator("range-slider").first
expect(slider).to_have_attribute("mode", "range")
slider.locator(".range-mode-toggle").click()
expect(slider).to_have_attribute("mode", "point")
def test_widgets_initialize_inside_htmx_swapped_content(
authenticated_page: Page, live_server
):
"""Widgets arriving via an htmx swap initialize without a page load.
The filter bar is re-fetched and swapped in with htmx.ajax — fresh,
uninitialized DOM. The swapped-in FilterSelect must open its panel and the
swapped-in slider must toggle exactly once, proving the htmx:load half of
onSwap and the once-per-element guard."""
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:list_games')}")
page.evaluate(
"htmx.ajax('GET', window.location.pathname, "
"{target: '#filter-bar', select: '#filter-bar', swap: 'outerHTML'})"
)
# The swapped-in bar arrives collapsed again; opening it proves the swap
# happened and the fresh DOM is in place.
open_filter_bar(page)
widget = status_filter_widget(page)
widget.locator("[data-search-select-search]").click()
expect(widget.locator("[data-search-select-options]")).to_be_visible()
slider = page.locator("range-slider").first
expect(slider).to_have_attribute("mode", "range")
slider.locator(".range-mode-toggle").click()
expect(slider).to_have_attribute("mode", "point")
def test_add_purchase_type_toggles_disabled_fields(
authenticated_page: Page, live_server
):
"""add_purchase.js disables name/related-game while type is "game"
and re-enables them for other types."""
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:add_purchase')}")
name_input = page.locator("#id_name")
expect(name_input).to_be_disabled()
# The Name field (a plain input) self-styles its disabled state via the
# INPUT_CLASS disabled: variants — not a global rule. not-allowed is
# mode-independent, so it holds in light and dark.
assert name_input.evaluate("el => getComputedStyle(el).cursor") == "not-allowed"
page.select_option("#id_type", "dlc")
expect(name_input).to_be_enabled()
assert name_input.evaluate("el => getComputedStyle(el).cursor") != "not-allowed"
page.select_option("#id_type", "game")
expect(name_input).to_be_disabled()
def test_add_purchase_related_game_is_flat_game_search(
authenticated_page: Page, live_server
):
"""The DLC/Season-Pass anchor is now a flat game search (related_game),
wired to the games search API and present regardless of which games are
selected — not the old parent-purchase dropdown filtered by chosen games."""
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:add_purchase')}")
related = page.locator('search-select[name="related_game"]')
expect(related).to_have_count(1)
expect(related).to_have_attribute("search-url", "/api/games/search")
def test_searchselect_border_matches_native_input(
authenticated_page: Page, live_server
):
"""A SearchSelect's wrapper has the same border as a native input, and turns
brand on focus (via focus-within on the wrapper, since the inner search box
is what's focused)."""
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:add_purchase')}")
price = page.locator("#id_price") # always-enabled native input
wrapper = page.locator("search-select[name='platform']")
search_input = page.locator("#id_platform")
border = "el => getComputedStyle(el).borderColor"
rest = price.evaluate(border)
assert wrapper.evaluate(border) == rest # same border at rest
search_input.focus()
focused_wrapper = wrapper.evaluate(border)
price.focus()
focused_input = price.evaluate(border)
assert focused_wrapper == focused_input # same brand border on focus
assert focused_wrapper != rest # focus actually changes it
def test_add_game_syncs_sort_name_from_name(authenticated_page: Page, live_server):
"""Typing into Name live-fills Sort name (sync bound to the add form, not
the navbar logout form which is the first <form> on the page)."""
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:add_game')}")
page.locator("#id_name").click()
page.locator("#id_name").type("Halo")
expect(page.locator("#id_sort_name")).to_have_value("Halo")
def test_add_purchase_type_game_disables_related_game_search(
authenticated_page: Page, live_server
):
"""When Type is 'game', the related-game SearchSelect is disabled.
#id_related_game is the inner search <input> (the real labelable control),
and the <search-select> wrapper fades via has-[:disabled]:opacity-50."""
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:add_purchase')}")
# #id_related_game is now on the inner <input data-search-select-search>
search_input = page.locator("#id_related_game")
# The wrapper has no id; find it by the stable `name` attribute.
wrapper = page.locator("search-select[name='related_game']")
name = page.locator("#id_name")
opacity = "el => getComputedStyle(el).opacity"
bg = "el => getComputedStyle(el).backgroundColor"
page.select_option("#id_type", "game")
expect(search_input).to_be_disabled()
# A disabled SearchSelect must look identical to a disabled native input:
# both fade (opacity-50) over the same surface.
assert wrapper.evaluate(opacity) == "0.5"
assert name.evaluate(opacity) == "0.5"
assert wrapper.evaluate(bg) == name.evaluate(bg)
# The inner input stays transparent (no nested box) with the same not-allowed
# cursor (no flicker across the widget).
assert search_input.evaluate(bg) == "rgba(0, 0, 0, 0)"
assert search_input.evaluate("el => getComputedStyle(el).cursor") == "not-allowed"
page.select_option("#id_type", "dlc")
expect(search_input).to_be_enabled()
# Enabled, both return to full opacity.
assert wrapper.evaluate(opacity) == "1"
assert name.evaluate(opacity) == "1"
def test_label_click_focuses_search_select(authenticated_page: Page, live_server):
"""Clicking a <label for="id_X"> on a SearchSelect field must focus the
search input — confirmed now that id is on the real <input> control."""
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:add_purchase')}")
# related_game is disabled when type is "game" (the default); switch so it
# is enabled, otherwise clicking the label for a disabled control fails.
page.select_option("#id_type", "dlc")
label = page.locator("label[for='id_related_game']")
search_input = page.locator("#id_related_game")
label.click()
expect(search_input).to_be_focused()
def test_add_game_sync_stops_once_sort_name_edited(
authenticated_page: Page, live_server
):
"""Name → Sort name mirrors live, but stops the moment the user edits Sort
name directly (the 'UntilChanged' contract). Editing Name afterwards must
not clobber the user's manual Sort name."""
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:add_game')}")
name = page.locator("#id_name")
sort = page.locator("#id_sort_name")
name.click()
name.type("Halo")
expect(sort).to_have_value("Halo") # live mirror before any manual edit
sort.fill("Custom Sort") # user takes over the target → sync drops
expect(sort).to_have_value("Custom Sort")
name.click()
name.press("End")
name.type(" 2")
expect(name).to_have_value("Halo 2")
expect(sort).to_have_value("Custom Sort") # not clobbered
def test_add_game_submit_and_create_session_redirects(
authenticated_page: Page, live_server
):
"""Submit & Create Session saves the game and redirects to add-session with
the new game pre-selected in the game SearchSelect."""
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:add_game')}")
page.fill("#id_name", "E2E Session Game")
page.click('button[name="submit_and_create_session"]')
page.wait_for_url(f"{live_server.url}/tracker/session/add/for-game/**")
expect(page.locator("#id_game")).to_have_value(re.compile(r"^E2E Session Game"))
+41 -2
View File
@@ -1,8 +1,16 @@
#!/bin/bash #!/bin/bash
set -euo pipefail set -euo pipefail
# Container-bootstrap configuration. These variables are consumed only by this
# entrypoint, NOT by Django (see timetracker/config.py for the app settings):
# PUID/PGID — uid/gid the container process runs as
# DATA_DIR — writable dir for the SQLite database (kept in
# sync with Django via the same env var + default)
# CREATE_DEFAULT_SUPERUSER — create an admin/admin user on first start
# STAGING / LOAD_SAMPLE_DATA — staging-only data bootstrap (see below)
PUID=${PUID:-1000} PUID=${PUID:-1000}
PGID=${PGID:-100} PGID=${PGID:-100}
DATA_DIR=${DATA_DIR:-/home/timetracker/app/data}
USERHOME=$(grep timetracker /etc/passwd | cut -d ":" -f6) USERHOME=$(grep timetracker /etc/passwd | cut -d ":" -f6)
usermod -d "/root" timetracker usermod -d "/root" timetracker
@@ -10,14 +18,45 @@ groupmod -o -g "$PGID" timetracker
usermod -o -u "$PUID" timetracker usermod -o -u "$PUID" timetracker
usermod -d "${USERHOME}" timetracker usermod -d "${USERHOME}" timetracker
mkdir -p /home/timetracker/app/data /var/log/supervisor mkdir -p "$DATA_DIR" /var/log/supervisor
chmod 755 /home/timetracker/app chmod 755 /home/timetracker/app
chmod 755 /home/timetracker/app/.venv chmod 755 /home/timetracker/app/.venv
chown "$PUID:$PGID" /home/timetracker/app/data chown "$PUID:$PGID" "$DATA_DIR"
chown "$PUID:$PGID" /var/log/supervisor chown "$PUID:$PGID" /var/log/supervisor
python manage.py migrate python manage.py migrate
python manage.py collectstatic --clear --no-input python manage.py collectstatic --clear --no-input
# Staging seeded from a production snapshot: remove copied sessions and the
# inherited django-q schedule/queue so staging neither shares prod's session
# cookies nor independently runs scheduled tasks (see issue #20).
if [ "${STAGING:-false}" = "true" ]; then
python manage.py scrub_staging
fi
# Public staging with a fresh database (e.g. Fly.io): load demo data instead
# of any production snapshot. Runs once while the games table is empty.
if [ "${LOAD_SAMPLE_DATA:-false}" = "true" ]; then
python manage.py shell -c "
from games.models import Game
from django.core.management import call_command
if not Game.objects.exists():
call_command('loaddata', 'sample.yaml')
print('Loaded sample data.')
"
fi
if [ "${CREATE_DEFAULT_SUPERUSER:-false}" = "true" ]; then
python manage.py shell -c "
from django.contrib.auth import get_user_model
User = get_user_model()
if not User.objects.filter(username='admin').exists():
User.objects.create_superuser('admin', '', 'admin')
print('Created default superuser: admin / admin')
"
fi
chown -R "$PUID:$PGID" "$DATA_DIR"
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisor.conf exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisor.conf
+29
View File
@@ -0,0 +1,29 @@
# Shared Fly.io configuration for ephemeral, per-branch GitHub staging deploys.
#
# The app name is NOT set here on purpose; each branch supplies its own via
# `flyctl deploy --app timetracker-staging-<slug>`. These instances run with a
# fresh database seeded from sample fixtures (never production data) and their
# own SECRET_KEY, so they are safe to expose on a public *.fly.dev hostname.
primary_region = "ams"
[build]
dockerfile = "Dockerfile"
[env]
DEBUG = "false"
TZ = "Europe/Prague"
DATA_DIR = "/home/timetracker/app/data"
LOAD_SAMPLE_DATA = "true"
CREATE_DEFAULT_SUPERUSER = "true"
[http_service]
internal_port = 8000
force_https = true
auto_stop_machines = "stop"
auto_start_machines = true
min_machines_running = 0
[[vm]]
size = "shared-cpu-1x"
memory = "512mb"
+15
View File
@@ -59,6 +59,12 @@ class GameOption(Schema): # mirrors SearchSelectOption
data: dict data: dict
class StringOption(Schema): # SearchSelectOption with a string value (e.g. group names)
value: str
label: str
data: dict
@game_router.get("/search", response=list[GameOption]) @game_router.get("/search", response=list[GameOption])
def search_games(request, q: str = "", limit: int = 10): def search_games(request, q: str = "", limit: int = 10):
qs = Game.objects.select_related("platform").order_by("sort_name") qs = Game.objects.select_related("platform").order_by("sort_name")
@@ -133,6 +139,15 @@ def search_platforms(request, q: str = "", limit: int = 10):
return [{"value": p.id, "label": p.name, "data": {}} for p in qs[:limit]] return [{"value": p.id, "label": p.name, "data": {}} for p in qs[:limit]]
@platform_router.get("/groups", response=list[StringOption])
def search_platform_groups(request, q: str = "", limit: int = 10):
qs = Platform.objects.exclude(group="")
if q:
qs = qs.filter(group__icontains=q)
groups = qs.values_list("group", flat=True).distinct().order_by("group")
return [{"value": group, "label": group, "data": {}} for group in groups[:limit]]
api.add_router("/playevent", playevent_router) api.add_router("/playevent", playevent_router)
api.add_router("/games", game_router) api.add_router("/games", game_router)
api.add_router("/devices", device_router) api.add_router("/devices", device_router)
+576 -54
View File
@@ -18,6 +18,7 @@ from django.db.models import Q
from common.criteria import ( from common.criteria import (
BoolCriterion, BoolCriterion,
ChoiceCriterion, ChoiceCriterion,
DateCriterion,
FloatCriterion, FloatCriterion,
IntCriterion, IntCriterion,
Modifier, Modifier,
@@ -58,15 +59,46 @@ class GameFilter(OperatorFilter):
original_year_released: IntCriterion | None = None original_year_released: IntCriterion | None = None
wikidata: StringCriterion | None = None wikidata: StringCriterion | None = None
platform: ChoiceCriterion | None = None # selectable filter widget platform: ChoiceCriterion | None = None # selectable filter widget
platform_group: MultiCriterion | None = None # platform__group__in
status: ChoiceCriterion | None = None # selectable filter widget status: ChoiceCriterion | None = None # selectable filter widget
mastered: BoolCriterion | None = None mastered: BoolCriterion | None = None
playtime_minutes: IntCriterion | None = None # converted to timedelta on to_q() playtime_hours: IntCriterion | None = None # converted to timedelta on to_q()
created_at: StringCriterion | None = None # date string created_at: StringCriterion | None = None # date string
updated_at: StringCriterion | None = None # date string updated_at: StringCriterion | None = None # date string
session_count: IntCriterion | None = None
session_average: IntCriterion | None = None # average in hours
purchase_count: IntCriterion | None = None # distinct purchases per game
playevent_count: IntCriterion | None = None # playevents per game
# Aggregate session durations (hours), summed across the game's sessions
manual_playtime_hours: IntCriterion | None = None
calculated_playtime_hours: IntCriterion | None = None
# Cross-entity: any session played on these devices / matching these flags
device: MultiCriterion | None = None # game has session on any of these devices
session_emulated: BoolCriterion | None = None # game has emulated session
# Cross-entity: matches against the game's purchases
purchase_refunded: BoolCriterion | None = None # game has refunded purchase
purchase_infinite: BoolCriterion | None = None # game has infinite purchase
purchase_price_total: FloatCriterion | None = None # sum of converted prices
purchase_price_any: FloatCriterion | None = None # any single purchase in range
purchase_type: ChoiceCriterion | None = None # game has purchase of type
purchase_ownership_type: ChoiceCriterion | None = None # by ownership
# Cross-entity: substring match against the game's playevent notes
playevent_note: StringCriterion | None = None
# Free-text search (combines name + sort_name + platform name) # Free-text search (combines name + sort_name + platform name)
search: StringCriterion | None = None search: StringCriterion | None = None
# Cross-entity filters
session_filter: SessionFilter | None = None
purchase_filter: PurchaseFilter | None = None
playevent_filter: PlayEventFilter | None = None
platform_filter: PlatformFilter | None = None
def to_q(self) -> Q: def to_q(self) -> Q:
q = Q() q = Q()
@@ -87,13 +119,183 @@ class GameFilter(OperatorFilter):
q &= self.status.to_q("status") q &= self.status.to_q("status")
if self.mastered is not None: if self.mastered is not None:
q &= self.mastered.to_q("mastered") q &= self.mastered.to_q("mastered")
if self.playtime_minutes is not None: if self.playtime_hours is not None:
q &= self._playtime_to_q(self.playtime_minutes) q &= self._playtime_to_q(self.playtime_hours)
if self.created_at is not None: if self.created_at is not None:
q &= self.created_at.to_q("created_at") q &= self.created_at.to_q("created_at")
if self.updated_at is not None: if self.updated_at is not None:
q &= self.updated_at.to_q("updated_at") q &= self.updated_at.to_q("updated_at")
if self.platform_group is not None:
q &= self.platform_group.to_q("platform__group")
if self.session_count is not None:
from django.db.models import Count
from games.models import Game
matching_ids = (
Game.objects.annotate(s_count=Count("sessions", distinct=True))
.filter(self.session_count.to_q("s_count"))
.values_list("id", flat=True)
)
q &= Q(id__in=matching_ids)
if self.session_average is not None:
from django.db.models import Avg
from games.models import Game
matching_ids = (
Game.objects.annotate(s_avg=Avg("sessions__duration_total"))
.filter(self._playtime_to_q_for_field(self.session_average, "s_avg"))
.values_list("id", flat=True)
)
q &= Q(id__in=matching_ids)
if self.purchase_count is not None:
from django.db.models import Count
from games.models import Game
matching_ids = (
Game.objects.annotate(p_count=Count("purchases", distinct=True))
.filter(self.purchase_count.to_q("p_count"))
.values_list("id", flat=True)
)
q &= Q(id__in=matching_ids)
if self.playevent_count is not None:
from django.db.models import Count
from games.models import Game
matching_ids = (
Game.objects.annotate(pe_count=Count("playevents", distinct=True))
.filter(self.playevent_count.to_q("pe_count"))
.values_list("id", flat=True)
)
q &= Q(id__in=matching_ids)
if self.manual_playtime_hours is not None:
from django.db.models import Sum
from games.models import Game
matching_ids = (
Game.objects.annotate(s_manual=Sum("sessions__duration_manual"))
.filter(
self._playtime_to_q_for_field(
self.manual_playtime_hours, "s_manual"
)
)
.values_list("id", flat=True)
)
q &= Q(id__in=matching_ids)
if self.calculated_playtime_hours is not None:
from django.db.models import Sum
from games.models import Game
matching_ids = (
Game.objects.annotate(s_calc=Sum("sessions__duration_calculated"))
.filter(
self._playtime_to_q_for_field(
self.calculated_playtime_hours, "s_calc"
)
)
.values_list("id", flat=True)
)
q &= Q(id__in=matching_ids)
if self.device is not None:
from games.models import Session
session_q = self.device.to_q("device_id")
matching_ids = Session.objects.filter(session_q).values_list(
"game_id", flat=True
)
q &= Q(id__in=matching_ids)
if self.session_emulated is not None:
from games.models import Session
emulated_ids = Session.objects.filter(
emulated=self.session_emulated.value
).values_list("game_id", flat=True)
if self.session_emulated.value:
q &= Q(id__in=emulated_ids)
else:
emulated_true_ids = Session.objects.filter(emulated=True).values_list(
"game_id", flat=True
)
q &= ~Q(id__in=emulated_true_ids)
if self.purchase_refunded is not None:
from games.models import Purchase
refunded_ids = Purchase.objects.filter(
date_refunded__isnull=False
).values_list("games__id", flat=True)
if self.purchase_refunded.value:
q &= Q(id__in=refunded_ids)
else:
q &= ~Q(id__in=refunded_ids)
if self.purchase_infinite is not None:
from games.models import Purchase
infinite_ids = Purchase.objects.filter(infinite=True).values_list(
"games__id", flat=True
)
if self.purchase_infinite.value:
q &= Q(id__in=infinite_ids)
else:
q &= ~Q(id__in=infinite_ids)
if self.purchase_price_total is not None:
from django.db.models import Sum
from games.models import Game
matching_ids = (
Game.objects.annotate(p_total=Sum("purchases__converted_price"))
.filter(self.purchase_price_total.to_q("p_total"))
.values_list("id", flat=True)
)
q &= Q(id__in=matching_ids)
if self.purchase_price_any is not None:
from games.models import Purchase
price_q = self.purchase_price_any.to_q("converted_price")
matching_ids = Purchase.objects.filter(price_q).values_list(
"games__id", flat=True
)
q &= Q(id__in=matching_ids)
if self.purchase_type is not None:
from games.models import Purchase
type_q = self.purchase_type.to_q("type")
matching_ids = Purchase.objects.filter(type_q).values_list(
"games__id", flat=True
)
q &= Q(id__in=matching_ids)
if self.purchase_ownership_type is not None:
from games.models import Purchase
ownership_q = self.purchase_ownership_type.to_q("ownership_type")
matching_ids = Purchase.objects.filter(ownership_q).values_list(
"games__id", flat=True
)
q &= Q(id__in=matching_ids)
if self.playevent_note is not None:
q &= self._playevent_note_to_q(self.playevent_note)
# ── free-text search (OR across multiple fields) ── # ── free-text search (OR across multiple fields) ──
if self.search is not None and self.search.value: if self.search is not None and self.search.value:
search_q = ( search_q = (
@@ -105,6 +307,43 @@ class GameFilter(OperatorFilter):
search_q = ~search_q search_q = ~search_q
q &= search_q q &= search_q
# Cross-entity filters
if self.session_filter is not None:
from games.models import Session
session_q = self.session_filter.to_q()
matching_ids = Session.objects.filter(session_q).values_list(
"game_id", flat=True
)
q &= Q(id__in=matching_ids)
if self.purchase_filter is not None:
from games.models import Purchase
purchase_q = self.purchase_filter.to_q()
matching_ids = Purchase.objects.filter(purchase_q).values_list(
"games__id", flat=True
)
q &= Q(id__in=matching_ids)
if self.playevent_filter is not None:
from games.models import PlayEvent
playevent_q = self.playevent_filter.to_q()
matching_ids = PlayEvent.objects.filter(playevent_q).values_list(
"game_id", flat=True
)
q &= Q(id__in=matching_ids)
if self.platform_filter is not None:
from games.models import Platform
platform_q = self.platform_filter.to_q()
matching_ids = Platform.objects.filter(platform_q).values_list(
"id", flat=True
)
q &= Q(platform_id__in=matching_ids)
# ── AND / OR / NOT sub-filters ── # ── AND / OR / NOT sub-filters ──
sub = self.sub_filter() sub = self.sub_filter()
if sub is not None: if sub is not None:
@@ -119,31 +358,34 @@ class GameFilter(OperatorFilter):
@staticmethod @staticmethod
def _playtime_to_q(c: IntCriterion) -> Q: def _playtime_to_q(c: IntCriterion) -> Q:
"""Convert minutes-based criterion to a DurationField Q object. return GameFilter._playtime_to_q_for_field(c, "playtime")
@staticmethod
def _playtime_to_q_for_field(c: IntCriterion, field: str) -> Q:
"""Convert hours-based criterion to a DurationField Q object.
Django stores DurationField as microseconds in SQLite, so we convert Django stores DurationField as microseconds in SQLite, so we convert
minutes → timedelta(microseconds=X) and use the appropriate lookups. hours → timedelta(microseconds=X) and use the appropriate lookups.
""" """
from datetime import timedelta from datetime import timedelta
from common.criteria import Modifier from common.criteria import Modifier
m = c.modifier m = c.modifier
field = "playtime" td_val = timedelta(hours=c.value)
td_val = timedelta(minutes=c.value)
if m == Modifier.EQUALS: if m == Modifier.EQUALS:
return Q( return Q(
**{ **{
f"{field}__gte": td_val, f"{field}__gte": td_val,
f"{field}__lt": timedelta(minutes=c.value + 1), f"{field}__lt": timedelta(hours=c.value + 1),
} }
) )
if m == Modifier.NOT_EQUALS: if m == Modifier.NOT_EQUALS:
return ~Q( return ~Q(
**{ **{
f"{field}__gte": td_val, f"{field}__gte": td_val,
f"{field}__lt": timedelta(minutes=c.value + 1), f"{field}__lt": timedelta(hours=c.value + 1),
} }
) )
if m == Modifier.GREATER_THAN: if m == Modifier.GREATER_THAN:
@@ -151,12 +393,12 @@ class GameFilter(OperatorFilter):
if m == Modifier.LESS_THAN: if m == Modifier.LESS_THAN:
return Q(**{f"{field}__lt": td_val}) return Q(**{f"{field}__lt": td_val})
if m == Modifier.BETWEEN and c.value2 is not None: if m == Modifier.BETWEEN and c.value2 is not None:
lo = timedelta(minutes=min(c.value, c.value2)) lo = timedelta(hours=min(c.value, c.value2))
hi = timedelta(minutes=max(c.value, c.value2)) hi = timedelta(hours=max(c.value, c.value2))
return Q(**{f"{field}__gte": lo, f"{field}__lte": hi}) return Q(**{f"{field}__gte": lo, f"{field}__lte": hi})
if m == Modifier.NOT_BETWEEN and c.value2 is not None: if m == Modifier.NOT_BETWEEN and c.value2 is not None:
lo = timedelta(minutes=min(c.value, c.value2)) lo = timedelta(hours=min(c.value, c.value2))
hi = timedelta(minutes=max(c.value, c.value2)) hi = timedelta(hours=max(c.value, c.value2))
return Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi}) return Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
if m == Modifier.IS_NULL: if m == Modifier.IS_NULL:
return Q(**{f"{field}": timedelta(0)}) return Q(**{f"{field}": timedelta(0)})
@@ -164,6 +406,17 @@ class GameFilter(OperatorFilter):
return ~Q(**{f"{field}": timedelta(0)}) return ~Q(**{f"{field}": timedelta(0)})
return Q() return Q()
@staticmethod
def _playevent_note_to_q(criterion: StringCriterion) -> Q:
"""Match games by substring / regex / null against their playevents' notes."""
from games.models import PlayEvent
event_q = criterion.to_q("note")
matching_ids = PlayEvent.objects.filter(event_q).values_list(
"game_id", flat=True
)
return Q(id__in=matching_ids)
# ── SessionFilter ────────────────────────────────────────────────────────── # ── SessionFilter ──────────────────────────────────────────────────────────
@@ -180,7 +433,10 @@ class SessionFilter(OperatorFilter):
device: MultiCriterion | None = None # filters on device_id device: MultiCriterion | None = None # filters on device_id
emulated: BoolCriterion | None = None emulated: BoolCriterion | None = None
note: StringCriterion | None = None note: StringCriterion | None = None
duration_minutes: IntCriterion | None = None # on duration_total duration_hours: IntCriterion | None = None # on duration_total (legacy alias)
duration_total_hours: IntCriterion | None = None
duration_manual_hours: IntCriterion | None = None
duration_calculated_hours: IntCriterion | None = None
is_active: BoolCriterion | None = None # timestamp_end IS NULL is_active: BoolCriterion | None = None # timestamp_end IS NULL
timestamp_start: StringCriterion | None = None # date string timestamp_start: StringCriterion | None = None # date string
timestamp_end: StringCriterion | None = None # date string timestamp_end: StringCriterion | None = None # date string
@@ -193,6 +449,47 @@ class SessionFilter(OperatorFilter):
# Cross-entity: sessions for games matching these criteria # Cross-entity: sessions for games matching these criteria
game_filter: GameFilter | None = None game_filter: GameFilter | None = None
# Cross-entity: sessions for devices matching these criteria
device_filter: DeviceFilter | None = None
def _duration_to_q(self, c: IntCriterion, field: str) -> Q:
from datetime import timedelta
q = Q()
td_val = timedelta(hours=c.value)
m = c.modifier
if m == Modifier.EQUALS:
q &= Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(hours=c.value + 1),
}
)
elif m == Modifier.NOT_EQUALS:
q &= ~Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(hours=c.value + 1),
}
)
elif m == Modifier.GREATER_THAN:
q &= Q(**{f"{field}__gt": td_val})
elif m == Modifier.LESS_THAN:
q &= Q(**{f"{field}__lt": td_val})
elif m == Modifier.BETWEEN and c.value2 is not None:
lo = timedelta(hours=min(c.value, c.value2))
hi = timedelta(hours=max(c.value, c.value2))
q &= Q(**{f"{field}__gte": lo, f"{field}__lte": hi})
elif m == Modifier.NOT_BETWEEN and c.value2 is not None:
lo = timedelta(hours=min(c.value, c.value2))
hi = timedelta(hours=max(c.value, c.value2))
q &= Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
elif m == Modifier.IS_NULL:
q &= Q(**{f"{field}": timedelta(0)})
elif m == Modifier.NOT_NULL:
q &= ~Q(**{f"{field}": timedelta(0)})
return q
def to_q(self) -> Q: def to_q(self) -> Q:
from datetime import timedelta from datetime import timedelta
@@ -206,41 +503,16 @@ class SessionFilter(OperatorFilter):
q &= self.emulated.to_q("emulated") q &= self.emulated.to_q("emulated")
if self.note is not None: if self.note is not None:
q &= self.note.to_q("note") q &= self.note.to_q("note")
if self.duration_minutes is not None: if self.duration_hours is not None:
c = self.duration_minutes q &= self._duration_to_q(self.duration_hours, "duration_total")
td_val = timedelta(minutes=c.value) if self.duration_total_hours is not None:
field = "duration_total" q &= self._duration_to_q(self.duration_total_hours, "duration_total")
m = c.modifier if self.duration_manual_hours is not None:
if m == Modifier.EQUALS: q &= self._duration_to_q(self.duration_manual_hours, "duration_manual")
q &= Q( if self.duration_calculated_hours is not None:
**{ q &= self._duration_to_q(
f"{field}__gte": td_val, self.duration_calculated_hours, "duration_calculated"
f"{field}__lt": timedelta(minutes=c.value + 1), )
}
)
elif m == Modifier.NOT_EQUALS:
q &= ~Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(minutes=c.value + 1),
}
)
elif m == Modifier.GREATER_THAN:
q &= Q(**{f"{field}__gt": td_val})
elif m == Modifier.LESS_THAN:
q &= Q(**{f"{field}__lt": td_val})
elif m == Modifier.BETWEEN and c.value2 is not None:
lo = timedelta(minutes=min(c.value, c.value2))
hi = timedelta(minutes=max(c.value, c.value2))
q &= Q(**{f"{field}__gte": lo, f"{field}__lte": hi})
elif m == Modifier.NOT_BETWEEN and c.value2 is not None:
lo = timedelta(minutes=min(c.value, c.value2))
hi = timedelta(minutes=max(c.value, c.value2))
q &= Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
elif m == Modifier.IS_NULL:
q &= Q(**{f"{field}": timedelta(0)})
elif m == Modifier.NOT_NULL:
q &= ~Q(**{f"{field}": timedelta(0)})
if self.is_active is not None: if self.is_active is not None:
if self.is_active.value: if self.is_active.value:
q &= Q(timestamp_end__isnull=True) q &= Q(timestamp_end__isnull=True)
@@ -278,6 +550,14 @@ class SessionFilter(OperatorFilter):
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True) matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
q &= Q(game_id__in=matching_ids) q &= Q(game_id__in=matching_ids)
# Cross-entity filter: sessions for devices matching DeviceFilter
if self.device_filter is not None:
from games.models import Device
device_q = self.device_filter.to_q()
matching_ids = Device.objects.filter(device_q).values_list("id", flat=True)
q &= Q(device_id__in=matching_ids)
# AND / OR / NOT # AND / OR / NOT
sub = self.sub_filter() sub = self.sub_filter()
if sub is not None: if sub is not None:
@@ -305,8 +585,8 @@ class PurchaseFilter(OperatorFilter):
name: StringCriterion | None = None name: StringCriterion | None = None
platform: ChoiceCriterion | None = None # platform_id platform: ChoiceCriterion | None = None # platform_id
games: ChoiceCriterion | None = None # games (M2M IDs) games: ChoiceCriterion | None = None # games (M2M IDs)
date_purchased: StringCriterion | None = None # date string date_purchased: DateCriterion | None = None
date_refunded: StringCriterion | None = None # date string date_refunded: DateCriterion | None = None
is_refunded: BoolCriterion | None = None # date_refunded IS NOT NULL is_refunded: BoolCriterion | None = None # date_refunded IS NOT NULL
price: FloatCriterion | None = None # on price field price: FloatCriterion | None = None # on price field
converted_price: FloatCriterion | None = None converted_price: FloatCriterion | None = None
@@ -317,12 +597,19 @@ class PurchaseFilter(OperatorFilter):
created_at: StringCriterion | None = None created_at: StringCriterion | None = None
updated_at: StringCriterion | None = None updated_at: StringCriterion | None = None
infinite: BoolCriterion | None = None
needs_price_update: BoolCriterion | None = None
converted_currency: StringCriterion | None = None
# Free-text search # Free-text search
search: StringCriterion | None = None search: StringCriterion | None = None
# Cross-entity: purchases for games matching these criteria # Cross-entity: purchases for games matching these criteria
game_filter: GameFilter | None = None game_filter: GameFilter | None = None
# Cross-entity: purchases for platforms matching these criteria
platform_filter: PlatformFilter | None = None
def to_q(self) -> Q: def to_q(self) -> Q:
q = Q() q = Q()
@@ -354,6 +641,12 @@ class PurchaseFilter(OperatorFilter):
q &= self.created_at.to_q("created_at") q &= self.created_at.to_q("created_at")
if self.updated_at is not None: if self.updated_at is not None:
q &= self.updated_at.to_q("updated_at") q &= self.updated_at.to_q("updated_at")
if self.infinite is not None:
q &= self.infinite.to_q("infinite")
if self.needs_price_update is not None:
q &= self.needs_price_update.to_q("needs_price_update")
if self.converted_currency is not None:
q &= self.converted_currency.to_q("converted_currency")
# Free-text search # Free-text search
if self.search is not None and self.search.value: if self.search is not None and self.search.value:
@@ -374,6 +667,16 @@ class PurchaseFilter(OperatorFilter):
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True) matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
q &= Q(games__id__in=matching_ids) q &= Q(games__id__in=matching_ids)
# Cross-entity platform filter
if self.platform_filter is not None:
from games.models import Platform
platform_q = self.platform_filter.to_q()
matching_ids = Platform.objects.filter(platform_q).values_list(
"id", flat=True
)
q &= Q(platform_id__in=matching_ids)
sub = self.sub_filter() sub = self.sub_filter()
if sub is not None: if sub is not None:
if self.AND is not None: if self.AND is not None:
@@ -420,9 +723,9 @@ class PurchaseFilter(OperatorFilter):
subquery = subquery.filter(games=game_id) subquery = subquery.filter(games=game_id)
if criterion.modifier == Modifier.INCLUDES_ONLY: if criterion.modifier == Modifier.INCLUDES_ONLY:
extra_ids = Game.objects.exclude( extra_ids = Game.objects.exclude(id__in=criterion.value).values_list(
id__in=criterion.value "id", flat=True
).values_list("id", flat=True) )
if extra_ids: if extra_ids:
subquery = subquery.exclude(games__in=extra_ids) subquery = subquery.exclude(games__in=extra_ids)
@@ -442,6 +745,213 @@ class PurchaseFilter(OperatorFilter):
return criterion.to_q("games") return criterion.to_q("games")
# ── DeviceFilter ───────────────────────────────────────────────────────────
@dataclass
class DeviceFilter(OperatorFilter):
"""Filter for the Device model."""
AND: DeviceFilter | None = None
OR: DeviceFilter | None = None
NOT: DeviceFilter | None = None
name: StringCriterion | None = None
type: ChoiceCriterion | None = None
created_at: StringCriterion | None = None
# Free-text search
search: StringCriterion | None = None
# Cross-entity: Devices that have sessions matching these criteria
session_filter: SessionFilter | None = None
def to_q(self) -> Q:
q = Q()
if self.name is not None:
q &= self.name.to_q("name")
if self.type is not None:
q &= self.type.to_q("type")
if self.created_at is not None:
q &= self.created_at.to_q("created_at")
# Free-text search
if self.search is not None and self.search.value:
search_q = Q(name__icontains=self.search.value) | Q(
type__icontains=self.search.value
)
if self.search.modifier == Modifier.EXCLUDES:
search_q = ~search_q
q &= search_q
# Cross-entity filter: session_filter
if self.session_filter is not None:
from games.models import Session
session_q = self.session_filter.to_q()
matching_ids = Session.objects.filter(session_q).values_list(
"device_id", flat=True
)
q &= Q(id__in=matching_ids)
sub = self.sub_filter()
if sub is not None:
if self.AND is not None:
q &= sub.to_q()
elif self.OR is not None:
q |= sub.to_q()
elif self.NOT is not None:
q &= ~sub.to_q()
return q
# ── PlatformFilter ─────────────────────────────────────────────────────────
@dataclass
class PlatformFilter(OperatorFilter):
"""Filter for the Platform model."""
AND: PlatformFilter | None = None
OR: PlatformFilter | None = None
NOT: PlatformFilter | None = None
name: StringCriterion | None = None
group: StringCriterion | None = None
icon: StringCriterion | None = None
created_at: StringCriterion | None = None
# Free-text search
search: StringCriterion | None = None
# Cross-entity
game_filter: GameFilter | None = None
purchase_filter: PurchaseFilter | None = None
def to_q(self) -> Q:
q = Q()
if self.name is not None:
q &= self.name.to_q("name")
if self.group is not None:
q &= self.group.to_q("group")
if self.icon is not None:
q &= self.icon.to_q("icon")
if self.created_at is not None:
q &= self.created_at.to_q("created_at")
# Free-text search
if self.search is not None and self.search.value:
search_q = Q(name__icontains=self.search.value) | Q(
group__icontains=self.search.value
)
if self.search.modifier == Modifier.EXCLUDES:
search_q = ~search_q
q &= search_q
# Cross-entity filter: game_filter
if self.game_filter is not None:
from games.models import Game
game_q = self.game_filter.to_q()
matching_ids = Game.objects.filter(game_q).values_list(
"platform_id", flat=True
)
q &= Q(id__in=matching_ids)
# Cross-entity filter: purchase_filter
if self.purchase_filter is not None:
from games.models import Purchase
purchase_q = self.purchase_filter.to_q()
matching_ids = Purchase.objects.filter(purchase_q).values_list(
"platform_id", flat=True
)
q &= Q(id__in=matching_ids)
sub = self.sub_filter()
if sub is not None:
if self.AND is not None:
q &= sub.to_q()
elif self.OR is not None:
q |= sub.to_q()
elif self.NOT is not None:
q &= ~sub.to_q()
return q
# ── PlayEventFilter ────────────────────────────────────────────────────────
@dataclass
class PlayEventFilter(OperatorFilter):
"""Filter for the PlayEvent model."""
AND: PlayEventFilter | None = None
OR: PlayEventFilter | None = None
NOT: PlayEventFilter | None = None
game: MultiCriterion | None = None # filters on game_id
started: StringCriterion | None = None # date string
ended: StringCriterion | None = None # date string
days_to_finish: IntCriterion | None = None
note: StringCriterion | None = None
created_at: StringCriterion | None = None
# Free-text search
search: StringCriterion | None = None
# Cross-entity: PlayEvents for games matching these criteria
game_filter: GameFilter | None = None
def to_q(self) -> Q:
q = Q()
if self.game is not None:
q &= self.game.to_q("game_id")
if self.started is not None:
q &= self.started.to_q("started")
if self.ended is not None:
q &= self.ended.to_q("ended")
if self.days_to_finish is not None:
q &= self.days_to_finish.to_q("days_to_finish")
if self.note is not None:
q &= self.note.to_q("note")
if self.created_at is not None:
q &= self.created_at.to_q("created_at")
# Free-text search
if self.search is not None and self.search.value:
search_q = Q(game__name__icontains=self.search.value) | Q(
note__icontains=self.search.value
)
if self.search.modifier == Modifier.EXCLUDES:
search_q = ~search_q
q &= search_q
# Cross-entity filter: game_filter
if self.game_filter is not None:
from games.models import Game
game_q = self.game_filter.to_q()
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
q &= Q(game_id__in=matching_ids)
sub = self.sub_filter()
if sub is not None:
if self.AND is not None:
q &= sub.to_q()
elif self.OR is not None:
q |= sub.to_q()
elif self.NOT is not None:
q &= ~sub.to_q()
return q
# ── Convenience helpers ──────────────────────────────────────────────────── # ── Convenience helpers ────────────────────────────────────────────────────
@@ -455,3 +965,15 @@ def parse_session_filter(json_str: str) -> SessionFilter | None:
def parse_purchase_filter(json_str: str) -> PurchaseFilter | None: def parse_purchase_filter(json_str: str) -> PurchaseFilter | None:
return filter_from_json(PurchaseFilter, json_str) return filter_from_json(PurchaseFilter, json_str)
def parse_device_filter(json_str: str) -> DeviceFilter | None:
return filter_from_json(DeviceFilter, json_str)
def parse_platform_filter(json_str: str) -> PlatformFilter | None:
return filter_from_json(PlatformFilter, json_str)
def parse_playevent_filter(json_str: str) -> PlayEventFilter | None:
return filter_from_json(PlayEventFilter, json_str)
+55 -36
View File
@@ -1,71 +1,90 @@
- model: games.game
pk: 1
fields:
name: Nioh 2
wikidata: Q67482292
- model: games.game
pk: 2
fields:
name: Elden Ring
wikidata: Q64826862
- model: games.game
pk: 3
fields:
name: Cyberpunk 2077
wikidata: Q3182559
- model: games.purchase
pk: 1
fields:
game: 1
platform: 1
date_purchased: 2021-02-13
date_refunded: null
- model: games.purchase
pk: 2
fields:
game: 2
platform: 1
date_purchased: 2022-02-24
date_refunded: null
- model: games.purchase
pk: 3
fields:
game: 3
platform: 1
date_purchased: 2020-12-07
date_refunded: null
- model: games.platform - model: games.platform
pk: 1 pk: 1
fields: fields:
name: Steam name: Steam
group: PC group: PC
created_at: "2020-01-01T00:00:00Z"
- model: games.platform - model: games.platform
pk: 3 pk: 3
fields: fields:
name: Xbox Gamepass name: Xbox Gamepass
group: PC group: PC
created_at: "2020-01-01T00:00:00Z"
- model: games.platform - model: games.platform
pk: 4 pk: 4
fields: fields:
name: Epic Games Store name: Epic Games Store
group: PC group: PC
created_at: "2020-01-01T00:00:00Z"
- model: games.platform - model: games.platform
pk: 5 pk: 5
fields: fields:
name: Playstation 5 name: Playstation 5
group: Playstation group: Playstation
created_at: "2020-01-01T00:00:00Z"
- model: games.platform - model: games.platform
pk: 6 pk: 6
fields: fields:
name: Playstation 4 name: Playstation 4
group: Playstation group: Playstation
created_at: "2020-01-01T00:00:00Z"
- model: games.platform - model: games.platform
pk: 7 pk: 7
fields: fields:
name: Nintendo Switch name: Nintendo Switch
group: Nintendo group: Nintendo
created_at: "2020-01-01T00:00:00Z"
- model: games.platform - model: games.platform
pk: 8 pk: 8
fields: fields:
name: Nintendo 3DS name: Nintendo 3DS
group: Nintendo group: Nintendo
created_at: "2020-01-01T00:00:00Z"
- model: games.game
pk: 1
fields:
name: Nioh 2
wikidata: Q67482292
created_at: "2021-02-13T00:00:00Z"
updated_at: "2021-02-13T00:00:00Z"
- model: games.game
pk: 2
fields:
name: Elden Ring
wikidata: Q64826862
created_at: "2022-02-24T00:00:00Z"
updated_at: "2022-02-24T00:00:00Z"
- model: games.game
pk: 3
fields:
name: Cyberpunk 2077
wikidata: Q3182559
created_at: "2020-12-07T00:00:00Z"
updated_at: "2020-12-07T00:00:00Z"
- model: games.purchase
pk: 1
fields:
games: [1]
platform: 1
date_purchased: 2021-02-13
date_refunded: null
created_at: "2021-02-13T00:00:00Z"
updated_at: "2021-02-13T00:00:00Z"
- model: games.purchase
pk: 2
fields:
games: [2]
platform: 1
date_purchased: 2022-02-24
date_refunded: null
created_at: "2022-02-24T00:00:00Z"
updated_at: "2022-02-24T00:00:00Z"
- model: games.purchase
pk: 3
fields:
games: [3]
platform: 1
date_purchased: 2020-12-07
date_refunded: null
created_at: "2020-12-07T00:00:00Z"
updated_at: "2020-12-07T00:00:00Z"
+121 -53
View File
@@ -1,13 +1,16 @@
from django import forms from django import forms
from django.contrib.auth.forms import AuthenticationForm
from django.db import transaction from django.db import transaction
from django.db.models import OuterRef, Subquery
from common.components import ( from common.components import (
DEFAULT_PREFETCH, DEFAULT_PREFETCH,
DISABLED_CONTROL_CLASS,
SearchSelect, SearchSelect,
SearchSelectOption, SearchSelectOption,
render,
searchselect_selected, searchselect_selected,
) )
from common.components.primitives import Checkbox
from games.models import ( from games.models import (
Device, Device,
Game, Game,
@@ -24,6 +27,77 @@ custom_datetime_widget = forms.DateTimeInput(
) )
autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"}) autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
# Form controls self-style: these utility strings live on the elements (applied
# by PrimitiveWidgetsMixin), so there is no form styling in input.css and no
# selector reaching in to style them. The disabled appearance is the shared
# DISABLED_CONTROL_CLASS so every form element looks the same disabled.
_DISABLED_CONTROL = DISABLED_CONTROL_CLASS
INPUT_CLASS = (
"mb-3 bg-neutral-secondary-medium border border-default-medium text-heading "
"text-sm rounded-base focus:ring-brand focus:border-brand block w-full "
f"px-3 py-2.5 shadow-xs placeholder:text-body {_DISABLED_CONTROL}"
)
SELECT_CLASS = (
"w-full px-3 py-2.5 bg-neutral-secondary-medium border border-default-medium "
"text-heading text-sm rounded-base focus:ring-brand focus:border-brand "
f"shadow-xs placeholder:text-body {_DISABLED_CONTROL}"
)
TEXTAREA_CLASS = (
"bg-neutral-secondary-medium border border-default-medium text-heading "
"text-sm rounded-base focus:ring-brand focus:border-brand block w-full p-3.5 "
f"shadow-xs placeholder:text-body {_DISABLED_CONTROL}"
)
class PrimitiveCheckboxWidget(forms.CheckboxInput):
"""Adapts Django's CheckboxInput to use our Checkbox component."""
def render(self, name, value, attrs=None, renderer=None):
final_attrs = self.build_attrs(self.attrs, attrs)
checked = self.check_test(value)
attributes = [
(k, str(v))
for k, v in final_attrs.items()
if k not in ("type", "name", "value", "checked")
]
# Django uses boolean values differently for checkboxes, we omit value if empty
# render() returns a safe string (Django widgets must not be autoescaped).
return render(
Checkbox(
name=name,
label=None,
checked=checked,
value=str(value) if value else "1",
attributes=attributes,
)
)
class PrimitiveWidgetsMixin:
"""Automatically applies primitive custom widgets to native Django form fields."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field_name, field in self.fields.items():
if isinstance(field, forms.BooleanField):
field.widget = PrimitiveCheckboxWidget()
# Maintain the field's explicit required status (usually False for booleans)
continue
widget = field.widget
# SearchSelect is a self-styled composite component; never stamp the
# native-control classes onto it.
if isinstance(widget, SearchSelectWidget):
continue
if isinstance(widget, forms.Select):
control_class = SELECT_CLASS
elif isinstance(widget, forms.Textarea):
control_class = TEXTAREA_CLASS
else:
control_class = INPUT_CLASS
existing = widget.attrs.get("class", "")
widget.attrs["class"] = f"{existing} {control_class}".strip()
class MultipleGameChoiceField(forms.ModelMultipleChoiceField): class MultipleGameChoiceField(forms.ModelMultipleChoiceField):
def label_from_instance(self, obj) -> str: def label_from_instance(self, obj) -> str:
@@ -102,19 +176,22 @@ class SearchSelectWidget(forms.Widget):
def render(self, name, value, attrs=None, renderer=None): def render(self, name, value, attrs=None, renderer=None):
selected = searchselect_selected(self._values(value), self.options_resolver) selected = searchselect_selected(self._values(value), self.options_resolver)
autofocus = bool((attrs or {}).get("autofocus")) autofocus = bool((attrs or {}).get("autofocus"))
return SearchSelect( # Django widgets must return a safe string; the component is a node.
name=name, return render(
selected=selected, SearchSelect(
options=None, name=name,
search_url=self.search_url, selected=selected,
multi_select=self.multi_select, options=None,
items_visible=self.items_visible, search_url=self.search_url,
items_scroll=self.items_scroll, multi_select=self.multi_select,
prefetch=self.prefetch, items_visible=self.items_visible,
always_visible=self.always_visible, items_scroll=self.items_scroll,
placeholder=self.placeholder, prefetch=self.prefetch,
id=(attrs or {}).get("id", ""), always_visible=self.always_visible,
autofocus=autofocus, placeholder=self.placeholder,
id=(attrs or {}).get("id", ""),
autofocus=autofocus,
)
) )
def value_from_datadict(self, data, files, name): def value_from_datadict(self, data, files, name):
@@ -128,7 +205,7 @@ class SearchSelectMultiple(SearchSelectWidget):
return data.get(name) return data.get(name)
class SessionForm(forms.ModelForm): class SessionForm(PrimitiveWidgetsMixin, forms.ModelForm):
game = SingleGameChoiceField( game = SingleGameChoiceField(
queryset=Game.objects.order_by("sort_name"), queryset=Game.objects.order_by("sort_name"),
widget=SearchSelectWidget( widget=SearchSelectWidget(
@@ -187,35 +264,13 @@ class SessionForm(forms.ModelForm):
return session return session
def related_purchase_queryset(): class PurchaseForm(PrimitiveWidgetsMixin, forms.ModelForm):
"""GAME purchases annotated with their first game's name.
Rendering the ``related_purchase`` ``<select>`` calls ``str()`` on every
option, and ``Purchase.__str__`` falls back to ``first_game`` — one extra
query per option (700+ on a large library). Annotating the first game's
name via a subquery lets the choice field build labels without those
per-row queries.
"""
first_game_name = Subquery(
Game.objects.filter(purchases=OuterRef("pk")).order_by("id").values("name")[:1]
)
return Purchase.objects.filter(type=Purchase.GAME).annotate(
_first_game_name=first_game_name
)
class RelatedPurchaseChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj) -> str:
# Mirrors Purchase.standardized_name but reads the annotated first-game
# name instead of querying first_game per option.
name = obj.name or getattr(obj, "_first_game_name", None)
return name or obj.standardized_name
class PurchaseForm(forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["platform"].queryset = Platform.objects.order_by("name") self.fields["platform"].queryset = Platform.objects.order_by("name")
# The bundle Price is optional: in price-per-game mode it is hidden and
# the per-game inputs carry the prices instead. Empty falls back to 0.
self.fields["price"].required = False
games = MultipleGameChoiceField( games = MultipleGameChoiceField(
queryset=Game.objects.order_by("sort_name"), queryset=Game.objects.order_by("sort_name"),
@@ -231,9 +286,12 @@ class PurchaseForm(forms.ModelForm):
search_url="/api/platforms/search", options_resolver=_platform_options search_url="/api/platforms/search", options_resolver=_platform_options
), ),
) )
related_purchase = RelatedPurchaseChoiceField( related_game = forms.ModelChoiceField(
queryset=related_purchase_queryset(), queryset=Game.objects.order_by("sort_name"),
required=False, required=False,
widget=SearchSelectWidget(
search_url="/api/games/search", options_resolver=_game_options
),
) )
price_currency = forms.CharField( price_currency = forms.CharField(
@@ -264,14 +322,14 @@ class PurchaseForm(forms.ModelForm):
"price_currency", "price_currency",
"ownership_type", "ownership_type",
"type", "type",
"related_purchase", "related_game",
"name", "name",
] ]
def clean(self): def clean(self):
cleaned_data = super().clean() cleaned_data = super().clean()
purchase_type = cleaned_data.get("type") purchase_type = cleaned_data.get("type")
related_purchase = cleaned_data.get("related_purchase") related_game = cleaned_data.get("related_game")
name = cleaned_data.get("name") name = cleaned_data.get("name")
# Set the type on the instance to use get_type_display() # Set the type on the instance to use get_type_display()
@@ -280,13 +338,18 @@ class PurchaseForm(forms.ModelForm):
if purchase_type != Purchase.GAME: if purchase_type != Purchase.GAME:
type_display = self.instance.get_type_display() type_display = self.instance.get_type_display()
if not related_purchase: if not related_game:
self.add_error( self.add_error(
"related_purchase", "related_game",
f"{type_display} must have a related purchase.", f"{type_display} must have a related game.",
) )
if not name: if not name:
self.add_error("name", f"{type_display} must have a name.") self.add_error("name", f"{type_display} must have a name.")
# An empty bundle Price (price-per-game mode) saves as 0, not NULL.
if cleaned_data.get("price") is None:
cleaned_data["price"] = 0
return cleaned_data return cleaned_data
@@ -305,7 +368,7 @@ class GameModelChoiceField(forms.ModelChoiceField):
return obj.sort_name return obj.sort_name
class GameForm(forms.ModelForm): class GameForm(PrimitiveWidgetsMixin, forms.ModelForm):
platform = forms.ModelChoiceField( platform = forms.ModelChoiceField(
queryset=Platform.objects.order_by("name"), queryset=Platform.objects.order_by("name"),
required=False, required=False,
@@ -329,7 +392,7 @@ class GameForm(forms.ModelForm):
widgets = {"name": autofocus_input_widget} widgets = {"name": autofocus_input_widget}
class PlatformForm(forms.ModelForm): class PlatformForm(PrimitiveWidgetsMixin, forms.ModelForm):
class Meta: class Meta:
model = Platform model = Platform
fields = [ fields = [
@@ -340,14 +403,14 @@ class PlatformForm(forms.ModelForm):
widgets = {"name": autofocus_input_widget} widgets = {"name": autofocus_input_widget}
class DeviceForm(forms.ModelForm): class DeviceForm(PrimitiveWidgetsMixin, forms.ModelForm):
class Meta: class Meta:
model = Device model = Device
fields = ["name", "type"] fields = ["name", "type"]
widgets = {"name": autofocus_input_widget} widgets = {"name": autofocus_input_widget}
class PlayEventForm(forms.ModelForm): class PlayEventForm(PrimitiveWidgetsMixin, forms.ModelForm):
game = SingleGameChoiceField( game = SingleGameChoiceField(
queryset=Game.objects.order_by("sort_name"), queryset=Game.objects.order_by("sort_name"),
widget=SearchSelectWidget( widget=SearchSelectWidget(
@@ -382,7 +445,7 @@ class PlayEventForm(forms.ModelForm):
return session return session
class GameStatusChangeForm(forms.ModelForm): class GameStatusChangeForm(PrimitiveWidgetsMixin, forms.ModelForm):
class Meta: class Meta:
model = GameStatusChange model = GameStatusChange
fields = [ fields = [
@@ -394,3 +457,8 @@ class GameStatusChangeForm(forms.ModelForm):
widgets = { widgets = {
"timestamp": custom_datetime_widget, "timestamp": custom_datetime_widget,
} }
class LoginForm(PrimitiveWidgetsMixin, AuthenticationForm):
"""Django's auth form with our primitive widget styling so login inputs
self-style like every other form (no styling-at-a-distance)."""
@@ -0,0 +1,21 @@
"""Write ts/generated/props.ts from the registered custom-element specs."""
from pathlib import Path
from django.conf import settings
from django.core.management.base import BaseCommand
# Importing the components package triggers element registration at import time.
import common.components # noqa: F401
from common.components.custom_elements import render_props_module
class Command(BaseCommand):
help = "Generate ts/generated/props.ts from registered custom elements."
def handle(self, *args, **options) -> None:
output_dir = Path(settings.BASE_DIR) / "ts" / "generated"
output_dir.mkdir(parents=True, exist_ok=True)
target = output_dir / "props.ts"
target.write_text(render_props_module(), encoding="utf-8")
self.stdout.write(self.style.SUCCESS(f"Wrote {target}"))
@@ -0,0 +1,28 @@
from django.contrib.sessions.models import Session
from django.core.management.base import BaseCommand
from django_q.models import OrmQ, Schedule, Task
class Command(BaseCommand):
help = (
"Remove copied production artifacts from a staging database seeded "
"from a production snapshot: clears authenticated sessions and the "
"django-q schedule/queue/results so staging does not share prod's "
"session cookies or independently run scheduled tasks."
)
def handle(self, *args, **kwargs):
sessions_deleted, _ = Session.objects.all().delete()
schedules_deleted, _ = Schedule.objects.all().delete()
tasks_deleted, _ = Task.objects.all().delete()
queued_deleted, _ = OrmQ.objects.all().delete()
self.stdout.write(
self.style.SUCCESS(
"Scrubbed staging database: "
f"{sessions_deleted} session(s), "
f"{schedules_deleted} schedule(s), "
f"{tasks_deleted} task result(s), "
f"{queued_deleted} queued task(s) removed."
)
)
@@ -4,15 +4,14 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('games', '0017_add_filter_preset'), ("games", "0017_add_filter_preset"),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name='session', model_name="session",
name='timestamp_start', name="timestamp_start",
field=models.DateTimeField(db_index=True, verbose_name='Start'), field=models.DateTimeField(db_index=True, verbose_name="Start"),
), ),
] ]
@@ -0,0 +1,28 @@
# Generated by Django 6.0.5 on 2026-06-13 18:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0018_alter_session_timestamp_start"),
]
operations = [
migrations.AlterField(
model_name="filterpreset",
name="mode",
field=models.CharField(
choices=[
("games", "Games"),
("sessions", "Sessions"),
("purchases", "Purchases"),
("playevents", "Play Events"),
("devices", "Devices"),
("platforms", "Platforms"),
],
default="games",
max_length=50,
),
),
]
@@ -0,0 +1,46 @@
# Generated by Django 6.0.6 on 2026-06-18 21:03
import django.db.models.deletion
from django.db import migrations, models
def backfill_related_game(apps, schema_editor):
"""Move each add-on purchase's parent link from the parent *purchase* to a
parent *game*. For a parent bought as a multi-game bundle there is no single
game, so use the bundle's first game (by sort_name) as the best guess."""
Purchase = apps.get_model("games", "Purchase")
for purchase in Purchase.objects.filter(related_purchase__isnull=False):
parent_game = purchase.related_purchase.games.order_by("sort_name").first()
if parent_game is not None:
purchase.related_game = parent_game
purchase.save(update_fields=["related_game"])
def noop_reverse(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("games", "0019_alter_filterpreset_mode"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="related_game",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="addon_purchases",
to="games.game",
),
),
migrations.RunPython(backfill_related_game, noop_reverse),
migrations.RemoveField(
model_name="purchase",
name="related_purchase",
),
]
+8 -5
View File
@@ -198,12 +198,13 @@ class Purchase(models.Model):
) )
type = models.CharField(max_length=255, choices=TYPES, default=GAME) type = models.CharField(max_length=255, choices=TYPES, default=GAME)
name = models.CharField(max_length=255, blank=True, default="") name = models.CharField(max_length=255, blank=True, default="")
related_purchase = models.ForeignKey( related_game = models.ForeignKey(
"self", Game,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
default=None, default=None,
null=True, null=True,
related_name="related_purchases", blank=True,
related_name="addon_purchases",
) )
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@@ -252,9 +253,9 @@ class Purchase(models.Model):
self.save() self.save()
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.type != Purchase.GAME and not self.related_purchase: if self.type != Purchase.GAME and not self.related_game:
raise ValidationError( raise ValidationError(
f"{self.get_type_display()} must have a related purchase." f"{self.get_type_display()} must have a related game."
) )
super().save(*args, **kwargs) super().save(*args, **kwargs)
@@ -501,6 +502,8 @@ class FilterPreset(models.Model):
("sessions", "Sessions"), ("sessions", "Sessions"),
("purchases", "Purchases"), ("purchases", "Purchases"),
("playevents", "Play Events"), ("playevents", "Play Events"),
("devices", "Devices"),
("platforms", "Platforms"),
] ]
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
File diff suppressed because it is too large Load Diff
-24
View File
@@ -1,24 +0,0 @@
import { syncSelectInputUntilChanged } from "./utils.js";
let syncData = [
{
source: "#id_game",
source_value: "dataset.name",
target: "#id_name",
target_value: "value",
},
{
source: "#id_game",
source_value: "textContent",
target: "#id_sort_name",
target_value: "value",
},
{
source: "#id_game",
source_value: "dataset.year",
target: "#id_year_released",
target_value: "value",
},
];
syncSelectInputUntilChanged(syncData, "form");
-12
View File
@@ -1,12 +0,0 @@
import { syncSelectInputUntilChanged } from "./utils.js";
let syncData = [
{
source: "#id_name",
source_value: "value",
target: "#id_sort_name",
target_value: "value",
},
];
syncSelectInputUntilChanged(syncData, "form");
-45
View File
@@ -1,45 +0,0 @@
import { getEl, disableElementsWhenTrue } from "./utils.js";
const RELATED_PURCHASE_URL = "/tracker/purchase/related-purchase-by-game";
// The games field is now a SearchSelect widget (a <div>, not a <select>), so we
// react to its custom "search-select:change" event instead of syncing a select.
document.addEventListener("search-select:change", (event) => {
if (event.detail.name !== "games") return;
// (a) Auto-fill platform from the clicked option's data-platform.
const last = event.detail.last;
const platformId = last && last.data ? last.data.platform : "";
if (platformId) {
const platformEl = getEl("#id_platform");
if (platformEl) platformEl.value = platformId;
}
// (b) Refresh #id_related_purchase for the currently selected games.
const query = event.detail.values
.map((value) => "games=" + encodeURIComponent(value))
.join("&");
fetch(RELATED_PURCHASE_URL + "?" + query, { credentials: "same-origin" })
.then((response) => {
if (response.status === 204) return null;
return response.text();
})
.then((html) => {
if (html === null) return;
const target = getEl("#id_related_purchase");
if (target) target.outerHTML = html;
});
});
function setupElementHandlers() {
disableElementsWhenTrue("#id_type", "game", [
"#id_name",
"#id_related_purchase",
]);
}
document.addEventListener("DOMContentLoaded", setupElementHandlers);
document.addEventListener("htmx:afterSwap", setupElementHandlers);
getEl("#id_type").addEventListener("change", () => {
setupElementHandlers();
});
-23
View File
@@ -1,23 +0,0 @@
import { toISOUTCString } from "./utils.js";
for (let button of document.querySelectorAll("[data-target]")) {
let target = button.getAttribute("data-target");
let type = button.getAttribute("data-type");
let targetElement = document.querySelector(`#id_${target}`);
button.addEventListener("click", (event) => {
event.preventDefault();
if (type == "now") {
targetElement.value = toISOUTCString(new Date());
} else if (type == "copy") {
const oppositeName =
targetElement.name == "timestamp_start"
? "timestamp_end"
: "timestamp_start";
document.querySelector(`[name='${oppositeName}']`).value =
targetElement.value;
} else if (type == "toggle") {
if (targetElement.type == "datetime-local") targetElement.type = "text";
else targetElement.type = "datetime-local";
}
});
}
+1
View File
@@ -0,0 +1 @@
(()=>{function x(n){n.directive("mask",(e,{value:l,expression:r},{effect:s,evaluateLater:i,cleanup:u})=>{let p=()=>r,f="";queueMicrotask(()=>{if(["function","dynamic"].includes(l)){let o=i(r);s(()=>{p=t=>{let c;return n.dontAutoEvaluateFunctions(()=>{o(d=>{c=typeof d=="function"?d(t):d},{scope:{$input:t,$money:M.bind({el:e})}})}),c},a(e,!1)})}else a(e,!1);if(e._x_model){e._x_model.get()!==e.value&&(e._x_model.get()===null&&e.value===""||e._x_model.set(e.value));let o=e._x_forceModelUpdate;e._x_forceModelUpdate=t=>{t=String(t);let c=p(t);c&&c!=="false"&&(t=m(c,t)),f=t,o(t),e._x_model.set(t)}}});let g=new AbortController;u(()=>{g.abort()}),e.addEventListener("input",()=>a(e),{signal:g.signal,capture:!0}),e.addEventListener("blur",()=>a(e,!1),{signal:g.signal});function a(o,t=!0){let c=o.value,d=p(c);if(!d||d==="false")return!1;if(f.length-o.value.length===1)return f=o.value;let h=()=>{f=o.value=m(d,c)};t?v(o,d,()=>{h()}):h()}}).before("model")}function v(n,e,l){let r=n.selectionStart,s=n.value;l();let i=s.slice(0,r),u=m(e,i).length;n.setSelectionRange(u,u)}var _={9:/[0-9]/,a:/[a-zA-Z]/,"*":/[a-zA-Z0-9]/};function m(n,e){let l=0,r=0,s="";for(;l<n.length&&r<e.length;){let i=n[l],u=e[r];i in _?(_[i].test(u)&&(s+=u,l++),r++):(s+=i,l++,i===e[r]&&r++)}return s}function M(n,e=".",l,r=2){if(n==="-")return"-";if(/^\D+$/.test(n))return"9";l==null&&(l=e===","?".":",");let s=(f,g)=>{let a="",o=0;for(let t=f.length-1;t>=0;t--)f[t]!==g&&(o===3?(a=f[t]+g+a,o=0):a=f[t]+a,o++);return a},i=n.startsWith("-")?"-":"",u=n.replaceAll(new RegExp(`[^0-9\\${e}]`,"g"),""),p=Array.from({length:u.split(e)[0].length}).fill("9").join("");return p=`${i}${s(p,l)}`,r>0&&n.includes(e)&&(p+=`${e}`+"9".repeat(r)),queueMicrotask(()=>{this.el.value.endsWith(e)||this.el.value[this.el.selectionStart-1]===e&&this.el.setSelectionRange(this.el.selectionStart-1,this.el.selectionStart-1)}),p}document.addEventListener("alpine:init",()=>{window.Alpine.plugin(x)});})();
+5
View File
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
-354
View File
@@ -1,354 +0,0 @@
/**
* Filter bar vanilla JavaScript implementation.
*
* Handles form submission, preset loading/saving, and preset list rendering.
* No HTMX plain fetch() and window.location for all interactions.
*/
(function () {
"use strict";
/** Build a criterion object from a value and optional second value. */
function criterion(value, value2, modifier) {
var c = { value: value, modifier: modifier };
if (value2 !== null && value2 !== undefined && value2 !== "") {
c.value2 = value2;
}
return c;
}
/** Read a <select> element's value, or "" if not found. */
function selectValue(form, name) {
var el = form.querySelector('[name="' + name + '"]');
return el ? el.value : "";
}
/** Read an <input type="number"> value, or "" if not found. */
function numberValue(form, name) {
var el = form.querySelector('[name="' + name + '"]');
if (!el || el.value === "") return "";
var val = parseFloat(el.value);
return isNaN(val) ? "" : val;
}
/** Read all checked checkboxes with a given name, returning an array of ints. */
function checkedValues(form, name) {
var els = form.querySelectorAll('[name="' + name + '"]:checked');
var ids = [];
els.forEach(function (el) {
var v = parseInt(el.value, 10);
if (!isNaN(v)) ids.push(v);
});
return ids;
}
/**
* Build the filter JSON object from form field values.
* Returns a plain object ready for JSON.stringify.
*/
function buildFilterJSON(form) {
var filter = {};
var yearMin = numberValue(form, "filter-year-min");
var yearMax = numberValue(form, "filter-year-max");
var playMin = numberValue(form, "filter-playtime-min");
var playMax = numberValue(form, "filter-playtime-max");
var mastered = form.querySelector('[name="filter-mastered"]');
// ── Search field ──
var searchInput = form.querySelector('[name="filter-search"]');
if (searchInput && searchInput.value.trim()) {
filter.search = { value: searchInput.value.trim(), modifier: "INCLUDES" };
}
// ── FilterSelect widgets (data-search-select-mode="filter") ──
// readSearchSelect serialises each into data-included/data-excluded/data-modifier.
readSearchSelect(form);
var widgets = form.querySelectorAll('[data-search-select][data-search-select-mode="filter"]');
widgets.forEach(function (widget) {
var field = widget.getAttribute("data-name");
var included = parseJSONAttr(widget, "data-included");
var excluded = parseJSONAttr(widget, "data-excluded");
// Two orthogonal axes: a presence modifier (NOT_NULL/IS_NULL) from the
// pinned (Any)/(None) pseudo-options clears the value set and has no
// values; the non-presence modifier (INCLUDES_ALL/INCLUDES_ONLY) governs
// how the include set matches. When neither is set the implicit default
// is INCLUDES ("any"). Must match Python _PRESENCE_MODIFIERS.
var modifier = widget.getAttribute("data-modifier");
var IS_PRESENCE = modifier === "NOT_NULL" || modifier === "IS_NULL";
if (IS_PRESENCE) {
filter[field] = { modifier: modifier };
} else if (included.length > 0 || excluded.length > 0) {
// All filter pills carry {id, label}; store them as-is so the filter
// URL and saved presets are self-describing (Stash-style).
filter[field] = {
value: included.map(function (item) { return {id: item.id, label: item.label}; }),
excludes: excluded.map(function (item) { return {id: item.id, label: item.label}; }),
modifier: modifier || "INCLUDES",
};
}
});
// ── Session-specific fields ──
var pageIsSessions =
!!form.querySelector('[data-search-select][data-search-select-mode="filter"][data-name="game"]');
// Emulated checkbox (sessions page)
var emulated = form.querySelector('[name="filter-emulated"]');
if (emulated && emulated.checked) {
filter.emulated = criterion(true, null, "EQUALS");
}
// Active checkbox (sessions page)
var active = form.querySelector('[name="filter-active"]');
if (active && active.checked) {
filter.is_active = criterion(true, null, "EQUALS");
}
if (yearMin !== "" && yearMax !== "") {
filter.year_released = criterion(yearMin, yearMax, "BETWEEN");
} else if (yearMin !== "") {
filter.year_released = criterion(yearMin, null, "GREATER_THAN");
} else if (yearMax !== "") {
filter.year_released = criterion(yearMax, null, "LESS_THAN");
}
if (playMin !== "" || playMax !== "") {
var pMin = playMin !== "" ? Math.round(playMin * 60) : 0;
var pMax = playMax !== "" ? Math.round(playMax * 60) : 0;
// Skip if both are 0 — means slider is at default (no real filter)
if (pMin === 0 && pMax === 0) {
// don't add filter
} else {
var durKey = pageIsSessions ? "duration_minutes" : "playtime_minutes";
if (playMin !== "" && playMax !== "") {
filter[durKey] = criterion(pMin, pMax, "BETWEEN");
} else if (playMin !== "") {
filter[durKey] = criterion(pMin, null, "GREATER_THAN");
} else if (playMax !== "") {
filter[durKey] = criterion(pMax, null, "LESS_THAN");
}
}
}
// ── Purchase-specific: num_purchases ──
var numGamesMin = numberValue(form, "filter-num-purchases-min");
var numGamesMax = numberValue(form, "filter-num-purchases-max");
if (numGamesMin !== "" && numGamesMax !== "") {
filter.num_purchases = criterion(parseInt(numGamesMin, 10), parseInt(numGamesMax, 10), "BETWEEN");
} else if (numGamesMin !== "") {
filter.num_purchases = criterion(parseInt(numGamesMin, 10), null, "GREATER_THAN");
} else if (numGamesMax !== "") {
filter.num_purchases = criterion(parseInt(numGamesMax, 10), null, "LESS_THAN");
}
if (mastered && mastered.checked) {
filter.mastered = criterion(true, null, "EQUALS");
}
return filter;
}
/** Extract the current page's base URL (without query string). */
function baseUrl() {
return window.location.pathname;
}
/** Safely parse a JSON attribute, returning empty array on failure. */
function parseJSONAttr(el, attr) {
var raw = el.getAttribute(attr);
if (!raw) return [];
try { return JSON.parse(raw); } catch (e) { return []; }
}
/**
* Called on filter bar form submit.
* Serializes filter fields, navigates to URL with filter param.
*/
window.applyFilterBar = function (event) {
event.preventDefault();
var form = event.target;
var filter = buildFilterJSON(form);
var filterStr = JSON.stringify(filter);
var url = baseUrl();
if (filterStr && filterStr !== "{}") {
url += "?filter=" + encodeURIComponent(filterStr);
}
window.location.href = url;
return false;
};
/**
* Clear all filter fields and reload the unfiltered view.
*/
window.clearFilterBar = function (formId, filterInputId) {
var form = document.getElementById(formId);
if (!form) return;
form.reset();
window.location.href = baseUrl();
};
// ── Presets ─────────────────────────────────────────────────────────────
/** Fetch and render the preset list. */
function loadPresets() {
var dropdown = document.getElementById("preset-dropdown");
if (!dropdown) return;
var url = dropdown.getAttribute("data-preset-list-url");
if (!url) return;
var mode = "games";
if (window.location.pathname.indexOf("session") !== -1) mode = "sessions";
else if (window.location.pathname.indexOf("purchase") !== -1) mode = "purchases";
fetch(url + "?mode=" + mode, { credentials: "same-origin" })
.then(function (r) {
if (!r.ok) throw new Error("Failed to load presets");
return r.text();
})
.then(function (html) {
dropdown.innerHTML = html;
// Re-attach delete handlers (list_presets view uses onclick attributes,
// but we also need to wire up inline handlers if they use data attributes)
setupPresetDeleteHandlers(dropdown);
})
.catch(function (err) {
dropdown.innerHTML =
'<span class="text-sm text-body italic">Presets unavailable</span>';
console.error(err);
});
}
/** Wire up click handlers for preset delete buttons. */
function setupPresetDeleteHandlers(container) {
var deleteLinks = container.querySelectorAll('[data-delete-preset]');
deleteLinks.forEach(function (link) {
link.addEventListener("click", function (e) {
e.preventDefault();
var presetId = link.getAttribute("data-delete-preset");
var deleteUrl = link.getAttribute("href");
if (!deleteUrl) return;
if (!confirm("Delete this preset?")) return;
fetch(deleteUrl, {
method: "POST",
credentials: "same-origin",
headers: { "X-CSRFToken": getCsrfToken() },
})
.then(function () {
// Remove the parent <li>
var li = link.closest("li");
if (li) li.remove();
// If no items left, show empty message
var ul = container.querySelector("ul");
if (ul && ul.querySelectorAll("li").length === 0) {
ul.innerHTML =
'<li class="px-4 py-2 text-sm text-body italic">No saved presets</li>';
}
})
.catch(function (err) {
console.error("Delete failed:", err);
});
});
});
}
/** Show the preset name input field and the confirm button. */
window.showPresetNameInput = function () {
var input = document.getElementById("preset-name-input");
var saveBtn = document.getElementById("save-preset-btn");
var confirmBtn = document.getElementById("confirm-save-preset-btn");
if (input) input.classList.remove("hidden");
if (saveBtn) saveBtn.classList.add("hidden");
if (confirmBtn) confirmBtn.classList.remove("hidden");
if (input) input.focus();
};
/** Save the current filter as a named preset. */
window.savePreset = function (formId, filterInputId, saveUrl) {
var input = document.getElementById("preset-name-input");
var name = input ? input.value.trim() : "";
if (!name) {
if (input) input.classList.add("border-red-500");
return;
}
var filterInput = document.getElementById(filterInputId);
var form = document.getElementById(formId);
var filterObj = form ? buildFilterJSON(form) : {};
var body = new URLSearchParams();
body.append("name", name);
var mode = "games";
if (window.location.pathname.indexOf("session") !== -1) mode = "sessions";
else if (window.location.pathname.indexOf("purchase") !== -1) mode = "purchases";
body.append("mode", mode);
body.append("filter", JSON.stringify(filterObj));
fetch(saveUrl, {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-CSRFToken": getCsrfToken(),
},
body: body.toString(),
})
.then(function (r) {
if (!r.ok) throw new Error("Save failed");
// Reset UI
if (input) {
input.value = "";
input.classList.add("hidden");
input.classList.remove("border-red-500");
}
var saveBtn = document.getElementById("save-preset-btn");
var confirmBtn = document.getElementById("confirm-save-preset-btn");
if (saveBtn) saveBtn.classList.remove("hidden");
if (confirmBtn) confirmBtn.classList.add("hidden");
// Refresh the preset list
loadPresets();
})
.catch(function (err) {
console.error("Failed to save preset:", err);
});
};
/** Extract CSRF token from the page. */
function getCsrfToken() {
var cookie = document.cookie
.split("; ")
.find(function (row) {
return row.startsWith("csrftoken=");
});
if (cookie) return cookie.split("=")[1];
var el = document.querySelector('input[name="csrfmiddlewaretoken"]');
return el ? el.value : "";
}
// ── Init on page load ───────────────────────────────────────────────────
// ── Inject search inputs into filter forms ──
function injectSearchInputs() {
document.querySelectorAll('[id^="filter-bar-form"]').forEach(function (form) {
if (form.querySelector('[name="filter-search"]')) return; // already added
var input = document.createElement("input");
input.type = "text";
input.name = "filter-search";
input.placeholder = "Search\u2026";
input.className = "block w-full rounded-base border border-default-medium bg-neutral-secondary-medium text-sm text-heading p-2 mb-4 focus:ring-brand focus:border-brand";
// Pre-fill from existing filter JSON
var hidden = form.querySelector('[name="filter"]');
if (hidden && hidden.parentNode) {
try {
var existing = JSON.parse(hidden.value || "{}");
if (existing.search && existing.search.value) {
input.value = existing.search.value;
}
} catch (e) {}
hidden.parentNode.insertBefore(input, hidden.nextSibling);
}
});
}
document.addEventListener("DOMContentLoaded", function () {
injectSearchInputs();
loadPresets();
});
})();
File diff suppressed because one or more lines are too long
-37
View File
@@ -1,37 +0,0 @@
(function() {
htmx.defineExtension("hx-redirect-toast", {
isInlineSwap: function(swapStyle) {
return swapStyle === "hx-redirect-toast";
},
handleSwap: function(swapStyle, target, fragment, settleInfo, htmxConfig) {
var xhr = htmxConfig.xhr;
var hxRedirect = xhr.getResponseHeader("HX-Redirect");
var hxTrigger = xhr.getResponseHeader("HX-Trigger");
// Redirect immediately (toast will be shown on the new page)
if (hxRedirect) {
window.location.href = hxRedirect;
}
// Only dispatch HX-Trigger events for toasts when not redirecting
if (!hxRedirect && hxTrigger) {
var triggers = JSON.parse(hxTrigger);
var events = Array.isArray(triggers) ? triggers : [triggers];
events.forEach(function(triggerObj) {
Object.entries(triggerObj).forEach(function(entry) {
var name = entry[0];
var detail = entry[1];
try { detail = JSON.parse(detail); } catch(e) {}
target.dispatchEvent(new CustomEvent(name, {
detail: detail,
bubbles: true,
cancelable: true
}));
});
});
}
// Return null to prevent any DOM swap
return null;
}
});
})();
-196
View File
@@ -1,196 +0,0 @@
/**
* Range slider custom draggable handles (no native <input type=range>).
*
* Supports two modes on each slider, toggled via the .range-mode-toggle button:
* range (default) two handles, min max constraint
* point single handle, sets both number inputs to the same value
*
* Handles track-fill positioning and sync between handles and the connected
* number inputs (linked via data-target attributes).
*/
(function () {
"use strict";
function initAll(force) {
document.querySelectorAll(".range-slider").forEach(function (slider) {
if (force) slider._rsInit = false;
if (slider._rsInit) return;
slider._rsInit = true;
var mode = slider.getAttribute("data-mode") || "range";
var trackFill = slider.querySelector(".range-track-fill");
var minHandle = slider.querySelector(".range-handle-min");
var maxHandle = slider.querySelector(".range-handle-max");
if (!minHandle || !maxHandle) return;
var minTarget = document.getElementById(
minHandle.getAttribute("data-target")
);
var maxTarget = document.getElementById(
maxHandle.getAttribute("data-target")
);
var dataMin = parseInt(slider.getAttribute("data-min"), 10);
var dataMax = parseInt(slider.getAttribute("data-max"), 10);
var step = parseInt(slider.getAttribute("data-step"), 10) || 1;
// ── Helpers ──
function valueToPercent(value) {
return ((value - dataMin) / (dataMax - dataMin)) * 100;
}
function percentToValue(percent) {
var raw = dataMin + (percent / 100) * (dataMax - dataMin);
return Math.round(raw / step) * step;
}
function clamp(value, lo, hi) {
return Math.max(lo, Math.min(hi, value));
}
function getTargetValue(target) {
return parseInt(target ? target.value : 0, 10) || dataMin;
}
function setTargetValue(target, value) {
if (target) target.value = value;
}
// ── Track fill positioning ──
function updateTrackFill() {
if (!trackFill) return;
var minValue = getTargetValue(minTarget);
var maxValue = getTargetValue(maxTarget);
if (mode === "point") {
trackFill.style.left = "0%";
trackFill.style.width = valueToPercent(maxValue) + "%";
} else {
var leftPct = valueToPercent(minValue);
var widthPct = valueToPercent(maxValue) - leftPct;
trackFill.style.left = leftPct + "%";
trackFill.style.width = widthPct + "%";
}
}
function updateHandles() {
minHandle.style.left = valueToPercent(getTargetValue(minTarget)) + "%";
maxHandle.style.left = valueToPercent(getTargetValue(maxTarget)) + "%";
updateTrackFill();
}
// ── Dragging ──
function makeDraggable(handle, isMin) {
handle.addEventListener("mousedown", function (e) {
e.preventDefault();
var rect = slider.getBoundingClientRect();
function onMove(ev) {
var pct = ((ev.clientX - rect.left) / rect.width) * 100;
var value = percentToValue(clamp(pct, 0, 100));
if (mode === "point") {
setTargetValue(minTarget, value);
setTargetValue(maxTarget, value);
if (minTarget)
minTarget.dispatchEvent(
new Event("input", { bubbles: true })
);
if (maxTarget)
maxTarget.dispatchEvent(
new Event("input", { bubbles: true })
);
} else if (isMin) {
setTargetValue(
minTarget,
clamp(value, dataMin, getTargetValue(maxTarget))
);
if (minTarget)
minTarget.dispatchEvent(
new Event("input", { bubbles: true })
);
} else {
setTargetValue(
maxTarget,
clamp(value, getTargetValue(minTarget), dataMax)
);
if (maxTarget)
maxTarget.dispatchEvent(
new Event("input", { bubbles: true })
);
}
updateHandles();
}
function onUp() {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
}
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
onMove(e);
});
}
makeDraggable(minHandle, true);
makeDraggable(maxHandle, false);
// ── Sync from number inputs back to handles ──
function syncFromInputs() {
if (mode === "point") {
var value =
getTargetValue(minTarget) || getTargetValue(maxTarget);
setTargetValue(minTarget, value);
setTargetValue(maxTarget, value);
}
updateHandles();
}
if (minTarget)
minTarget.addEventListener("input", syncFromInputs);
if (maxTarget)
maxTarget.addEventListener("input", syncFromInputs);
// ── Mode toggle ──
var block = slider.closest(".range-slider-block");
var toggleButton =
block && block.querySelector(".range-mode-toggle");
if (toggleButton) {
toggleButton.addEventListener("click", function () {
var newMode = mode === "range" ? "point" : "range";
slider.setAttribute("data-mode", newMode);
// Swap toggle icons
var iconRange = toggleButton.querySelector(
".range-mode-icon-range"
);
var iconPoint = toggleButton.querySelector(
".range-mode-icon-point"
);
if (iconRange) iconRange.classList.toggle("hidden");
if (iconPoint) iconPoint.classList.toggle("hidden");
var dashSpan = block && block.querySelector(".range-dash");
if (newMode === "point") {
minHandle.style.display = "none";
setTargetValue(minTarget, getTargetValue(maxTarget));
if (minTarget) minTarget.classList.add("hidden");
if (dashSpan) dashSpan.classList.add("hidden");
} else {
minHandle.style.display = "";
if (minTarget) minTarget.classList.remove("hidden");
if (dashSpan) dashSpan.classList.remove("hidden");
}
mode = newMode;
updateHandles();
});
}
// ── Initial position ──
updateHandles();
});
}
document.addEventListener("DOMContentLoaded", initAll);
document.addEventListener("htmx:afterSwap", initAll);
window.initRangeSliders = initAll;
})();
-647
View File
@@ -1,647 +0,0 @@
/**
* SearchSelect widget a search box paired with a dropdown of options.
* Multi-select renders chosen items as removable pills (inline with the search
* box), each backed by a hidden <input>. Single-select renders no pill: the
* committed label lives inside the search box (which doubles as a combobox
* focus clears it to search, picking an option fills it), with a lone hidden
* <input> carrying the value. Both keep hidden inputs so Django validation works.
*
* Filter mode (data-search-select-mode="filter", rendered by FilterSelect): value rows
* carry +/ buttons that add include (✓) / exclude () pills, plus pinned
* modifier pseudo-options ((Any)/(None)) that are mutually exclusive with value
* pills. Filter widgets have no hidden inputs; readSearchSelect serialises their
* state into data-included / data-excluded / data-modifier for the filter bar.
*
* initAll() runs on DOMContentLoaded + htmx:afterSwap, each widget guarded with
* element._searchSelectInit.
*
* Dynamically-added rows and pills are cloned from hidden <template> elements
* the server renders with the same Python components (Pill / SearchSelect /
* FilterSelect). The JS only fills in the label slot ([data-search-select-label]), value,
* and data-* attributes so all markup and Tailwind class strings live in one
* place (the Python components), never duplicated here.
*/
(() => {
"use strict";
const DEBOUNCE_MS = 100;
// Must match Python common/components/filters.py:_PRESENCE_MODIFIERS.
// These modifiers are mutually exclusive with value pills — selecting
// one clears all value pills. Non-presence modifiers (INCLUDES_ALL,
// INCLUDES_ONLY) coexist with value pills.
const PRESENCE_MODIFIERS = ["NOT_NULL", "IS_NULL"];
const initAll = () => {
document.querySelectorAll("[data-search-select]").forEach(element => {
if (element._searchSelectInit) return;
element._searchSelectInit = true;
initWidget(element);
});
};
const initWidget = (container) => {
const search = container.querySelector("[data-search-select-search]");
const options = container.querySelector("[data-search-select-options]");
const pills = container.querySelector("[data-search-select-pills]");
if (!search || !options || !pills) return;
const name = container.getAttribute("data-name");
const searchUrl = container.getAttribute("data-search-url");
const isFilter = container.getAttribute("data-search-select-mode") === "filter";
const multi = container.getAttribute("data-multi") === "true";
const alwaysVisible = container.getAttribute("data-always-visible") === "true";
const prefetch = parseInt(container.getAttribute("data-prefetch"), 10) || 0;
const syncUrl = container.getAttribute("data-sync-url") === "true";
const noResults = options.querySelector("[data-search-select-no-results]");
let debounceTimer = null;
let pendingRequest = null; // in-flight AbortController, so newer queries win
let hasPrefetched = false;
const hasVisibleContent = () => {
const optionRows = options.querySelectorAll("[data-search-select-option]");
for (let i = 0; i < optionRows.length; i++) {
if (optionRows[i].style.display !== "none") return true;
}
if (noResults && !noResults.classList.contains("hidden")) return true;
if (options.querySelector("[data-search-select-modifier-option]")) return true;
return false;
};
const showPanel = () => {
if (alwaysVisible || hasVisibleContent()) {
options.classList.remove("hidden");
}
};
const hidePanel = () => {
if (!alwaysVisible) options.classList.add("hidden");
};
const setNoResults = (visible) => {
if (!noResults) return;
noResults.classList.toggle("hidden", !visible);
if (visible) showPanel();
};
// ── Highlight tracking (filter mode) ──
let highlightedRow = null;
const highlightOption = (row) => {
clearHighlight();
if (!row) return;
row.setAttribute("data-search-select-highlighted", "");
highlightedRow = row;
row.scrollIntoView({ block: "nearest" });
};
const clearHighlight = () => {
if (highlightedRow) {
highlightedRow.removeAttribute("data-search-select-highlighted");
highlightedRow = null;
}
};
const getVisibleOptions = () => {
const all = options.querySelectorAll("[data-search-select-option]");
return Array.from(all).filter(row => row.style.display !== "none");
};
const autoHighlight = (query) => {
const visible = getVisibleOptions();
if (visible.length === 0) {
clearHighlight();
return;
}
const lower = query.toLowerCase();
// 1. Starts-with match
for (let i = 0; i < visible.length; i++) {
const label = (visible[i].getAttribute("data-label") || "").toLowerCase();
if (lower && label.startsWith(lower)) {
highlightOption(visible[i]);
return;
}
}
// 2. Substring match (fuzzy-lite)
for (let j = 0; j < visible.length; j++) {
const subLabel = (visible[j].getAttribute("data-label") || "").toLowerCase();
if (lower && subLabel.includes(lower)) {
highlightOption(visible[j]);
return;
}
}
// 3. Fallback: first visible option
highlightOption(visible[0]);
};
// Get active values in both form and filter modes
const getSelectedValues = () => {
const vals = new Set();
pills.querySelectorAll('input[type="hidden"]').forEach(input => {
vals.add(input.value);
});
pills.querySelectorAll("[data-pill]").forEach(pill => {
const val = pill.getAttribute("data-value");
if (val) vals.add(val);
});
return vals;
};
// ── Render server-fetched rows into the panel ──
const renderRows = (items) => {
const selectedVals = getSelectedValues();
const preservedOptions = [];
// Extract existing option data for currently selected values before removing
options.querySelectorAll("[data-search-select-option]").forEach(row => {
const val = row.getAttribute("data-value");
if (selectedVals.has(val)) {
preservedOptions.push(optionFromRow(row));
}
row.remove();
});
const renderedValues = new Set();
// Render preserved options first (to keep them at the top)
preservedOptions.forEach(opt => {
options.insertBefore(buildRow(opt), noResults || null);
renderedValues.add(String(opt.value));
});
// Render newly fetched items (excluding already rendered preserved ones)
// Fix DOM-limit vs fetch mismatch: Do not slice the items, render all returned items.
items.forEach(item => {
if (!renderedValues.has(String(item.value))) {
options.insertBefore(buildRow(item), noResults || null);
renderedValues.add(String(item.value));
}
});
showPanel();
};
// ── Clone a server-rendered <template> prototype by name. The server emits
// the mode-appropriate prototypes, so the JS never names a class. ──
const cloneTemplate = (name) => {
const template = container.querySelector(`template[data-search-select-template="${name}"]`);
return template
? template.content.firstElementChild.cloneNode(true)
: null;
};
const setLabel = (node, label) => {
const slot = node.querySelector("[data-search-select-label]");
if (slot) slot.textContent = label;
};
const applyData = (node, data = {}) => {
Object.keys(data).forEach(key => {
node.setAttribute(`data-${key}`, data[key]);
});
};
// Build an option row by cloning the "row" template (the same prototype the
// server renders, so fetched and pre-rendered rows are identical).
const buildRow = (option) => {
const row = cloneTemplate("row");
if (!row) return document.createComment("ss-row");
row.setAttribute("data-value", option.value);
row.setAttribute("data-label", option.label);
applyData(row, option.data);
setLabel(row, option.label);
row._searchSelectOption = option;
return row;
};
// ── Client-side filter of the currently loaded rows. Returns the number of
// visible rows so the caller decides whether to show the no-results node. ──
const filterRows = (query) => {
const lower = query.toLowerCase();
let visibleCount = 0;
options.querySelectorAll("[data-search-select-option]").forEach(item => {
const label = (item.getAttribute("data-label") || "").toLowerCase();
const match = label.includes(lower);
item.style.display = match ? "" : "none";
if (match) visibleCount += 1;
});
return visibleCount;
};
// ── Fetch matching rows from the server. The previous in-flight request is
// aborted so a slower earlier response can never overwrite a newer one. ──
const fetchFromServer = (query) => {
if (pendingRequest) pendingRequest.abort();
pendingRequest = new AbortController();
let url = `${searchUrl}?q=${encodeURIComponent(query)}`;
if (prefetch && !query) url += `&limit=${prefetch}`;
fetch(url, { credentials: "same-origin", signal: pendingRequest.signal })
.then(response => response.json())
.then(items => {
pendingRequest = null;
renderRows(items);
// Re-apply the live query: the box may hold more text than was sent.
setNoResults(filterRows(search.value.trim()) === 0);
autoHighlight(search.value.trim());
})
.catch(error => {
if (error?.name === "AbortError") return; // superseded
pendingRequest = null;
setNoResults(true);
});
};
// Called on every keystroke. With a search_url, filter the loaded window
// instantly (zero latency) and debounce a server request for the rest;
// no-results stays hidden until the response decides it, to avoid a flash
// over an incomplete window. Without a search_url the loaded set is complete,
// so the client-side filter is authoritative.
const runSearch = () => {
const query = search.value.trim();
if (searchUrl) {
filterRows(query);
setNoResults(false);
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
fetchFromServer(query);
}, DEBOUNCE_MS);
} else {
setNoResults(filterRows(query) === 0);
}
autoHighlight(query);
showPanel();
};
// ── Single-select combobox: the search box shows the committed label;
// focusing clears it to search, blurring restores it (or deselects). ──
if (!multi) container._searchSelectLabel = search.value;
search.addEventListener("focus", () => {
if (!multi) {
// Hide the committed label so the box becomes a fresh search field.
search.value = "";
container._searchSelectDirty = false;
}
if (searchUrl) {
if (prefetch && !hasPrefetched) {
// Seed the window immediately on first open (not debounced).
hasPrefetched = true;
fetchFromServer("");
} else {
// Show whatever is already loaded; the server decides no-results.
filterRows(search.value.trim());
setNoResults(false);
autoHighlight(search.value.trim());
}
} else {
setNoResults(filterRows(search.value.trim()) === 0);
autoHighlight(search.value.trim());
}
showPanel();
});
search.addEventListener("input", () => {
clearHighlight();
if (!multi) {
if (!container._searchSelectDirty) {
const label = container._searchSelectLabel || "";
if (search.value.startsWith(label)) {
search.value = search.value.slice(label.length);
}
container._searchSelectDirty = true;
}
}
runSearch();
});
if (!multi) {
search.addEventListener("blur", () => {
// Defer so an option click (which fires before blur settles) wins.
setTimeout(() => {
if (container._searchSelectDirty && search.value.trim() === "") {
// User intentionally cleared the box → deselect.
pills.innerHTML = "";
container._searchSelectLabel = "";
emitChange(null);
} else {
// Focused-and-left, or typed a partial query without picking →
// restore the committed label (no-op right after a selection).
search.value = container._searchSelectLabel || "";
}
}, 120);
});
}
// ── Keyboard navigation (both form and filter modes) ──
search.addEventListener("keydown", (event) => {
const { key } = event;
if (!multi && key === "Backspace" && !container._searchSelectDirty) {
event.preventDefault();
search.value = "";
search.dispatchEvent(new Event("input", { bubbles: true }));
return;
}
if (!["ArrowDown", "ArrowUp", "Enter", "Escape"].includes(key)) return;
const visible = getVisibleOptions();
if (visible.length === 0) {
if (key === "Escape") hidePanel();
return;
}
if (key === "ArrowDown") {
event.preventDefault();
showPanel();
const downIdx = highlightedRow ? visible.indexOf(highlightedRow) : -1;
highlightOption(visible[(downIdx + 1) % visible.length]);
} else if (key === "ArrowUp") {
event.preventDefault();
showPanel();
const upIdx = highlightedRow ? visible.indexOf(highlightedRow) : -1;
highlightOption(visible[(upIdx - 1 + visible.length) % visible.length]);
} else if (key === "Enter") {
if (highlightedRow) {
event.preventDefault();
const option = optionFromRow(highlightedRow);
if (isFilter) {
addFilterPill(option, "include");
search.value = "";
} else {
selectOption(option);
}
clearHighlight();
hidePanel();
}
} else if (key === "Escape") {
clearHighlight();
hidePanel();
}
});
// Clicking an option must not blur the input before the click selects.
options.addEventListener("mousedown", (event) => {
event.preventDefault();
});
// ── Option click → select (form mode) or include/exclude (filter mode) ──
options.addEventListener("click", (event) => {
if (isFilter) {
handleFilterOptionClick(event);
return;
}
const row = event.target.closest("[data-search-select-option]");
if (!row) return;
selectOption(optionFromRow(row));
});
const handleFilterOptionClick = (event) => {
// Pinned modifier pseudo-option → set the (mutually exclusive) modifier.
const modifierRow = event.target.closest("[data-search-select-modifier-option]");
if (modifierRow) {
setModifier(
modifierRow.getAttribute("data-search-select-modifier-option"),
modifierRow.getAttribute("data-label")
);
return;
}
// Include / exclude button on a value row.
const button = event.target.closest("[data-search-select-action]");
if (button) {
const row = button.closest("[data-search-select-option]");
if (!row) return;
addFilterPill(optionFromRow(row), button.getAttribute("data-search-select-action"));
return;
}
// Click on the option row itself → include.
const optionRow = event.target.closest("[data-search-select-option]");
if (optionRow) {
addFilterPill(optionFromRow(optionRow), "include");
}
};
// Add (or re-type) an include/exclude pill for a value. Selecting any value
// clears a presence modifier — NOT_NULL / IS_NULL are mutually exclusive
// with value pills. Non-presence modifiers (INCLUDES_ALL / INCLUDES_ONLY)
// persist alongside value pills.
const addFilterPill = (option, kind) => {
const modPill = pills.querySelector("[data-search-select-modifier]");
if (modPill) {
const modVal = modPill.getAttribute("data-search-select-modifier");
if (PRESENCE_MODIFIERS.includes(modVal)) {
clearModifier();
}
}
const existing = pills.querySelector(
`[data-pill][data-value="${cssEscape(option.value)}"]`
);
if (existing) existing.remove();
pills.appendChild(buildFilterValuePill(option, kind));
search.value = "";
emitChange(null);
};
const buildFilterValuePill = (option, kind) => {
const pill = cloneTemplate(kind === "include" ? "pill-include" : "pill-exclude");
pill.setAttribute("data-value", option.value);
pill.setAttribute("data-label", option.label);
applyData(pill, option.data);
setLabel(pill, option.label);
return pill;
};
// Set the modifier pill. Presence modifiers (NOT_NULL / IS_NULL) clear all
// value pills — they are mutually exclusive. Non-presence modifiers
// (INCLUDES_ALL / INCLUDES_ONLY) are prepended before existing value pills.
const setModifier = (modifierValue, label) => {
// Remove any existing modifier pill to avoid duplicates.
clearModifierPill();
if (PRESENCE_MODIFIERS.includes(modifierValue)) {
pills.innerHTML = "";
}
const pill = cloneTemplate("pill-modifier");
pill.setAttribute("data-search-select-modifier", modifierValue);
setLabel(pill, label);
pills.insertBefore(pill, pills.firstChild);
container.setAttribute("data-modifier", modifierValue);
hidePanel();
emitChange(null);
};
// Remove the modifier pill and its container attribute. Safe to call when
// there is no modifier pill (no-op). Does not touch value pills.
const clearModifierPill = () => {
const modifierPill = pills.querySelector("[data-search-select-modifier]");
if (modifierPill) modifierPill.remove();
container.removeAttribute("data-modifier");
};
const clearModifier = () => {
clearModifierPill();
};
const optionFromRow = (row) => {
if (row._searchSelectOption) return row._searchSelectOption;
const data = {};
Object.keys(row.dataset).forEach(key => {
if (key !== "value" && key !== "label" && key !== "ssOption") {
data[key] = row.dataset[key];
}
});
return {
value: row.getAttribute("data-value"),
label: row.getAttribute("data-label"),
data,
};
};
const selectOption = (option) => {
if (multi) {
if (!pills.querySelector(`input[value="${cssEscape(option.value)}"]`)) {
addPill(option);
}
search.value = "";
} else {
// Single-select: no pill — show the label in the search box and keep a
// lone hidden input under [data-search-select-pills] for submission.
pills.innerHTML = "";
pills.appendChild(buildHidden(option.value));
search.value = option.label;
container._searchSelectLabel = option.label;
container._searchSelectDirty = false;
hidePanel();
}
emitChange(option);
};
const addPill = (option) => {
const pill = buildPill(option);
if (pill) pills.appendChild(pill);
pills.appendChild(buildHidden(option.value));
};
const buildPill = (option) => {
const pill = cloneTemplate("pill");
if (!pill) return null;
pill.setAttribute("data-value", option.value);
applyData(pill, option.data);
setLabel(pill, option.label);
return pill;
};
const buildHidden = (value) => {
const input = document.createElement("input");
input.type = "hidden";
input.name = name;
input.value = value;
return input;
};
// ── Pill × → remove ──
pills.addEventListener("click", (event) => {
const removeButton = event.target.closest("[data-pill-remove]");
if (!removeButton) return;
const pill = removeButton.closest("[data-pill]");
if (!pill) return;
if (isFilter) {
// Filter pills have no hidden input.
if (pill.hasAttribute("data-search-select-modifier")) {
clearModifierPill();
} else {
pill.remove();
}
emitChange(null);
return;
}
const value = pill.getAttribute("data-value");
pill.remove();
const hidden = pills.querySelector(`input[value="${cssEscape(value)}"]`);
if (hidden) hidden.remove();
emitChange(null);
});
const currentValues = () => {
return Array.from(pills.querySelectorAll('input[type="hidden"]')).map(input => input.value);
};
const emitChange = (last) => {
const values = currentValues();
if (syncUrl) syncToUrl(values);
container.dispatchEvent(
new CustomEvent("search-select:change", {
bubbles: true,
detail: { name, values, last },
})
);
};
const syncToUrl = (values) => {
const params = new URLSearchParams(window.location.search);
params.delete(name);
values.forEach(v => {
params.append(name, v);
});
const qs = params.toString();
history.replaceState(null, "", qs ? `?${qs}` : window.location.pathname);
};
// On init, restore from URL params if the server supplied no selected pills.
if (syncUrl && !pills.querySelector("[data-pill]")) {
const initial = new URLSearchParams(window.location.search).getAll(name);
initial.forEach(v => {
addPill({ value: v, label: v, data: {} });
});
}
// ── Close panel on outside click ──
document.addEventListener("click", (event) => {
if (!container.contains(event.target)) hidePanel();
});
};
/** Minimal escape for use inside an attribute-value selector. */
const cssEscape = (value) => String(value).replace(/["\\]/g, "\\$&");
// Serialise each widget's current state onto data-* attributes for the caller.
// Form widgets expose data-values (the submitted hidden-input values); filter
// widgets expose data-included / data-excluded / data-modifier for the filter
// bar to read.
window.readSearchSelect = (form) => {
form.querySelectorAll("[data-search-select]").forEach(container => {
const pills = container.querySelector("[data-search-select-pills]");
if (container.getAttribute("data-search-select-mode") === "filter") {
const included = [];
const excluded = [];
let modifier = "";
if (pills) {
pills.querySelectorAll("[data-pill]").forEach(pill => {
const pillModifier = pill.getAttribute("data-search-select-modifier");
if (pillModifier) {
modifier = pillModifier; // last modifier pill wins
return; // skip value extraction for this pill
}
const value = pill.getAttribute("data-value");
const label = pill.getAttribute("data-label") || "";
if (pill.getAttribute("data-search-select-type") === "exclude") {
excluded.push({ id: value, label });
} else {
included.push({ id: value, label });
}
});
}
container.setAttribute("data-included", JSON.stringify(included));
container.setAttribute("data-excluded", JSON.stringify(excluded));
if (modifier) container.setAttribute("data-modifier", modifier);
else container.removeAttribute("data-modifier");
return;
}
const values = pills
? Array.from(pills.querySelectorAll('input[type="hidden"]')).map(input => input.value)
: [];
container.setAttribute("data-values", JSON.stringify(values));
});
};
document.addEventListener("DOMContentLoaded", initAll);
document.addEventListener("htmx:afterSwap", initAll);
})();
-212
View File
@@ -1,212 +0,0 @@
/**
* @description Formats Date to a UTC string accepted by the datetime-local input field.
* @param {Date} date
* @returns {string}
*/
function toISOUTCString(date) {
function stringAndPad(number) {
return number.toString().padStart(2, 0);
}
const year = date.getFullYear();
const month = stringAndPad(date.getMonth() + 1);
const day = stringAndPad(date.getDate());
const hours = stringAndPad(date.getHours());
const minutes = stringAndPad(date.getMinutes());
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
/**
* @description Sync values between source and target elements based on syncData configuration.
* @param {Array} syncData - Array of objects to define source and target elements with their respective value types.
*/
function syncSelectInputUntilChanged(syncData, parentSelector = document) {
const parentElement =
parentSelector === document
? document
: document.querySelector(parentSelector);
if (!parentElement) {
console.error(`The parent selector "${parentSelector}" is not valid.`);
return;
}
// Set up a single change event listener on the document for handling all source changes
parentElement.addEventListener("change", function (event) {
// Loop through each sync configuration item
syncData.forEach((syncItem) => {
// Check if the change event target matches the source selector
if (event.target.matches(syncItem.source)) {
const sourceElement = event.target;
const valueToSync = getValueFromProperty(
sourceElement,
syncItem.source_value
);
const targetElement = document.querySelector(syncItem.target);
if (targetElement && valueToSync !== null) {
console.log(`Changing value of ${syncItem.target} to ${valueToSync}`)
targetElement[syncItem.target_value] = valueToSync;
}
}
});
});
// Set up a single focus event listener on the document for handling all target focuses
parentElement.addEventListener(
"focus",
function (event) {
// Loop through each sync configuration item
syncData.forEach((syncItem) => {
// Check if the focus event target matches the target selector
if (event.target.matches(syncItem.target)) {
// Remove the change event listener to stop syncing
// This assumes you want to stop syncing once any target receives focus
// You may need a more sophisticated way to remove listeners if you want to stop
// syncing selectively based on other conditions
document.removeEventListener("change", syncSelectInputUntilChanged);
}
});
},
true
); // Use capture phase to ensure the event is captured during focus, not bubble
}
/**
* @description Retrieve the value from the source element based on the provided property.
* @param {Element} sourceElement - The source HTML element.
* @param {string} property - The property to retrieve the value from.
*/
function getValueFromProperty(sourceElement, property) {
let source =
sourceElement instanceof HTMLSelectElement
? sourceElement.selectedOptions[0]
: sourceElement;
if (property.startsWith("dataset.")) {
let datasetKey = property.slice(8); // Remove 'dataset.' part
return source.dataset[datasetKey];
} else if (property in source) {
return source[property];
} else {
console.error(`Property ${property} is not valid for the option element.`);
return null;
}
}
/**
* @description Returns a single element by name.
* @param {string} selector The selector to look for.
*/
function getEl(selector) {
if (selector.startsWith("#")) {
return document.getElementById(selector.slice(1));
} else if (selector.startsWith(".")) {
return document.getElementsByClassName(selector);
} else {
return document.getElementsByTagName(selector);
}
}
/**
* @description Applies different behaviors to elements based on multiple conditional configurations.
* Each configuration is an array containing a condition function, an array of target element selectors,
* and two callback functions for handling matched and unmatched conditions.
* @param {...Array} configs Each configuration is an array of the form:
* - 0: {function(): boolean} condition - Function that returns true or false based on a condition.
* - 1: {string[]} targetElements - Array of CSS selectors for target elements.
* - 2: {function(HTMLElement): void} callbackfn1 - Function to execute when condition is true.
* - 3: {function(HTMLElement): void} callbackfn2 - Function to execute when condition is false.
*/
function conditionalElementHandler(...configs) {
configs.forEach(([condition, targetElements, callbackfn1, callbackfn2]) => {
if (condition()) {
targetElements.forEach((elementName) => {
let el = getEl(elementName);
if (el === null) {
console.error(`Element ${elementName} doesn't exist.`);
} else {
callbackfn1(el);
}
});
} else {
targetElements.forEach((elementName) => {
let el = getEl(elementName);
if (el === null) {
console.error(`Element ${elementName} doesn't exist.`);
} else {
callbackfn2(el);
}
});
}
});
}
function disableElementsWhenValueNotEqual(
targetSelect,
targetValue,
elementList
) {
return conditionalElementHandler([
() => {
let target = getEl(targetSelect);
console.debug(
`${disableElementsWhenTrue.name}: triggered on ${target.id}`
);
console.debug(`
${disableElementsWhenTrue.name}: matching against value(s): ${targetValue}`);
if (targetValue instanceof Array) {
if (targetValue.every((value) => target.value != value)) {
console.debug(
`${disableElementsWhenTrue.name}: none of the values is equal to ${target.value}, returning true.`
);
return true;
}
} else {
console.debug(
`${disableElementsWhenTrue.name}: none of the values is equal to ${target.value}, returning true.`
);
return target.value != targetValue;
}
},
elementList,
(el) => {
console.debug(
`${disableElementsWhenTrue.name}: evaluated true, disabling ${el.id}.`
);
el.disabled = "disabled";
},
(el) => {
console.debug(
`${disableElementsWhenTrue.name}: evaluated false, NOT disabling ${el.id}.`
);
el.disabled = "";
},
]);
}
function disableElementsWhenTrue(targetSelect, targetValue, elementList) {
return conditionalElementHandler([
() => {
console.log(`${disableElementsWhenTrue.name}: triggered on ${targetSelect}`)
console.log(`Value of ${targetSelect} is ${targetValue}: ${getEl(targetSelect).value == targetValue}`)
return getEl(targetSelect).value == targetValue;
},
elementList,
(el) => {
console.log(`${disableElementsWhenTrue.name}: disabling ${el.id}`)
el.disabled = "disabled";
},
(el) => {
console.log(`${disableElementsWhenTrue.name}: enabling ${el.id}`)
el.disabled = "";
},
]);
}
export {
toISOUTCString,
syncSelectInputUntilChanged,
getEl,
conditionalElementHandler,
disableElementsWhenValueNotEqual,
disableElementsWhenTrue,
getValueFromProperty,
};
+2 -4
View File
@@ -2,8 +2,6 @@ import logging
import requests import requests
from django.db import models from django.db import models
from django.template.defaultfilters import floatformat
from games.models import ExchangeRate, Purchase from games.models import ExchangeRate, Purchase
logger = logging.getLogger("games") logger = logging.getLogger("games")
@@ -38,7 +36,7 @@ def _get_exchange_rate(currency_from, currency_to, year):
currency_from=currency_from, currency_from=currency_from,
currency_to=currency_to, currency_to=currency_to,
year=year, year=year,
rate=floatformat(rate, 2), rate=rate,
) )
rate = exchange_rate.rate rate = exchange_rate.rate
else: else:
@@ -84,7 +82,7 @@ def convert_prices():
if rate: if rate:
_save_converted_price( _save_converted_price(
purchase, purchase,
floatformat(purchase.price * rate, 0), round(purchase.price * rate, 0),
needs_update, needs_update,
) )
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-black dark:text-white w-4 h-4">
<path d="M3 12a9 9 0 1 0 3-6.7L3 8" />
<path d="M3 3v5h5" />
</svg>

After

Width:  |  Height:  |  Size: 313 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="text-black dark:text-white w-4 h-4">
<path fill="currentColor" d="M14 4l2.29 2.29-2.88 2.88 1.42 1.42 2.88-2.88L20 12V4z M10 4H4v8l2.29-2.29 4.71 4.71V20h2v-8.41l-5.29-5.3z"></path>
</svg>

After

Width:  |  Height:  |  Size: 270 B

+13 -3
View File
@@ -106,9 +106,14 @@ urlpatterns = [
name="refund_purchase", name="refund_purchase",
), ),
path( path(
"purchase/related-purchase-by-game", "purchase/<int:purchase_id>/split/confirm",
purchase.related_purchase_by_game, purchase.split_purchase_confirmation,
name="related_purchase_by_game", name="split_purchase_confirmation",
),
path(
"purchase/<int:purchase_id>/split",
purchase.split_purchase,
name="split_purchase",
), ),
path("session/add", session.add_session, name="add_session"), path("session/add", session.add_session, name="add_session"),
path( path(
@@ -142,6 +147,11 @@ urlpatterns = [
session.end_session, session.end_session,
name="list_sessions_end_session", name="list_sessions_end_session",
), ),
path(
"session/start/reset-to-now/from-list/<int:session_id>",
session.reset_session_start,
name="list_sessions_reset_session_start",
),
path("session/list", session.list_sessions, name="list_sessions"), path("session/list", session.list_sessions, name="list_sessions"),
path("session/search", session.search_sessions, name="search_sessions"), path("session/search", session.search_sessions, name="search_sessions"),
path( path(
+20 -29
View File
@@ -3,51 +3,42 @@ registration/login.html)."""
from django.contrib.auth import views as auth_views from django.contrib.auth import views as auth_views
from django.http import HttpResponse from django.http import HttpResponse
from django.utils.safestring import SafeText, mark_safe
from common.components import Component, CsrfInput, Div, Input from common.components import CsrfInput, Div, Element, FormFields, Node, StyledButton
from common.layout import render_page from common.layout import render_page
from games.forms import LoginForm
def _login_content(form, request) -> SafeText: def _login_content(form, request) -> Node:
table = Component(
tag_name="table",
children=[
CsrfInput(request),
mark_safe(str(form.as_table())),
Component(
tag_name="tr",
children=[
Component(tag_name="td"),
Component(
tag_name="td",
children=[
Input(type="submit", attributes=[("value", "Login")])
],
),
],
),
],
)
return Div( return Div(
[("class", "flex items-center flex-col")], [("class", "flex items-center flex-col")],
[ [
Component( Element(
tag_name="h2", "h2",
attributes=[("class", "text-3xl text-white mb-8")], attributes=[("class", "text-3xl text-white mb-8")],
children=["Please log in to continue"], children=["Please log in to continue"],
), ),
Component( Element(
tag_name="form", "form",
attributes=[("method", "post")], attributes=[
children=[table], ("method", "post"),
("class", "flex flex-col gap-3 w-full max-w-sm"),
],
children=[
CsrfInput(request),
FormFields(form),
StyledButton([], "Login", type="submit"),
],
), ),
], ],
) )
class LoginView(auth_views.LoginView): class LoginView(auth_views.LoginView):
"""Django's LoginView, but the page body is built in Python.""" """Django's LoginView, but the page body is built in Python and the form is
our `LoginForm` so its inputs self-style like every other form."""
authentication_form = LoginForm
def render_to_response(self, context, **response_kwargs) -> HttpResponse: def render_to_response(self, context, **response_kwargs) -> HttpResponse:
return render_page( return render_page(
+27 -6
View File
@@ -6,26 +6,37 @@ from django.urls import reverse
from common.components import ( from common.components import (
A, A,
AddForm, AddForm,
Button,
ButtonGroup, ButtonGroup,
DeviceFilterBar,
Fragment,
Icon, Icon,
StyledButton,
paginated_table_content, paginated_table_content,
) )
from common.layout import render_page from common.layout import render_page
from common.time import dateformat, local_strftime from common.time import dateformat, local_strftime
from common.utils import paginate from common.utils import paginate
from games.filters import parse_device_filter
from games.forms import DeviceForm from games.forms import DeviceForm
from games.models import Device from games.models import Device
@login_required @login_required
def list_devices(request: HttpRequest) -> HttpResponse: def list_devices(request: HttpRequest) -> HttpResponse:
devices, page_obj, elided_page_range = paginate( devices = Device.objects.order_by("-created_at")
request, Device.objects.order_by("-created_at")
) filter_json = request.GET.get("filter", "")
if filter_json:
device_filter = parse_device_filter(filter_json)
if device_filter is not None:
devices = devices.filter(device_filter.to_q())
devices, page_obj, elided_page_range = paginate(request, devices)
data = { data = {
"header_action": A([], Button([], "Add device"), url_name="games:add_device"), "header_action": A(href=reverse("games:add_device"))[
StyledButton()["Add device"]
],
"columns": [ "columns": [
"Name", "Name",
"Type", "Type",
@@ -61,7 +72,17 @@ def list_devices(request: HttpRequest) -> HttpResponse:
elided_page_range=elided_page_range, elided_page_range=elided_page_range,
request=request, request=request,
) )
return render_page(request, content, title="Manage devices") filter_bar = DeviceFilterBar(
filter_json=filter_json,
preset_list_url=reverse("games:list_presets") + "?mode=devices",
preset_save_url=reverse("games:save_preset") + "?mode=devices",
)
content = Fragment(filter_bar, content)
return render_page(
request,
content,
title="Manage devices",
)
@login_required @login_required
+1 -2
View File
@@ -8,7 +8,6 @@ from django.contrib.auth.decorators import login_required
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse from django.urls import reverse
from django.utils.safestring import mark_safe
from games.models import FilterPreset from games.models import FilterPreset
@@ -40,7 +39,7 @@ def list_presets(request: HttpRequest) -> HttpResponse:
if not items: if not items:
items = ['<li class="px-4 py-2 text-sm text-body italic">No saved presets</li>'] items = ['<li class="px-4 py-2 text-sm text-body italic">No saved presets</li>']
return HttpResponse(mark_safe(f'<ul class="py-1">{"".join(items)}</ul>')) return HttpResponse(f'<ul class="py-1">{"".join(items)}</ul>')
@login_required @login_required
+142 -156
View File
@@ -2,39 +2,43 @@ from typing import Any
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.middleware.csrf import get_token
from django.db.models import Q from django.db.models import Q
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.middleware.csrf import get_token
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.template.defaultfilters import date as date_filter from django.template.defaultfilters import date as date_filter
from django.urls import reverse from django.urls import reverse
from django.utils.safestring import SafeText, mark_safe from django.utils.safestring import SafeText
from common.components import ( from common.components import (
H1,
A, A,
AddForm, AddForm,
Button,
ButtonGroup, ButtonGroup,
Component,
CsrfInput, CsrfInput,
Div, Div,
Element,
FilterBar, FilterBar,
Fragment,
GameStatus, GameStatus,
GameStatusSelector, GameStatusSelector,
H1,
Icon, Icon,
SearchField,
LinkedPurchase, LinkedPurchase,
Modal, Modal,
ModuleScript, ModuleScript,
NameWithIcon, NameWithIcon,
Node,
Popover, Popover,
PopoverTruncated, PopoverTruncated,
PurchasePrice, PurchasePrice,
Safe,
SearchField,
SimpleTable, SimpleTable,
StyledButton,
Ul,
paginated_table_content, paginated_table_content,
) )
from common.icons import get_icon from common.components.primitives import Li, P, Span, Strong
from common.layout import render_page from common.layout import render_page
from common.time import ( from common.time import (
dateformat, dateformat,
@@ -86,12 +90,11 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
data = { data = {
"header_action": Div( "header_action": Div(
children=[ class_="flex justify-between",
SearchField(search_string=search_string), )[
A([], Button([], "Add game"), url_name="games:add_game"), SearchField(search_string=search_string),
], A(href=reverse("games:add_game"))[StyledButton()["Add game"]],
attributes=[("class", "flex justify-between")], ],
),
"columns": [ "columns": [
"Name", "Name",
"Sort Name", "Sort Name",
@@ -143,14 +146,11 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
preset_list_url=reverse("games:list_presets"), preset_list_url=reverse("games:list_presets"),
preset_save_url=reverse("games:save_preset"), preset_save_url=reverse("games:save_preset"),
) )
content = mark_safe(str(filter_bar) + str(content)) content = Fragment(filter_bar, content)
return render_page( return render_page(
request, request,
content, content,
title="Manage games", title="Manage games",
scripts=ModuleScript("range_slider.js")
+ ModuleScript("search_select.js")
+ ModuleScript("filter_bar.js"),
) )
@@ -163,6 +163,10 @@ def add_game(request: HttpRequest) -> HttpResponse:
return HttpResponseRedirect( return HttpResponseRedirect(
reverse("games:add_purchase_for_game", kwargs={"game_id": game.id}) reverse("games:add_purchase_for_game", kwargs={"game_id": game.id})
) )
elif "submit_and_create_session" in request.POST:
return HttpResponseRedirect(
reverse("games:add_session_for_game", kwargs={"game_id": game.id})
)
else: else:
return redirect("games:list_games") return redirect("games:list_games")
@@ -171,16 +175,26 @@ def add_game(request: HttpRequest) -> HttpResponse:
AddForm( AddForm(
form, form,
request=request, request=request,
additional_row=Button( additional_row=Fragment(
[], StyledButton(
"Submit & Create Purchase", [],
color="gray", "Submit & Create Purchase",
type="submit", color="gray",
name="submit_and_redirect", type="submit",
name="submit_and_redirect",
),
StyledButton(
[],
"Submit & Create Session",
color="gray",
type="submit",
name="submit_and_create_session",
),
), ),
), ),
title="Add New Game", title="Add New Game",
scripts=ModuleScript("search_select.js") + ModuleScript("add_game.js"), scripts=ModuleScript("dist/elements/search-select.js")
+ ModuleScript("dist/add_game.js"),
) )
@@ -193,22 +207,16 @@ def _delete_game_confirmation_modal(
) -> SafeText: ) -> SafeText:
data_items = [] data_items = []
if session_count: if session_count:
data_items.append( data_items.append(Li(children=[f"{session_count} session(s)"]))
Component(tag_name="li", children=[f"{session_count} session(s)"])
)
if purchase_count: if purchase_count:
data_items.append( data_items.append(Li(children=[f"{purchase_count} purchase(s)"]))
Component(tag_name="li", children=[f"{purchase_count} purchase(s)"])
)
if playevent_count: if playevent_count:
data_items.append( data_items.append(Li(children=[f"{playevent_count} play event(s)"]))
Component(tag_name="li", children=[f"{playevent_count} play event(s)"])
)
if not (session_count or purchase_count or playevent_count): if not (session_count or purchase_count or playevent_count):
data_items.append(Component(tag_name="li", children=["No associated data"])) data_items.append(Li(children=["No associated data"]))
form = Component( form = Element(
tag_name="form", "form",
attributes=[ attributes=[
("hx-post", reverse("games:delete_game", args=[game.id])), ("hx-post", reverse("games:delete_game", args=[game.id])),
("hx-replace-url", "true"), ("hx-replace-url", "true"),
@@ -218,8 +226,7 @@ def _delete_game_confirmation_modal(
], ],
children=[ children=[
CsrfInput(request), CsrfInput(request),
Component( P(
tag_name="p",
attributes=[ attributes=[
( (
"class", "class",
@@ -231,8 +238,7 @@ def _delete_game_confirmation_modal(
"This will permanently delete this game and all associated data:" "This will permanently delete this game and all associated data:"
], ],
), ),
Component( Ul(
tag_name="ul",
attributes=[ attributes=[
( (
"class", "class",
@@ -242,8 +248,7 @@ def _delete_game_confirmation_modal(
], ],
children=data_items, children=data_items,
), ),
Component( P(
tag_name="p",
attributes=[ attributes=[
( (
"class", "class",
@@ -256,14 +261,14 @@ def _delete_game_confirmation_modal(
Div( Div(
[("class", "items-center mt-5")], [("class", "items-center mt-5")],
[ [
Button( StyledButton(
[("class", "w-full")], [("class", "w-full")],
"Delete", "Delete",
color="red", color="red",
size="lg", size="lg",
type="submit", type="submit",
), ),
Button( StyledButton(
[("class", "mt-0 w-full")], [("class", "mt-0 w-full")],
"Cancel", "Cancel",
color="gray", color="gray",
@@ -279,8 +284,7 @@ def _delete_game_confirmation_modal(
return Modal( return Modal(
"delete-game-confirmation-modal", "delete-game-confirmation-modal",
children=[ children=[
Component( P(
tag_name="h1",
attributes=[ attributes=[
( (
"class", "class",
@@ -289,12 +293,11 @@ def _delete_game_confirmation_modal(
], ],
children=["Delete Game"], children=["Delete Game"],
), ),
Component( P(
tag_name="p",
attributes=[("class", "dark:text-white text-center mt-5")], attributes=[("class", "dark:text-white text-center mt-5")],
children=[ children=[
"Are you sure you want to delete ", "Are you sure you want to delete ",
Component(tag_name="strong", children=[game.name]), Strong(children=[game.name]),
"?", "?",
], ],
), ),
@@ -336,7 +339,7 @@ def edit_game(request: HttpRequest, game_id: int) -> HttpResponse:
request, request,
AddForm(form, request=request), AddForm(form, request=request),
title="Edit Game", title="Edit Game",
scripts=ModuleScript("search_select.js"), scripts=ModuleScript("dist/elements/search-select.js"),
) )
@@ -349,69 +352,69 @@ _STAT_SVGS = {
"playrange": '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5m-9-6h.008v.008H12v-.008ZM12 15h.008v.008H12V15Zm0 2.25h.008v.008H12v-.008ZM9.75 15h.008v.008H9.75V15Zm0 2.25h.008v.008H9.75v-.008ZM7.5 15h.008v.008H7.5V15Zm0 2.25h.008v.008H7.5v-.008Zm6.75-4.5h.008v.008h-.008v-.008Zm0 2.25h.008v.008h-.008V15Zm0 2.25h.008v.008h-.008v-.008Zm2.25-4.5h.008v.008H16.5v-.008Zm0 2.25h.008v.008H16.5V15Z" /></svg>', "playrange": '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5m-9-6h.008v.008H12v-.008ZM12 15h.008v.008H12V15Zm0 2.25h.008v.008H12v-.008ZM9.75 15h.008v.008H9.75V15Zm0 2.25h.008v.008H9.75v-.008ZM7.5 15h.008v.008H7.5V15Zm0 2.25h.008v.008H7.5v-.008Zm6.75-4.5h.008v.008h-.008v-.008Zm0 2.25h.008v.008h-.008V15Zm0 2.25h.008v.008h-.008v-.008Zm2.25-4.5h.008v.008H16.5v-.008Zm0 2.25h.008v.008H16.5V15Z" /></svg>',
} }
_PLAYED_ROW_TEMPLATE = """<div class="flex gap-2 items-center" x-data="{ open: false }"> _PLAYED_BTN = (
<span class="uppercase">Played</span> "px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 "
<div class="inline-flex rounded-md shadow-2xs" role="group" x-data="{ played: @@PLAYED_COUNT@@ }"> "hover:bg-gray-100 hover:text-blue-700 dark:bg-gray-800 dark:border-gray-700 "
<a href="@@ADD_PE@@"> "dark:text-white dark:hover:bg-gray-700 hover:cursor-pointer"
<button type="button" class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer"> )
<span x-text="played"></span> times _PLAYED_MENU = (
</button> "absolute top-full -left-px w-auto whitespace-nowrap z-10 text-sm font-medium "
</a> "bg-gray-800/20 backdrop-blur-lg rounded-md rounded-tl-none border "
<button type="button" x-on:click="open = !open" @click.outside="open = false" class="relative px-4 py-2 text-sm font-medium text-gray-900 bg-white border-e border-b border-t border-gray-200 rounded-e-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white align-middle hover:cursor-pointer"> "border-gray-200 dark:border-gray-700"
@@ARROWDOWN@@ )
<div
class="absolute top-full -left-px w-auto whitespace-nowrap z-10 text-sm font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-tl-none border border-gray-200 dark:border-gray-700"
x-show="open"
>
<ul
class=""
>
<li class="px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-tr-md">
<a href="@@ADD_PE_FOR_GAME@@">Add playthrough...</a>
</li>
<li
x-on:click="createPlayEvent"
class="relative px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-b-md"
>
Played times +1
</li>
<script>
function createPlayEvent() {
this.played++;
// TODO: migrate to hx-post + hx-on::after-request for HTMX-native toast handling
fetchWithHtmxTriggers('@@API_CREATE@@', {
method: 'POST',
headers: { 'X-CSRFToken': '@@CSRF@@', 'Content-Type': 'application/json' },
body: '{"game_id": @@GAME_ID@@}'
})
.catch(() => {
this.played--;
console.error('Failed to record play');
});
}
</script>
</ul>
</div>
</button>
</div>
</div>"""
def _played_row(game: Game, request: HttpRequest) -> SafeText: def _played_row(game: Game, request: HttpRequest) -> Node:
"""The 'Played N times' control with its Alpine.js dropdown.""" """'Played N times' control as a custom element (ts/elements/play-event-row.ts)."""
replacements = { from common.components import Element
"@@PLAYED_COUNT@@": str(game.playevents.count()), from common.components.custom_elements import _PlayEventRow
"@@ADD_PE@@": reverse("games:add_playevent"), from common.components.primitives import Button
"@@ARROWDOWN@@": get_icon("arrowdown"),
"@@ADD_PE_FOR_GAME@@": reverse("games:add_playevent_for_game", args=[game.id]), played: int = 0
"@@API_CREATE@@": reverse("api-1.0.0:create_playevent"), played = game.playevents.count()
"@@CSRF@@": get_token(request),
"@@GAME_ID@@": str(game.id), count_button = A(href=reverse("games:add_playevent"))[
} Button(class_=_PLAYED_BTN + " rounded-s-lg")[
html = _PLAYED_ROW_TEMPLATE Span(data_count="")[str(played)], " times"
for token, value in replacements.items(): ]
html = html.replace(token, value) ]
return mark_safe(html) menu = Div(data_menu="", hidden=True, class_=_PLAYED_MENU)[
Ul()[
Li(class_="px-4 py-2")[
A(href=reverse("games:add_playevent_for_game", args=[game.id]))[
"Add playthrough..."
]
],
Li(class_="px-4 py-2 cursor-pointer")[
Element(
"button",
[("type", "button"), ("data-add-play", "")],
children=["Played times +1"],
)
],
]
]
toggle = Element(
"button",
[
("type", "button"),
("data-toggle", ""),
("class", _PLAYED_BTN + " rounded-e-lg"),
],
[Icon("arrowdown")],
)
# Menu is a SIBLING of the toggle (not nested inside it): a <button> may not
# contain another <button>, and that invalid nesting makes the HTML parser
# close ancestors early, ejecting later page sections from their container.
toggle_group = Div(class_="relative inline-flex")[toggle, menu]
group = Div(class_="inline-flex items-stretch rounded-md shadow-2xs")[
count_button, toggle_group
]
return _PlayEventRow(
game_id=game.id,
csrf=get_token(request),
api_create_url=reverse("api-1.0.0:create_playevent"),
)[Div(class_="flex gap-2 items-center")[Span(class_="uppercase")["Played"], group]]
def _stat_popover(popover_id: str, tooltip: str, svg_key: str, value: str) -> SafeText: def _stat_popover(popover_id: str, tooltip: str, svg_key: str, value: str) -> SafeText:
@@ -419,17 +422,13 @@ def _stat_popover(popover_id: str, tooltip: str, svg_key: str, value: str) -> Sa
popover_content=tooltip, popover_content=tooltip,
wrapped_classes="flex gap-2 items-center", wrapped_classes="flex gap-2 items-center",
id=popover_id, id=popover_id,
children=[mark_safe(_STAT_SVGS[svg_key]), str(value)], children=[Safe(_STAT_SVGS[svg_key]), str(value)],
) )
def _meta_row( def _meta_row(label: str, value: Node | str, extra: Node | str = "") -> Node:
label: str, value: SafeText | str, extra: SafeText | str = "" children: list[Node | str] = [
) -> SafeText: Span(attributes=[("class", "uppercase")], children=[label]),
children: list[SafeText | str] = [
Component(
tag_name="span", attributes=[("class", "uppercase")], children=[label]
),
value, value,
] ]
if extra: if extra:
@@ -452,27 +451,25 @@ def _game_action_buttons(game: Game) -> SafeText:
"dark:text-white dark:hover:text-white dark:hover:bg-red-700 " "dark:text-white dark:hover:text-white dark:hover:bg-red-700 "
"dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer" "dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer"
) )
edit_link = Component( edit_link = A(
tag_name="a", href=reverse("games:edit_game", args=[game.id]),
attributes=[("href", reverse("games:edit_game", args=[game.id]))],
children=[ children=[
Component( Element(
tag_name="button", "button",
attributes=[("type", "button"), ("class", edit_class)], attributes=[("type", "button"), ("class", edit_class)],
children=["Edit"], children=["Edit"],
) )
], ],
) )
delete_link = Component( delete_link = A(
tag_name="a", href="#",
attributes=[ attributes=[
("href", "#"),
("hx-get", reverse("games:delete_game_confirmation", args=[game.id])), ("hx-get", reverse("games:delete_game_confirmation", args=[game.id])),
("hx-target", "#global-modal-container"), ("hx-target", "#global-modal-container"),
], ],
children=[ children=[
Component( Element(
tag_name="button", "button",
attributes=[("type", "button"), ("class", delete_class)], attributes=[("type", "button"), ("class", delete_class)],
children=["Delete"], children=["Delete"],
) )
@@ -499,21 +496,16 @@ def _game_history(statuschanges) -> SafeText:
status=change.new_status, status=change.new_status,
children=[change.get_new_status_display()], children=[change.get_new_status_display()],
) )
edit = Component( edit = A(
tag_name="a", href=reverse("games:edit_statuschange", args=[change.id]),
attributes=[("href", reverse("games:edit_statuschange", args=[change.id]))],
children=["Edit"], children=["Edit"],
) )
delete = Component( delete = A(
tag_name="a", href=reverse("games:delete_statuschange", args=[change.id]),
attributes=[
("href", reverse("games:delete_statuschange", args=[change.id]))
],
children=["Delete"], children=["Delete"],
) )
items.append( items.append(
Component( Li(
tag_name="li",
attributes=[("class", "text-slate-500")], attributes=[("class", "text-slate-500")],
children=[ children=[
f"{prefix} status from ", f"{prefix} status from ",
@@ -528,8 +520,7 @@ def _game_history(statuschanges) -> SafeText:
], ],
) )
) )
return Component( return Ul(
tag_name="ul",
attributes=[("class", "list-disc list-inside")], attributes=[("class", "list-disc list-inside")],
children=items, children=items,
) )
@@ -576,19 +567,17 @@ def _game_overview_metrics(game: Game) -> dict[str, Any]:
def _game_header(game: Game, request: HttpRequest, metrics: dict[str, Any]) -> SafeText: def _game_header(game: Game, request: HttpRequest, metrics: dict[str, Any]) -> SafeText:
grey_value_class = "text-black dark:text-slate-300" grey_value_class = "text-black dark:text-slate-300"
title_span = Component( title_span = Span(
tag_name="span",
attributes=[("class", "text-balance max-w-120 text-4xl")], attributes=[("class", "text-balance max-w-120 text-4xl")],
children=[ children=[
Component( Span(
tag_name="span",
attributes=[("class", "font-bold font-serif")], attributes=[("class", "font-bold font-serif")],
children=[game.name], children=[game.name],
), ),
] ]
+ ( + (
[ [
mark_safe("&nbsp;"), Safe("&nbsp;"),
Popover( Popover(
popover_content="Original release year", popover_content="Original release year",
wrapped_classes="text-slate-500 text-2xl", wrapped_classes="text-slate-500 text-2xl",
@@ -634,8 +623,7 @@ def _game_header(game: Game, request: HttpRequest, metrics: dict[str, Any]) -> S
[ [
_meta_row( _meta_row(
"Original year", "Original year",
Component( Span(
tag_name="span",
attributes=[("class", grey_value_class)], attributes=[("class", grey_value_class)],
children=[str(game.original_year_released)], children=[str(game.original_year_released)],
), ),
@@ -648,8 +636,7 @@ def _game_header(game: Game, request: HttpRequest, metrics: dict[str, Any]) -> S
_played_row(game, request), _played_row(game, request),
_meta_row( _meta_row(
"Platform", "Platform",
Component( Span(
tag_name="span",
attributes=[("class", grey_value_class)], attributes=[("class", grey_value_class)],
children=[str(game.platform)], children=[str(game.platform)],
), ),
@@ -711,10 +698,9 @@ def _sessions_section(game: Game, request: HttpRequest) -> SafeText:
header_action = Div( header_action = Div(
children=[ children=[
A( A(href=reverse("games:add_session"))[
url_name="games:add_session", StyledButton(icon=True, color="blue", size="xs")[Icon("plus")]
children=Button(icon=True, size="xs", children=[Icon("play"), "LOG"]), ],
),
A( A(
href=reverse( href=reverse(
"games:list_sessions_start_session_from_session", "games:list_sessions_start_session_from_session",
@@ -723,7 +709,7 @@ def _sessions_section(game: Game, request: HttpRequest) -> SafeText:
children=Popover( children=Popover(
popover_content=last_session.game.name, popover_content=last_session.game.name,
children=[ children=[
Button( StyledButton(
icon=True, icon=True,
color="gray", color="gray",
size="xs", size="xs",
@@ -809,7 +795,7 @@ def _history_section(game: Game) -> SafeText:
) )
_GET_SESSION_COUNT_SCRIPT = mark_safe( _GET_SESSION_COUNT_SCRIPT = Safe(
"<script>\n" "<script>\n"
" function getSessionCount() {\n" " function getSessionCount() {\n"
" return document.getElementById('session-count')" " return document.getElementById('session-count')"
+4 -11
View File
@@ -13,17 +13,14 @@ from django.urls import reverse
from django.utils.timezone import localtime from django.utils.timezone import localtime
from django.utils.timezone import now as timezone_now from django.utils.timezone import now as timezone_now
from common.components import ExternalScript
from common.layout import render_page from common.layout import render_page
from common.time import format_duration from common.time import format_duration
from games.models import Game, Platform, Purchase, Session from games.models import Game, Platform, Purchase, Session
from games.views.stats_content import stats_content from games.views.stats_content import stats_content
from games.views.stats_data import compute_stats from games.views.stats_data import compute_stats
# Flowbite-datepicker UMD bundle, hoisted into the stats pages for YearPicker. # The Flowbite-datepicker UMD bundle is declared as media on the YearPicker
_STATS_SCRIPTS = ExternalScript( # component, so Page() loads it automatically on the stats pages.
"https://cdn.jsdelivr.net/npm/flowbite-datepicker@2.0.0/dist/Datepicker.umd.min.js"
)
def model_counts(request: HttpRequest) -> dict[str, bool]: def model_counts(request: HttpRequest) -> dict[str, bool]:
@@ -77,9 +74,7 @@ def use_custom_redirect(
def stats_alltime(request: HttpRequest) -> HttpResponse: def stats_alltime(request: HttpRequest) -> HttpResponse:
request.session["return_path"] = request.path request.session["return_path"] = request.path
data = compute_stats(None) data = compute_stats(None)
return render_page( return render_page(request, stats_content(data), title=data["title"])
request, stats_content(data), title=data["title"], scripts=_STATS_SCRIPTS
)
@login_required @login_required
@@ -93,9 +88,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
return HttpResponseRedirect(reverse("games:stats_alltime")) return HttpResponseRedirect(reverse("games:stats_alltime"))
request.session["return_path"] = request.path request.session["return_path"] = request.path
data = compute_stats(year) data = compute_stats(year)
return render_page( return render_page(request, stats_content(data), title=data["title"])
request, stats_content(data), title=data["title"], scripts=_STATS_SCRIPTS
)
@login_required @login_required
+27 -8
View File
@@ -6,14 +6,17 @@ from django.urls import reverse
from common.components import ( from common.components import (
A, A,
AddForm, AddForm,
Button,
ButtonGroup, ButtonGroup,
Fragment,
Icon, Icon,
PlatformFilterBar,
StyledButton,
paginated_table_content, paginated_table_content,
) )
from common.layout import render_page from common.layout import render_page
from common.time import dateformat, local_strftime from common.time import dateformat, local_strftime
from common.utils import paginate from common.utils import paginate
from games.filters import parse_platform_filter
from games.forms import PlatformForm from games.forms import PlatformForm
from games.models import Platform from games.models import Platform
from games.views.general import use_custom_redirect from games.views.general import use_custom_redirect
@@ -21,14 +24,20 @@ from games.views.general import use_custom_redirect
@login_required @login_required
def list_platforms(request: HttpRequest) -> HttpResponse: def list_platforms(request: HttpRequest) -> HttpResponse:
platforms, page_obj, elided_page_range = paginate( platforms = Platform.objects.order_by("name")
request, Platform.objects.order_by("name")
) filter_json = request.GET.get("filter", "")
if filter_json:
platform_filter = parse_platform_filter(filter_json)
if platform_filter is not None:
platforms = platforms.filter(platform_filter.to_q())
platforms, page_obj, elided_page_range = paginate(request, platforms)
data = { data = {
"header_action": A( "header_action": A(href=reverse("games:add_platform"))[
[], Button([], "Add platform"), url_name="games:add_platform" StyledButton()["Add platform"]
), ],
"columns": [ "columns": [
"Name", "Name",
"Icon", "Icon",
@@ -68,7 +77,17 @@ def list_platforms(request: HttpRequest) -> HttpResponse:
elided_page_range=elided_page_range, elided_page_range=elided_page_range,
request=request, request=request,
) )
return render_page(request, content, title="Manage platforms") filter_bar = PlatformFilterBar(
filter_json=filter_json,
preset_list_url=reverse("games:list_presets") + "?mode=platforms",
preset_save_url=reverse("games:save_preset") + "?mode=platforms",
)
content = Fragment(filter_bar, content)
return render_page(
request,
content,
title="Manage platforms",
)
@login_required @login_required
+29 -10
View File
@@ -12,15 +12,18 @@ from django.urls import reverse
from common.components import ( from common.components import (
A, A,
AddForm, AddForm,
Button,
ButtonGroup, ButtonGroup,
Fragment,
Icon, Icon,
ModuleScript, ModuleScript,
PlayEventFilterBar,
StyledButton,
paginated_table_content, paginated_table_content,
) )
from common.layout import render_page from common.layout import render_page
from common.time import dateformat, format_duration, local_strftime from common.time import dateformat, format_duration, local_strftime
from common.utils import paginate from common.utils import paginate
from games.filters import parse_playevent_filter
from games.forms import PlayEventForm from games.forms import PlayEventForm
from games.models import Game, PlayEvent, Session from games.models import Game, PlayEvent, Session
@@ -83,9 +86,9 @@ def create_playevent_tabledata(
for row in row_list for row in row_list
] ]
return { return {
"header_action": A( "header_action": A(href=reverse("games:add_playevent"))[
[], Button([], "Add play event"), url_name="games:add_playevent" StyledButton()["Add play event"]
), ],
"columns": list(filtered_column_list), "columns": list(filtered_column_list),
"rows": filtered_row_list, "rows": filtered_row_list,
} }
@@ -126,9 +129,15 @@ def _get_formatted_playtime_for_game_sessions_in_range(
@login_required @login_required
def list_playevents(request: HttpRequest) -> HttpResponse: def list_playevents(request: HttpRequest) -> HttpResponse:
playevents, page_obj, elided_page_range = paginate( playevents = PlayEvent.objects.order_by("-created_at")
request, PlayEvent.objects.order_by("-created_at")
) filter_json = request.GET.get("filter", "")
if filter_json:
playevent_filter = parse_playevent_filter(filter_json)
if playevent_filter is not None:
playevents = playevents.filter(playevent_filter.to_q())
playevents, page_obj, elided_page_range = paginate(request, playevents)
data = create_playevent_tabledata(playevents, request=request) data = create_playevent_tabledata(playevents, request=request)
content = paginated_table_content( content = paginated_table_content(
data, data,
@@ -136,7 +145,17 @@ def list_playevents(request: HttpRequest) -> HttpResponse:
elided_page_range=elided_page_range, elided_page_range=elided_page_range,
request=request, request=request,
) )
return render_page(request, content, title="Manage play events") filter_bar = PlayEventFilterBar(
filter_json=filter_json,
preset_list_url=reverse("games:list_presets") + "?mode=playevents",
preset_save_url=reverse("games:save_preset") + "?mode=playevents",
)
content = Fragment(filter_bar, content)
return render_page(
request,
content,
title="Manage play events",
)
@login_required @login_required
@@ -197,7 +216,7 @@ def add_playevent(request: HttpRequest, game_id: int = 0) -> HttpResponse:
request, request,
AddForm(form, request=request), AddForm(form, request=request),
title="Add new playthrough", title="Add new playthrough",
scripts=ModuleScript("search_select.js"), scripts=ModuleScript("dist/elements/search-select.js"),
) )
@@ -214,7 +233,7 @@ def edit_playevent(request: HttpRequest, playevent_id: int) -> HttpResponse:
request, request,
AddForm(form, request=request), AddForm(form, request=request),
title="Edit Play Event", title="Edit Play Event",
scripts=ModuleScript("search_select.js"), scripts=ModuleScript("dist/elements/search-select.js"),
) )
+238 -61
View File
@@ -5,33 +5,40 @@ from django.http import (
HttpResponse, HttpResponse,
HttpResponseRedirect, HttpResponseRedirect,
) )
from django.db import transaction
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils import timezone
from django.views.decorators.http import require_POST
from django.template.defaultfilters import date as date_filter from django.template.defaultfilters import date as date_filter
from django.template.defaultfilters import floatformat from django.template.defaultfilters import floatformat
from django.urls import reverse
from django.utils import timezone
from django.utils.safestring import SafeText, mark_safe from django.utils.safestring import SafeText, mark_safe
from django.views.decorators.http import require_POST
from common.components import ( from common.components import (
A, A,
AddForm, AddForm,
Button,
ButtonGroup, ButtonGroup,
Component, Checkbox,
CsrfInput, CsrfInput,
Div, Div,
Element,
FormFields,
Fragment,
GameLink, GameLink,
Icon, Icon,
Input,
LinkedPurchase, LinkedPurchase,
Modal, Modal,
ModuleScript, ModuleScript,
Node,
PriceConverted, PriceConverted,
PurchasePrice, PurchasePrice,
SelectionFields,
StyledButton,
TableRow, TableRow,
paginated_table_content, paginated_table_content,
) )
from common.components.primitives import Li, P, Td, Tr, Ul
from common.layout import render_page from common.layout import render_page
from common.time import dateformat from common.time import dateformat
from common.utils import paginate from common.utils import paginate
@@ -40,7 +47,7 @@ from games.models import Game, Purchase
from games.views.general import use_custom_redirect from games.views.general import use_custom_redirect
def _render_purchase_buttons(purchase_id, is_refunded): def _render_purchase_buttons(purchase_id, is_refunded, can_split=False):
"""Return button group HTML for a purchase row.""" """Return button group HTML for a purchase row."""
return ButtonGroup( return ButtonGroup(
[ [
@@ -56,6 +63,19 @@ def _render_purchase_buttons(purchase_id, is_refunded):
} }
if not is_refunded if not is_refunded
else {}, else {},
{
"href": "#",
"hx_get": reverse(
"games:split_purchase_confirmation",
args=[purchase_id],
),
"hx_target": "#global-modal-container",
"slot": Icon("split"),
"title": "Split into per-game purchases",
"color": "gray",
}
if can_split
else {},
{ {
"href": reverse("games:edit_purchase", args=[purchase_id]), "href": reverse("games:edit_purchase", args=[purchase_id]),
"slot": Icon("edit"), "slot": Icon("edit"),
@@ -88,7 +108,11 @@ def _render_purchase_row(purchase):
else "-" else "-"
), ),
purchase.created_at.strftime(dateformat), purchase.created_at.strftime(dateformat),
_render_purchase_buttons(purchase.id, bool(purchase.date_refunded)), _render_purchase_buttons(
purchase.id,
bool(purchase.date_refunded),
can_split=purchase.num_purchases > 1,
),
], ],
} }
@@ -108,9 +132,9 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
purchases, page_obj, elided_page_range = paginate(request, purchases) purchases, page_obj, elided_page_range = paginate(request, purchases)
data = { data = {
"header_action": A( "header_action": A(href=reverse("games:add_purchase"))[
[], Button([], "Add purchase"), url_name="games:add_purchase" StyledButton()["Add purchase"]
), ],
"columns": [ "columns": [
"Name", "Name",
"Type", "Type",
@@ -129,34 +153,29 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
elided_page_range=elided_page_range, elided_page_range=elided_page_range,
request=request, request=request,
) )
from common.components import PurchaseFilterBar, ModuleScript from common.components import PurchaseFilterBar
filter_bar = PurchaseFilterBar( filter_bar = PurchaseFilterBar(
filter_json=filter_json, filter_json=filter_json,
preset_list_url=reverse("games:list_presets"), preset_list_url=reverse("games:list_presets"),
preset_save_url=reverse("games:save_preset"), preset_save_url=reverse("games:save_preset"),
) )
content = mark_safe(str(filter_bar) + str(content)) content = Fragment(filter_bar, content)
return render_page( return render_page(
request, request,
content, content,
title="Manage purchases", title="Manage purchases",
scripts=ModuleScript("range_slider.js")
+ ModuleScript("search_select.js")
+ ModuleScript("filter_bar.js"),
) )
def _purchase_additional_row() -> SafeText: def _purchase_additional_row() -> SafeText:
"""The 'Submit & Create Session' row shown below the main Submit button.""" """The 'Submit & Create Session' row shown below the main Submit button."""
return Component( return Tr(
tag_name="tr",
children=[ children=[
Component(tag_name="td"), Td(),
Component( Td(
tag_name="td",
children=[ children=[
Button( StyledButton(
[], [],
"Submit & Create Session", "Submit & Create Session",
color="gray", color="gray",
@@ -169,6 +188,76 @@ def _purchase_additional_row() -> SafeText:
) )
def _pricing_controls() -> Node:
"""Pricing UI for the add-purchase form.
By default the form's own single Price field is the bundle price. When 2+
games are selected and "Separate price per game" is checked, the per-game
inputs (the general ``selection-fields`` element) take over and the bundle
Price is hidden. Toggle/visibility wiring lives in ts/add_purchase.ts; the
hidden ``pricing_mode`` tells the view which path to take.
"""
return Div(attributes=[("id", "pricing-controls")])[
Div(attributes=[("id", "separate-prices-row"), ("class", "hidden")])[
Checkbox(
name="separate_prices",
label="Separate price per game",
attributes=[("id", "id_separate_prices")],
),
],
Input(
type="hidden",
attributes=[
("name", "pricing_mode"),
("id", "id_pricing_mode"),
("value", "combined"),
],
),
SelectionFields(
source="games",
name_prefix="price_for_game_",
field_type="number",
min_items=2,
active=False,
input_attributes=[
("step", "0.01"),
("min", "0"),
("inputmode", "decimal"),
("placeholder", "Price"),
],
),
]
@transaction.atomic
def _create_separate_purchases(form: PurchaseForm, post) -> None:
"""Create one single-game Purchase per selected game from the shared form
fields, each priced from its own ``price_for_game_<id>`` input. The
``m2m_changed`` signal sets ``num_purchases``/``price_per_game`` once each
game is attached."""
data = form.cleaned_data
shared = {
"platform": data.get("platform"),
"date_purchased": data["date_purchased"],
"date_refunded": data.get("date_refunded"),
"infinite": data.get("infinite", False),
"price_currency": data["price_currency"],
"ownership_type": data["ownership_type"],
"type": data["type"],
"related_game": data.get("related_game"),
"name": data.get("name") or "",
}
for game in data["games"]:
raw_price = post.get(f"price_for_game_{game.id}", "")
try:
price = float(raw_price) if raw_price not in (None, "") else 0.0
except ValueError:
price = 0.0
purchase = Purchase(price=price, **shared)
purchase.save()
purchase.games.set([game])
@login_required @login_required
def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse: def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
initial = {"date_purchased": timezone.now()} initial = {"date_purchased": timezone.now()}
@@ -176,6 +265,9 @@ def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
if request.method == "POST": if request.method == "POST":
form = PurchaseForm(request.POST or None, initial=initial) form = PurchaseForm(request.POST or None, initial=initial)
if form.is_valid(): if form.is_valid():
if request.POST.get("pricing_mode") == "per_game":
_create_separate_purchases(form, request.POST)
return redirect("games:list_purchases")
purchase = form.save() purchase = form.save()
if "submit_and_redirect" in request.POST: if "submit_and_redirect" in request.POST:
return HttpResponseRedirect( return HttpResponseRedirect(
@@ -201,10 +293,16 @@ def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
return render_page( return render_page(
request, request,
AddForm(form, request=request, additional_row=_purchase_additional_row()), AddForm(
form,
request=request,
fields=Fragment(FormFields(form), _pricing_controls()),
additional_row=_purchase_additional_row(),
),
title="Add New Purchase", title="Add New Purchase",
scripts=mark_safe( scripts=mark_safe(
ModuleScript("search_select.js") + ModuleScript("add_purchase.js") ModuleScript("dist/elements/search-select.js")
+ ModuleScript("dist/add_purchase.js")
), ),
) )
@@ -222,7 +320,8 @@ def edit_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
AddForm(form, request=request, additional_row=_purchase_additional_row()), AddForm(form, request=request, additional_row=_purchase_additional_row()),
title="Edit Purchase", title="Edit Purchase",
scripts=mark_safe( scripts=mark_safe(
ModuleScript("search_select.js") + ModuleScript("add_purchase.js") ModuleScript("dist/elements/search-select.js")
+ ModuleScript("dist/add_purchase.js")
), ),
) )
@@ -262,8 +361,7 @@ def _view_purchase_content(purchase: Purchase) -> SafeText:
Div( Div(
[("class", row_class)], [("class", row_class)],
[ [
Component( P(
tag_name="p",
children=[ children=[
"Price per game: ", "Price per game: ",
PriceConverted([floatformat(purchase.price_per_game, 0)]), PriceConverted([floatformat(purchase.price_per_game, 0)]),
@@ -273,10 +371,9 @@ def _view_purchase_content(purchase: Purchase) -> SafeText:
], ],
), ),
Div([("class", row_class)], ["Games included in this purchase:"]), Div([("class", row_class)], ["Games included in this purchase:"]),
Component( Ul(
tag_name="ul",
children=[ children=[
Component(tag_name="li", children=[GameLink(game.id, game.name)]) Li(children=[GameLink(game.id, game.name)])
for game in purchase.games.all() for game in purchase.games.all()
], ],
), ),
@@ -307,9 +404,9 @@ def drop_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
return redirect("games:list_purchases") return redirect("games:list_purchases")
def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> SafeText: def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> Node:
form = Component( form = Element(
tag_name="form", "form",
attributes=[ attributes=[
("hx-post", reverse("games:refund_purchase", args=[purchase_id])), ("hx-post", reverse("games:refund_purchase", args=[purchase_id])),
("hx-target", f"#purchase-row-{purchase_id}"), ("hx-target", f"#purchase-row-{purchase_id}"),
@@ -317,22 +414,21 @@ def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> SafeTe
], ],
children=[ children=[
CsrfInput(request), CsrfInput(request),
Component( P(
tag_name="p",
attributes=[("class", "dark:text-white text-center mt-3 text-sm")], attributes=[("class", "dark:text-white text-center mt-3 text-sm")],
children=["Games will be marked as abandoned."], children=["Games will be marked as abandoned."],
), ),
Div( Div(
[("class", "items-center mt-5")], [("class", "items-center mt-5")],
[ [
Button( StyledButton(
[("class", "w-full")], [("class", "w-full")],
"Refund", "Refund",
color="blue", color="blue",
size="lg", size="lg",
type="submit", type="submit",
), ),
Button( StyledButton(
[("class", "mt-0 w-full")], [("class", "mt-0 w-full")],
"Cancel", "Cancel",
color="gray", color="gray",
@@ -346,8 +442,8 @@ def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> SafeTe
return Modal( return Modal(
"refund-confirmation-modal", "refund-confirmation-modal",
children=[ children=[
Component( Element(
tag_name="h1", "h1",
attributes=[ attributes=[
( (
"class", "class",
@@ -356,8 +452,7 @@ def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> SafeTe
], ],
children=["Confirm Refund"], children=["Confirm Refund"],
), ),
Component( P(
tag_name="p",
attributes=[("class", "dark:text-white text-center mt-5")], attributes=[("class", "dark:text-white text-center mt-5")],
children=["Are you sure you want to mark this purchase as refunded?"], children=["Are you sure you want to mark this purchase as refunded?"],
), ),
@@ -393,6 +488,108 @@ def refund_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
return HttpResponse(row_html + modal_close, status=200) return HttpResponse(row_html + modal_close, status=200)
def _split_confirmation_modal(purchase: Purchase, request: HttpRequest) -> Node:
count = purchase.num_purchases
form = Element(
"form",
attributes=[("hx-post", reverse("games:split_purchase", args=[purchase.id]))],
children=[
CsrfInput(request),
P(
attributes=[("class", "dark:text-white text-center mt-3 text-sm")],
children=[
f"Creates {count} separate purchases, one per game, with the "
"price split evenly. Each can then be priced and refunded "
"independently."
],
),
Div(
[("class", "items-center mt-5")],
[
StyledButton(
[("class", "w-full")],
"Split",
color="blue",
size="lg",
type="submit",
),
StyledButton(
[("class", "mt-0 w-full")],
"Cancel",
color="gray",
size="base",
onclick="this.closest('#split-confirmation-modal').remove()",
),
],
),
],
)
return Modal(
"split-confirmation-modal",
children=[
Element(
"h1",
attributes=[
(
"class",
"text-2xl leading-6 font-medium dark:text-white text-center",
)
],
children=["Split purchase"],
),
P(
attributes=[("class", "dark:text-white text-center mt-5")],
children=[
f"Split “{purchase.standardized_name}” into per-game purchases?"
],
),
form,
],
)
@login_required
def split_purchase_confirmation(request: HttpRequest, purchase_id: int) -> HttpResponse:
purchase = get_object_or_404(Purchase, id=purchase_id)
return HttpResponse(_split_confirmation_modal(purchase, request))
@login_required
@require_POST
@transaction.atomic
def split_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
"""Replace one multi-game (unsplittable-style) purchase with one single-game
purchase per game, splitting the price evenly as a starting point. Each new
purchase is then independently priceable and refundable."""
purchase = get_object_or_404(Purchase, id=purchase_id)
games = list(purchase.games.all())
count = len(games)
if count > 1:
share = purchase.price / count
for game in games:
new_purchase = Purchase(
price=share,
price_currency=purchase.price_currency,
date_purchased=purchase.date_purchased,
date_refunded=purchase.date_refunded,
infinite=purchase.infinite,
ownership_type=purchase.ownership_type,
type=purchase.type,
related_game=purchase.related_game,
name=purchase.name,
platform=purchase.platform,
needs_price_update=True,
)
new_purchase.save()
new_purchase.games.set([game])
purchase.delete()
messages.success(request, f"Split into {count} purchases")
response = HttpResponse(status=204)
response["HX-Redirect"] = reverse("games:list_purchases")
return response
@login_required @login_required
def finish_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse: def finish_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
purchase = get_object_or_404(Purchase, id=purchase_id) purchase = get_object_or_404(Purchase, id=purchase_id)
@@ -400,23 +597,3 @@ def finish_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
game.status = Game.Status.FINISHED game.status = Game.Status.FINISHED
game.save() game.save()
return redirect("games:list_purchases") return redirect("games:list_purchases")
def related_purchase_by_game(request: HttpRequest) -> HttpResponse:
games: list[str] = request.GET.getlist("games")
if games:
from games.forms import related_purchase_queryset
form = PurchaseForm()
qs = related_purchase_queryset().filter(games__in=games).order_by(
"games__sort_name"
)
form.fields["related_purchase"].queryset = qs
first_option = qs.first()
if first_option:
form.fields["related_purchase"].initial = first_option.id
return HttpResponse(str(form["related_purchase"]))
else:
# abort swap
return HttpResponse(status=204)
+150 -200
View File
@@ -1,31 +1,35 @@
from typing import Any from typing import Any, TypedDict
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.db.models import Q from django.db.models import Q
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.middleware.csrf import get_token from django.middleware.csrf import get_token
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.template.defaultfilters import date as date_filter
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.safestring import SafeText, mark_safe from django.utils.safestring import mark_safe
from common.components import ( from common.components import (
A, A,
AddForm, AddForm,
Button,
ButtonGroup, ButtonGroup,
Component,
Div, Div,
FormFields,
Fragment,
Icon, Icon,
ModuleScript, ModuleScript,
NameWithIcon, NameWithIcon,
Node,
Popover, Popover,
SearchField, SearchField,
SessionDeviceSelector, SessionDeviceSelector,
SessionTimestampButtons,
StyledButton,
TableRow,
paginated_table_content, paginated_table_content,
) )
from common.layout import render_page from common.layout import NavbarPlaytime, render_page
from games.views.general import model_counts
from common.time import ( from common.time import (
dateformat, dateformat,
local_strftime, local_strftime,
@@ -36,6 +40,83 @@ from games.forms import SessionForm
from games.models import Device, Game, Session from games.models import Device, Game, Session
class SessionRowData(TypedDict):
row_id: str
hx_trigger: str
hx_get: str
hx_select: str
hx_swap: str
cell_data: list[Node | str]
def session_row_data(session: Session, device_list, csrf_token: str) -> SessionRowData:
"""Canonical session-list row. Single source of truth shared by
list_sessions and the htmx finish/reset fragments."""
row_selector = f"#session-row-{session.pk}"
end_url = reverse("games:list_sessions_end_session", args=[session.pk])
reset_url = reverse("games:list_sessions_reset_session_start", args=[session.pk])
actions = ButtonGroup(
[
{
"href": end_url,
"hx_get": end_url,
"hx_target": row_selector,
"hx_swap": "outerHTML",
"slot": Icon("end"),
"title": "Finish session now",
"color": "green",
}
if session.timestamp_end is None
else {},
{
"href": reset_url,
"hx_get": reset_url,
"hx_target": row_selector,
"hx_swap": "outerHTML",
"hx_confirm": "Reset this session's start time to now?",
"slot": Icon("reset"),
"title": "Reset start to now",
"color": "gray",
}
if session.timestamp_end is None
else {},
{
"href": reverse("games:edit_session", args=[session.pk]),
"slot": Icon("edit"),
"title": "Edit",
},
{
"href": reverse("games:delete_session", args=[session.pk]),
"slot": Icon("delete"),
"title": "Delete",
"color": "red",
},
]
)
return SessionRowData(
row_id=f"session-row-{session.pk}",
hx_trigger="device-changed from:body",
hx_get="",
hx_select=row_selector,
hx_swap="outerHTML",
cell_data=[
NameWithIcon(session=session),
f"{local_strftime(session.timestamp_start)}"
f"{f'{local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
session.duration_formatted_with_mark(),
SessionDeviceSelector(session, device_list, csrf_token),
session.created_at.strftime(dateformat),
actions,
],
)
def session_row(session: Session, device_list, csrf_token: str) -> Node:
"""The single-session <tr> node, rendered through the same TableRow
path the list table uses."""
return TableRow(session_row_data(session, device_list, csrf_token))
@login_required @login_required
def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse: def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse:
sessions = Session.objects.order_by("-timestamp_start", "created_at") sessions = Session.objects.order_by("-timestamp_start", "created_at")
@@ -65,6 +146,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
except Session.DoesNotExist: except Session.DoesNotExist:
last_session = None last_session = None
sessions, page_obj, elided_page_range = paginate(request, sessions) sessions, page_obj, elided_page_range = paginate(request, sessions)
csrf_token = get_token(request)
data = { data = {
"header_action": Div( "header_action": Div(
@@ -73,13 +155,13 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
Div( Div(
children=[ children=[
A( A(
url_name="games:add_session", href=reverse("games:add_session"),
children=Button( )[
StyledButton(
icon=True, icon=True,
size="xs", size="xs",
children=[Icon("play"), "LOG"], )[Icon("play"), "LOG"]
), ],
),
A( A(
href=reverse( href=reverse(
"games:list_sessions_start_session_from_session", "games:list_sessions_start_session_from_session",
@@ -88,7 +170,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
children=Popover( children=Popover(
popover_content=last_session.game.name, popover_content=last_session.game.name,
children=[ children=[
Button( StyledButton(
icon=True, icon=True,
color="gray", color="gray",
size="xs", size="xs",
@@ -116,50 +198,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
"Actions", "Actions",
], ],
"rows": [ "rows": [
{ session_row_data(session, device_list, csrf_token) for session in sessions
"row_id": f"session-row-{session.pk}",
"hx_trigger": "device-changed from:body",
"hx_get": "",
"hx_select": f"#session-row-{session.pk}",
"hx_swap": "outerHTML",
"cell_data": [
NameWithIcon(session=session),
f"{local_strftime(session.timestamp_start)}{f'{local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
session.duration_formatted_with_mark(),
SessionDeviceSelector(session, device_list, get_token(request)),
session.created_at.strftime(dateformat),
ButtonGroup(
[
{
"href": reverse(
"games:list_sessions_end_session", args=[session.pk]
),
"slot": Icon("end"),
"title": "Finish session now",
"color": "green",
}
if session.timestamp_end is None
else {},
{
"href": reverse(
"games:edit_session", args=[session.pk]
),
"slot": Icon("edit"),
"title": "Edit",
},
{
"href": reverse(
"games:delete_session", args=[session.pk]
),
"slot": Icon("delete"),
"title": "Delete",
"color": "red",
},
]
),
],
}
for session in sessions
], ],
} }
content = paginated_table_content( content = paginated_table_content(
@@ -176,14 +215,11 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
preset_list_url=reverse("games:list_presets"), preset_list_url=reverse("games:list_presets"),
preset_save_url=reverse("games:save_preset"), preset_save_url=reverse("games:save_preset"),
) )
content = mark_safe(str(filter_bar) + str(content)) content = Fragment(filter_bar, content)
return render_page( return render_page(
request, request,
content, content,
title="Manage sessions", title="Manage sessions",
scripts=ModuleScript("range_slider.js")
+ ModuleScript("search_select.js")
+ ModuleScript("filter_bar.js"),
) )
@@ -192,52 +228,34 @@ def search_sessions(request: HttpRequest) -> HttpResponse:
return list_sessions(request, search_string=request.GET.get("search_string", "")) return list_sessions(request, search_string=request.GET.get("search_string", ""))
def _session_fields(form) -> SafeText: def _timestamp_buttons(field_name: str) -> Node:
"""Manual per-field layout for the session form. """The now/toggle/copy helper buttons appended to a timestamp field's row."""
this_side = "start" if field_name == "timestamp_start" else "end"
other_side = "end" if field_name == "timestamp_start" else "start"
return SessionTimestampButtons(
class_="flex flex-row gap-3 justify-start mt-3",
hx_boost="false",
)[
StyledButton(data_target=field_name, data_type="now", size="xs")["Set to now"],
StyledButton(data_target=field_name, data_type="toggle", size="xs")[
"Toggle text"
],
StyledButton(data_target=field_name, data_type="copy", size="xs")[
f"Copy {this_side} value to {other_side}"
],
]
Mirrors the old add_session.html: each field gets its label and widget,
and the timestamp fields gain a row of now/toggle/copy helper buttons. def _session_fields(form) -> Node:
""" """Session form fields via the shared renderer, with timestamp helper
rows: list[SafeText] = [] buttons appended to the two timestamp rows."""
for field in form: return FormFields(
children: list[SafeText | str] = [ form,
mark_safe(str(field.label_tag())), extras={
mark_safe(str(field)), name: _timestamp_buttons(name)
] for name in ("timestamp_start", "timestamp_end")
if field.name in ("timestamp_start", "timestamp_end"): },
this_side = "start" if field.name == "timestamp_start" else "end" )
other_side = "end" if field.name == "timestamp_start" else "start"
children.append(
Component(
tag_name="span",
attributes=[
(
"class",
"form-row-button-group flex-row gap-3 justify-start mt-3",
),
("hx-boost", "false"),
],
children=[
Button(
[("data-target", field.name), ("data-type", "now")],
"Set to now",
size="xs",
),
Button(
[("data-target", field.name), ("data-type", "toggle")],
"Toggle text",
size="xs",
),
Button(
[("data-target", field.name), ("data-type", "copy")],
f"Copy {this_side} value to {other_side}",
size="xs",
),
],
)
)
rows.append(Div(children=children))
return mark_safe("\n".join(rows))
@login_required @login_required
@@ -251,7 +269,7 @@ def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
return redirect("games:list_sessions") return redirect("games:list_sessions")
else: else:
if game_id: if game_id:
game = Game.objects.get(id=game_id) game = get_object_or_404(Game, id=game_id)
form = SessionForm( form = SessionForm(
initial={ initial={
**initial, **initial,
@@ -266,9 +284,7 @@ def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
request, request,
AddForm(form, request=request, fields=_session_fields(form), submit_class=""), AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
title="Add New Session", title="Add New Session",
scripts=mark_safe( scripts=mark_safe(ModuleScript("dist/elements/search-select.js")),
ModuleScript("search_select.js") + ModuleScript("add_session.js")
),
) )
@@ -283,98 +299,18 @@ def edit_session(request: HttpRequest, session_id: int) -> HttpResponse:
request, request,
AddForm(form, request=request, fields=_session_fields(form), submit_class=""), AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
title="Edit Session", title="Edit Session",
scripts=mark_safe( scripts=mark_safe(ModuleScript("dist/elements/search-select.js")),
ModuleScript("search_select.js") + ModuleScript("add_session.js")
),
) )
def _session_row_fragment(session: Session) -> SafeText: def _row_with_navbar(request: HttpRequest, session: Session) -> HttpResponse:
"""A single session <tr> (the old list_sessions.html#session-row partial), device_list = Device.objects.order_by("name")
returned by the inline end/clone-session HTMX endpoints.""" counts = model_counts(request)
name_link = Component( fragment = Fragment(
tag_name="a", session_row(session, device_list, get_token(request)),
attributes=[ NavbarPlaytime(counts["today_played"], counts["last_7_played"], oob=True),
(
"class",
"underline decoration-slate-500 sm:decoration-2 inline-block "
"truncate max-w-20char group-hover:absolute group-hover:max-w-none "
"group-hover:-top-8 group-hover:-left-6 group-hover:min-w-60 "
"group-hover:px-6 group-hover:py-3.5 group-hover:bg-purple-600 "
"group-hover:rounded-xs group-hover:outline-dashed "
"group-hover:outline-purple-400 group-hover:outline-4 "
"group-hover:decoration-purple-900 group-hover:text-purple-100",
),
("href", reverse("games:view_game", args=[session.game.id])),
],
children=[session.game.name],
) )
name_td = Component( return HttpResponse(str(fragment))
tag_name="td",
attributes=[
(
"class",
"px-2 sm:px-4 md:px-6 md:py-2 purchase-name relative align-top "
"w-24 h-12 group",
)
],
children=[
Component(
tag_name="span",
attributes=[("class", "inline-block relative")],
children=[name_link],
)
],
)
start_td = Component(
tag_name="td",
attributes=[
("class", "px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden sm:table-cell")
],
children=[date_filter(session.timestamp_start, "d/m/Y H:i")],
)
if not session.timestamp_end:
end_url = reverse("games:list_sessions_end_session", args=[session.id])
end_inner: SafeText | str = Component(
tag_name="a",
attributes=[
("href", end_url),
("hx-get", end_url),
("hx-target", "closest tr"),
("hx-swap", "outerHTML"),
("hx-indicator", "#indicator"),
(
"onClick",
"document.querySelector('#last-session-start')"
".classList.remove('invisible')",
),
],
children=[
Component(
tag_name="span",
attributes=[("class", "text-yellow-300")],
children=["Finish now?"],
)
],
)
elif session.duration_manual:
end_inner = "--"
else:
end_inner = date_filter(session.timestamp_end, "d/m/Y H:i")
end_td = Component(
tag_name="td",
attributes=[
("class", "px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell")
],
children=[end_inner],
)
duration_td = Component(
tag_name="td",
attributes=[("class", "px-2 sm:px-4 md:px-6 md:py-2 font-mono")],
children=[session.duration_formatted()],
)
return Component(tag_name="tr", children=[name_td, start_td, end_td, duration_td])
def clone_session_by_id(session_id: int) -> Session: def clone_session_by_id(session_id: int) -> Session:
@@ -392,9 +328,13 @@ def clone_session_by_id(session_id: int) -> Session:
def new_session_from_existing_session( def new_session_from_existing_session(
request: HttpRequest, session_id: int request: HttpRequest, session_id: int
) -> HttpResponse: ) -> HttpResponse:
session = clone_session_by_id(session_id) clone_session_by_id(session_id)
if request.htmx: if request.htmx:
return HttpResponse(_session_row_fragment(session)) # Clone adds a new row whose position depends on sort + pagination,
# which a single-row swap cannot place — refresh the list instead.
response = HttpResponse(status=204)
response["HX-Refresh"] = "true"
return response
return redirect("games:list_sessions") return redirect("games:list_sessions")
@@ -404,7 +344,17 @@ def end_session(request: HttpRequest, session_id: int) -> HttpResponse:
session.timestamp_end = timezone.now() session.timestamp_end = timezone.now()
session.save() session.save()
if request.htmx: if request.htmx:
return HttpResponse(_session_row_fragment(session)) return _row_with_navbar(request, session)
return redirect("games:list_sessions")
@login_required
def reset_session_start(request: HttpRequest, session_id: int) -> HttpResponse:
session = get_object_or_404(Session, id=session_id)
session.timestamp_start = timezone.now()
session.save()
if request.htmx:
return _row_with_navbar(request, session)
return redirect("games:list_sessions") return redirect("games:list_sessions")
+44 -36
View File
@@ -9,9 +9,19 @@ from django.template.defaultfilters import date as date_filter
from django.template.defaultfilters import floatformat from django.template.defaultfilters import floatformat
from django.urls import reverse from django.urls import reverse
from django.utils.html import conditional_escape from django.utils.html import conditional_escape
from django.utils.safestring import SafeText, mark_safe
from common.components import A, Component, Div, GameLink, YearPicker from common.components import (
A,
Div,
Element,
GameLink,
Node,
Safe,
Td,
Th,
Tr,
YearPicker,
)
from common.time import durationformat, format_duration from common.time import durationformat, format_duration
_CELL = "px-2 sm:px-4 md:px-6 md:py-2" _CELL = "px-2 sm:px-4 md:px-6 md:py-2"
@@ -19,41 +29,40 @@ _CELL_MONO = f"{_CELL} font-mono"
_NAME_TH = f"{_CELL} purchase-name truncate max-w-20char" _NAME_TH = f"{_CELL} purchase-name truncate max-w-20char"
def _td(children, cls: str = _CELL_MONO) -> SafeText: def _td(children, cls: str = _CELL_MONO) -> Node:
if not isinstance(children, list): if not isinstance(children, list):
children = [children] children = [children]
children = [c if isinstance(c, (str, SafeText)) else str(c) for c in children] return Td(attributes=[("class", cls)], children=children)
return Component(tag_name="td", attributes=[("class", cls)], children=children)
def _th(text: str, cls: str = _CELL) -> SafeText: def _th(text: str, cls: str = _CELL) -> Node:
return Component(tag_name="th", attributes=[("class", cls)], children=[text]) return Th(attributes=[("class", cls)], children=[text])
def _tr(cells: list) -> SafeText: def _tr(cells: list) -> Node:
return Component(tag_name="tr", children=cells) return Tr(children=cells)
def _kv(label, value) -> SafeText: def _kv(label, value) -> Node:
"""A label/value row: plain label cell + mono value cell.""" """A label/value row: plain label cell + mono value cell."""
return _tr([_td(label, _CELL), _td(value)]) return _tr([_td(label, _CELL), _td(value)])
def _h1(title: str) -> SafeText: def _h1(title: str) -> Node:
return Component( return Element(
tag_name="h1", "h1",
attributes=[("class", "text-3xl text-heading text-center my-6")], attributes=[("class", "text-3xl text-heading text-center my-6")],
children=[title], children=[title],
) )
def _table(rows: list, thead: SafeText | None = None) -> SafeText: def _table(rows: list, thead: Node | None = None) -> Node:
children = [] children = []
if thead is not None: if thead is not None:
children.append(thead) children.append(thead)
children.append(Component(tag_name="tbody", children=rows)) children.append(Element("tbody", children=rows))
return Component( return Element(
tag_name="table", "table",
attributes=[("class", "responsive-table")], attributes=[("class", "responsive-table")],
children=children, children=children,
) )
@@ -63,7 +72,7 @@ def _dur(value) -> str:
return format_duration(value, durationformat) return format_duration(value, durationformat)
def _purchase_name(purchase) -> SafeText: def _purchase_name(purchase) -> Node:
"""Mirror of the `purchase-name` partial in the old template.""" """Mirror of the `purchase-name` partial in the old template."""
game_name = getattr(purchase, "game_name", None) game_name = getattr(purchase, "game_name", None)
first_game = purchase.first_game first_game = purchase.first_game
@@ -71,12 +80,12 @@ def _purchase_name(purchase) -> SafeText:
name = game_name or purchase.name name = game_name or purchase.name
link = GameLink(first_game.id, name) link = GameLink(first_game.id, name)
suffix = f" ({first_game.name} {purchase.get_type_display()})" suffix = f" ({first_game.name} {purchase.get_type_display()})"
return mark_safe(str(link) + conditional_escape(suffix)) return Safe(str(link) + conditional_escape(suffix))
name = game_name or first_game.name name = game_name or first_game.name
return GameLink(first_game.id, name) return GameLink(first_game.id, name)
def _year_nav(year, year_range, url_template) -> SafeText: def _year_nav(year, year_range, url_template) -> Node:
# `year` is an int for a specific year, or "Alltime" (from compute_stats) # `year` is an int for a specific year, or "Alltime" (from compute_stats)
# for the all-time view. Normalize to int-or-None so nothing downstream has # for the all-time view. Normalize to int-or-None so nothing downstream has
# to know about the "Alltime" sentinel. # to know about the "Alltime" sentinel.
@@ -92,10 +101,9 @@ def _year_nav(year, year_range, url_template) -> SafeText:
else "text-body hover:text-heading underline decoration-dotted" else "text-body hover:text-heading underline decoration-dotted"
) )
alltime_btn = A( alltime_btn = A(
url_name="games:stats_alltime", href=reverse("games:stats_alltime"),
attributes=[("class", alltime_classes)], class_=alltime_classes,
children=["All-time stats"], )["All-time stats"]
)
picker = YearPicker( picker = YearPicker(
year=year_int, year=year_int,
available_years=tuple(year_range or []), available_years=tuple(year_range or []),
@@ -107,7 +115,7 @@ def _year_nav(year, year_range, url_template) -> SafeText:
) )
def _playtime_table(ctx) -> SafeText: def _playtime_table(ctx) -> Node:
year = ctx.get("year") year = ctx.get("year")
rows = [ rows = [
_kv("Hours", ctx.get("total_hours")), _kv("Hours", ctx.get("total_hours")),
@@ -186,7 +194,7 @@ def _playtime_table(ctx) -> SafeText:
return _table(rows) return _table(rows)
def _purchases_table(ctx) -> SafeText: def _purchases_table(ctx) -> Node:
rows = [ rows = [
_kv("Total", ctx.get("all_purchased_this_year_count")), _kv("Total", ctx.get("all_purchased_this_year_count")),
_kv( _kv(
@@ -213,18 +221,18 @@ def _purchases_table(ctx) -> SafeText:
return _table(rows) return _table(rows)
def _two_col_table(header: str, items, name_key, value_fn) -> SafeText: def _two_col_table(header: str, items, name_key, value_fn) -> Node:
thead = Component( thead = Element(
tag_name="thead", "thead",
children=[_tr([_th(header), _th("Playtime")])], children=[_tr([_th(header), _th("Playtime")])],
) )
rows = [_tr([_td(name_key(item)), _td(value_fn(item))]) for item in items] rows = [_tr([_td(name_key(item)), _td(value_fn(item))]) for item in items]
return _table(rows, thead) return _table(rows, thead)
def _finished_table(purchases) -> SafeText: def _finished_table(purchases) -> Node:
thead = Component( thead = Element(
tag_name="thead", "thead",
children=[_tr([_th("Name", _NAME_TH), _th("Date")])], children=[_tr([_th("Name", _NAME_TH), _th("Date")])],
) )
rows = [ rows = [
@@ -234,9 +242,9 @@ def _finished_table(purchases) -> SafeText:
return _table(rows, thead) return _table(rows, thead)
def _priced_table(purchases, currency) -> SafeText: def _priced_table(purchases, currency) -> Node:
thead = Component( thead = Element(
tag_name="thead", "thead",
children=[ children=[
_tr([_th("Name", _NAME_TH), _th(f"Price ({currency})"), _th("Date")]) _tr([_th("Name", _NAME_TH), _th(f"Price ({currency})"), _th("Date")])
], ],
@@ -254,7 +262,7 @@ def _priced_table(purchases, currency) -> SafeText:
return _table(rows, thead) return _table(rows, thead)
def stats_content(ctx: dict) -> SafeText: def stats_content(ctx: dict) -> Node:
year = ctx.get("year") year = ctx.get("year")
currency = ctx.get("total_spent_currency") currency = ctx.get("total_spent_currency")
# Build a navigation URL with an `__year__` placeholder the picker's JS # Build a navigation URL with an `__year__` placeholder the picker's JS
+8 -8
View File
@@ -7,12 +7,13 @@ from django.utils.safestring import SafeText
from common.components import ( from common.components import (
A, A,
AddForm, AddForm,
Button,
Component,
CsrfInput, CsrfInput,
Div, Div,
Element,
StyledButton,
paginated_table_content, paginated_table_content,
) )
from common.components.primitives import P
from common.layout import render_page from common.layout import render_page
from common.time import dateformat, local_strftime from common.time import dateformat, local_strftime
from common.utils import paginate from common.utils import paginate
@@ -75,22 +76,21 @@ def _delete_statuschange_content(statuschange, request: HttpRequest) -> SafeText
inner = Div( inner = Div(
[], [],
[ [
Component( P(
tag_name="p",
children=["Are you sure you want to delete this status change?"], children=["Are you sure you want to delete this status change?"],
), ),
Button( StyledButton(
[("class", "w-full")], "Delete", color="red", type="submit", size="lg" [("class", "w-full")], "Delete", color="red", type="submit", size="lg"
), ),
A( A(
[("class", "")], [("class", "")],
Button([("class", "w-full")], "Cancel", color="gray"), StyledButton([("class", "w-full")], "Cancel", color="gray"),
href=reverse("games:view_game", args=[statuschange.game.id]), href=reverse("games:view_game", args=[statuschange.game.id]),
), ),
], ],
) )
form = Component( form = Element(
tag_name="form", "form",
attributes=[("method", "post"), ("class", "dark:text-white")], attributes=[("method", "post"), ("class", "dark:text-white")],
children=[CsrfInput(request), inner], children=[CsrfInput(request), inner],
) )
+3 -1
View File
@@ -1,10 +1,12 @@
{ {
"packageManager": "pnpm@10.33.0",
"devDependencies": { "devDependencies": {
"@tailwindcss/forms": "^0.5.7", "@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.13", "@tailwindcss/typography": "^0.5.13",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"npm-check-updates": "^16.14.20", "npm-check-updates": "^16.14.20",
"tailwindcss": "^4.1.18" "tailwindcss": "^4.1.18",
"typescript": "^5.6.0"
}, },
"dependencies": { "dependencies": {
"@tailwindcss/cli": "^4.1.18", "@tailwindcss/cli": "^4.1.18",
+3358
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,2 +1,2 @@
allowBuilds: overrides:
'@parcel/watcher': false tar: ^7.5.11
+15
View File
@@ -0,0 +1,15 @@
# Alternative to a .env file for non-Docker / bare-metal deployments.
# Copy to settings.ini (next to manage.py) or point INI_FILE at it.
# Real environment variables and a .env file both take precedence over this.
# See docs/configuration.md for the full reference.
[timetracker]
DEBUG = false
SECRET_KEY = change-me-to-a-long-random-string
APP_URL = https://tracker.kucharczyk.xyz
TZ = Europe/Prague
DATA_DIR = /var/lib/timetracker
# Optional explicit overrides (comma-separated); win over APP_URL when set.
# ALLOWED_HOSTS = *
# CSRF_TRUSTED_ORIGINS = https://tracker.kucharczyk.xyz
+6
View File
@@ -11,6 +11,12 @@ pkgs.mkShell {
pnpm pnpm
]; ];
# manylinux wheels with native extensions (greenlet, pulled in by
# pytest-playwright) link against libstdc++.so.6, which the nixpkgs
# Python cannot find on its default search path. Scoped to this dev
# shell only — a global LD_LIBRARY_PATH would leak into other programs.
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc.lib ];
shellHook = '' shellHook = ''
uv venv --clear uv venv --clear
. .venv/bin/activate . .venv/bin/activate
+260 -148
View File
@@ -2,21 +2,29 @@ import unittest
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import django import django
from django.test import SimpleTestCase
from django.utils.safestring import SafeText, mark_safe from django.utils.safestring import SafeText, mark_safe
from common import components from common import components
from games.models import Platform, Game, Purchase, Session from games.models import Game, Platform, Purchase, Session
# Component builders return lazy ``Node`` objects; these tests assert on rendered
# HTML, so node-returning calls are wrapped in ``str(...)`` at the call site
# (``Node.__str__`` returns a ``SafeText``). Non-node helpers (``randomid``,
# ``_resolve_name_with_icon``, ``_render_element``) are called
# directly.
class ComponentIntegrationTest(unittest.TestCase): class ComponentIntegrationTest(unittest.TestCase):
"""Test Component() works correctly with caching transparent.""" """Test Element() renders correctly with caching transparent."""
def test_tag_name_component(self): def test_tag_name_component(self):
result = components.Component( result = str(
tag_name="div", components.Element(
attributes=[("class", "test")], tag_name="div",
children="hello", attributes=[("class", "test")],
children="hello",
)
) )
self.assertEqual(result, '<div class="test">hello</div>') self.assertEqual(result, '<div class="test">hello</div>')
@@ -28,9 +36,17 @@ class ComponentCacheTest(unittest.TestCase):
components._render_element.cache_clear() components._render_element.cache_clear()
def test_identical_components_hit_cache(self): def test_identical_components_hit_cache(self):
components.Component(tag_name="div", attributes=[("class", "x")], children="hi") str(
components.Element(
tag_name="div", attributes=[("class", "x")], children="hi"
)
)
misses = components._render_element.cache_info().misses misses = components._render_element.cache_info().misses
components.Component(tag_name="div", attributes=[("class", "x")], children="hi") str(
components.Element(
tag_name="div", attributes=[("class", "x")], children="hi"
)
)
info = components._render_element.cache_info() info = components._render_element.cache_info()
self.assertEqual(info.misses, misses) # no new miss self.assertEqual(info.misses, misses) # no new miss
self.assertGreaterEqual(info.hits, 1) # served from cache self.assertGreaterEqual(info.hits, 1) # served from cache
@@ -39,10 +55,12 @@ class ComponentCacheTest(unittest.TestCase):
self.assertEqual(components._render_element.cache_parameters()["maxsize"], 4096) self.assertEqual(components._render_element.cache_parameters()["maxsize"], 4096)
def test_safe_and_unsafe_children_do_not_collide(self): def test_safe_and_unsafe_children_do_not_collide(self):
"""A SafeText "<b>" and a plain "<b>" are equal as strings but must """A Safe-node ``<b>`` and a plain-string ``<b>`` render differently —
render differently the cache key must keep them distinct.""" the cache key must keep them distinct."""
safe = components.Component(tag_name="span", children=[mark_safe("<b>x</b>")]) safe = str(
unsafe = components.Component(tag_name="span", children=["<b>x</b>"]) components.Element(tag_name="span", children=[components.Safe("<b>x</b>")])
)
unsafe = str(components.Element(tag_name="span", children=["<b>x</b>"]))
self.assertIn("<b>x</b>", safe) self.assertIn("<b>x</b>", safe)
self.assertIn("&lt;b&gt;x&lt;/b&gt;", unsafe) self.assertIn("&lt;b&gt;x&lt;/b&gt;", unsafe)
self.assertNotEqual(safe, unsafe) self.assertNotEqual(safe, unsafe)
@@ -114,33 +132,37 @@ class PopoverDeterministicTest(unittest.TestCase):
"""Test that Popover() produces deterministic HTML output.""" """Test that Popover() produces deterministic HTML output."""
def test_same_popover_same_id(self): def test_same_popover_same_id(self):
r1 = components.Popover("hello", wrapped_content="hello") r1 = str(components.Popover("hello", wrapped_content="hello"))
r2 = components.Popover("hello", wrapped_content="hello") r2 = str(components.Popover("hello", wrapped_content="hello"))
self.assertEqual(r1, r2) self.assertEqual(r1, r2)
def test_different_content_different_id(self): def test_different_content_different_id(self):
r1 = components.Popover("content_a", wrapped_content="content_a") r1 = str(components.Popover("content_a", wrapped_content="content_a"))
r2 = components.Popover("content_b", wrapped_content="content_b") r2 = str(components.Popover("content_b", wrapped_content="content_b"))
self.assertNotEqual(r1, r2) self.assertNotEqual(r1, r2)
def test_wrapped_classes_affect_id(self): def test_wrapped_classes_affect_id(self):
r1 = components.Popover("c", wrapped_content="c", wrapped_classes="class_x") r1 = str(
r2 = components.Popover("c", wrapped_content="c", wrapped_classes="class_y") components.Popover("c", wrapped_content="c", wrapped_classes="class_x")
)
r2 = str(
components.Popover("c", wrapped_content="c", wrapped_classes="class_y")
)
self.assertNotEqual(r1, r2) self.assertNotEqual(r1, r2)
def test_wrapped_content_affects_id(self): def test_wrapped_content_affects_id(self):
r1 = components.Popover("popover", wrapped_content="wrapped_a") r1 = str(components.Popover("popover", wrapped_content="wrapped_a"))
r2 = components.Popover("popover", wrapped_content="wrapped_b") r2 = str(components.Popover("popover", wrapped_content="wrapped_b"))
self.assertNotEqual(r1, r2) self.assertNotEqual(r1, r2)
def test_popover_content_affects_id(self): def test_popover_content_affects_id(self):
r1 = components.Popover("popover_a", wrapped_content="wrapped") r1 = str(components.Popover("popover_a", wrapped_content="wrapped"))
r2 = components.Popover("popover_b", wrapped_content="wrapped") r2 = str(components.Popover("popover_b", wrapped_content="wrapped"))
self.assertNotEqual(r1, r2) self.assertNotEqual(r1, r2)
def test_full_html_deterministic(self): def test_full_html_deterministic(self):
r1 = components.Popover("hello world", wrapped_content="hello world") r1 = str(components.Popover("hello world", wrapped_content="hello world"))
r2 = components.Popover("hello world", wrapped_content="hello world") r2 = str(components.Popover("hello world", wrapped_content="hello world"))
self.assertEqual(r1.encode(), r2.encode()) self.assertEqual(r1.encode(), r2.encode())
@@ -180,63 +202,50 @@ class ComponentReturnTypeTest(unittest.TestCase):
"""Test that component functions return SafeText and render correctly.""" """Test that component functions return SafeText and render correctly."""
def test_div_returns_safe_text(self): def test_div_returns_safe_text(self):
result = components.Div([("class", "x")], "hello") result = str(components.Div([("class", "x")], "hello"))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
def test_div_deterministic(self): def test_div_deterministic(self):
r1 = components.Div([("class", "x")], "hello") r1 = str(components.Div([("class", "x")], "hello"))
r2 = components.Div([("class", "x")], "hello") r2 = str(components.Div([("class", "x")], "hello"))
self.assertEqual(r1, r2) self.assertEqual(r1, r2)
self.assertIn('<div class="x">hello</div>', r1) self.assertIn('<div class="x">hello</div>', r1)
def test_div_no_args(self): def test_div_no_args(self):
result = components.Div(children="test") result = str(components.Div(children="test"))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
self.assertIn("<div>test</div>", result) self.assertIn("<div>test</div>", result)
def test_a_returns_safe_text(self): def test_a_returns_safe_text(self):
result = components.A([], "link") result = str(components.A([], "link"))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
def test_a_literal_href(self): def test_a_literal_href(self):
result = components.A([], "x", href="/literal/path") result = str(components.A([], "x", href="/literal/path"))
self.assertIn('href="/literal/path"', result) self.assertIn('href="/literal/path"', result)
def test_a_url_name_reversed(self):
from unittest.mock import patch
with patch(
"common.components.primitives.reverse", return_value="/resolved/url"
):
result = components.A([], "link", url_name="some_name")
self.assertIn('href="/resolved/url"', result)
def test_a_no_url_or_href(self): def test_a_no_url_or_href(self):
result = components.A([], "link") result = str(components.A([], "link"))
self.assertIn("<a>link</a>", result) self.assertIn("<a>link</a>", result)
self.assertNotIn("href=", result) self.assertNotIn("href=", result)
def test_a_both_url_name_and_href_raises(self):
with self.assertRaises(ValueError):
components.A(href="/path", url_name="some_name")
def test_button_returns_safe_text(self): def test_button_returns_safe_text(self):
result = components.Button([], "click") result = str(components.StyledButton([], "click"))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
self.assertIn("<button", result) self.assertIn("<button", result)
def test_button_default_colors(self): def test_button_default_colors(self):
result = components.Button([], "click") result = str(components.StyledButton([], "click"))
self.assertIn("text-white bg-brand", result) self.assertIn("text-white bg-brand", result)
def test_name_with_icon_no_link(self): def test_name_with_icon_no_link(self):
result = components.NameWithIcon(name="Game", linkify=False) result = str(components.NameWithIcon(name="Game", linkify=False))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
self.assertIn("Game", result) self.assertIn("Game", result)
self.assertNotIn("<a ", result) self.assertNotIn("<a ", result)
def test_name_with_icon_no_trailing_comma(self): def test_name_with_icon_no_trailing_comma(self):
result = components.NameWithIcon(name="Test", linkify=False) result = str(components.NameWithIcon(name="Test", linkify=False))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
self.assertNotIsInstance(result, tuple) self.assertNotIsInstance(result, tuple)
@@ -246,21 +255,23 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
def test_component_output_starts_with_tag(self): def test_component_output_starts_with_tag(self):
for label, html in [ for label, html in [
("A", components.A(href="/foo", children=["link"])), ("A", str(components.A(href="/foo", children=["link"]))),
("Button", components.Button([], "click")), ("Button", str(components.StyledButton([], "click"))),
("Div", components.Div([], ["hello"])), ("Div", str(components.Div([], ["hello"]))),
("Input", components.Input()), ("Input", str(components.Input())),
("ButtonGroup", components.ButtonGroup([])), ("ButtonGroup", str(components.ButtonGroup([]))),
( (
"ButtonGroup with buttons", "ButtonGroup with buttons",
components.ButtonGroup( str(
[{"href": "/", "slot": components.Icon("edit")}] components.ButtonGroup(
[{"href": "/", "slot": components.Icon("edit")}]
)
), ),
), ),
("SearchField", components.SearchField()), ("SearchField", str(components.SearchField())),
("PriceConverted", components.PriceConverted(["27 CZK"])), ("PriceConverted", str(components.PriceConverted(["27 CZK"]))),
("H1", components.H1(["Title"])), ("H1", str(components.H1(["Title"]))),
("H1 with badge", components.H1(["Title"], badge="3")), ("H1 with badge", str(components.H1(["Title"], badge="3"))),
]: ]:
with self.subTest(component=label): with self.subTest(component=label):
self.assertTrue( self.assertTrue(
@@ -269,90 +280,112 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
) )
def test_button_with_icon_children_not_escaped(self): def test_button_with_icon_children_not_escaped(self):
result = components.Button( result = str(
icon=True, components.StyledButton(
size="xs", icon=True,
children=[components.Icon("play"), "LOG"], size="xs",
children=[components.Icon("play"), "LOG"],
)
) )
self.assertTrue(str(result).startswith("<button")) self.assertTrue(str(result).startswith("<button"))
def test_popover_with_button_children_not_escaped(self): def test_popover_with_button_children_not_escaped(self):
result = components.Popover( result = str(
popover_content="test tooltip", components.Popover(
children=[ popover_content="test tooltip",
components.Button( children=[
icon=True, components.StyledButton(
color="gray", icon=True,
size="xs", color="gray",
children=[components.Icon("play"), "test"], size="xs",
), children=[components.Icon("play"), "test"],
], ),
],
)
) )
self.assertTrue(str(result).startswith("<span data-popover-target")) self.assertTrue(str(result).startswith("<span data-popover-target"))
def test_name_with_icon_output_not_escaped(self): def test_name_with_icon_output_not_escaped(self):
result = components.NameWithIcon(name="Test", linkify=False) result = str(components.NameWithIcon(name="Test", linkify=False))
self.assertTrue(str(result).startswith("<div")) self.assertTrue(str(result).startswith("<div"))
class ComponentEdgeCasesTest(unittest.TestCase): class ComponentEdgeCasesTest(unittest.TestCase):
"""Test Component() edge cases and error handling.""" """Test Element() edge cases and error handling."""
def test_no_tag_name_raises(self): def test_no_tag_name_raises(self):
with self.assertRaises(ValueError) as ctx: with self.assertRaises(ValueError) as ctx:
components.Component(children="hello") str(components.Element("", children="hello"))
self.assertIn("tag_name", str(ctx.exception)) self.assertIn("tag_name", str(ctx.exception))
def test_single_string_children_wrapped(self): def test_single_string_children_wrapped(self):
result = components.Component(tag_name="span", children="hello") result = str(components.Element(tag_name="span", children="hello"))
self.assertIn("hello", result) self.assertIn("hello", result)
def test_multiple_children_joined_with_newlines(self): def test_multiple_children_joined_with_newlines(self):
result = components.Component(tag_name="div", children=["hello", "world"]) result = str(components.Element(tag_name="div", children=["hello", "world"]))
self.assertIn("hello\nworld", result) self.assertIn("hello\nworld", result)
self.assertIn("<div>", result) self.assertIn("<div>", result)
self.assertIn("</div>", result) self.assertIn("</div>", result)
def test_raw_html_children_are_escaped(self): def test_raw_html_children_are_escaped(self):
result = components.Component( result = str(
tag_name="div", children=["<script>alert('xss')</script>"] components.Element(
tag_name="div", children=["<script>alert('xss')</script>"]
)
) )
self.assertNotIn("<script>", result) self.assertNotIn("<script>", result)
self.assertIn("&lt;script&gt;", result) self.assertIn("&lt;script&gt;", result)
def test_mark_safe_children_pass_through(self): def test_safe_node_children_pass_through(self):
result = components.Component( result = str(
tag_name="div", children=[mark_safe("<span>safe</span>")] components.Element(
tag_name="div", children=[components.Safe("<span>safe</span>")]
)
) )
self.assertIn("<span>safe</span>", result) self.assertIn("<span>safe</span>", result)
def test_mark_safe_string_children_are_escaped(self):
# Trusted markup must be a Safe node; a mark_safe string is still a
# string, so it is escaped like any other text child.
result = str(
components.Element(
tag_name="div", children=[mark_safe("<span>safe</span>")]
)
)
self.assertIn("&lt;span&gt;safe&lt;/span&gt;", result)
def test_attribute_values_are_escaped(self): def test_attribute_values_are_escaped(self):
result = components.Component( result = str(
tag_name="div", components.Element(
attributes=[("data-x", 'foo"bar')], tag_name="div",
attributes=[("data-x", 'foo"bar')],
)
) )
self.assertIn("&quot;", result) self.assertIn("&quot;", result)
self.assertNotIn('"foo"bar"', result) self.assertNotIn('"foo"bar"', result)
def test_attributes_serialized_correctly(self): def test_attributes_serialized_correctly(self):
result = components.Component( result = str(
tag_name="div", attributes=[("class", "foo"), ("id", "bar")] components.Element(
tag_name="div", attributes=[("class", "foo"), ("id", "bar")]
)
) )
self.assertIn('class="foo"', result) self.assertIn('class="foo"', result)
self.assertIn('id="bar"', result) self.assertIn('id="bar"', result)
def test_empty_attributes_no_extra_space(self): def test_empty_attributes_no_extra_space(self):
result = components.Component(tag_name="span", children="x") result = str(components.Element(tag_name="span", children="x"))
self.assertEqual(result, "<span>x</span>") self.assertEqual(result, "<span>x</span>")
self.assertNotIn(" <span", result) self.assertNotIn(" <span", result)
def test_non_string_children_not_supported(self): def test_non_string_children_not_supported(self):
"""Component only accepts str for children, not integers.""" """Component only accepts str for children, not integers."""
result = components.Component(tag_name="span", children=str(42)) result = str(components.Element(tag_name="span", children=str(42)))
self.assertIn("42", result) self.assertIn("42", result)
def test_returns_safetext(self): def test_returns_safetext(self):
result = components.Component(tag_name="div", children="test") result = str(components.Element(tag_name="div", children="test"))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
@@ -360,22 +393,22 @@ class IconTest(unittest.TestCase):
"""Test Icon() component function.""" """Test Icon() component function."""
def test_valid_icon_renders_svg(self): def test_valid_icon_renders_svg(self):
result = components.Icon("play") result = str(components.Icon("play"))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
self.assertIn("<svg", result) self.assertIn("<svg", result)
self.assertIn("</svg>", result) self.assertIn("</svg>", result)
def test_unavailable_icon_falls_back(self): def test_unavailable_icon_falls_back(self):
result = components.Icon("zzz_nonexistent_platform") result = str(components.Icon("zzz_nonexistent_platform"))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
self.assertIn("<svg", result) self.assertIn("<svg", result)
def test_icon_passes_attributes_to_template(self): def test_icon_passes_attributes_to_template(self):
result = components.Icon("play", attributes=[("title", "Play")]) result = str(components.Icon("play", attributes=[("title", "Play")]))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
def test_returns_safetext(self): def test_returns_safetext(self):
result = components.Icon("delete") result = str(components.Icon("delete"))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
@@ -383,17 +416,19 @@ class InputTest(unittest.TestCase):
"""Test the Input() component.""" """Test the Input() component."""
def test_input_default_type_text(self): def test_input_default_type_text(self):
result = components.Input() result = str(components.Input())
self.assertIn("<input", result) self.assertIn("<input", result)
self.assertIn('type="text"', result) self.assertIn('type="text"', result)
def test_input_custom_type(self): def test_input_custom_type(self):
result = components.Input(type="submit") result = str(components.Input(type="submit"))
self.assertIn('type="submit"', result) self.assertIn('type="submit"', result)
def test_input_attributes_merged_with_type(self): def test_input_attributes_merged_with_type(self):
result = components.Input( result = str(
type="email", attributes=[("id", "email"), ("class", "form-input")] components.Input(
type="email", attributes=[("id", "email"), ("class", "form-input")]
)
) )
self.assertIn('type="email"', result) self.assertIn('type="email"', result)
self.assertIn('id="email"', result) self.assertIn('id="email"', result)
@@ -404,12 +439,12 @@ class PopoverTruncatedTest(unittest.TestCase):
"""Test PopoverTruncated() component function.""" """Test PopoverTruncated() component function."""
def test_short_string_no_popover(self): def test_short_string_no_popover(self):
result = components.PopoverTruncated("hi") result = str(components.PopoverTruncated("hi"))
self.assertEqual(result, "hi") self.assertEqual(result, "hi")
def test_long_string_wrapped_in_popover(self): def test_long_string_wrapped_in_popover(self):
long_text = "a" * 100 long_text = "a" * 100
result = components.PopoverTruncated(long_text) result = str(components.PopoverTruncated(long_text))
# Should NOT equal the truncated form directly # Should NOT equal the truncated form directly
truncated = components.truncate(long_text, 30) truncated = components.truncate(long_text, 30)
self.assertNotEqual(result, truncated) self.assertNotEqual(result, truncated)
@@ -418,47 +453,55 @@ class PopoverTruncatedTest(unittest.TestCase):
def test_custom_ellipsis_used(self): def test_custom_ellipsis_used(self):
long_text = "a" * 50 long_text = "a" * 50
result = components.PopoverTruncated(long_text, ellipsis=">>") result = str(components.PopoverTruncated(long_text, ellipsis=">>"))
# Django template escapes >> to &gt;&gt; in the wrapped_content # Django template escapes >> to &gt;&gt; in the wrapped_content
self.assertIn("&gt;&gt;", result) self.assertIn("&gt;&gt;", result)
def test_popover_if_not_truncated_flag(self): def test_popover_if_not_truncated_flag(self):
short_text = "hi" short_text = "hi"
result = components.PopoverTruncated( result = str(
short_text, popover_content="full content", popover_if_not_truncated=True components.PopoverTruncated(
short_text,
popover_content="full content",
popover_if_not_truncated=True,
)
) )
# Should be wrapped in popover even though short # Should be wrapped in popover even though short
self.assertNotEqual(result, "hi") self.assertNotEqual(result, "hi")
self.assertIn("data-popover-target", result) self.assertIn("data-popover-target", result)
def test_popover_content_override(self): def test_popover_content_override(self):
result = components.PopoverTruncated("short", popover_content="custom popover") result = str(
components.PopoverTruncated("short", popover_content="custom popover")
)
# With popover_if_not_truncated=False (default), short text returns as-is # With popover_if_not_truncated=False (default), short text returns as-is
self.assertEqual(result, "short") self.assertEqual(result, "short")
def test_popover_content_override_with_flag(self): def test_popover_content_override_with_flag(self):
result = components.PopoverTruncated( result = str(
"short", popover_content="custom popover", popover_if_not_truncated=True components.PopoverTruncated(
"short", popover_content="custom popover", popover_if_not_truncated=True
)
) )
self.assertIn("custom popover", result) self.assertIn("custom popover", result)
def test_endpart_visible_in_output(self): def test_endpart_visible_in_output(self):
long_text = "a" * 50 long_text = "a" * 50
result = components.PopoverTruncated(long_text, endpart="...") result = str(components.PopoverTruncated(long_text, endpart="..."))
self.assertIn("...", result) self.assertIn("...", result)
def test_returns_safetext(self): def test_returns_safetext(self):
result = components.PopoverTruncated("a" * 100) result = str(components.PopoverTruncated("a" * 100))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
def test_default_length(self): def test_default_length(self):
text = "a" * 31 text = "a" * 31
result = components.PopoverTruncated(text) result = str(components.PopoverTruncated(text))
# 31 chars exceeds default length of 30, so should be truncated # 31 chars exceeds default length of 30, so should be truncated
self.assertIn("data-popover-target", result) self.assertIn("data-popover-target", result)
def test_length_zero(self): def test_length_zero(self):
result = components.PopoverTruncated("hello", length=0) result = str(components.PopoverTruncated("hello", length=0))
# Even empty length triggers popover for any content # Even empty length triggers popover for any content
self.assertIn("data-popover-target", result) self.assertIn("data-popover-target", result)
@@ -490,7 +533,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
def test_name_with_icon_linkify_with_game(self): def test_name_with_icon_linkify_with_game(self):
platform = self._create_platform(name="Steam", icon="steam") platform = self._create_platform(name="Steam", icon="steam")
game = self._create_game(platform) game = self._create_game(platform)
result = components.NameWithIcon(game=game, linkify=True) result = str(components.NameWithIcon(game=game, linkify=True))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
self.assertIn("<a ", result) self.assertIn("<a ", result)
self.assertIn("Test Game", result) self.assertIn("Test Game", result)
@@ -499,7 +542,9 @@ class ModelDependentComponentsTest(django.test.TestCase):
def test_name_with_icon_no_linkify(self): def test_name_with_icon_no_linkify(self):
platform = self._create_platform(name="GOG", icon="gog") platform = self._create_platform(name="GOG", icon="gog")
game = self._create_game(platform) game = self._create_game(platform)
result = components.NameWithIcon(name="Test Game", game=game, linkify=False) result = str(
components.NameWithIcon(name="Test Game", game=game, linkify=False)
)
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
self.assertNotIn("<a ", result) self.assertNotIn("<a ", result)
self.assertIn("Test Game", result) self.assertIn("Test Game", result)
@@ -512,13 +557,13 @@ class ModelDependentComponentsTest(django.test.TestCase):
timestamp_start="2025-01-01 00:00:00+00:00", timestamp_start="2025-01-01 00:00:00+00:00",
emulated=True, emulated=True,
) )
result = components.NameWithIcon(session=session, linkify=True) result = str(components.NameWithIcon(session=session, linkify=True))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
self.assertIn("<a ", result) self.assertIn("<a ", result)
self.assertIn("Emulated", result) self.assertIn("Emulated", result)
def test_name_with_icon_no_platform(self): def test_name_with_icon_no_platform(self):
result = components.NameWithIcon(name="Standalone", linkify=False) result = str(components.NameWithIcon(name="Standalone", linkify=False))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
self.assertIn("Standalone", result) self.assertIn("Standalone", result)
@@ -529,7 +574,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
game=game, game=game,
timestamp_start="2025-01-01 00:00:00+00:00", timestamp_start="2025-01-01 00:00:00+00:00",
) )
result = components.NameWithIcon(session=session, linkify=True) result = str(components.NameWithIcon(session=session, linkify=True))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
self.assertIn("Epic Game", result) self.assertIn("Epic Game", result)
@@ -537,7 +582,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
platform = self._create_platform() platform = self._create_platform()
game = self._create_game(platform) game = self._create_game(platform)
purchase = self._create_purchase([game], price=29.99) purchase = self._create_purchase([game], price=29.99)
result = components.PurchasePrice(purchase) result = str(components.PurchasePrice(purchase))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
# floatformat rounds to 1 decimal: 29.99 -> 30.0 # floatformat rounds to 1 decimal: 29.99 -> 30.0
self.assertIn("30.0", result) self.assertIn("30.0", result)
@@ -548,7 +593,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
platform = self._create_platform(icon="steam") platform = self._create_platform(icon="steam")
game = self._create_game(platform, name="Single Game") game = self._create_game(platform, name="Single Game")
purchase = self._create_purchase([game], price=14.99) purchase = self._create_purchase([game], price=14.99)
result = components.LinkedPurchase(purchase) result = str(components.LinkedPurchase(purchase))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
self.assertIn("Single Game", result) self.assertIn("Single Game", result)
self.assertIn("<a ", result) self.assertIn("<a ", result)
@@ -559,7 +604,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
game1 = self._create_game(platform, name="Game One") game1 = self._create_game(platform, name="Game One")
game2 = self._create_game(platform, name="Game Two") game2 = self._create_game(platform, name="Game Two")
purchase = self._create_purchase([game1, game2], price=24.99) purchase = self._create_purchase([game1, game2], price=24.99)
result = components.LinkedPurchase(purchase) result = str(components.LinkedPurchase(purchase))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
self.assertIn("2 games", result) self.assertIn("2 games", result)
self.assertIn("<a ", result) self.assertIn("<a ", result)
@@ -575,7 +620,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
) )
purchase.name = "Bundle" purchase.name = "Bundle"
purchase.save() purchase.save()
result = components.LinkedPurchase(purchase) result = str(components.LinkedPurchase(purchase))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
self.assertIn("Bundle", result) self.assertIn("Bundle", result)
@@ -584,7 +629,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
game1 = self._create_game(platform, name="Alpha") game1 = self._create_game(platform, name="Alpha")
game2 = self._create_game(platform, name="Beta") game2 = self._create_game(platform, name="Beta")
purchase = self._create_purchase([game1, game2], price=19.99) purchase = self._create_purchase([game1, game2], price=19.99)
result = components.LinkedPurchase(purchase) result = str(components.LinkedPurchase(purchase))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
self.assertIn("Alpha", result) self.assertIn("Alpha", result)
self.assertIn("Beta", result) self.assertIn("Beta", result)
@@ -595,18 +640,18 @@ class PurchaseTruncatedTest(unittest.TestCase):
def test_endpart_shorter_than_length(self): def test_endpart_shorter_than_length(self):
text = "a" * 50 text = "a" * 50
result = components.PopoverTruncated(text, length=10, endpart="x") result = str(components.PopoverTruncated(text, length=10, endpart="x"))
# endpart=x takes 1 char, so content gets truncated at 9 chars # endpart=x takes 1 char, so content gets truncated at 9 chars
self.assertIn("data-popover-target", result) self.assertIn("data-popover-target", result)
self.assertIn("x", result) self.assertIn("x", result)
def test_no_truncation_no_ellipsis(self): def test_no_truncation_no_ellipsis(self):
result = components.PopoverTruncated("short text") result = str(components.PopoverTruncated("short text"))
self.assertEqual(result, "short text") self.assertEqual(result, "short text")
def test_custom_length(self): def test_custom_length(self):
text = "hello world" text = "hello world"
result = components.PopoverTruncated(text, length=6) result = str(components.PopoverTruncated(text, length=6))
self.assertIn("data-popover-target", result) self.assertIn("data-popover-target", result)
@@ -620,12 +665,14 @@ class NameWithIconPlatformTest(django.test.TestCase):
cls.game = Game.objects.create(name="Zelda", platform=cls.platform) cls.game = Game.objects.create(name="Zelda", platform=cls.platform)
def test_name_with_icon_shows_platform_icon(self): def test_name_with_icon_shows_platform_icon(self):
result = components.NameWithIcon(name="Zelda", game=self.game, linkify=True) result = str(
components.NameWithIcon(name="Zelda", game=self.game, linkify=True)
)
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
self.assertIn("Zelda", result) self.assertIn("Zelda", result)
def test_name_with_icon_no_game_id_no_platform(self): def test_name_with_icon_no_game_id_no_platform(self):
result = components.NameWithIcon(name="Unknown Game", linkify=False) result = str(components.NameWithIcon(name="Unknown Game", linkify=False))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
self.assertIn("Unknown Game", result) self.assertIn("Unknown Game", result)
@@ -749,9 +796,11 @@ class SimpleTableRenderingTest(unittest.TestCase):
def test_simple_table_renders_list_rows(self): def test_simple_table_renders_list_rows(self):
"""Verify list-style rows render as <tr> with <th scope='row'> + <td>.""" """Verify list-style rows render as <tr> with <th scope='row'> + <td>."""
result = str( result = str(
components.SimpleTable( str(
columns=["Game", "Started", "Ended"], components.SimpleTable(
rows=[["Game1", "2025-01-01", "2025-03-01"]], columns=["Game", "Started", "Ended"],
rows=[["Game1", "2025-01-01", "2025-03-01"]],
)
) )
) )
tbody = self._tbody(result) tbody = self._tbody(result)
@@ -774,9 +823,11 @@ class SimpleTableRenderingTest(unittest.TestCase):
def test_simple_table_multiple_rows(self): def test_simple_table_multiple_rows(self):
"""Verify multiple rows all render.""" """Verify multiple rows all render."""
result = str( result = str(
components.SimpleTable( str(
columns=["Game", "Started"], components.SimpleTable(
rows=[["GameA", "2025-01-01"], ["GameB", "2025-02-01"]], columns=["Game", "Started"],
rows=[["GameA", "2025-01-01"], ["GameB", "2025-02-01"]],
)
) )
) )
tbody = self._tbody(result) tbody = self._tbody(result)
@@ -786,13 +837,13 @@ class SimpleTableRenderingTest(unittest.TestCase):
def test_simple_table_header_action_as_caption(self): def test_simple_table_header_action_as_caption(self):
"""Verify header_action renders inside <caption>.""" """Verify header_action renders inside <caption>."""
from django.utils.safestring import mark_safe
result = str( result = str(
components.SimpleTable( str(
columns=["Game", "Started"], components.SimpleTable(
rows=[["Game1", "2025-01-01"]], columns=["Game", "Started"],
header_action=mark_safe('<a href="/add">Add</a>'), rows=[["Game1", "2025-01-01"]],
header_action=components.Safe('<a href="/add">Add</a>'),
)
) )
) )
self.assertIn("<caption", result) self.assertIn("<caption", result)
@@ -802,15 +853,17 @@ class SimpleTableRenderingTest(unittest.TestCase):
def test_simple_table_dict_rows_with_cell_data(self): def test_simple_table_dict_rows_with_cell_data(self):
"""Verify dict-style rows with row_id and cell_data render correctly.""" """Verify dict-style rows with row_id and cell_data render correctly."""
result = str( result = str(
components.SimpleTable( str(
columns=["Name", "Date"], components.SimpleTable(
rows=[ columns=["Name", "Date"],
{ rows=[
"row_id": "session-row-1", {
"hx_trigger": "device-changed", "row_id": "session-row-1",
"cell_data": ["Game1", "2025-01-01"], "hx_trigger": "device-changed",
} "cell_data": ["Game1", "2025-01-01"],
], }
],
)
) )
) )
tbody = self._tbody(result) tbody = self._tbody(result)
@@ -821,5 +874,64 @@ class SimpleTableRenderingTest(unittest.TestCase):
self.assertIn("2025-01-01", tbody) self.assertIn("2025-01-01", tbody)
class ComponentPrimitivesTest(SimpleTestCase):
def test_checkbox_primitive(self):
html = str(
components.Checkbox(
name="test-check", label="Accept Terms", checked=True, value="yes"
)
)
self.assertIn('type="checkbox"', html)
self.assertIn('name="test-check"', html)
self.assertIn('value="yes"', html)
self.assertIn('checked="true"', html)
self.assertIn("Accept Terms", html)
def test_checkbox_headless(self):
html = str(components.Checkbox(name="test-headless", label=None, checked=True))
self.assertNotIn("<label", html)
self.assertIn("<input", html)
self.assertIn('type="checkbox"', html)
self.assertIn('name="test-headless"', html)
def test_radio_primitive(self):
html = str(
components.Radio(
name="test-radio", label="Option A", checked=False, value="A"
)
)
self.assertIn('type="radio"', html)
self.assertIn('name="test-radio"', html)
self.assertIn('value="A"', html)
self.assertNotIn('checked="true"', html)
self.assertIn("Option A", html)
class PrimitiveWidgetsTest(SimpleTestCase):
def test_mixin_applies_widget_to_boolean_fields_only(self):
from django import forms
from games.forms import PrimitiveCheckboxWidget, PrimitiveWidgetsMixin
class DummyForm(PrimitiveWidgetsMixin, forms.Form):
agree = forms.BooleanField(required=False)
name = forms.CharField(required=False)
form = DummyForm()
self.assertIsInstance(form.fields["agree"].widget, PrimitiveCheckboxWidget)
self.assertNotIsInstance(form.fields["name"].widget, PrimitiveCheckboxWidget)
def test_primitive_checkbox_widget_renders_headless(self):
from games.forms import PrimitiveCheckboxWidget
widget = PrimitiveCheckboxWidget()
html = widget.render(name="agree", value=True)
self.assertNotIn("<label", html)
self.assertIn("<input", html)
self.assertIn('type="checkbox"', html)
self.assertIn('name="agree"', html)
self.assertIn('checked="true"', html)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+277
View File
@@ -0,0 +1,277 @@
"""Tests for the configuration reader in ``timetracker/config.py``."""
import pytest
from django.core.exceptions import DisallowedHost, ImproperlyConfigured
from django.middleware.csrf import CsrfViewMiddleware
from django.test import RequestFactory, override_settings
from timetracker import config as config_module
from timetracker.config import config, derive_hosts_and_origins
@pytest.fixture(autouse=True)
def _clear_caches():
"""Each test sees freshly parsed files."""
config_module.reset_caches()
yield
config_module.reset_caches()
@pytest.fixture
def env_file(tmp_path, monkeypatch):
def _write(contents: str):
path = tmp_path / ".env"
path.write_text(contents)
monkeypatch.setenv("ENV_FILE", str(path))
config_module.reset_caches()
return path
return _write
@pytest.fixture
def ini_file(tmp_path, monkeypatch):
def _write(contents: str):
path = tmp_path / "settings.ini"
path.write_text(contents)
monkeypatch.setenv("INI_FILE", str(path))
config_module.reset_caches()
return path
return _write
def test_default_returned_when_unset():
assert config("TOTALLY_UNSET_VALUE", default="fallback") == "fallback"
def test_missing_without_default_raises():
with pytest.raises(ImproperlyConfigured):
config("TOTALLY_UNSET_VALUE")
def test_env_var_overrides_default(monkeypatch):
monkeypatch.setenv("SOME_SETTING", "from-env")
assert config("SOME_SETTING", default="fallback") == "from-env"
def test_priority_env_beats_files(monkeypatch, env_file, ini_file):
ini_file("[timetracker]\nVALUE = from-ini\n")
env_file("VALUE=from-dotenv\n")
monkeypatch.setenv("VALUE", "from-env")
assert config("VALUE") == "from-env"
def test_priority_dotenv_beats_ini(env_file, ini_file):
ini_file("[timetracker]\nVALUE = from-ini\n")
env_file("VALUE=from-dotenv\n")
assert config("VALUE") == "from-dotenv"
def test_priority_ini_beats_default(ini_file):
ini_file("[timetracker]\nVALUE = from-ini\n")
assert config("VALUE", default="fallback") == "from-ini"
def test_ini_preserves_key_case(ini_file):
ini_file("[timetracker]\nSECRET_KEY = abc\n")
assert config("SECRET_KEY") == "abc"
# --- __FILE secret pointer -------------------------------------------------
def test_file_pointer_read_and_stripped(tmp_path, monkeypatch):
secret = tmp_path / "secret"
secret.write_text("super-secret-value\n") # trailing newline must be stripped
monkeypatch.setenv("SECRET_KEY__FILE", str(secret))
assert config("SECRET_KEY", allow_file=True) == "super-secret-value"
def test_file_pointer_ignored_without_allow_file(tmp_path, monkeypatch):
secret = tmp_path / "secret"
secret.write_text("ignored")
monkeypatch.setenv("SECRET_KEY__FILE", str(secret))
assert config("SECRET_KEY", default="fallback") == "fallback"
def test_file_pointer_beats_env(tmp_path, monkeypatch):
secret = tmp_path / "secret"
secret.write_text("from-file")
monkeypatch.setenv("SECRET_KEY__FILE", str(secret))
monkeypatch.setenv("SECRET_KEY", "from-env")
assert config("SECRET_KEY", allow_file=True) == "from-file"
# --- casting ---------------------------------------------------------------
@pytest.mark.parametrize(
"raw,expected",
[
("true", True),
("True", True),
("1", True),
("yes", True),
("on", True),
("false", False),
("0", False),
("no", False),
("", False),
],
)
def test_cast_bool(monkeypatch, raw, expected):
monkeypatch.setenv("FLAG", raw)
assert config("FLAG", cast=bool) is expected
def test_cast_list(monkeypatch):
monkeypatch.setenv("HOSTS", "a.example, b.example , ,c.example")
assert config("HOSTS", cast=list) == ["a.example", "b.example", "c.example"]
def test_cast_int(monkeypatch):
monkeypatch.setenv("COUNT", "42")
assert config("COUNT", cast=int) == 42
def test_cast_not_applied_to_default():
# A None default passes through untouched even with a cast set.
assert config("UNSET", default=None, cast=list) is None
# --- required_in_prod ------------------------------------------------------
def test_required_in_prod_raises_when_prod(monkeypatch):
monkeypatch.setenv("DEBUG", "false")
with pytest.raises(ImproperlyConfigured):
config("SECRET_KEY", default="dev-default", required_in_prod=True)
def test_required_in_prod_uses_default_in_debug(monkeypatch):
monkeypatch.setenv("DEBUG", "true")
assert config("SECRET_KEY", default="dev-default", required_in_prod=True) == (
"dev-default"
)
def test_deprecated_prod_var_implies_production(monkeypatch):
monkeypatch.delenv("DEBUG", raising=False)
monkeypatch.setenv("PROD", "1")
with pytest.raises(ImproperlyConfigured):
config("SECRET_KEY", default="dev-default", required_in_prod=True)
# --- .env parser edge cases ------------------------------------------------
def test_env_parser_quotes_comments_and_export(env_file):
env_file(
"\n".join(
[
"# a comment line",
"PLAIN=value",
"export EXPORTED=exported-value",
'DOUBLE="quoted value"',
"SINGLE='single quoted'",
"INLINE=value # trailing comment",
'HASH_IN_QUOTES="a # b"',
"EMPTY=",
'QUOTED_THEN_COMMENT="keep" # drop',
]
)
+ "\n"
)
assert config("PLAIN") == "value"
assert config("EXPORTED") == "exported-value"
assert config("DOUBLE") == "quoted value"
assert config("SINGLE") == "single quoted"
assert config("INLINE") == "value"
assert config("HASH_IN_QUOTES") == "a # b"
assert config("EMPTY", default="x") == ""
assert config("QUOTED_THEN_COMMENT") == "keep"
def test_missing_files_are_ignored(monkeypatch, tmp_path):
monkeypatch.setenv("ENV_FILE", str(tmp_path / "does-not-exist.env"))
monkeypatch.setenv("INI_FILE", str(tmp_path / "does-not-exist.ini"))
config_module.reset_caches()
assert config("ANYTHING", default="fallback") == "fallback"
# --- derive_hosts_and_origins -----------------------------------------------
def test_single_url_derives_one_host_and_origin():
hosts, origins = derive_hosts_and_origins("https://tracker.example.com")
assert hosts == ["tracker.example.com"]
assert origins == ["https://tracker.example.com"]
def test_multiple_urls_derive_multiple_hosts_and_origins():
hosts, origins = derive_hosts_and_origins(
"https://tracker.example.com,https://www.tracker.example.com"
)
assert hosts == ["tracker.example.com", "www.tracker.example.com"]
assert origins == ["https://tracker.example.com", "https://www.tracker.example.com"]
def test_whitespace_around_commas_is_stripped():
hosts, origins = derive_hosts_and_origins(
"https://a.example.com , https://b.example.com"
)
assert hosts == ["a.example.com", "b.example.com"]
assert origins == ["https://a.example.com", "https://b.example.com"]
def test_url_with_port_is_preserved_in_origin():
hosts, origins = derive_hosts_and_origins("http://localhost:8000")
assert hosts == ["localhost"]
assert origins == ["http://localhost:8000"]
# --- Django integration: derived values are accepted by Django internals -----
@pytest.mark.parametrize(
"app_url,request_host",
[
("https://tracker.example.com", "tracker.example.com"),
(
"https://tracker.example.com,https://www.tracker.example.com",
"www.tracker.example.com",
),
("http://localhost:8000", "localhost"),
],
)
def test_derived_hosts_accepted_by_django(app_url, request_host):
hosts, _ = derive_hosts_and_origins(app_url)
factory = RequestFactory()
with override_settings(ALLOWED_HOSTS=hosts):
request = factory.get("/", HTTP_HOST=request_host)
assert request.get_host() == request_host
def test_host_not_in_derived_list_is_rejected():
hosts, _ = derive_hosts_and_origins("https://tracker.example.com")
factory = RequestFactory()
with override_settings(ALLOWED_HOSTS=hosts):
request = factory.get("/", HTTP_HOST="evil.example.com")
with pytest.raises(DisallowedHost):
request.get_host()
def test_derived_origins_accepted_by_csrf_middleware():
_, origins = derive_hosts_and_origins(
"https://tracker.example.com,https://other.example.com"
)
factory = RequestFactory()
middleware = CsrfViewMiddleware(lambda request: None)
with override_settings(CSRF_TRUSTED_ORIGINS=origins):
for origin in origins:
request = factory.post("/", HTTP_ORIGIN=origin)
request.META["HTTP_REFERER"] = origin + "/"
# _check_token is not called here; _is_secure_referer_ok / origin
# matching is what we want — process_view returns None when trusted.
assert middleware.process_request(request) is None
+95
View File
@@ -0,0 +1,95 @@
import unittest
from typing import TypedDict
from common.components import custom_element_builder, render
from common.components.custom_elements import (
ElementSpec,
_ts_for_spec,
register_element,
)
class SampleProps(TypedDict):
game_id: int
status: str
is_on: bool
class CustomElementBuilderTest(unittest.TestCase):
def test_serializes_props_to_kebab_attributes(self):
x_sample = custom_element_builder("x-sample")
html = render(x_sample(game_id=3, status="f")["hi"])
self.assertIn("<x-sample", html)
self.assertIn('game-id="3"', html)
self.assertIn('status="f"', html)
self.assertIn(">hi</x-sample>", html)
def test_declares_compiled_module_media(self):
from common.components import collect_media
x_sample = custom_element_builder("x-sample")
node = x_sample(game_id=3)
self.assertEqual(collect_media(node).js, ("dist/elements/x-sample.js",))
class CodegenTest(unittest.TestCase):
def test_emits_interface_and_reader(self):
spec = ElementSpec("x-sample", "XSample", SampleProps)
ts = _ts_for_spec(spec)
self.assertIn("export interface XSampleProps {", ts)
self.assertIn("gameId: number;", ts)
self.assertIn("status: string;", ts)
self.assertIn("isOn: boolean;", ts)
self.assertIn(
"export function readXSampleProps(el: HTMLElement): XSampleProps", ts
)
self.assertIn('Number(el.getAttribute("game-id"))', ts)
self.assertIn('el.getAttribute("status") ?? ""', ts)
self.assertIn('el.getAttribute("is-on") === "true"', ts)
class RegistryTest(unittest.TestCase):
def test_register_adds_spec(self):
from common.components.custom_elements import ELEMENT_REGISTRY
before = len(ELEMENT_REGISTRY)
register_element("x-reg-test", "XRegTest", SampleProps)
self.assertEqual(len(ELEMENT_REGISTRY), before + 1)
self.assertEqual(ELEMENT_REGISTRY[-1].tag, "x-reg-test")
class GameStatusSelectorRenderTest(unittest.TestCase):
def test_emits_tag_props_and_media(self):
from types import SimpleNamespace
from common.components import GameStatusSelector, collect_media, render
game = SimpleNamespace(id=7, status="f", get_status_display=lambda: "Finished")
node = GameStatusSelector(game, [("u", "Unplayed"), ("f", "Finished")], "tok")
html = render(node)
self.assertIn("<game-status-selector", html)
self.assertIn('game-id="7"', html)
self.assertIn('status="f"', html)
self.assertIn('csrf="tok"', html)
self.assertIn("data-option", html)
self.assertIn('data-value="u"', html)
self.assertNotIn("x-data", html) # no Alpine left
self.assertIn("dist/elements/game-status-selector.js", collect_media(node).js)
class SessionDeviceSelectorRenderTest(unittest.TestCase):
def test_emits_tag_and_options(self):
from types import SimpleNamespace
from common.components import SessionDeviceSelector, render
session = SimpleNamespace(id=4, device=SimpleNamespace(name="Desktop"))
devices = [
SimpleNamespace(id=1, name="Desktop"),
SimpleNamespace(id=2, name="Deck"),
]
html = render(SessionDeviceSelector(session, devices, "tok"))
self.assertIn("<session-device-selector", html)
self.assertIn('session-id="4"', html)
self.assertIn('data-value="2"', html)
self.assertNotIn("x-data", html)
+196
View File
@@ -0,0 +1,196 @@
"""Unit tests for the DateRangePicker component family.
Pins the structural contract of DateRangeField / DateRangeCalendar /
DateRangePicker segment inputs ordered by ``dateformat_hyphenated``, the
hidden ISO ``{prefix}-min`` / ``{prefix}-max`` inputs that ``filter_bar.js``
serializes, the calendar's preset/footer hooks — and the PurchaseFilterBar
integration that replaced the native-date DateRangeFilter for the Purchased
field.
"""
import json
import re
from django.test import SimpleTestCase, TestCase
from common.components import (
DateRangeCalendar,
DateRangeField,
DateRangePicker,
PurchaseFilterBar,
)
from common.time import date_parts, dateformat_hyphenated
_ESCAPED_TAG_MARKERS = ["&lt;div", "&lt;span", "&lt;button", "&lt;input"]
class DatePartsTest(SimpleTestCase):
def test_default_format_yields_day_month_year(self):
parts = date_parts()
self.assertEqual([part.name for part in parts], ["day", "month", "year"])
self.assertEqual([part.placeholder for part in parts], ["DD", "MM", "YYYY"])
self.assertEqual([part.length for part in parts], [2, 2, 4])
def test_parts_follow_format_order(self):
parts = date_parts("%Y-%d-%m")
self.assertEqual([part.name for part in parts], ["year", "day", "month"])
def test_dateformat_hyphenated_is_parseable(self):
self.assertEqual(len(date_parts(dateformat_hyphenated)), 3)
class DateRangeFieldTest(SimpleTestCase):
def render(self, **kwargs):
defaults = {"label": "Purchased", "input_name_prefix": "filter-date-purchased"}
defaults.update(kwargs)
return str(DateRangeField(**defaults))
def test_renders_hidden_iso_inputs(self):
html = self.render(min_value="2024-03-15", max_value="2024-09-20")
self.assertIn('name="filter-date-purchased-min"', html)
self.assertIn('name="filter-date-purchased-max"', html)
self.assertIn('data-date-range-hidden="min"', html)
self.assertIn('data-date-range-hidden="max"', html)
self.assertIn('value="2024-03-15"', html)
self.assertIn('value="2024-09-20"', html)
def test_renders_segments_in_dateformat_order_for_both_sides(self):
html = self.render()
for side in ("min", "max"):
side_segments = re.findall(
rf'data-date-part="(\w+)" data-date-side="{side}"', html
)
self.assertEqual(side_segments, ["day", "month", "year"])
def test_segment_placeholders_and_lengths(self):
html = self.render()
self.assertEqual(html.count('placeholder="DD"'), 2)
self.assertEqual(html.count('placeholder="MM"'), 2)
self.assertEqual(html.count('placeholder="YYYY"'), 2)
self.assertEqual(html.count('maxlength="2"'), 4)
self.assertEqual(html.count('maxlength="4"'), 2)
self.assertEqual(html.count('inputmode="numeric"'), 6)
def test_prefills_segments_from_iso_values(self):
html = self.render(min_value="2024-03-15")
self.assertIn('value="15" data-date-part="day" data-date-side="min"', html)
self.assertIn('value="03" data-date-part="month" data-date-side="min"', html)
self.assertIn('value="2024" data-date-part="year" data-date-side="min"', html)
# The max side stays empty.
self.assertIn('value="" data-date-part="day" data-date-side="max"', html)
def test_malformed_iso_value_renders_empty_segments(self):
html = self.render(min_value="not-a-date")
self.assertIn('value="" data-date-part="day" data-date-side="min"', html)
def test_renders_calendar_toggle(self):
html = self.render()
self.assertIn("data-date-range-calendar-toggle", html)
self.assertIn('aria-label="Open Purchased calendar"', html)
def test_no_native_date_inputs(self):
self.assertNotIn('type="date"', self.render())
class DateRangeCalendarTest(SimpleTestCase):
def render(self):
return str(DateRangeCalendar(input_name_prefix="filter-date-purchased"))
def test_renders_all_presets(self):
html = self.render()
for preset in (
"today",
"yesterday",
"last_7_days",
"last_30_days",
"this_month",
"last_month",
"this_year",
):
self.assertIn(f'data-date-range-preset="{preset}"', html)
def test_renders_footer_buttons(self):
html = self.render()
self.assertIn("data-date-range-cancel", html)
self.assertIn("data-date-range-clear", html)
self.assertIn("data-date-range-select", html)
self.assertIn(">Cancel<", html)
self.assertIn(">Clear<", html)
self.assertIn(">Select<", html)
def test_renders_grid_and_navigation_hooks(self):
html = self.render()
self.assertIn("data-date-range-grid", html)
self.assertIn("data-date-range-month-label", html)
self.assertIn("data-date-range-prev", html)
self.assertIn("data-date-range-next", html)
def test_starts_hidden(self):
self.assertIn('class="hidden absolute', self.render())
def test_all_buttons_are_type_button(self):
"""No button inside the calendar may submit the surrounding filter form."""
html = self.render()
button_count = html.count("<button")
self.assertEqual(html.count('<button type="button"'), button_count)
class DateRangePickerTest(SimpleTestCase):
def test_composes_field_and_calendar(self):
html = str(
DateRangePicker(
label="Purchased",
input_name_prefix="filter-date-purchased",
min_value="2024-01-01",
max_value="2024-12-31",
)
)
self.assertIn("<date-range-picker", html)
self.assertIn('data-input-name-prefix="filter-date-purchased"', html)
self.assertIn("data-date-range-field", html)
self.assertIn("data-date-range-calendar", html)
for marker in _ESCAPED_TAG_MARKERS:
self.assertNotIn(marker, html)
class PurchaseFilterBarDateRangePickerTest(TestCase):
"""The Purchased filter uses the DateRangePicker; Refunded keeps the
native-date DateRangeFilter (the picker is a tryout on one field)."""
def render(self, filter_json=""):
return str(
PurchaseFilterBar(
filter_json=filter_json, preset_list_url="/l", preset_save_url="/s"
)
)
def test_purchased_uses_date_range_picker(self):
html = self.render()
self.assertIn("<date-range-picker", html)
self.assertIn('data-input-name-prefix="filter-date-purchased"', html)
# The hidden ISO inputs keep the names filter_bar.js serializes.
self.assertIn('name="filter-date-purchased-min"', html)
self.assertIn('name="filter-date-purchased-max"', html)
def test_refunded_keeps_native_date_inputs(self):
html = self.render()
refunded_min = html.find('name="filter-date-refunded-min"')
self.assertGreater(refunded_min, 0)
self.assertIn('type="date"', html)
self.assertNotIn('data-input-name-prefix="filter-date-refunded"', html)
def test_prefilled_between_filter_round_trips_into_picker(self):
filter_json = json.dumps(
{
"date_purchased": {
"value": "2024-03-15",
"value2": "2024-09-20",
"modifier": "BETWEEN",
}
}
)
html = self.render(filter_json)
self.assertIn('value="2024-03-15"', html)
self.assertIn('value="2024-09-20"', html)
self.assertIn('value="15" data-date-part="day" data-date-side="min"', html)
self.assertIn('value="20" data-date-part="day" data-date-side="max"', html)
+181 -5
View File
@@ -44,8 +44,8 @@ class FilterBarRenderingTest(TestCase):
def _assert_range_slider(self, html): def _assert_range_slider(self, html):
"""Every filter bar must use the RangeSlider component with custom """Every filter bar must use the RangeSlider component with custom
draggable <div> handles, a track fill, and mode-toggle button.""" draggable <div> handles, a track fill, and mode-toggle button."""
self.assertIn("range-slider-block", html) self.assertIn("<range-slider", html)
self.assertIn('data-mode="range"', html) self.assertIn('mode="range"', html)
self.assertIn("range-mode-toggle", html) self.assertIn("range-mode-toggle", html)
self.assertIn("range-mode-icon-range", html) self.assertIn("range-mode-icon-range", html)
self.assertIn("range-mode-icon-point", html) self.assertIn("range-mode-icon-point", html)
@@ -107,8 +107,8 @@ class FilterBarRenderingTest(TestCase):
# No legacy match-mode <select>. # No legacy match-mode <select>.
self.assertNotIn("data-search-select-match", html) self.assertNotIn("data-search-select-match", html)
# Platform is single-valued: no M2M modifier options in its section. # Platform is single-valued: no M2M modifier options in its section.
games_start = html.find('data-name="games"') games_start = html.find('name="games"')
platform_start = html.find('data-name="platform"') platform_start = html.find('name="platform"')
platform_section = html[platform_start:] platform_section = html[platform_start:]
self.assertNotIn("INCLUDES_ALL", platform_section) self.assertNotIn("INCLUDES_ALL", platform_section)
self.assertGreater(games_start, 0) self.assertGreater(games_start, 0)
@@ -150,7 +150,7 @@ class FilterBarRenderingTest(TestCase):
filter_json=filter_json, preset_list_url="/l", preset_save_url="/s" filter_json=filter_json, preset_list_url="/l", preset_save_url="/s"
) )
) )
self.assertIn('data-search-select-mode="filter"', html) self.assertIn('filter-mode="true"', html)
self.assertIn( self.assertIn(
'data-search-select-type="include"', html 'data-search-select-type="include"', html
) # rendered as an include pill ) # rendered as an include pill
@@ -186,3 +186,179 @@ class FilterBarRenderingTest(TestCase):
self.assertNotIn("data-match=", html) self.assertNotIn("data-match=", html)
self.assertIn("Finished", html) self.assertIn("Finished", html)
self.assertNoEscapedTags(html) self.assertNoEscapedTags(html)
def test_device_filter_bar(self):
from common.components import DeviceFilterBar
html = str(
DeviceFilterBar(
filter_json="",
preset_list_url="/presets/devices/list",
preset_save_url="/presets/devices/save",
)
)
self._assert_shell(html, "/presets/devices/list", "/presets/devices/save")
def test_platform_filter_bar(self):
from common.components import PlatformFilterBar
html = str(
PlatformFilterBar(
filter_json="",
preset_list_url="/presets/platforms/list",
preset_save_url="/presets/platforms/save",
)
)
self._assert_shell(html, "/presets/platforms/list", "/presets/platforms/save")
def test_playevent_filter_bar(self):
from common.components import PlayEventFilterBar
html = str(
PlayEventFilterBar(
filter_json="",
preset_list_url="/presets/playevents/list",
preset_save_url="/presets/playevents/save",
)
)
self._assert_shell(html, "/presets/playevents/list", "/presets/playevents/save")
def test_game_filter_bar_has_new_widgets(self):
"""The expanded games FilterBar exposes platform_group, device, playevent_note,
purchase_type / purchase_ownership_type, plus count and aggregate-playtime
range sliders and the new boolean checkboxes."""
html = str(
FilterBar(
filter_json="",
preset_list_url="/l",
preset_save_url="/s",
)
)
# New search-backed selects
self.assertIn('search-url="/api/devices/search"', html)
self.assertIn('search-url="/api/platforms/groups"', html)
# New enum selects (purchase type / ownership)
self.assertIn('name="purchase_type"', html)
self.assertIn('name="purchase_ownership_type"', html)
# Free-text widget for playevent notes (now StringFilter)
self.assertIn('name="filter-playevent_note"', html)
self.assertIn('name="filter-playevent_note-modifier"', html)
# New range slider input prefixes
self.assertIn('name="filter-purchase-count-min"', html)
self.assertIn('name="filter-playevent-count-min"', html)
self.assertIn('name="filter-manual-playtime-hours-min"', html)
self.assertIn('name="filter-calculated-playtime-hours-min"', html)
self.assertIn('name="filter-original-year-min"', html)
self.assertIn('name="filter-purchase-price-total-min"', html)
self.assertIn('name="filter-purchase-price-any-min"', html)
# New boolean checkboxes
self.assertIn('name="filter-purchase-refunded"', html)
self.assertIn('name="filter-purchase-infinite"', html)
self.assertIn('name="filter-session-emulated"', html)
# Removed boolean checkboxes
self.assertNotIn('name="filter-has-purchases"', html)
self.assertNotIn('name="filter-has-playevents"', html)
# Playtime label renamed
self.assertIn("Total playtime", html)
def test_purchase_filter_bar_renders_date_inputs(self):
"""PurchaseFilterBar surfaces date_purchased and date_refunded as
type=date input pairs with -min/-max naming."""
html = str(
PurchaseFilterBar(
filter_json="", preset_list_url="/l", preset_save_url="/s"
)
)
for name in (
"filter-date-purchased-min",
"filter-date-purchased-max",
"filter-date-refunded-min",
"filter-date-refunded-max",
):
self.assertIn(f'name="{name}"', html)
self.assertIn(f'id="{name}"', html)
# Inputs are native date pickers, not text.
self.assertIn('type="date"', html)
self.assertNoEscapedTags(html)
def test_purchase_filter_bar_prepopulates_dates_between(self):
"""A BETWEEN filter populates both date bounds via _parse_range."""
filter_json = json.dumps(
{
"date_purchased": {
"value": "2024-01-01",
"value2": "2024-12-31",
"modifier": "BETWEEN",
}
}
)
html = str(
PurchaseFilterBar(
filter_json=filter_json,
preset_list_url="/l",
preset_save_url="/s",
)
)
self.assertIn(
'name="filter-date-purchased-min" id="filter-date-purchased-min" '
'value="2024-01-01"',
html,
)
self.assertIn(
'name="filter-date-purchased-max" id="filter-date-purchased-max" '
'value="2024-12-31"',
html,
)
def test_purchase_filter_bar_prepopulates_dates_single_bound(self):
"""A single-bound (GREATER_THAN) filter populates min only."""
filter_json = json.dumps(
{
"date_refunded": {
"value": "2024-06-01",
"modifier": "GREATER_THAN",
}
}
)
html = str(
PurchaseFilterBar(
filter_json=filter_json,
preset_list_url="/l",
preset_save_url="/s",
)
)
self.assertIn(
'name="filter-date-refunded-min" id="filter-date-refunded-min" '
'value="2024-06-01"',
html,
)
# Max input is still present but with empty value.
self.assertIn(
'name="filter-date-refunded-max" id="filter-date-refunded-max" value=""',
html,
)
def test_boolean_fields_render_as_radio_groups(self):
"""Boolean fields must render as radio groups with True/False choices."""
from common.components import FilterBar, SessionFilterBar, PurchaseFilterBar
# 1. Games Filter Bar
games_html = str(FilterBar(filter_json=""))
self.assertIn('type="radio"', games_html)
self.assertIn('name="filter-mastered"', games_html)
self.assertIn('value="true"', games_html)
self.assertIn('value="false"', games_html)
# 2. Session Filter Bar
session_html = str(SessionFilterBar(filter_json=""))
self.assertIn('type="radio"', session_html)
self.assertIn('name="filter-emulated"', session_html)
self.assertIn('value="true"', session_html)
self.assertIn('value="false"', session_html)
# 3. Purchase Filter Bar
purchase_html = str(PurchaseFilterBar(filter_json=""))
self.assertIn('type="radio"', purchase_html)
self.assertIn('name="filter-refunded"', purchase_html)
self.assertIn('value="true"', purchase_html)
self.assertIn('value="false"', purchase_html)
+20 -1
View File
@@ -2,7 +2,7 @@
from django.test import SimpleTestCase from django.test import SimpleTestCase
from common.components.filters import _parse_bool, _parse_range from common.components.filters import _parse_bool, _parse_range, _parse_bool_nullable
class ParseRangeTest(SimpleTestCase): class ParseRangeTest(SimpleTestCase):
@@ -66,3 +66,22 @@ class ParseBoolTest(SimpleTestCase):
def test_missing_value_in_field(self): def test_missing_value_in_field(self):
self.assertFalse(_parse_bool({"field": {}}, "field")) self.assertFalse(_parse_bool({"field": {}}, "field"))
class ParseBoolNullableTest(SimpleTestCase):
def test_missing_key(self):
self.assertIsNone(_parse_bool_nullable({}, "field"))
def test_null_value(self):
self.assertIsNone(_parse_bool_nullable({"field": None}, "field"))
self.assertIsNone(_parse_bool_nullable({"field": {}}, "field"))
def test_boolean_values(self):
self.assertTrue(_parse_bool_nullable({"field": {"value": True}}, "field"))
self.assertFalse(_parse_bool_nullable({"field": {"value": False}}, "field"))
def test_string_values(self):
self.assertTrue(_parse_bool_nullable({"field": {"value": "true"}}, "field"))
self.assertTrue(_parse_bool_nullable({"field": {"value": "1"}}, "field"))
self.assertFalse(_parse_bool_nullable({"field": {"value": "false"}}, "field"))
self.assertFalse(_parse_bool_nullable({"field": {"value": "0"}}, "field"))

Some files were not shown because too many files have changed in this diff Show More