From 5fd82c78d481cea17525e6da8757e03b0a2a3fed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Sat, 13 Jun 2026 21:01:26 +0200 Subject: [PATCH 01/23] Add TypeScript toolchain (tsc per-module, build-only) --- .gitignore | 4 ++++ Makefile | 18 ++++++++++++++---- package.json | 3 ++- ts/globals.d.ts | 7 +++++++ tsconfig.json | 14 ++++++++++++++ 5 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 ts/globals.d.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore index 851e426..0f08154 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,7 @@ dist/ .python-version .direnv .hermes/ + +# TypeScript: compiled output and codegen are build-only +/games/static/js/dist/ +/ts/generated/ diff --git a/Makefile b/Makefile index fc20c4f..5c61872 100644 --- a/Makefile +++ b/Makefile @@ -25,12 +25,22 @@ init: server: uv run python -Wa manage.py runserver +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: @pnpm concurrently \ - --names "Django,Tailwind" \ - --prefix-colors "blue,green" \ + --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 tailwindcss -i ./common/input.css -o ./games/static/base.css --watch" \ + "pnpm exec tsc --watch" caddy: @@ -85,7 +95,7 @@ format: format-check: uv run ruff format --check -check: lint format-check test +check: lint format-check ts-check test date: uv run python -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))' diff --git a/package.json b/package.json index 9860a1e..7666c0d 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "@tailwindcss/typography": "^0.5.13", "concurrently": "^8.2.2", "npm-check-updates": "^16.14.20", - "tailwindcss": "^4.1.18" + "tailwindcss": "^4.1.18", + "typescript": "^5.6.0" }, "dependencies": { "@tailwindcss/cli": "^4.1.18", diff --git a/ts/globals.d.ts b/ts/globals.d.ts new file mode 100644 index 0000000..b0c8127 --- /dev/null +++ b/ts/globals.d.ts @@ -0,0 +1,7 @@ +export {}; + +declare global { + interface Window { + fetchWithHtmxTriggers(input: RequestInfo | URL, init?: RequestInit): Promise; + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..20c5484 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "noEmitOnError": true, + "forceConsistentCasingInFileNames": true, + "rootDir": "ts", + "outDir": "games/static/js/dist" + }, + "include": ["ts/**/*.ts"] +} From 763c00c50e144dfa468124f0c5550f97762b37e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Sat, 13 Jun 2026 21:03:57 +0200 Subject: [PATCH 02/23] htpy-style sugar on Element: kwargs attributes + [] children --- common/components/core.py | 10 ++++++++ common/components/primitives.py | 19 ++++++++++++++- tests/test_node_tree.py | 43 +++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/common/components/core.py b/common/components/core.py index 0e518e5..43763c1 100644 --- a/common/components/core.py +++ b/common/components/core.py @@ -235,6 +235,16 @@ class Element(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: diff --git a/common/components/primitives.py b/common/components/primitives.py index 46341f8..b43fa29 100644 --- a/common/components/primitives.py +++ b/common/components/primitives.py @@ -52,14 +52,31 @@ _SIZE_CLASSES = { # tag name is data, not a separate class/function body. Add a tag = one line. +def _attrs_from_kwargs(attrs: dict[str, object]) -> list[HTMLAttribute]: + """Translate htpy-style attribute kwargs to (name, value) pairs. + + ``class_`` -> ``class`` (trailing underscore stripped); ``hx_get`` -> + ``hx-get`` (inner underscores to hyphens); ``True`` -> bare attribute; + ``False`` / ``None`` -> omitted.""" + result: list[HTMLAttribute] = [] + for key, value in attrs.items(): + if value is None or value is False: + continue + name = key.rstrip("_").replace("_", "-") + result.append((name, name if value is True else value)) # type: ignore[arg-type] + return result + + def _html_element(tag_name: str): """Build a generic element builder for ``tag_name`` (the whitelist factory).""" def element( attributes: Attributes | None = None, children: Children = None, + **attrs: object, ) -> Element: - return Element(tag_name, attributes, children) + merged = as_attributes(attributes) + _attrs_from_kwargs(attrs) + return Element(tag_name, merged, children) element.__name__ = element.__qualname__ = tag_name[:1].upper() + tag_name[1:] element.__doc__ = f"Builder for the <{tag_name}> element." diff --git a/tests/test_node_tree.py b/tests/test_node_tree.py index 27db6a2..fe137d5 100644 --- a/tests/test_node_tree.py +++ b/tests/test_node_tree.py @@ -174,5 +174,48 @@ class RealComponentMediaTest(unittest.TestCase): self.assertIn("range_slider.js", media.js) +class HtpyStyleSugarTest(unittest.TestCase): + def test_getitem_sets_children(self): + from common.components import Div, Span + + self.assertEqual( + render(Div(class_="card")[Span()["hi"]]), + '
hi
', + ) + + def test_getitem_multiple_children(self): + from common.components import Div + + self.assertEqual(render(Div()["a", "b"]), "
a\nb
") + + def test_kwargs_class_underscore_becomes_class(self): + from common.components import Div + + self.assertIn('class="x"', render(Div(class_="x"))) + + def test_kwargs_inner_underscore_becomes_hyphen(self): + from common.components import Div + + self.assertIn('hx-get="/y"', render(Div(hx_get="/y"))) + + def test_kwargs_true_renders_bare_attr(self): + from common.components import Div + + self.assertIn('hidden="hidden"', render(Div(hidden=True))) + + def test_kwargs_false_and_none_omitted(self): + from common.components import Div + + html = render(Div(hidden=False, title=None)) + self.assertNotIn("hidden", html) + self.assertNotIn("title", html) + + def test_getitem_preserves_media(self): + from common.components import Div, Media, collect_media + + node = Div(class_="x").with_media(Media(js=("a.js",)))["child"] + self.assertEqual(collect_media(node).js, ("a.js",)) + + if __name__ == "__main__": unittest.main() From 0f0dfc48fbaa29327f3278c215f3d0349c0c8547 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Sat, 13 Jun 2026 21:05:49 +0200 Subject: [PATCH 03/23] Custom-element registry, builder, and TS codegen --- common/components/__init__.py | 3 + common/components/custom_elements.py | 104 ++++++++++++++++++ .../management/commands/gen_element_types.py | 21 ++++ tests/test_custom_elements.py | 58 ++++++++++ 4 files changed, 186 insertions(+) create mode 100644 common/components/custom_elements.py create mode 100644 games/management/commands/gen_element_types.py create mode 100644 tests/test_custom_elements.py diff --git a/common/components/__init__.py b/common/components/__init__.py index 5072ee1..6e8795e 100644 --- a/common/components/__init__.py +++ b/common/components/__init__.py @@ -18,6 +18,7 @@ from common.components.core import ( randomid, render, ) +from common.components.custom_elements import custom_element, register_element from common.components.date_range_picker import ( DateRangeCalendar, DateRangeField, @@ -91,6 +92,8 @@ from common.utils import truncate __all__ = [ "truncate", "BaseComponent", + "custom_element", + "register_element", "Element", "Fragment", "Media", diff --git a/common/components/custom_elements.py b/common/components/custom_elements.py new file mode 100644 index 0000000..61eec33 --- /dev/null +++ b/common/components/custom_elements.py @@ -0,0 +1,104 @@ +"""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 Mapping, TypedDict, get_type_hints + +from common.components.core import Children, Element, HTMLAttribute, Media, Node + + +@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("_", "-") + + +def custom_element( + tag: str, props: Mapping[str, object], *, children: Children = None +) -> Node: + """Emit ``children`` and declare its compiled module. + + The module path mirrors the source layout: ``ts/elements/.ts`` compiles + to ``dist/elements/.js``, which ``Media`` loads via ``ModuleScript``.""" + attributes: list[HTMLAttribute] = [ + (_kebab(key), value) # type: ignore[misc] + for key, value in props.items() + ] + return Element(tag, attributes, children).with_media( + Media(js=(f"dist/elements/{tag}.js",)) + ) + + +# ── 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) diff --git a/games/management/commands/gen_element_types.py b/games/management/commands/gen_element_types.py new file mode 100644 index 0000000..6e2402e --- /dev/null +++ b/games/management/commands/gen_element_types.py @@ -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}")) diff --git a/tests/test_custom_elements.py b/tests/test_custom_elements.py new file mode 100644 index 0000000..ff77e2b --- /dev/null +++ b/tests/test_custom_elements.py @@ -0,0 +1,58 @@ +import unittest +from typing import TypedDict + +from common.components import custom_element, 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): + html = render( + custom_element("x-sample", {"game_id": 3, "status": "f"}, children=["hi"]) + ) + self.assertIn("hi", html) + + def test_declares_compiled_module_media(self): + from common.components import collect_media + + node = custom_element("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") From 04552aa8f6ca0f674323836327a7d48437c1e016 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Sat, 13 Jun 2026 21:09:52 +0200 Subject: [PATCH 04/23] GameStatusSelector: custom element + typed contract (retire Alpine) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Game status dropdown is now a 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 in a cell never got its - - - - -""" +_PLAYED_BTN = ( + "px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 " + "hover:bg-gray-100 hover:text-blue-700 dark:bg-gray-800 dark:border-gray-700 " + "dark:text-white dark:hover:bg-gray-700 hover:cursor-pointer" +) +_PLAYED_MENU = ( + "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" +) def _played_row(game: Game, request: HttpRequest) -> Node: - """The 'Played N times' control with its Alpine.js dropdown.""" - replacements = { - "@@PLAYED_COUNT@@": str(game.playevents.count()), - "@@ADD_PE@@": reverse("games:add_playevent"), - "@@ARROWDOWN@@": get_icon("arrowdown"), - "@@ADD_PE_FOR_GAME@@": reverse("games:add_playevent_for_game", args=[game.id]), - "@@API_CREATE@@": reverse("api-1.0.0:create_playevent"), - "@@CSRF@@": get_token(request), - "@@GAME_ID@@": str(game.id), - } - html = _PLAYED_ROW_TEMPLATE - for token, value in replacements.items(): - html = html.replace(token, value) - return Safe(html) + """'Played N times' control as a custom element (ts/elements/play-event-row.ts).""" + from common.components import Element, custom_element + from common.components.custom_elements import PlayEventRowProps + + played = game.playevents.count() + + count_button = A(href=reverse("games:add_playevent"))[ + Element( + "button", + [("type", "button"), ("class", _PLAYED_BTN + " rounded-s-lg")], + [Span(data_count="")[str(played)], " times"], + ) + ] + menu = Div(data_menu="", hidden=True, class_=_PLAYED_MENU)[ + Ul()[ + Li(attributes=[("class", "px-4 py-2")])[ + A(href=reverse("games:add_playevent_for_game", args=[game.id]))[ + "Add playthrough..." + ] + ], + Li(attributes=[("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 relative"), + ], + [Icon("arrowdown"), menu], + ) + group = Div(class_="inline-flex rounded-md shadow-2xs relative")[ + count_button, toggle + ] + return custom_element( + "play-event-row", + PlayEventRowProps( + game_id=game.id, + csrf=get_token(request), + api_create_url=reverse("api-1.0.0:create_playevent"), + ), + children=[ + 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: diff --git a/tests/test_rendered_pages.py b/tests/test_rendered_pages.py index 8f7b8ab..73bb7ce 100644 --- a/tests/test_rendered_pages.py +++ b/tests/test_rendered_pages.py @@ -168,7 +168,7 @@ class RenderedPagesTest(TestCase): "Platform", 'id="history-container"', "status-changed from:body", - "createPlayEvent", # the played-row Alpine dropdown script + "")) + def test_view_game_uses_play_event_row_element(self): + game = Game.objects.create(name="Played Game", platform=self.platform) + html = self.get("games:view_game", game.id).content.decode() + self.assertIn("("[data-toggle]"); + const menu = this.querySelector("[data-menu]"); + const count = this.querySelector("[data-count]"); + const addPlay = this.querySelector("[data-add-play]"); + if (!toggle || !menu) return; + + const close = () => { + menu.hidden = true; + }; + toggle.addEventListener("click", (event) => { + event.stopPropagation(); + menu.hidden = !menu.hidden; + }); + document.addEventListener("click", (event) => { + if (!this.contains(event.target as Node)) close(); + }); + + addPlay?.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + if (count) count.textContent = String(Number(count.textContent) + 1); + close(); + window + .fetchWithHtmxTriggers(props.apiCreateUrl, { + method: "POST", + headers: { "Content-Type": "application/json", "X-CSRFToken": props.csrf }, + body: JSON.stringify({ game_id: props.gameId }), + }) + .catch(() => { + if (count) count.textContent = String(Number(count.textContent) - 1); + console.error("Failed to record play"); + }); + }); + } +} + +customElements.define("play-event-row", PlayEventRowElement); From c7af814364fdfaaec5bb9b45a4977daba26705fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Sat, 13 Jun 2026 21:27:46 +0200 Subject: [PATCH 07/23] Clear pre-existing ruff lint + format debt (make check now green) --- common/components/date_range_picker.py | 5 +- common/components/filters.py | 4 +- e2e/conftest.py | 1 + e2e/test_date_filter_e2e.py | 4 +- e2e/test_range_slider_e2e.py | 48 +++++++++---------- e2e/test_string_filter_e2e.py | 38 ++++++++------- games/filters.py | 4 +- .../0018_alter_session_timestamp_start.py | 9 ++-- tests/test_filter_bars.py | 1 - tests/test_filter_helpers.py | 1 - tests/test_filters.py | 14 ++++-- 11 files changed, 65 insertions(+), 64 deletions(-) diff --git a/common/components/date_range_picker.py b/common/components/date_range_picker.py index 02b611d..860694b 100644 --- a/common/components/date_range_picker.py +++ b/common/components/date_range_picker.py @@ -17,7 +17,6 @@ widget into a ``DateCriterion`` unchanged. All behaviour is wired by ``games/static/js/date_range_picker.js``. """ - from common.components.core import Element, HTMLAttribute, Media, Node, Safe from common.components.primitives import Div, Input, Span from common.time import DatePartSpec, date_parts @@ -101,9 +100,7 @@ def _iso_part_values(iso_value: str, parts: list[DatePartSpec]) -> dict[str, str return values -def _segment_input( - *, part: DatePartSpec, side: str, label: str, value: str -) -> Node: +def _segment_input(*, part: DatePartSpec, side: str, label: str, value: str) -> Node: side_label = "from" if side == "min" else "to" return Input( attributes=[ diff --git a/common/components/filters.py b/common/components/filters.py index 1d158f1..056ec49 100644 --- a/common/components/filters.py +++ b/common/components/filters.py @@ -173,9 +173,7 @@ def _split_modifier(modifier: str, has_m2m: bool = False) -> str: return "" -def _enum_filter( - field_name: str, options, choice: FilterChoice, *, nullable -) -> Node: +def _enum_filter(field_name: str, options, choice: FilterChoice, *, nullable) -> Node: """A FilterSelect over a small, fully pre-rendered option set (enum field). Enum fields are single-valued, so no M2M modifiers (all/only are diff --git a/e2e/conftest.py b/e2e/conftest.py index 6df92e6..3a49536 100644 --- a/e2e/conftest.py +++ b/e2e/conftest.py @@ -7,6 +7,7 @@ import pytest # synchronous operations inside the async context safely. os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true") + @pytest.fixture(scope="session") 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 diff --git a/e2e/test_date_filter_e2e.py b/e2e/test_date_filter_e2e.py index a108d69..33f9879 100644 --- a/e2e/test_date_filter_e2e.py +++ b/e2e/test_date_filter_e2e.py @@ -121,9 +121,7 @@ def test_max_only_serializes_as_less_than(live_server, page): ".dispatchEvent(new Event('submit', {cancelable: true}))" ) parsed = _filter_from_url(page.url) - assert parsed == { - "date_refunded": {"value": "2025-06-30", "modifier": "LESS_THAN"} - } + assert parsed == {"date_refunded": {"value": "2025-06-30", "modifier": "LESS_THAN"}} @pytest.mark.django_db diff --git a/e2e/test_range_slider_e2e.py b/e2e/test_range_slider_e2e.py index 8ff5fe9..0ee7043 100644 --- a/e2e/test_range_slider_e2e.py +++ b/e2e/test_range_slider_e2e.py @@ -1,8 +1,4 @@ -"""End-to-end Playwright test for the RangeSlider JS synchronization, cross-over, and clamping behavior. -""" - -import json -import urllib.parse +"""End-to-end Playwright test for the RangeSlider JS synchronization, cross-over, and clamping behavior.""" import pytest from django.http import HttpResponse @@ -41,17 +37,17 @@ urlpatterns = [ @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" @@ -60,16 +56,16 @@ def test_range_slider_crossover_min_higher_than_max(live_server, page): @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" @@ -78,20 +74,20 @@ def test_range_slider_crossover_max_less_than_min(live_server, page): @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 - + 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 - + min_input.blur() # triggers "change" event + assert min_input.input_value() == "0" @@ -99,18 +95,20 @@ def test_range_slider_strict_bounds_clamping_on_blur(live_server, page): @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"]') - + 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 diff --git a/e2e/test_string_filter_e2e.py b/e2e/test_string_filter_e2e.py index f2de0a3..855b650 100644 --- a/e2e/test_string_filter_e2e.py +++ b/e2e/test_string_filter_e2e.py @@ -38,9 +38,7 @@ def prefilled_bar_view(request): "value": "Switch", "modifier": "INCLUDES", }, - "group": { - "modifier": "IS_NULL" - } + "group": {"modifier": "IS_NULL"}, } ) return HttpResponse(_bar_page(filter_json=filter_json)) @@ -63,19 +61,21 @@ def _filter_from_url(url: str) -> dict: @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 = page.locator( + 'input[name="filter-name-modifier"][value="INCLUDES"]' + ) includes_radio.click() - + with page.expect_navigation(): page.evaluate( "document.getElementById('filter-bar-form')" @@ -92,15 +92,15 @@ def test_string_filter_null_states(live_server, page): 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')" @@ -114,19 +114,23 @@ def test_string_filter_null_states(live_server, page): @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() - + 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() + assert page.locator( + 'input[name="filter-group-modifier"][value="IS_NULL"]' + ).is_checked() @pytest.mark.django_db @@ -136,11 +140,11 @@ def test_string_filter_deselect_re_enables(live_server, page): 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() diff --git a/games/filters.py b/games/filters.py index 29721bb..8b6d25e 100644 --- a/games/filters.py +++ b/games/filters.py @@ -412,7 +412,9 @@ class GameFilter(OperatorFilter): from games.models import PlayEvent event_q = criterion.to_q("note") - matching_ids = PlayEvent.objects.filter(event_q).values_list("game_id", flat=True) + matching_ids = PlayEvent.objects.filter(event_q).values_list( + "game_id", flat=True + ) return Q(id__in=matching_ids) diff --git a/games/migrations/0018_alter_session_timestamp_start.py b/games/migrations/0018_alter_session_timestamp_start.py index 9e8bbde..7d29296 100644 --- a/games/migrations/0018_alter_session_timestamp_start.py +++ b/games/migrations/0018_alter_session_timestamp_start.py @@ -4,15 +4,14 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('games', '0017_add_filter_preset'), + ("games", "0017_add_filter_preset"), ] operations = [ migrations.AlterField( - model_name='session', - name='timestamp_start', - field=models.DateTimeField(db_index=True, verbose_name='Start'), + model_name="session", + name="timestamp_start", + field=models.DateTimeField(db_index=True, verbose_name="Start"), ), ] diff --git a/tests/test_filter_bars.py b/tests/test_filter_bars.py index 098b178..3ce025a 100644 --- a/tests/test_filter_bars.py +++ b/tests/test_filter_bars.py @@ -362,4 +362,3 @@ class FilterBarRenderingTest(TestCase): self.assertIn('name="filter-refunded"', purchase_html) self.assertIn('value="true"', purchase_html) self.assertIn('value="false"', purchase_html) - diff --git a/tests/test_filter_helpers.py b/tests/test_filter_helpers.py index b073f94..ca1dc15 100644 --- a/tests/test_filter_helpers.py +++ b/tests/test_filter_helpers.py @@ -85,4 +85,3 @@ class ParseBoolNullableTest(SimpleTestCase): 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")) - diff --git a/tests/test_filters.py b/tests/test_filters.py index ec3e49d..15d5da7 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -560,8 +560,14 @@ class TestFilterBarRendering: def test_mastered_not_checked_by_default(self): html = str(FilterBar(filter_json="")) - assert 'name="filter-mastered" value="true" class="rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand" checked="true"' not in html - assert 'name="filter-mastered" value="false" class="rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand" checked="true"' not in html + assert ( + 'name="filter-mastered" value="true" class="rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand" checked="true"' + not in html + ) + assert ( + 'name="filter-mastered" value="false" class="rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand" checked="true"' + not in html + ) def test_mastered_checked_when_filtered(self): html = str( @@ -784,7 +790,7 @@ class TestExpandedFiltersAgainstDB: from games.filters import SessionFilter from games.models import Session - data = self._setup_entities() + self._setup_entities() # Test duration_total_hours equals 4 sf_tot = SessionFilter.from_json( @@ -808,7 +814,7 @@ class TestExpandedFiltersAgainstDB: from games.filters import PurchaseFilter from games.models import Purchase - data = self._setup_entities() + self._setup_entities() pf = PurchaseFilter.from_json( { From ce976e8f2ec62d4b3c7b108b6abf11d35e27d3fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Sat, 13 Jun 2026 21:28:20 +0200 Subject: [PATCH 08/23] Build TS in Docker (Node assets stage); document custom-element pattern --- CLAUDE.md | 9 +++++++++ Dockerfile | 21 +++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 6456fa5..231f07e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,6 +124,15 @@ Only a small number of HTML templates remain (platform icon snippets and partial - `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/.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 `