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
timetracker/ — Django project: settings, URL root, ASGI/WSGI
tests/ — Pytest tests
e2e/ — Playwright browser tests (run via `make test-e2e`)
contrib/ — One-off scripts (exchange rate import)
docs/ — Additional documentation
```
@@ -113,13 +114,15 @@ Only a small number of HTML templates remain (platform icon snippets and partial
### Frontend stack
- **HTMX** (`games/static/js/htmx.min.js`) — partial page updates
- **Alpine.js** (CDN) — reactive dropdowns (`GameStatusSelector`, `SessionDeviceSelector`), toast store
- **Flowbite** (CDN) — navbar collapse, dropdown toggles
- **Alpine.js** (vendored: `alpine.min.js`, `alpine-mask.min.js`) — reactive dropdowns (`GameStatusSelector`, `SessionDeviceSelector`), toast store
- **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`
- 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/`:
- `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)
- `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
@@ -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"`).
**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
- **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,
SimpleTable,
Span,
StaticScript,
TableHeader,
TableRow,
TableTd,
@@ -112,6 +113,7 @@ __all__ = [
"searchselect_selected",
"SimpleTable",
"Span",
"StaticScript",
"Label",
"TableHeader",
"TableRow",
+3 -1
View File
@@ -559,9 +559,11 @@ def _filter_collapse_button() -> SafeText:
tag_name="button",
attributes=[
("type", "button"),
# Slider handles are positioned in percentages, so initializing
# them while the body is hidden is safe — no re-init on reveal.
(
"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",
+6
View File
@@ -554,6 +554,12 @@ def ExternalScript(url: str) -> SafeText:
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(
year: int | None = None,
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" {django_htmx_script(nonce=None)}\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'
' <script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/mask@3.x.x/dist/cdn.min.js"></script>\n'
' <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>\n'
# Vendored bundles (flowbite 2.4.1, alpinejs/@alpinejs/mask 3.15.12) —
# served locally so pages work offline (and in browser tests). The mask
# 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"
" </head>\n"
)
+12 -4
View File
@@ -4,35 +4,43 @@ from django.http import HttpResponse
from django.test import override_settings
from common.components import SearchSelect
def e2e_test_view(request):
html = f"""
<!DOCTYPE html>
<html>
<head>
<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>
<body>
<div style="padding: 50px;">
{SearchSelect(
{
SearchSelect(
name="games",
selected=[{"value": "7", "label": "Game A", "data": {}}],
options=[
{"value": "7", "label": "Game A", "data": {}},
{"value": "8", "label": "Game B", "data": {}},
],
multi_select=False
)}
multi_select=False,
)
}
</div>
</body>
</html>
"""
return HttpResponse(html)
urlpatterns = [
path("test-search-select/", e2e_test_view),
]
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_search_select_e2e")
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";
@@ -38,8 +38,9 @@ function setupElementHandlers() {
]);
}
document.addEventListener("DOMContentLoaded", setupElementHandlers);
document.addEventListener("htmx:afterSwap", setupElementHandlers);
getEl("#id_type").addEventListener("change", () => {
onSwap("#id_type", (typeSelect) => {
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
+6 -6
View File
@@ -4,6 +4,8 @@
* Handles form submission, preset loading/saving, and preset list rendering.
* No HTMX — plain fetch() and window.location for all interactions.
*/
import { onSwap } from "./utils.js";
(function () {
"use strict";
@@ -410,9 +412,8 @@
// ── Init on page load ───────────────────────────────────────────────────
// ── Inject search inputs into filter forms ──
function injectSearchInputs() {
document.querySelectorAll('[id^="filter-bar-form"]').forEach(function (form) {
// ── Inject the search input into a filter form ──
function injectSearchInput(form) {
if (form.querySelector('[name="filter-search"]')) return; // already added
var input = document.createElement("input");
input.type = "text";
@@ -430,7 +431,6 @@
} catch (e) {}
hidden.parentNode.insertBefore(input, hidden.nextSibling);
}
});
}
/**
@@ -470,8 +470,8 @@
});
}
document.addEventListener("DOMContentLoaded", function () {
injectSearchInputs();
onSwap('[id^="filter-bar-form"]', function (form) {
injectSearchInput(form);
setupDeselectableRadios();
setupStringFilters();
loadPresets();
File diff suppressed because one or more lines are too long
+4 -10
View File
@@ -8,15 +8,12 @@
* Handles track-fill positioning and sync between handles and the connected
* number inputs (linked via data-target attributes).
*/
import { onSwap } from "./utils.js";
(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;
function initializeSlider(slider) {
var mode = slider.getAttribute("data-mode") || "range";
var trackFill = slider.querySelector(".range-track-fill");
var minHandle = slider.querySelector(".range-handle-min");
@@ -227,10 +224,7 @@
// ── Initial position ──
updateHandles();
});
}
document.addEventListener("DOMContentLoaded", initAll);
document.addEventListener("htmx:afterSwap", initAll);
window.initRangeSliders = initAll;
onSwap(".range-slider", initializeSlider);
})();
+5 -12
View File
@@ -12,8 +12,8 @@
* 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.
* Widgets are initialized via onSwap() (utils.js), which covers the initial
* page load and every htmx-swapped fragment, once per widget.
*
* Dynamically-added rows and pills are cloned from hidden <template> elements
* 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
* place (the Python components), never duplicated here.
*/
import { onSwap } from "./utils.js";
(() => {
"use strict";
@@ -32,14 +34,6 @@
// 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]");
@@ -666,6 +660,5 @@
});
};
document.addEventListener("DOMContentLoaded", initAll);
document.addEventListener("htmx:afterSwap", initAll);
onSwap("[data-search-select]", initWidget);
})();
+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.
* @param {Date} date
@@ -202,6 +227,7 @@ function disableElementsWhenTrue(targetSelect, targetValue, elementList) {
}
export {
onSwap,
toISOUTCString,
syncSelectInputUntilChanged,
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 now as timezone_now
from common.components import ExternalScript
from common.components import StaticScript
from common.layout import render_page
from common.time import format_duration
from games.models import Game, Platform, Purchase, Session
from games.views.stats_content import stats_content
from games.views.stats_data import compute_stats
# Flowbite-datepicker UMD bundle, hoisted into the stats pages for YearPicker.
_STATS_SCRIPTS = ExternalScript(
"https://cdn.jsdelivr.net/npm/flowbite-datepicker@2.0.0/dist/Datepicker.umd.min.js"
)
# Flowbite-datepicker UMD bundle (vendored, v2.0.0), hoisted into the stats
# pages for YearPicker.
_STATS_SCRIPTS = StaticScript("datepicker.umd.js")
def model_counts(request: HttpRequest) -> dict[str, bool]:
+6
View File
@@ -11,6 +11,12 @@ pkgs.mkShell {
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 = ''
uv venv --clear
. .venv/bin/activate