Compare commits

...

4 Commits

Author SHA1 Message Date
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
18 changed files with 3257 additions and 285 deletions
+9 -4
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
``` ```
@@ -113,13 +114,15 @@ 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.
### Deployment ### Deployment
@@ -155,6 +158,8 @@ 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.
+2
View File
@@ -59,6 +59,7 @@ from common.components.primitives import (
SearchField, SearchField,
SimpleTable, SimpleTable,
Span, Span,
StaticScript,
TableHeader, TableHeader,
TableRow, TableRow,
TableTd, TableTd,
@@ -112,6 +113,7 @@ __all__ = [
"searchselect_selected", "searchselect_selected",
"SimpleTable", "SimpleTable",
"Span", "Span",
"StaticScript",
"Label", "Label",
"TableHeader", "TableHeader",
"TableRow", "TableRow",
+3 -1
View File
@@ -559,9 +559,11 @@ def _filter_collapse_button() -> SafeText:
tag_name="button", tag_name="button",
attributes=[ attributes=[
("type", "button"), ("type", "button"),
# Slider handles are positioned in percentages, so initializing
# them while the body is hidden is safe — no re-init on reveal.
( (
"onclick", "onclick",
"var b=document.getElementById('filter-bar-body');b.classList.toggle('hidden');if(!b.classList.contains('hidden')&&window.initRangeSliders)window.initRangeSliders()", "document.getElementById('filter-bar-body').classList.toggle('hidden')",
), ),
( (
"class", "class",
+6
View File
@@ -554,6 +554,12 @@ def ExternalScript(url: str) -> SafeText:
return mark_safe(f'<script src="{url}"></script>') return mark_safe(f'<script src="{url}"></script>')
def StaticScript(filename: str) -> SafeText:
"""A plain (classic, non-module) `<script src=...>` tag for a static JS
file — for vendored UMD bundles, which break inside module scope."""
return mark_safe(f'<script src="{static("js/" + filename)}"></script>')
def YearPicker( def YearPicker(
year: int | None = None, year: int | None = None,
available_years: tuple[int, ...] = (), available_years: tuple[int, ...] = (),
+6 -3
View File
@@ -309,9 +309,12 @@ def Page(
f' <script src="{static("js/htmx-redirect-toast.js")}"></script>\n' f' <script src="{static("js/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"
) )
+18 -10
View File
@@ -4,35 +4,43 @@ 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.js is an ES module and initializes via onSwap(),
which rides on htmx.onLoad — so htmx must be present. -->
<script src="/static/js/htmx.min.js"></script>
<script type="module" src="/static/js/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):
+135
View File
@@ -0,0 +1,135 @@
"""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 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('input[type="submit"]')
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('[data-search-select][data-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)
block = page.locator(".range-slider-block").first
slider = block.locator(".range-slider")
expect(slider).to_have_attribute("data-mode", "range")
block.locator(".range-mode-toggle").click()
expect(slider).to_have_attribute("data-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()
block = page.locator(".range-slider-block").first
slider = block.locator(".range-slider")
expect(slider).to_have_attribute("data-mode", "range")
block.locator(".range-mode-toggle").click()
expect(slider).to_have_attribute("data-mode", "point")
def test_add_purchase_type_toggles_disabled_fields(
authenticated_page: Page, live_server
):
"""add_purchase.js disables name/related-purchase 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()
page.select_option("#id_type", "dlc")
expect(name_input).to_be_enabled()
page.select_option("#id_type", "game")
expect(name_input).to_be_disabled()
+5 -4
View File
@@ -1,4 +1,4 @@
import { getEl, disableElementsWhenTrue } from "./utils.js"; import { getEl, disableElementsWhenTrue, onSwap } from "./utils.js";
const RELATED_PURCHASE_URL = "/tracker/purchase/related-purchase-by-game"; const RELATED_PURCHASE_URL = "/tracker/purchase/related-purchase-by-game";
@@ -38,8 +38,9 @@ function setupElementHandlers() {
]); ]);
} }
document.addEventListener("DOMContentLoaded", setupElementHandlers); onSwap("#id_type", (typeSelect) => {
document.addEventListener("htmx:afterSwap", setupElementHandlers);
getEl("#id_type").addEventListener("change", () => {
setupElementHandlers(); setupElementHandlers();
typeSelect.addEventListener("change", () => {
setupElementHandlers();
});
}); });
+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
+42 -42
View File
@@ -4,6 +4,8 @@
* Handles form submission, preset loading/saving, and preset list rendering. * Handles form submission, preset loading/saving, and preset list rendering.
* No HTMX plain fetch() and window.location for all interactions. * No HTMX plain fetch() and window.location for all interactions.
*/ */
import { onSwap } from "./utils.js";
(function () { (function () {
"use strict"; "use strict";
@@ -410,27 +412,25 @@
// ── Init on page load ─────────────────────────────────────────────────── // ── Init on page load ───────────────────────────────────────────────────
// ── Inject search inputs into filter forms ── // ── Inject the search input into a filter form ──
function injectSearchInputs() { function injectSearchInput(form) {
document.querySelectorAll('[id^="filter-bar-form"]').forEach(function (form) { if (form.querySelector('[name="filter-search"]')) return; // already added
if (form.querySelector('[name="filter-search"]')) return; // already added var input = document.createElement("input");
var input = document.createElement("input"); input.type = "text";
input.type = "text"; input.name = "filter-search";
input.name = "filter-search"; input.placeholder = "Search\u2026";
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";
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
// Pre-fill from existing filter JSON var hidden = form.querySelector('[name="filter"]');
var hidden = form.querySelector('[name="filter"]'); if (hidden && hidden.parentNode) {
if (hidden && hidden.parentNode) { try {
try { var existing = JSON.parse(hidden.value || "{}");
var existing = JSON.parse(hidden.value || "{}"); if (existing.search && existing.search.value) {
if (existing.search && existing.search.value) { input.value = existing.search.value;
input.value = existing.search.value; }
} } catch (e) {}
} catch (e) {} hidden.parentNode.insertBefore(input, hidden.nextSibling);
hidden.parentNode.insertBefore(input, hidden.nextSibling); }
}
});
} }
/** /**
@@ -438,25 +438,25 @@
*/ */
function setupDeselectableRadios() { function setupDeselectableRadios() {
document.querySelectorAll('input[type="radio"]').forEach(function (radio) { document.querySelectorAll('input[type="radio"]').forEach(function (radio) {
radio.addEventListener('click', function (e) { radio.addEventListener('click', function (e) {
if (this.wasChecked) { if (this.wasChecked) {
this.checked = false; this.checked = false;
this.wasChecked = false; this.wasChecked = false;
this.dispatchEvent(new Event('change', { bubbles: true })); this.dispatchEvent(new Event('change', { bubbles: true }));
} else { } else {
var name = this.getAttribute('name'); var name = this.getAttribute('name');
if (name) { if (name) {
document.querySelectorAll('input[type="radio"][name="' + name + '"]').forEach(function (r) { document.querySelectorAll('input[type="radio"][name="' + name + '"]').forEach(function (r) {
r.wasChecked = false; r.wasChecked = false;
}); });
}
this.wasChecked = true;
} }
}); this.wasChecked = true;
if (radio.checked) {
radio.wasChecked = true;
} }
}); });
if (radio.checked) {
radio.wasChecked = true;
}
});
} }
/** /**
@@ -464,14 +464,14 @@
*/ */
function setupStringFilters() { function setupStringFilters() {
document.querySelectorAll('input[data-string-modifier-radio]').forEach(function (radio) { document.querySelectorAll('input[data-string-modifier-radio]').forEach(function (radio) {
radio.addEventListener('change', function () { radio.addEventListener('change', function () {
window.toggleStringFilterInput(this); window.toggleStringFilterInput(this);
}); });
}); });
} }
document.addEventListener("DOMContentLoaded", function () { onSwap('[id^="filter-bar-form"]', function (form) {
injectSearchInputs(); injectSearchInput(form);
setupDeselectableRadios(); setupDeselectableRadios();
setupStringFilters(); setupStringFilters();
loadPresets(); loadPresets();
File diff suppressed because one or more lines are too long
+194 -200
View File
@@ -8,229 +8,223 @@
* Handles track-fill positioning and sync between handles and the connected * Handles track-fill positioning and sync between handles and the connected
* number inputs (linked via data-target attributes). * number inputs (linked via data-target attributes).
*/ */
import { onSwap } from "./utils.js";
(function () { (function () {
"use strict"; "use strict";
function initAll(force) { function initializeSlider(slider) {
document.querySelectorAll(".range-slider").forEach(function (slider) { var mode = slider.getAttribute("data-mode") || "range";
if (force) slider._rsInit = false; var trackFill = slider.querySelector(".range-track-fill");
if (slider._rsInit) return; var minHandle = slider.querySelector(".range-handle-min");
slider._rsInit = true; var maxHandle = slider.querySelector(".range-handle-max");
if (!minHandle || !maxHandle) return;
var mode = slider.getAttribute("data-mode") || "range"; var minTarget = document.getElementById(
var trackFill = slider.querySelector(".range-track-fill"); minHandle.getAttribute("data-target")
var minHandle = slider.querySelector(".range-handle-min"); );
var maxHandle = slider.querySelector(".range-handle-max"); var maxTarget = document.getElementById(
if (!minHandle || !maxHandle) return; 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;
var minTarget = document.getElementById( // ── Helpers ──
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 valueToPercent(value) { function getTargetValue(target, defaultVal) {
return ((value - dataMin) / (dataMax - dataMin)) * 100; if (!target || target.value === "") return defaultVal;
} var parsed = parseInt(target.value, 10);
function percentToValue(percent) { return isNaN(parsed) ? defaultVal : parsed;
var raw = dataMin + (percent / 100) * (dataMax - dataMin); }
return Math.round(raw / step) * step; function setTargetValue(target, value) {
} if (target) target.value = value;
function clamp(value, lo, hi) { }
return Math.max(lo, Math.min(hi, value));
}
function getTargetValue(target, defaultVal) { // ── Track fill positioning ──
if (!target || target.value === "") return defaultVal;
var parsed = parseInt(target.value, 10);
return isNaN(parsed) ? defaultVal : parsed;
}
function setTargetValue(target, value) {
if (target) target.value = value;
}
// ── Track fill positioning ── function updateTrackFill() {
if (!trackFill) return;
function updateTrackFill() { var minVal = clamp(getTargetValue(minTarget, dataMin), dataMin, dataMax);
if (!trackFill) return; var maxVal = clamp(getTargetValue(maxTarget, dataMax), dataMin, dataMax);
var minVal = clamp(getTargetValue(minTarget, dataMin), dataMin, dataMax); if (mode === "point") {
var maxVal = clamp(getTargetValue(maxTarget, dataMax), dataMin, dataMax); trackFill.style.left = "0%";
if (mode === "point") { trackFill.style.width = valueToPercent(maxVal) + "%";
trackFill.style.left = "0%"; } else {
trackFill.style.width = valueToPercent(maxVal) + "%"; var leftPct = valueToPercent(minVal);
} else { var rightPct = valueToPercent(maxVal);
var leftPct = valueToPercent(minVal); if (leftPct > rightPct) {
var rightPct = valueToPercent(maxVal); var tmp = leftPct;
if (leftPct > rightPct) { leftPct = rightPct;
var tmp = leftPct; rightPct = tmp;
leftPct = rightPct;
rightPct = tmp;
}
var widthPct = rightPct - leftPct;
trackFill.style.left = leftPct + "%";
trackFill.style.width = widthPct + "%";
} }
var widthPct = rightPct - leftPct;
trackFill.style.left = leftPct + "%";
trackFill.style.width = widthPct + "%";
} }
}
function updateHandles() { function updateHandles() {
var minVal = clamp(getTargetValue(minTarget, dataMin), dataMin, dataMax); var minVal = clamp(getTargetValue(minTarget, dataMin), dataMin, dataMax);
var maxVal = clamp(getTargetValue(maxTarget, dataMax), dataMin, dataMax); var maxVal = clamp(getTargetValue(maxTarget, dataMax), dataMin, dataMax);
minHandle.style.left = valueToPercent(minVal) + "%"; minHandle.style.left = valueToPercent(minVal) + "%";
maxHandle.style.left = valueToPercent(maxVal) + "%"; maxHandle.style.left = valueToPercent(maxVal) + "%";
updateTrackFill(); updateTrackFill();
} }
// ── Dragging ── // ── Dragging ──
function makeDraggable(handle, isMin) { function makeDraggable(handle, isMin) {
handle.addEventListener("mousedown", function (e) { handle.addEventListener("mousedown", function (e) {
e.preventDefault(); e.preventDefault();
var rect = slider.getBoundingClientRect(); var rect = slider.getBoundingClientRect();
function onMove(ev) { function onMove(ev) {
var pct = ((ev.clientX - rect.left) / rect.width) * 100; var pct = ((ev.clientX - rect.left) / rect.width) * 100;
var value = percentToValue(clamp(pct, 0, 100)); var value = percentToValue(clamp(pct, 0, 100));
if (mode === "point") { if (mode === "point") {
setTargetValue(minTarget, value); setTargetValue(minTarget, value);
setTargetValue(maxTarget, value); setTargetValue(maxTarget, value);
if (minTarget) if (minTarget)
minTarget.dispatchEvent( minTarget.dispatchEvent(
new Event("input", { bubbles: true }) new Event("input", { bubbles: true })
);
if (maxTarget)
maxTarget.dispatchEvent(
new Event("input", { bubbles: true })
);
} else if (isMin) {
setTargetValue(
minTarget,
clamp(value, dataMin, getTargetValue(maxTarget, dataMax))
); );
if (minTarget) if (maxTarget)
minTarget.dispatchEvent( maxTarget.dispatchEvent(
new Event("input", { bubbles: true }) new Event("input", { bubbles: true })
); );
} else { } else if (isMin) {
setTargetValue( setTargetValue(
maxTarget, minTarget,
clamp(value, getTargetValue(minTarget, dataMin), dataMax) clamp(value, dataMin, getTargetValue(maxTarget, dataMax))
);
if (minTarget)
minTarget.dispatchEvent(
new Event("input", { bubbles: true })
); );
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(e) {
if (mode === "point") {
var src = (e && e.target) || minTarget || maxTarget;
var val = src ? src.value : "";
setTargetValue(minTarget, val);
setTargetValue(maxTarget, val);
} else if (e && e.target) {
var minVal = getTargetValue(minTarget, dataMin);
var maxVal = getTargetValue(maxTarget, dataMax);
if (e.target === minTarget) {
if (minVal > maxVal) {
setTargetValue(maxTarget, minVal);
}
} else if (e.target === maxTarget) {
if (maxVal < minVal) {
setTargetValue(minTarget, maxVal);
}
}
}
updateHandles();
}
function enforceStrictBounds(e) {
if (e && e.target) {
var val = parseInt(e.target.value, 10);
if (!isNaN(val)) {
var clamped = clamp(val, dataMin, dataMax);
if (clamped !== val) {
setTargetValue(e.target, clamped);
e.target.dispatchEvent(new Event("input", { bubbles: true }));
}
}
}
}
if (minTarget) {
minTarget.addEventListener("input", syncFromInputs);
minTarget.addEventListener("change", enforceStrictBounds);
}
if (maxTarget) {
maxTarget.addEventListener("input", syncFromInputs);
maxTarget.addEventListener("change", enforceStrictBounds);
}
// ── 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, maxTarget ? maxTarget.value : "");
if (minTarget) minTarget.classList.add("hidden");
if (dashSpan) dashSpan.classList.add("hidden");
} else { } else {
minHandle.style.display = ""; setTargetValue(
if (minTarget) minTarget.classList.remove("hidden"); maxTarget,
if (dashSpan) dashSpan.classList.remove("hidden"); clamp(value, getTargetValue(minTarget, dataMin), dataMax)
);
if (maxTarget)
maxTarget.dispatchEvent(
new Event("input", { bubbles: true })
);
} }
mode = newMode;
updateHandles(); updateHandles();
}); }
}
// ── Initial position ── 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(e) {
if (mode === "point") {
var src = (e && e.target) || minTarget || maxTarget;
var val = src ? src.value : "";
setTargetValue(minTarget, val);
setTargetValue(maxTarget, val);
} else if (e && e.target) {
var minVal = getTargetValue(minTarget, dataMin);
var maxVal = getTargetValue(maxTarget, dataMax);
if (e.target === minTarget) {
if (minVal > maxVal) {
setTargetValue(maxTarget, minVal);
}
} else if (e.target === maxTarget) {
if (maxVal < minVal) {
setTargetValue(minTarget, maxVal);
}
}
}
updateHandles(); updateHandles();
}); }
function enforceStrictBounds(e) {
if (e && e.target) {
var val = parseInt(e.target.value, 10);
if (!isNaN(val)) {
var clamped = clamp(val, dataMin, dataMax);
if (clamped !== val) {
setTargetValue(e.target, clamped);
e.target.dispatchEvent(new Event("input", { bubbles: true }));
}
}
}
}
if (minTarget) {
minTarget.addEventListener("input", syncFromInputs);
minTarget.addEventListener("change", enforceStrictBounds);
}
if (maxTarget) {
maxTarget.addEventListener("input", syncFromInputs);
maxTarget.addEventListener("change", enforceStrictBounds);
}
// ── 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, maxTarget ? maxTarget.value : "");
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); onSwap(".range-slider", initializeSlider);
document.addEventListener("htmx:afterSwap", initAll);
window.initRangeSliders = initAll;
})(); })();
+5 -12
View File
@@ -12,8 +12,8 @@
* pills. Filter widgets have no hidden inputs; readSearchSelect serialises their * pills. Filter widgets have no hidden inputs; readSearchSelect serialises their
* state into data-included / data-excluded / data-modifier for the filter bar. * state into data-included / data-excluded / data-modifier for the filter bar.
* *
* initAll() runs on DOMContentLoaded + htmx:afterSwap, each widget guarded with * Widgets are initialized via onSwap() (utils.js), which covers the initial
* element._searchSelectInit. * page load and every htmx-swapped fragment, once per widget.
* *
* Dynamically-added rows and pills are cloned from hidden <template> elements * Dynamically-added rows and pills are cloned from hidden <template> elements
* the server renders with the same Python components (Pill / SearchSelect / * the server renders with the same Python components (Pill / SearchSelect /
@@ -21,6 +21,8 @@
* and data-* attributes so all markup and Tailwind class strings live in one * and data-* attributes so all markup and Tailwind class strings live in one
* place (the Python components), never duplicated here. * place (the Python components), never duplicated here.
*/ */
import { onSwap } from "./utils.js";
(() => { (() => {
"use strict"; "use strict";
@@ -32,14 +34,6 @@
// INCLUDES_ONLY) coexist with value pills. // INCLUDES_ONLY) coexist with value pills.
const PRESENCE_MODIFIERS = ["NOT_NULL", "IS_NULL"]; 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 initWidget = (container) => {
const search = container.querySelector("[data-search-select-search]"); const search = container.querySelector("[data-search-select-search]");
const options = container.querySelector("[data-search-select-options]"); const options = container.querySelector("[data-search-select-options]");
@@ -666,6 +660,5 @@
}); });
}; };
document.addEventListener("DOMContentLoaded", initAll); onSwap("[data-search-select]", initWidget);
document.addEventListener("htmx:afterSwap", initAll);
})(); })();
+26
View File
@@ -1,3 +1,28 @@
/**
* @description Runs initializeElement once for each element matching selector,
* on initial page load and inside every htmx-swapped fragment (a port of
* FastHTML's proc_htmx). htmx fires htmx:load for the initial document and for
* each swapped-in element, so a single registration covers both; the WeakSet
* guarantees once-per-element initialization, replacing the old
* DOMContentLoaded + htmx:afterSwap + per-element guard-flag pattern.
* @param {string} selector
* @param {function(Element): void} initializeElement
*/
function onSwap(selector, initializeElement) {
const initialized = new WeakSet();
htmx.onLoad((swappedElement) => {
const elements = Array.from(htmx.findAll(swappedElement, selector));
if (swappedElement.matches && swappedElement.matches(selector)) {
elements.unshift(swappedElement);
}
for (const element of elements) {
if (initialized.has(element)) continue;
initialized.add(element);
initializeElement(element);
}
});
}
/** /**
* @description Formats Date to a UTC string accepted by the datetime-local input field. * @description Formats Date to a UTC string accepted by the datetime-local input field.
* @param {Date} date * @param {Date} date
@@ -202,6 +227,7 @@ function disableElementsWhenTrue(targetSelect, targetValue, elementList) {
} }
export { export {
onSwap,
toISOUTCString, toISOUTCString,
syncSelectInputUntilChanged, syncSelectInputUntilChanged,
getEl, getEl,
+4 -5
View File
@@ -13,17 +13,16 @@ 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.components import StaticScript
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. # Flowbite-datepicker UMD bundle (vendored, v2.0.0), hoisted into the stats
_STATS_SCRIPTS = ExternalScript( # pages for YearPicker.
"https://cdn.jsdelivr.net/npm/flowbite-datepicker@2.0.0/dist/Datepicker.umd.min.js" _STATS_SCRIPTS = StaticScript("datepicker.umd.js")
)
def model_counts(request: HttpRequest) -> dict[str, bool]: def model_counts(request: HttpRequest) -> dict[str, bool]:
+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