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
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
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
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
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
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
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
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
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>
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>
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>