Merge pull request #16 from KucharczykL/claude/custom-elements

This commit is contained in:
2026-06-14 13:26:27 +02:00
committed by GitHub
54 changed files with 4486 additions and 456 deletions
+3
View File
@@ -19,3 +19,6 @@ DATA_DIR=/home/timetracker/app/data
# CSRF trusted origins
CSRF_TRUSTED_ORIGINS=https://tracker.kucharczyk.xyz
# Create a default admin/admin superuser on startup (for initial setup only)
CREATE_DEFAULT_SUPERUSER=false
+11
View File
@@ -19,6 +19,17 @@ jobs:
- name: Install dependencies
run: uv sync --frozen
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install pnpm and JS dependencies
run: npm install -g pnpm && pnpm install --frozen-lockfile --ignore-scripts
- name: Build TypeScript
run: make ts
- name: Install Playwright browsers
run: uv run playwright install --with-deps chromium
+5 -1
View File
@@ -4,7 +4,6 @@ __pycache__
.venv/
node_modules
package-lock.json
pnpm-lock.yaml
db.sqlite3
data/
/static/
@@ -13,3 +12,8 @@ dist/
.python-version
.direnv
.hermes/
# Build artifacts: generated in CI/Docker assets stage, not committed
/games/static/base.css
/games/static/js/dist/
/ts/generated/
+9
View File
@@ -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/<tag>.ts` (TypeScript, vanilla DOM, `customElements.define`); the native `connectedCallback` replaces `onSwap` (it fires on parse *and* htmx swap). The server↔client contract is one Python `TypedDict` per element registered with `register_element(...)` in `common/components/custom_elements.py`; `manage.py gen_element_types` codegens `ts/generated/props.ts` (interface + attribute reader) so renaming a prop fails `tsc`.
- **Build:** `tsc` per-module (`tsconfig.json`) compiles `ts/``games/static/js/dist/` (build-only, gitignored). `make ts` = codegen + compile; `make ts-check` (in `make check`) = codegen + `tsc --noEmit`; `make dev` runs `tsc --watch`. The Docker image builds CSS + TS in a Node stage. Run `make ts` after editing any `.ts` so e2e/local serving sees fresh output.
- **htpy-style markup:** generic builders take kwargs attributes and `[]` children — `Div(class_="x", hx_get="/y")[child1, child2]` (`class_``class`, `hx_get``hx-get`, `True`→bare attr, `False`/`None`→omitted). Still a walkable `Element` tree, so `Media` bubbles.
- **Do NOT** author HTML/JS as Python f-strings or add new inline Alpine `x-data` blobs. Alpine remains only for trivial pre-existing toggles (toast store, etc.).
- **Tables collect cell media:** `SimpleTable` stringifies cells, so it explicitly `collect_media`s its rows/header and re-attaches it — a custom element in a table cell still gets its `<script>` emitted by `Page()`.
### Deployment
Docker-based: multi-stage Dockerfile (uv builder → slim runtime), Caddy as reverse proxy on port 8000, Gunicorn with UvicornWorker (ASGI), Supervisor to manage Caddy + Gunicorn + django-q2. `make dev-prod` mimics production locally. CI/CD via GitHub Actions (`.github/workflows/build-docker.yml`): builds Docker image; Drone CI (`.drone.yml`) also present for deployments via Portainer webhook.
+21
View File
@@ -15,6 +15,23 @@ COPY . .
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev
# Codegen the TypeScript prop contracts (needs Django); tsc compiles them in
# the assets stage below.
RUN uv run python manage.py gen_element_types
# Front-end assets: Tailwind CSS + the TypeScript custom elements. Built here so
# the compiled output ships in the image (dist/ is build-only, not committed).
FROM node:22-bookworm-slim AS assets
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm install --frozen-lockfile --ignore-scripts
COPY . .
COPY --from=builder /home/timetracker/app/ts/generated ./ts/generated
RUN pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css \
&& pnpm exec tsc
FROM python:3.14-slim-bookworm
@@ -44,6 +61,10 @@ WORKDIR /home/timetracker/app
COPY --from=builder --chown=timetracker:timetracker /home/timetracker/app /home/timetracker/app
# Built front-end assets from the Node stage (Tailwind CSS + compiled TS).
COPY --from=assets --chown=timetracker:timetracker /app/games/static/base.css /home/timetracker/app/games/static/base.css
COPY --from=assets --chown=timetracker:timetracker /app/games/static/js/dist /home/timetracker/app/games/static/js/dist
COPY --chown=timetracker:timetracker Caddyfile /etc/caddy/Caddyfile
COPY --chown=timetracker:timetracker supervisor.conf /etc/supervisor/conf.d/supervisor.conf
COPY --chown=timetracker:timetracker entrypoint.sh /
+14 -4
View File
@@ -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=" "))'
+7 -2
View File
@@ -18,6 +18,7 @@ from common.components.core import (
randomid,
render,
)
from common.components.custom_elements import SessionTimestampButtons, register_element
from common.components.date_range_picker import (
DateRangeCalendar,
DateRangeField,
@@ -47,7 +48,6 @@ from common.components.primitives import (
H1,
A,
AddForm,
Button,
ButtonGroup,
Checkbox,
CsrfInput,
@@ -67,6 +67,7 @@ from common.components.primitives import (
SimpleTable,
Span,
StaticScript,
StyledButton,
TableHeader,
TableRow,
TableTd,
@@ -76,6 +77,7 @@ from common.components.primitives import (
Tr,
Ul,
YearPicker,
custom_element_builder,
paginated_table_content,
)
from common.components.search_select import (
@@ -91,6 +93,9 @@ from common.utils import truncate
__all__ = [
"truncate",
"BaseComponent",
"register_element",
"SessionTimestampButtons",
"custom_element_builder",
"Element",
"Fragment",
"Media",
@@ -104,7 +109,7 @@ __all__ = [
"randomid",
"A",
"AddForm",
"Button",
"StyledButton",
"ButtonGroup",
"Checkbox",
"CsrfInput",
+10
View File
@@ -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:
+127
View File
@@ -0,0 +1,127 @@
"""Custom-element builder, registry, and TypeScript codegen.
A custom element is a light-DOM Web Component: the Python builder emits a
semantic tag whose typed props become kebab-case attributes and whose behavior
lives in a compiled TS module (loaded via Media). One ``TypedDict`` per element
is the single source of truth for the server<->client contract;
``gen_element_types`` turns each registered spec into a TS interface + attribute
reader so drift fails ``tsc``.
"""
from dataclasses import dataclass
from typing import TypedDict, get_type_hints
from common.components.core import Media
from common.components.primitives import custom_element_builder
@dataclass(frozen=True)
class ElementSpec:
tag: str # e.g. "game-status-selector"
ts_name: str # e.g. "GameStatusSelector"
props: type # a TypedDict subclass
ELEMENT_REGISTRY: list[ElementSpec] = []
def register_element(tag: str, ts_name: str, props: type) -> None:
"""Register an element so codegen can emit its TS contract."""
ELEMENT_REGISTRY.append(ElementSpec(tag, ts_name, props))
def _kebab(name: str) -> str:
return name.replace("_", "-")
# ── Codegen ──────────────────────────────────────────────────────────────────
_TYPE_MAP = {int: "number", float: "number", str: "string", bool: "boolean"}
def _camel(name: str) -> str:
head, *tail = name.split("_")
return head + "".join(part.title() for part in tail)
def _reader_expr(name: str, python_type: type) -> str:
attr = _kebab(name)
if python_type in (int, float):
return f'Number(el.getAttribute("{attr}"))'
if python_type is bool:
return f'el.getAttribute("{attr}") === "true"'
return f'el.getAttribute("{attr}") ?? ""'
def _ts_for_spec(spec: ElementSpec) -> str:
hints = get_type_hints(spec.props)
interface_lines = "\n".join(
f" {_camel(name)}: {_TYPE_MAP[python_type]};"
for name, python_type in hints.items()
)
reader_lines = "\n".join(
f" {_camel(name)}: {_reader_expr(name, python_type)},"
for name, python_type in hints.items()
)
return (
f"export interface {spec.ts_name}Props {{\n{interface_lines}\n}}\n\n"
f"export function read{spec.ts_name}Props(el: HTMLElement): "
f"{spec.ts_name}Props {{\n return {{\n{reader_lines}\n }};\n}}"
)
def render_props_module() -> str:
"""The full ``ts/generated/props.ts`` content for every registered element."""
header = "// GENERATED by `manage.py gen_element_types` — do not edit.\n"
blocks = [_ts_for_spec(spec) for spec in ELEMENT_REGISTRY]
return header + "\n" + "\n\n".join(blocks) + "\n"
# ── Element prop schemas (registered at import time) ─────────────────────────
class GameStatusSelectorProps(TypedDict):
game_id: int
status: str
csrf: str
register_element("game-status-selector", "GameStatusSelector", GameStatusSelectorProps)
class SessionDeviceSelectorProps(TypedDict):
session_id: int
csrf: str
register_element(
"session-device-selector", "SessionDeviceSelector", SessionDeviceSelectorProps
)
class PlayEventRowProps(TypedDict):
game_id: int
csrf: str
api_create_url: str
register_element("play-event-row", "PlayEventRow", PlayEventRowProps)
class SessionTimestampButtonsProps(TypedDict):
pass
register_element(
"session-timestamp-buttons", "SessionTimestampButtons", SessionTimestampButtonsProps
)
# ── Named tag builders (consistent htpy-style with Div/Span) ─────────────────
# Underscore-prefixed: used internally by domain wrappers.
# Public ones (no domain wrapper): exported directly.
_GameStatusSelector = custom_element_builder("game-status-selector")
_SessionDeviceSelector = custom_element_builder("session-device-selector")
_PlayEventRow = custom_element_builder("play-event-row")
SessionTimestampButtons = custom_element_builder("session-timestamp-buttons")
+1 -4
View File
@@ -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=[
+90 -121
View File
@@ -209,131 +209,100 @@ def PurchasePrice(purchase) -> Node:
)
def GameStatusSelector(game, game_statuses, csrf_token: str) -> Node:
"""Alpine.js dropdown to change a game's status."""
options_html = "\n".join(
f"<template x-if=\"status == '{value}'\">"
f"{GameStatus(status=value, children=[label], display='flex')}"
f"</template>"
for value, label in game_statuses
)
list_items = "\n".join(
f"<li><a href=\"#\" @click.prevent.stop=\"setStatus('{value}', '{label}'); open = false;\" "
f'class="block px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 '
f'dark:focus:ring-blue-500 dark:focus:text-white rounded-sm no-underline! border-0!" '
f":class=\"{{'font-bold': status === '{value}'}}\">"
f"{GameStatus(status=value, children=[label], display='flex', class_='text-slate-300')}"
f"</a></li>"
for value, label in game_statuses
)
_SELECTOR_MENU_CLASS = (
"absolute top-[105%] left-0 w-full whitespace-nowrap z-10 text-sm "
"font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-t-none "
"border border-gray-200 dark:border-gray-700"
)
_SELECTOR_TOGGLE_CLASS = (
"relative px-4 py-2 text-sm font-medium bg-white border border-gray-200 "
"rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 "
"dark:hover:text-white dark:hover:bg-gray-700 hover:cursor-pointer"
)
_SELECTOR_OPTION_CLASS = (
"block w-full text-left px-4 py-2 rounded-sm cursor-pointer "
"hover:bg-gray-700 hover:text-white dark:hover:bg-gray-700 "
"dark:hover:text-white border-0"
)
return Safe(f"""
<div class="flex gap-2 items-center"
x-data="{{
status: '{game.status}',
status_display: '{game.get_status_display()}',
open: false,
saving: false,
setStatus(newStatus, newStatusDisplay) {{
this.status = newStatus;
this.status_display = newStatusDisplay;
this.saving = true;
fetchWithHtmxTriggers(`/api/games/{game.id}/status`, {{
method: 'PATCH',
headers: {{
'Content-Type': 'application/json',
'X-CSRFToken': '{csrf_token}'
}},
body: JSON.stringify({{ status: newStatus }})
}})
.then(() => {{
document.body.dispatchEvent(new CustomEvent('status-changed'));
}})
.catch(() => {{
console.error('Failed to update status');
}})
.finally(() => this.saving = false);
}}
}}">
{_dropdown_button_html(options_html, list_items)}
</div>
""")
def GameStatusSelector(game, game_statuses, csrf_token: str) -> Node:
"""Light-DOM custom element; behavior in ts/elements/game-status-selector.ts."""
from common.components.core import Element
from common.components.custom_elements import _GameStatusSelector, GameStatusSelectorProps
from common.components.primitives import Li, Ul
options = [
Li()[
Element(
"button",
[
("type", "button"),
("data-option", ""),
("data-value", str(value)),
("class", _SELECTOR_OPTION_CLASS),
],
GameStatus(status=value, children=[label], display="flex"),
)
]
for value, label in game_statuses
]
current_label = Span(data_label="")[
GameStatus(
status=game.status,
children=[game.get_status_display()],
display="flex",
)
]
toggle = Element(
"button",
[("type", "button"), ("data-toggle", ""), ("class", _SELECTOR_TOGGLE_CLASS)],
Span(class_="flex flex-row gap-4 justify-between items-center")[
current_label, Icon("arrowdown")
],
)
menu = Div(data_menu="", hidden=True, class_=_SELECTOR_MENU_CLASS)[Ul()[*options]]
dropdown = Div(
data_dropdown="", class_="inline-flex rounded-md shadow-2xs relative"
)[toggle, menu]
return _GameStatusSelector(game_id=game.id, status=game.status, csrf=csrf_token)[
Div(class_="flex gap-2 items-center")[dropdown]
]
def SessionDeviceSelector(session, session_devices, csrf_token: str) -> Node:
"""Alpine.js dropdown to change a session's device."""
device_id = session.device_id or "null"
device_name = (session.device.name if session.device else "Unknown").replace(
"'", "\\'"
)
"""Light-DOM custom element; behavior in ts/elements/session-device-selector.ts."""
from common.components.core import Element
from common.components.custom_elements import _SessionDeviceSelector, SessionDeviceSelectorProps
from common.components.primitives import Li, Ul
list_items = "\n".join(
f'<li><a href="#" @click.prevent.stop="setDevice({d.id}, \'{d.name.replace(chr(39), chr(92) + chr(39))}\'); open = false;" '
f'class="block px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 '
f'dark:focus:ring-blue-500 dark:focus:text-white rounded-sm no-underline! border-0!" '
f":class=\"{{'font-bold': deviceId === {d.id}}}\">{d.name}</a></li>"
for d in session_devices
current_name = session.device.name if session.device else "Unknown"
options = [
Li()[
Element(
"button",
[
("type", "button"),
("data-option", ""),
("data-value", str(device.id)),
("class", _SELECTOR_OPTION_CLASS),
],
children=[device.name],
)
return Safe(f"""
<div class="flex gap-2 items-center"
x-data="{{
originalDeviceId: {device_id},
originalDeviceName: '{device_name}',
deviceId: {device_id},
deviceName: '{device_name}',
open: false,
saving: false,
setDevice(newDeviceId, newDeviceName) {{
this.deviceId = newDeviceId;
this.deviceName = newDeviceName;
this.saving = true;
fetchWithHtmxTriggers(`/api/session/{session.id}/device`, {{
method: 'PATCH',
headers: {{
'Content-Type': 'application/json',
'X-CSRFToken': '{csrf_token}'
}},
body: JSON.stringify({{ device_id: newDeviceId }})
}})
.then((res) => {{
document.body.dispatchEvent(new CustomEvent('device-changed'));
}})
.catch(() => {{
this.deviceName = this.originalDeviceName;
this.deviceId = this.originalDeviceId;
console.error('Failed to update device');
}})
.finally(() => this.saving = false);
}}
}}">
{
_dropdown_button_html(
'<span x-text="deviceName"></span>' + str(Icon("arrowdown")), list_items
)
}
</div>
""")
def _dropdown_button_html(button_content: str, list_items: str) -> str:
"""Shared dropdown button + list structure for Alpine.js selectors."""
return (
'<div class="inline-flex rounded-md shadow-2xs" role="group" @click.outside="open = false">'
'<button type="button" @click="open = !open" '
'class="relative px-4 py-2 text-sm font-medium bg-white border border-gray-200 '
"rounded-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 "
"focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 "
"dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 "
'dark:focus:text-white align-middle hover:cursor-pointer">'
f'<span class="flex flex-row gap-4 justify-between items-center">{button_content}</span>'
'<div class="absolute top-[105%] left-0 w-full whitespace-nowrap z-10 text-sm '
"font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-t-none border "
'border-gray-200 dark:border-gray-700" x-show="open" style="display: none;">'
'<ul class="[&_li:first-of-type_a]:rounded-none [&_li:last-of-type_a]:rounded-t-none">'
f"{list_items}"
"</ul>"
"</div>"
"</button>"
"</div>"
]
for device in session_devices
]
toggle = Element(
"button",
[("type", "button"), ("data-toggle", ""), ("class", _SELECTOR_TOGGLE_CLASS)],
Span(class_="flex flex-row gap-4 justify-between items-center")[
Span(data_label="")[current_name], Icon("arrowdown")
],
)
menu = Div(data_menu="", hidden=True, class_=_SELECTOR_MENU_CLASS)[Ul()[*options]]
dropdown = Div(
data_dropdown="", class_="inline-flex rounded-md shadow-2xs relative"
)[toggle, menu]
return _SessionDeviceSelector(session_id=session.id, csrf=csrf_token)[
Div(class_="flex gap-2 items-center")[dropdown]
]
+1 -3
View File
@@ -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
+56 -38
View File
@@ -9,7 +9,6 @@ Everything returns a :class:`Node`; string-built widgets return :class:`Safe`.
from django.middleware.csrf import get_token
from django.templatetags.static import static
from django.urls import reverse
from django.utils.html import conditional_escape
from django.utils.safestring import SafeText, mark_safe
@@ -25,6 +24,7 @@ from common.components.core import (
Safe,
as_attributes,
as_children,
collect_media,
randomid,
)
from common.icons import get_icon
@@ -52,20 +52,53 @@ _SIZE_CLASSES = {
# tag name is data, not a separate class/function body. Add a tag = one line.
def _html_element(tag_name: str):
"""Build a generic element builder for ``tag_name`` (the whitelist factory)."""
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 custom_element_builder(tag_name: str):
"""Create a tag builder for a custom element with auto-attached Media.
The module path follows the convention ``ts/elements/<tag>.ts`` →
``dist/elements/<tag>.js``.
"""
return _html_element(tag_name, Media(js=(f"dist/elements/{tag_name}.js",)))
def _html_element(tag_name: str, media: Media | None = None):
"""Build a generic element builder for ``tag_name`` (the whitelist factory).
If ``media`` is provided, every node created by the builder will carry it
(used for custom elements whose compiled JS must be loaded automatically).
"""
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)
node = Element(tag_name, merged, children)
return node.with_media(media) if media else node
element.__name__ = element.__qualname__ = tag_name[:1].upper() + tag_name[1:]
element.__doc__ = f"Builder for the <{tag_name}> element."
return element
A = _html_element("a")
Button = _html_element("button")
Div = _html_element("div")
P = _html_element("p")
Ul = _html_element("ul")
@@ -186,35 +219,7 @@ def PopoverTruncated(
return input_string
def A(
attributes: Attributes | None = None,
children: Children = None,
url_name: str | None = None,
href: str | None = None,
) -> Element:
"""
Returns an anchor <a> tag.
Accepts one of two mutually-exclusive URL specifications:
- url_name: URL pattern name, resolved via reverse()
- href: Literal path string passed through as-is
"""
attributes = as_attributes(attributes)
children = children or []
if url_name is not None and href is not None:
raise ValueError("Provide exactly one of 'url_name' or 'href', not both.")
additional_attributes = []
if url_name is not None:
additional_attributes = [("href", reverse(url_name))]
elif href is not None:
additional_attributes = [("href", href)]
return Element(
"a", attributes=attributes + additional_attributes, children=children
)
def Button(
def StyledButton(
attributes: Attributes | None = None,
children: Children = None,
size: str = "base",
@@ -227,8 +232,9 @@ def Button(
title: str = "",
onclick: str = "",
name: str = "",
**attrs: object,
) -> Element:
attributes = as_attributes(attributes)
attributes = as_attributes(attributes) + _attrs_from_kwargs(attrs)
children = children or []
# Separate custom class from other generic attributes
@@ -650,7 +656,7 @@ def AddForm(
children=[
CsrfInput(request),
field_markup,
Div(children=[Button(submit_attrs, "Submit", type="submit")]),
Div(children=[StyledButton(submit_attrs, "Submit", type="submit")]),
Div(
[("class", "submit-button-container")],
[additional_row] if additional_row else [],
@@ -971,15 +977,26 @@ def SimpleTable(
columns = columns or []
rows = rows or []
# Rows/header are stringified into the table markup, so their components'
# declared Media would be lost; collect it from the nodes first and attach
# it to the returned node so Page() still emits each cell component's JS
# (e.g. a <game-status-selector> in a cell).
media = Media()
header_html = ""
if header_action:
header_html = str(TableHeader(children=[header_action]))
header_node = TableHeader(children=[header_action])
header_html = str(header_node)
media = media + collect_media(header_node)
columns_html = "".join(
f'<th scope="col" class="px-6 py-3">{conditional_escape(col)}</th>'
for col in columns
)
rows_html = "".join(str(TableRow(data=row)) for row in rows)
row_nodes = [TableRow(data=row) for row in rows]
rows_html = "".join(str(node) for node in row_nodes)
for node in row_nodes:
media = media + collect_media(node)
pagination_html = ""
if page_obj and elided_page_range:
@@ -995,7 +1012,8 @@ def SimpleTable(
f"<tr>{columns_html}</tr></thead>"
'<tbody class="dark:divide-y max-sm:[&_td:not(:first-child):not(:last-child)]:hidden">'
f"{rows_html}</tbody></table></div>"
f"{pagination_html}</div>"
f"{pagination_html}</div>",
media=media,
)
+7 -2
View File
@@ -12,6 +12,7 @@ from typing import TYPE_CHECKING
from django.contrib.messages import get_messages
from django.http import HttpRequest, HttpResponse
from django.middleware.csrf import get_token
from django.templatetags.static import static
from django.urls import reverse
from django.utils.html import conditional_escape
@@ -186,7 +187,7 @@ def _main_script(mastered: bool) -> str:
return _MAIN_SCRIPT_A + ("true" if mastered else "false") + _MAIN_SCRIPT_B
def Navbar(*, today_played: str, last_7_played: str, current_year: int) -> "Node":
def Navbar(*, today_played: str, last_7_played: str, current_year: int, csrf_token: str) -> "Node":
"""Top navigation bar.
Static chrome, so it's a single ``Safe`` node wrapping its markup rather
@@ -270,7 +271,10 @@ def Navbar(*, today_played: str, last_7_played: str, current_year: int) -> "Node
<a href="{reverse("games:stats_by_year", args=[current_year])}" class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Stats</a>
</li>
<li>
<a href="{reverse("logout")}" class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Log out</a>
<form method="post" action="{reverse("logout")}">
<input type="hidden" name="csrfmiddlewaretoken" value="{csrf_token}">
<button type="submit" class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Log out</button>
</form>
</li>
</ul>
</div>
@@ -309,6 +313,7 @@ def Page(
today_played=counts["today_played"],
last_7_played=counts["last_7_played"],
current_year=year,
csrf_token=get_token(request),
)
messages = [
+1
View File
@@ -12,6 +12,7 @@ services:
- PUID=${PUID:-1000}
- PGID=${PGID:-100}
- DATA_DIR=${DATA_DIR:-/home/timetracker/app/data}
- CREATE_DEFAULT_SUPERUSER=${CREATE_DEFAULT_SUPERUSER:-false}
ports:
- "${TIMETRACKER_EXTERNAL_PORT:-8000}:8000"
volumes:
+51
View File
@@ -0,0 +1,51 @@
# Custom Element API: Two patterns, one goal
## Pattern 1: Named builder (current, preferred)
A tag builder with auto-attached `Media`, created via `custom_element_builder()`:
```python
# definition (custom_elements.py)
SessionTimestampButtons = custom_element_builder("session-timestamp-buttons")
# usage (session.py)
SessionTimestampButtons(class_="form-row-button-group", hx_boost="false")[
Button(data_target="timestamp_start", data_type="now", size="xs")["Set to now"],
Button(data_target="timestamp_start", data_type="toggle", size="xs")["Toggle text"],
]
```
**Pros:** explicit dependency, visible import, fails loudly if builder deleted
**Cons:** one line of ceremony per element
## Pattern 2: Element + registry (proposed, not implemented)
A global `CUSTOM_ELEMENT_MEDIA` dict in `core.py` that maps tag names to their `Media`. `register_element()` populates it automatically at import time, so `Element("session-timestamp-buttons")` silently picks up its JS dependency:
```python
# definition (custom_elements.py)
register_element("session-timestamp-buttons", "SessionTimestampButtons", EmptyProps)
# CUSTOM_ELEMENT_MEDIA["session-timestamp-buttons"] = Media(js=("dist/elements/...",))
# usage (session.py) — no builder import needed
Element("session-timestamp-buttons",
[("class", "form-row-button-group"), ("hx-boost", "false")],
children=[...],
)
```
**Pros:** one universal API — `Div(...)`, `Button(...)`, `Element("custom-tag")` all same pattern
**Cons:** implicit dependency — deleting a `register_element()` call produces no error, just broken JS at runtime
## Recommendation
Start with Pattern 1 (named builders) — safe by default. Add Pattern 2 later if the ceremony becomes annoying. The two are **not mutually exclusive**: a named builder is just a thin wrapper around an `Element`; the registry can be added without changing any call sites.
## Quick reference
| Want | Write |
|------|-------|
| Plain HTML tag | `Div(class_="flex")["text"]` |
| Custom element (builder) | `SessionTimestampButtons(class_="...")[child]` |
| Raw element | `Element("custom-tag", attributes_list, children=[...])` |
| Builder from scratch | `custom_element_builder("tag-name")` |
+1
View File
@@ -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
+84
View File
@@ -0,0 +1,84 @@
import pytest
from django.urls import reverse
from playwright.sync_api import Page, expect
@pytest.fixture
def authenticated_page(live_server, page: Page, django_user_model) -> Page:
django_user_model.objects.create_user(username="tester", password="secret123")
page.goto(f"{live_server.url}{reverse('login')}")
page.fill('input[name="username"]', "tester")
page.fill('input[name="password"]', "secret123")
page.click('input[type="submit"]')
page.wait_for_url(f"{live_server.url}/tracker**")
return page
@pytest.mark.django_db
def test_game_status_selector_opens_and_patches(authenticated_page: Page, live_server):
from games.models import Game, Platform
platform = Platform.objects.create(name="PC", icon="pc")
game = Game.objects.create(name="Test Game", platform=platform, status="u")
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:list_games')}")
host = page.locator("game-status-selector").first
expect(host).to_be_attached()
host.locator("[data-toggle]").click()
expect(host.locator("[data-menu]")).to_be_visible()
with page.expect_response(
lambda r: "/status" in r.url and r.request.method == "PATCH"
):
host.locator('[data-option][data-value="f"]').click()
expect(host.locator("[data-menu]")).to_be_hidden()
game.refresh_from_db()
assert game.status == "f"
@pytest.mark.django_db
def test_session_device_selector_patches(authenticated_page: Page, live_server):
from games.models import Device, Game, Platform, Session
platform = Platform.objects.create(name="PC", icon="pc")
game = Game.objects.create(name="Test Game", platform=platform)
desktop = Device.objects.create(name="Desktop")
deck = Device.objects.create(name="Deck")
session = Session.objects.create(
game=game, device=desktop, timestamp_start="2025-01-01 00:00:00+00:00"
)
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:list_sessions')}")
host = page.locator("session-device-selector").first
expect(host).to_be_attached()
host.locator("[data-toggle]").click()
with page.expect_response(
lambda r: "/device" in r.url and r.request.method == "PATCH"
):
host.locator(f'[data-option][data-value="{deck.id}"]').click()
session.refresh_from_db()
assert session.device_id == deck.id
@pytest.mark.django_db
def test_play_event_row_increments(authenticated_page: Page, live_server):
from games.models import Game, Platform
platform = Platform.objects.create(name="PC", icon="pc")
game = Game.objects.create(name="Test Game", platform=platform)
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:view_game', args=[game.id])}")
host = page.locator("play-event-row").first
expect(host).to_be_attached()
host.locator("[data-toggle]").click()
with page.expect_response(
lambda r: "playevent" in r.url.lower() and r.request.method == "POST"
):
host.locator("[data-add-play]").click()
expect(host.locator("[data-count]")).to_have_text("1")
assert game.playevents.count() == 1
+1 -3
View File
@@ -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
+4 -6
View File
@@ -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
@@ -101,7 +97,9 @@ def test_range_slider_empty_max_thumb_does_not_jump_to_beginning(live_server, pa
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")
+10 -6
View File
@@ -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))
@@ -73,7 +71,9 @@ def test_string_filter_defaults_and_toggles(live_server, page):
# 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():
@@ -121,12 +121,16 @@ def test_string_filter_prefilled_states(live_server, page):
# 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
+12
View File
@@ -20,4 +20,16 @@ chown "$PUID:$PGID" /var/log/supervisor
python manage.py migrate
python manage.py collectstatic --clear --no-input
if [ "${CREATE_DEFAULT_SUPERUSER:-false}" = "true" ]; then
python manage.py shell -c "
from django.contrib.auth import get_user_model
User = get_user_model()
if not User.objects.filter(username='admin').exists():
User.objects.create_superuser('admin', '', 'admin')
print('Created default superuser: admin / admin')
"
fi
chown -R "$PUID:$PGID" /home/timetracker/app/data
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisor.conf
+3 -1
View File
@@ -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)
@@ -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}"))
@@ -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"),
),
]
@@ -0,0 +1,28 @@
# Generated by Django 6.0.5 on 2026-06-13 18:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0018_alter_session_timestamp_start"),
]
operations = [
migrations.AlterField(
model_name="filterpreset",
name="mode",
field=models.CharField(
choices=[
("games", "Games"),
("sessions", "Sessions"),
("purchases", "Purchases"),
("playevents", "Play Events"),
("devices", "Devices"),
("platforms", "Platforms"),
],
default="games",
max_length=50,
),
),
]
+10 -25
View File
@@ -1790,6 +1790,9 @@
.items-start {
align-items: flex-start;
}
.items-stretch {
align-items: stretch;
}
.justify-between {
justify-content: space-between;
}
@@ -1963,17 +1966,10 @@
border-top-right-radius: var(--radius-lg);
border-bottom-right-radius: var(--radius-lg);
}
.rounded-tr-md {
border-top-right-radius: var(--radius-md);
}
.rounded-b {
border-bottom-right-radius: var(--radius);
border-bottom-left-radius: var(--radius);
}
.rounded-b-md {
border-bottom-right-radius: var(--radius-md);
border-bottom-left-radius: var(--radius-md);
}
.border {
border-style: var(--tw-border-style);
border-width: 1px;
@@ -1982,10 +1978,6 @@
border-style: var(--tw-border-style);
border-width: 0px;
}
.border-0\! {
border-style: var(--tw-border-style) !important;
border-width: 0px !important;
}
.border-2 {
border-style: var(--tw-border-style);
border-width: 2px;
@@ -2774,9 +2766,6 @@
.line-through {
text-decoration-line: line-through;
}
.no-underline\! {
text-decoration-line: none !important;
}
.underline {
text-decoration-line: underline;
}
@@ -3162,6 +3151,13 @@
}
}
}
.hover\:bg-gray-700 {
&:hover {
@media (hover: hover) {
background-color: var(--color-gray-700);
}
}
}
.hover\:bg-green-500 {
&:hover {
@media (hover: hover) {
@@ -4200,17 +4196,6 @@
text-underline-offset: 4px;
}
}
.\[\&_li\:first-of-type_a\]\:rounded-none {
& li:first-of-type a {
border-radius: 0;
}
}
.\[\&_li\:last-of-type_a\]\:rounded-t-none {
& li:last-of-type a {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
}
.\[\&_td\:last-child\]\:text-right {
& td:last-child {
text-align: right;
-23
View File
@@ -1,23 +0,0 @@
import { toISOUTCString } from "./utils.js";
for (let button of document.querySelectorAll("[data-target]")) {
let target = button.getAttribute("data-target");
let type = button.getAttribute("data-type");
let targetElement = document.querySelector(`#id_${target}`);
button.addEventListener("click", (event) => {
event.preventDefault();
if (type == "now") {
targetElement.value = toISOUTCString(new Date());
} else if (type == "copy") {
const oppositeName =
targetElement.name == "timestamp_start"
? "timestamp_end"
: "timestamp_start";
document.querySelector(`[name='${oppositeName}']`).value =
targetElement.value;
} else if (type == "toggle") {
if (targetElement.type == "datetime-local") targetElement.type = "text";
else targetElement.type = "datetime-local";
}
});
}
+38
View File
@@ -0,0 +1,38 @@
import { onSwap } from "./utils.js";
onSwap("#year-picker-input", function(pickerEl) {
const selectedYear = pickerEl.dataset.selectedYear;
const urlTemplate = pickerEl.dataset.urlTemplate;
const currentYear = new Date().getFullYear();
const availableYears = new Set(
pickerEl.dataset.availableYears
.split(",")
.map(s => parseInt(s.trim()))
.filter(n => !isNaN(n))
);
const picker = new Datepicker(pickerEl, {
pickLevel: 2,
format: "yyyy",
minDate: new Date(1999, 0, 1),
maxDate: new Date(currentYear, 11, 31),
autohide: false,
orientation: "bottom end",
showOnClick: false,
showOnFocus: false,
beforeShowYear: (date) => ({ enabled: availableYears.has(date.getFullYear()) }),
});
pickerEl._pickerInstance = picker;
picker.element.addEventListener("changeDate", (event) => {
const year = event.detail.date?.getFullYear();
if (year && urlTemplate) {
window.location.href = urlTemplate.replace("__year__", year);
}
});
if (selectedYear) {
picker.dates = [new Date(parseInt(selectedYear), 0, 1)];
picker.update();
}
});
+7 -5
View File
@@ -4,14 +4,14 @@ from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from common.components import (
Fragment,
A,
AddForm,
Button,
ButtonGroup,
Icon,
paginated_table_content,
DeviceFilterBar,
Fragment,
Icon,
StyledButton,
paginated_table_content,
)
from common.layout import render_page
from common.time import dateformat, local_strftime
@@ -34,7 +34,9 @@ def list_devices(request: HttpRequest) -> HttpResponse:
devices, page_obj, elided_page_range = paginate(request, devices)
data = {
"header_action": A([], Button([], "Add device"), url_name="games:add_device"),
"header_action": A(href=reverse("games:add_device"))[
StyledButton()["Add device"]
],
"columns": [
"Name",
"Type",
+74 -77
View File
@@ -8,19 +8,18 @@ from django.middleware.csrf import get_token
from django.shortcuts import get_object_or_404, redirect
from django.template.defaultfilters import date as date_filter
from django.urls import reverse
from django.utils.safestring import SafeText, mark_safe
from django.utils.safestring import SafeText
from common.components import (
Fragment,
H1,
A,
AddForm,
Button,
ButtonGroup,
CsrfInput,
Div,
Element,
FilterBar,
Fragment,
GameStatus,
GameStatusSelector,
Icon,
@@ -35,11 +34,11 @@ from common.components import (
Safe,
SearchField,
SimpleTable,
StyledButton,
Ul,
paginated_table_content,
)
from common.components.primitives import Li, P, Span, Strong
from common.icons import get_icon
from common.layout import render_page
from common.time import (
dateformat,
@@ -91,12 +90,11 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
data = {
"header_action": Div(
children=[
class_="flex justify-between",
)[
SearchField(search_string=search_string),
A([], Button([], "Add game"), url_name="games:add_game"),
A(href=reverse("games:add_game"))[StyledButton()["Add game"]],
],
attributes=[("class", "flex justify-between")],
),
"columns": [
"Name",
"Sort Name",
@@ -173,7 +171,7 @@ def add_game(request: HttpRequest) -> HttpResponse:
AddForm(
form,
request=request,
additional_row=Button(
additional_row=StyledButton(
[],
"Submit & Create Purchase",
color="gray",
@@ -249,14 +247,14 @@ def _delete_game_confirmation_modal(
Div(
[("class", "items-center mt-5")],
[
Button(
StyledButton(
[("class", "w-full")],
"Delete",
color="red",
size="lg",
type="submit",
),
Button(
StyledButton(
[("class", "mt-0 w-full")],
"Cancel",
color="gray",
@@ -340,69 +338,69 @@ _STAT_SVGS = {
"playrange": '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5m-9-6h.008v.008H12v-.008ZM12 15h.008v.008H12V15Zm0 2.25h.008v.008H12v-.008ZM9.75 15h.008v.008H9.75V15Zm0 2.25h.008v.008H9.75v-.008ZM7.5 15h.008v.008H7.5V15Zm0 2.25h.008v.008H7.5v-.008Zm6.75-4.5h.008v.008h-.008v-.008Zm0 2.25h.008v.008h-.008V15Zm0 2.25h.008v.008h-.008v-.008Zm2.25-4.5h.008v.008H16.5v-.008Zm0 2.25h.008v.008H16.5V15Z" /></svg>',
}
_PLAYED_ROW_TEMPLATE = """<div class="flex gap-2 items-center" x-data="{ open: false }">
<span class="uppercase">Played</span>
<div class="inline-flex rounded-md shadow-2xs" role="group" x-data="{ played: @@PLAYED_COUNT@@ }">
<a href="@@ADD_PE@@">
<button type="button" class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
<span x-text="played"></span> times
</button>
</a>
<button type="button" x-on:click="open = !open" @click.outside="open = false" class="relative px-4 py-2 text-sm font-medium text-gray-900 bg-white border-e border-b border-t border-gray-200 rounded-e-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white align-middle hover:cursor-pointer">
@@ARROWDOWN@@
<div
class="absolute top-full -left-px w-auto whitespace-nowrap z-10 text-sm font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-tl-none border border-gray-200 dark:border-gray-700"
x-show="open"
>
<ul
class=""
>
<li class="px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-tr-md">
<a href="@@ADD_PE_FOR_GAME@@">Add playthrough...</a>
</li>
<li
x-on:click="createPlayEvent"
class="relative px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-b-md"
>
Played times +1
</li>
<script>
function createPlayEvent() {
this.played++;
// TODO: migrate to hx-post + hx-on::after-request for HTMX-native toast handling
fetchWithHtmxTriggers('@@API_CREATE@@', {
method: 'POST',
headers: { 'X-CSRFToken': '@@CSRF@@', 'Content-Type': 'application/json' },
body: '{"game_id": @@GAME_ID@@}'
})
.catch(() => {
this.played--;
console.error('Failed to record play');
});
}
</script>
</ul>
</div>
</button>
</div>
</div>"""
_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
from common.components.custom_elements import _PlayEventRow
from common.components.primitives import Button
played: int = 0
played = game.playevents.count()
count_button = A(href=reverse("games:add_playevent"))[
Button(class_=_PLAYED_BTN + " rounded-s-lg")[
Span(data_count="")[str(played)], " times"
]
]
menu = Div(data_menu="", hidden=True, class_=_PLAYED_MENU)[
Ul()[
Li(class_="px-4 py-2")[
A(href=reverse("games:add_playevent_for_game", args=[game.id]))[
"Add playthrough..."
]
],
Li(class_="px-4 py-2 cursor-pointer")[
Element(
"button",
[("type", "button"), ("data-add-play", "")],
children=["Played times +1"],
)
],
]
]
toggle = Element(
"button",
[
("type", "button"),
("data-toggle", ""),
("class", _PLAYED_BTN + " rounded-e-lg"),
],
[Icon("arrowdown")],
)
# Menu is a SIBLING of the toggle (not nested inside it): a <button> may not
# contain another <button>, and that invalid nesting makes the HTML parser
# close ancestors early, ejecting later page sections from their container.
toggle_group = Div(class_="relative inline-flex")[toggle, menu]
group = Div(class_="inline-flex items-stretch rounded-md shadow-2xs")[
count_button, toggle_group
]
return _PlayEventRow(
game_id=game.id,
csrf=get_token(request),
api_create_url=reverse("api-1.0.0:create_playevent"),
)[Div(class_="flex gap-2 items-center")[Span(class_="uppercase")["Played"], group]]
def _stat_popover(popover_id: str, tooltip: str, svg_key: str, value: str) -> SafeText:
@@ -686,10 +684,9 @@ def _sessions_section(game: Game, request: HttpRequest) -> SafeText:
header_action = Div(
children=[
A(
url_name="games:add_session",
children=Button(icon=True, size="xs", children=[Icon("play"), "LOG"]),
),
A(href=reverse("games:add_session"))[
StyledButton(icon=True, color="blue", size="xs")[Icon("plus")]
],
A(
href=reverse(
"games:list_sessions_start_session_from_session",
@@ -698,7 +695,7 @@ def _sessions_section(game: Game, request: HttpRequest) -> SafeText:
children=Popover(
popover_content=last_session.game.name,
children=[
Button(
StyledButton(
icon=True,
color="gray",
size="xs",
@@ -784,7 +781,7 @@ def _history_section(game: Game) -> SafeText:
)
_GET_SESSION_COUNT_SCRIPT = mark_safe(
_GET_SESSION_COUNT_SCRIPT = Safe(
"<script>\n"
" function getSessionCount() {\n"
" return document.getElementById('session-count')"
+6 -6
View File
@@ -4,14 +4,14 @@ from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from common.components import (
Fragment,
A,
AddForm,
Button,
ButtonGroup,
Fragment,
Icon,
paginated_table_content,
PlatformFilterBar,
StyledButton,
paginated_table_content,
)
from common.layout import render_page
from common.time import dateformat, local_strftime
@@ -35,9 +35,9 @@ def list_platforms(request: HttpRequest) -> HttpResponse:
platforms, page_obj, elided_page_range = paginate(request, platforms)
data = {
"header_action": A(
[], Button([], "Add platform"), url_name="games:add_platform"
),
"header_action": A(href=reverse("games:add_platform"))[
StyledButton()["Add platform"]
],
"columns": [
"Name",
"Icon",
+6 -7
View File
@@ -9,17 +9,16 @@ from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.urls import reverse
from common.components import (
Fragment,
A,
AddForm,
Button,
ButtonGroup,
Fragment,
Icon,
ModuleScript,
paginated_table_content,
PlayEventFilterBar,
StyledButton,
paginated_table_content,
)
from common.layout import render_page
from common.time import dateformat, format_duration, local_strftime
@@ -87,9 +86,9 @@ def create_playevent_tabledata(
for row in row_list
]
return {
"header_action": A(
[], Button([], "Add play event"), url_name="games:add_playevent"
),
"header_action": A(href=reverse("games:add_playevent"))[
StyledButton()["Add play event"]
],
"columns": list(filtered_column_list),
"rows": filtered_row_list,
}
+8 -8
View File
@@ -14,14 +14,13 @@ from django.utils.safestring import SafeText, mark_safe
from django.views.decorators.http import require_POST
from common.components import (
Fragment,
A,
AddForm,
Button,
ButtonGroup,
CsrfInput,
Div,
Element,
Fragment,
GameLink,
Icon,
LinkedPurchase,
@@ -30,6 +29,7 @@ from common.components import (
Node,
PriceConverted,
PurchasePrice,
StyledButton,
TableRow,
paginated_table_content,
)
@@ -110,9 +110,9 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
purchases, page_obj, elided_page_range = paginate(request, purchases)
data = {
"header_action": A(
[], Button([], "Add purchase"), url_name="games:add_purchase"
),
"header_action": A(href=reverse("games:add_purchase"))[
StyledButton()["Add purchase"]
],
"columns": [
"Name",
"Type",
@@ -153,7 +153,7 @@ def _purchase_additional_row() -> SafeText:
Td(),
Td(
children=[
Button(
StyledButton(
[],
"Submit & Create Session",
color="gray",
@@ -319,14 +319,14 @@ def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> Node:
Div(
[("class", "items-center mt-5")],
[
Button(
StyledButton(
[("class", "w-full")],
"Refund",
color="blue",
size="lg",
type="submit",
),
Button(
StyledButton(
[("class", "mt-0 w-full")],
"Cancel",
color="gray",
+23 -38
View File
@@ -11,12 +11,11 @@ from django.utils import timezone
from django.utils.safestring import SafeText, mark_safe
from common.components import (
Fragment,
A,
AddForm,
Button,
ButtonGroup,
Div,
Fragment,
Icon,
ModuleScript,
NameWithIcon,
@@ -25,6 +24,8 @@ from common.components import (
Safe,
SearchField,
SessionDeviceSelector,
SessionTimestampButtons,
StyledButton,
paginated_table_content,
)
from common.components.primitives import Span, Td, Tr
@@ -76,13 +77,13 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
Div(
children=[
A(
url_name="games:add_session",
children=Button(
href=reverse("games:add_session"),
)[
StyledButton(
icon=True,
size="xs",
children=[Icon("play"), "LOG"],
),
),
)[Icon("play"), "LOG"]
],
A(
href=reverse(
"games:list_sessions_start_session_from_session",
@@ -91,7 +92,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
children=Popover(
popover_content=last_session.game.name,
children=[
Button(
StyledButton(
icon=True,
color="gray",
size="xs",
@@ -208,32 +209,20 @@ def _session_fields(form) -> Fragment:
this_side = "start" if field.name == "timestamp_start" else "end"
other_side = "end" if field.name == "timestamp_start" else "start"
children.append(
Span(
attributes=[
(
"class",
"form-row-button-group flex-row gap-3 justify-start mt-3",
),
("hx-boost", "false"),
SessionTimestampButtons(
class_="form-row-button-group flex-row gap-3 justify-start mt-3",
hx_boost="false",
)[
StyledButton(data_target=field.name, data_type="now", size="xs")[
"Set to now"
],
children=[
Button(
[("data-target", field.name), ("data-type", "now")],
"Set to now",
size="xs",
),
Button(
[("data-target", field.name), ("data-type", "toggle")],
"Toggle text",
size="xs",
),
Button(
[("data-target", field.name), ("data-type", "copy")],
f"Copy {this_side} value to {other_side}",
size="xs",
),
StyledButton(data_target=field.name, data_type="toggle", size="xs")[
"Toggle text"
],
)
StyledButton(data_target=field.name, data_type="copy", size="xs")[
f"Copy {this_side} value to {other_side}"
],
]
)
rows.append(Div(children=children))
return Fragment(*rows, separator="\n")
@@ -265,9 +254,7 @@ def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
request,
AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
title="Add New Session",
scripts=mark_safe(
ModuleScript("search_select.js") + ModuleScript("add_session.js")
),
scripts=mark_safe(ModuleScript("search_select.js")),
)
@@ -282,9 +269,7 @@ def edit_session(request: HttpRequest, session_id: int) -> HttpResponse:
request,
AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
title="Edit Session",
scripts=mark_safe(
ModuleScript("search_select.js") + ModuleScript("add_session.js")
),
scripts=mark_safe(ModuleScript("search_select.js")),
)
+4 -4
View File
@@ -9,6 +9,7 @@ from django.template.defaultfilters import date as date_filter
from django.template.defaultfilters import floatformat
from django.urls import reverse
from django.utils.html import conditional_escape
from common.components import (
A,
Div,
@@ -100,10 +101,9 @@ def _year_nav(year, year_range, url_template) -> Node:
else "text-body hover:text-heading underline decoration-dotted"
)
alltime_btn = A(
url_name="games:stats_alltime",
attributes=[("class", alltime_classes)],
children=["All-time stats"],
)
href=reverse("games:stats_alltime"),
class_=alltime_classes,
)["All-time stats"]
picker = YearPicker(
year=year_int,
available_years=tuple(year_range or []),
+3 -3
View File
@@ -7,10 +7,10 @@ from django.utils.safestring import SafeText
from common.components import (
A,
AddForm,
Button,
CsrfInput,
Div,
Element,
StyledButton,
paginated_table_content,
)
from common.components.primitives import P
@@ -79,12 +79,12 @@ def _delete_statuschange_content(statuschange, request: HttpRequest) -> SafeText
P(
children=["Are you sure you want to delete this status change?"],
),
Button(
StyledButton(
[("class", "w-full")], "Delete", color="red", type="submit", size="lg"
),
A(
[("class", "")],
Button([("class", "w-full")], "Cancel", color="gray"),
StyledButton([("class", "w-full")], "Cancel", color="gray"),
href=reverse("games:view_game", args=[statuschange.game.id]),
),
],
+2 -1
View File
@@ -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",
+3329
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -1,2 +0,0 @@
allowBuilds:
'@parcel/watcher': false
+7 -19
View File
@@ -6,7 +6,7 @@ from django.test import SimpleTestCase
from django.utils.safestring import SafeText, mark_safe
from common import components
from games.models import Platform, Game, Purchase, Session
from games.models import Game, Platform, Purchase, Session
# Component builders return lazy ``Node`` objects; these tests assert on rendered
# HTML, so node-returning calls are wrapped in ``str(...)`` at the call site
@@ -224,31 +224,18 @@ class ComponentReturnTypeTest(unittest.TestCase):
result = str(components.A([], "x", href="/literal/path"))
self.assertIn('href="/literal/path"', result)
def test_a_url_name_reversed(self):
from unittest.mock import patch
with patch(
"common.components.primitives.reverse", return_value="/resolved/url"
):
result = str(components.A([], "link", url_name="some_name"))
self.assertIn('href="/resolved/url"', result)
def test_a_no_url_or_href(self):
result = str(components.A([], "link"))
self.assertIn("<a>link</a>", result)
self.assertNotIn("href=", result)
def test_a_both_url_name_and_href_raises(self):
with self.assertRaises(ValueError):
str(components.A(href="/path", url_name="some_name"))
def test_button_returns_safe_text(self):
result = str(components.Button([], "click"))
result = str(components.StyledButton([], "click"))
self.assertIsInstance(result, SafeText)
self.assertIn("<button", result)
def test_button_default_colors(self):
result = str(components.Button([], "click"))
result = str(components.StyledButton([], "click"))
self.assertIn("text-white bg-brand", result)
def test_name_with_icon_no_link(self):
@@ -269,7 +256,7 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
def test_component_output_starts_with_tag(self):
for label, html in [
("A", str(components.A(href="/foo", children=["link"]))),
("Button", str(components.Button([], "click"))),
("Button", str(components.StyledButton([], "click"))),
("Div", str(components.Div([], ["hello"]))),
("Input", str(components.Input())),
("ButtonGroup", str(components.ButtonGroup([]))),
@@ -294,7 +281,7 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
def test_button_with_icon_children_not_escaped(self):
result = str(
components.Button(
components.StyledButton(
icon=True,
size="xs",
children=[components.Icon("play"), "LOG"],
@@ -307,7 +294,7 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
components.Popover(
popover_content="test tooltip",
children=[
components.Button(
components.StyledButton(
icon=True,
color="gray",
size="xs",
@@ -923,6 +910,7 @@ class ComponentPrimitivesTest(SimpleTestCase):
class PrimitiveWidgetsTest(SimpleTestCase):
def test_mixin_applies_widget_to_boolean_fields_only(self):
from django import forms
from games.forms import PrimitiveCheckboxWidget, PrimitiveWidgetsMixin
class DummyForm(PrimitiveWidgetsMixin, forms.Form):
+95
View File
@@ -0,0 +1,95 @@
import unittest
from typing import TypedDict
from common.components import custom_element_builder, render
from common.components.custom_elements import (
ElementSpec,
_ts_for_spec,
register_element,
)
class SampleProps(TypedDict):
game_id: int
status: str
is_on: bool
class CustomElementBuilderTest(unittest.TestCase):
def test_serializes_props_to_kebab_attributes(self):
x_sample = custom_element_builder("x-sample")
html = render(x_sample(game_id=3, status="f")["hi"])
self.assertIn("<x-sample", html)
self.assertIn('game-id="3"', html)
self.assertIn('status="f"', html)
self.assertIn(">hi</x-sample>", html)
def test_declares_compiled_module_media(self):
from common.components import collect_media
x_sample = custom_element_builder("x-sample")
node = x_sample(game_id=3)
self.assertEqual(collect_media(node).js, ("dist/elements/x-sample.js",))
class CodegenTest(unittest.TestCase):
def test_emits_interface_and_reader(self):
spec = ElementSpec("x-sample", "XSample", SampleProps)
ts = _ts_for_spec(spec)
self.assertIn("export interface XSampleProps {", ts)
self.assertIn("gameId: number;", ts)
self.assertIn("status: string;", ts)
self.assertIn("isOn: boolean;", ts)
self.assertIn(
"export function readXSampleProps(el: HTMLElement): XSampleProps", ts
)
self.assertIn('Number(el.getAttribute("game-id"))', ts)
self.assertIn('el.getAttribute("status") ?? ""', ts)
self.assertIn('el.getAttribute("is-on") === "true"', ts)
class RegistryTest(unittest.TestCase):
def test_register_adds_spec(self):
from common.components.custom_elements import ELEMENT_REGISTRY
before = len(ELEMENT_REGISTRY)
register_element("x-reg-test", "XRegTest", SampleProps)
self.assertEqual(len(ELEMENT_REGISTRY), before + 1)
self.assertEqual(ELEMENT_REGISTRY[-1].tag, "x-reg-test")
class GameStatusSelectorRenderTest(unittest.TestCase):
def test_emits_tag_props_and_media(self):
from types import SimpleNamespace
from common.components import GameStatusSelector, collect_media, render
game = SimpleNamespace(id=7, status="f", get_status_display=lambda: "Finished")
node = GameStatusSelector(game, [("u", "Unplayed"), ("f", "Finished")], "tok")
html = render(node)
self.assertIn("<game-status-selector", html)
self.assertIn('game-id="7"', html)
self.assertIn('status="f"', html)
self.assertIn('csrf="tok"', html)
self.assertIn("data-option", html)
self.assertIn('data-value="u"', html)
self.assertNotIn("x-data", html) # no Alpine left
self.assertIn("dist/elements/game-status-selector.js", collect_media(node).js)
class SessionDeviceSelectorRenderTest(unittest.TestCase):
def test_emits_tag_and_options(self):
from types import SimpleNamespace
from common.components import SessionDeviceSelector, render
session = SimpleNamespace(id=4, device=SimpleNamespace(name="Desktop"))
devices = [
SimpleNamespace(id=1, name="Desktop"),
SimpleNamespace(id=2, name="Deck"),
]
html = render(SessionDeviceSelector(session, devices, "tok"))
self.assertIn("<session-device-selector", html)
self.assertIn('session-id="4"', html)
self.assertIn('data-value="2"', html)
self.assertNotIn("x-data", html)
-1
View File
@@ -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)
-1
View File
@@ -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"))
+10 -4
View File
@@ -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(
{
+43
View File
@@ -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"]]),
'<div class="card"><span>hi</span></div>',
)
def test_getitem_multiple_children(self):
from common.components import Div
self.assertEqual(render(Div()["a", "b"]), "<div>a\nb</div>")
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()
+10 -2
View File
@@ -139,7 +139,7 @@ class RenderedPagesTest(TestCase):
def test_add_session_form_has_timestamp_helpers(self):
html = self.get("games:add_session").content.decode()
self.assertIn("add_session.js", html)
self.assertIn("session-timestamp-buttons", html)
for marker in [
"Set to now",
"Toggle text",
@@ -168,7 +168,7 @@ class RenderedPagesTest(TestCase):
"Platform",
'id="history-container"',
"status-changed from:body",
"createPlayEvent", # the played-row Alpine dropdown script
"<play-event-row", # the played-row custom element
'hx-target="#global-modal-container"', # delete trigger
"Purchases",
"Sessions",
@@ -179,6 +179,14 @@ class RenderedPagesTest(TestCase):
self.assertNoEscapedTags(html)
self.assertEqual(html.count("<div"), html.count("</div>"))
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("<play-event-row", html)
self.assertIn('game-id="', html)
self.assertNotIn("@@", html) # token-replace hack gone
self.assertNotIn("createPlayEvent", html) # the old Alpine fn is gone
def test_view_game_empty_sections(self):
"""A game with no sessions/purchases/etc shows the empty messages."""
lonely = Game.objects.create(name="Lonely Game", platform=self.platform)
+50
View File
@@ -0,0 +1,50 @@
export interface DropdownConfig {
patchUrl: string;
bodyKey: string; // server field name, e.g. "status" or "device_id"
event: string; // dispatched on document.body after a successful PATCH
csrf: string;
numericValue?: boolean; // parse the option value as a number
}
// Wires a light-DOM value-selector dropdown that lives inside `host`.
// Markup hooks (rendered server-side): [data-toggle], [data-menu],
// [data-label], and one or more [data-option][data-value].
export function initDropdown(host: HTMLElement, config: DropdownConfig): void {
const toggle = host.querySelector<HTMLElement>("[data-toggle]");
const menu = host.querySelector<HTMLElement>("[data-menu]");
const label = host.querySelector<HTMLElement>("[data-label]");
if (!toggle || !menu || !label) return;
const close = () => {
menu.hidden = true;
};
toggle.addEventListener("click", (event) => {
event.stopPropagation();
menu.hidden = !menu.hidden;
});
document.addEventListener("click", (event) => {
if (!host.contains(event.target as Node)) close();
});
host.querySelectorAll<HTMLElement>("[data-option]").forEach((option) => {
option.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const raw = option.dataset.value ?? "";
label.innerHTML = option.innerHTML;
close();
const body: Record<string, unknown> = {
[config.bodyKey]: config.numericValue ? Number(raw) : raw,
};
window
.fetchWithHtmxTriggers(config.patchUrl, {
method: "PATCH",
headers: { "Content-Type": "application/json", "X-CSRFToken": config.csrf },
body: JSON.stringify(body),
})
.then(() => document.body.dispatchEvent(new CustomEvent(config.event)))
.catch(() => console.error("Failed to update", config.patchUrl));
});
});
}
+16
View File
@@ -0,0 +1,16 @@
import { readGameStatusSelectorProps } from "../generated/props.js";
import { initDropdown } from "./dropdown.js";
class GameStatusSelectorElement extends HTMLElement {
connectedCallback(): void {
const props = readGameStatusSelectorProps(this);
initDropdown(this, {
patchUrl: `/api/games/${props.gameId}/status`,
bodyKey: "status",
event: "status-changed",
csrf: props.csrf,
});
}
}
customElements.define("game-status-selector", GameStatusSelectorElement);
+42
View File
@@ -0,0 +1,42 @@
import { readPlayEventRowProps } from "../generated/props.js";
class PlayEventRowElement extends HTMLElement {
connectedCallback(): void {
const props = readPlayEventRowProps(this);
const toggle = this.querySelector<HTMLElement>("[data-toggle]");
const menu = this.querySelector<HTMLElement>("[data-menu]");
const count = this.querySelector<HTMLElement>("[data-count]");
const addPlay = this.querySelector<HTMLElement>("[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);
+17
View File
@@ -0,0 +1,17 @@
import { readSessionDeviceSelectorProps } from "../generated/props.js";
import { initDropdown } from "./dropdown.js";
class SessionDeviceSelectorElement extends HTMLElement {
connectedCallback(): void {
const props = readSessionDeviceSelectorProps(this);
initDropdown(this, {
patchUrl: `/api/session/${props.sessionId}/device`,
bodyKey: "device_id",
event: "device-changed",
csrf: props.csrf,
numericValue: true,
});
}
}
customElements.define("session-device-selector", SessionDeviceSelectorElement);
+49
View File
@@ -0,0 +1,49 @@
// import { toISOUTCString } from "../../games/static/js/utils.js";
/**
* @description Formats Date to a UTC string accepted by the datetime-local input field.
* @param {Date} date
* @returns {string}
*/
function toISOUTCString(date: Date): string {
function stringAndPad(number: number): string {
return number.toString().padStart(2, "0");
}
const year = date.getFullYear();
const month = stringAndPad(date.getMonth() + 1);
const day = stringAndPad(date.getDate());
const hours = stringAndPad(date.getHours());
const minutes = stringAndPad(date.getMinutes());
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
class SessionTimestampButtonsElement extends HTMLElement {
connectedCallback(): void {
for (const button of this.querySelectorAll("[data-target]")) {
const target = button.getAttribute("data-target");
const type = button.getAttribute("data-type");
if (!target || !type) continue;
const targetElement = document.querySelector(`#id_${target}`);
if (!(targetElement instanceof HTMLInputElement)) return;
button.addEventListener("click", (event) => {
event.preventDefault();
if (type == "now") {
targetElement.value = toISOUTCString(new Date());
} else if (type == "copy") {
const oppositeName =
targetElement.name == "timestamp_start"
? "timestamp_end"
: "timestamp_start";
const opposite = document.querySelector(`[name='${oppositeName}']`);
if (!(opposite instanceof HTMLInputElement)) return;
opposite.value = targetElement.value;
} else if (type == "toggle") {
if (targetElement.type == "datetime-local") targetElement.type = "text";
else targetElement.type = "datetime-local";
}
});
}
}
}
customElements.define("session-timestamp-buttons", SessionTimestampButtonsElement);
+7
View File
@@ -0,0 +1,7 @@
export {};
declare global {
interface Window {
fetchWithHtmxTriggers(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
}
}
+14
View File
@@ -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"]
}