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
CSRF_TRUSTED_ORIGINS=https://tracker.kucharczyk.xyz 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 - name: Install dependencies
run: uv sync --frozen 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 - name: Install Playwright browsers
run: uv run playwright install --with-deps chromium run: uv run playwright install --with-deps chromium
+5 -1
View File
@@ -4,7 +4,6 @@ __pycache__
.venv/ .venv/
node_modules node_modules
package-lock.json package-lock.json
pnpm-lock.yaml
db.sqlite3 db.sqlite3
data/ data/
/static/ /static/
@@ -13,3 +12,8 @@ dist/
.python-version .python-version
.direnv .direnv
.hermes/ .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`, …) - `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. - **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 ### 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. 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 \ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev 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 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 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 Caddyfile /etc/caddy/Caddyfile
COPY --chown=timetracker:timetracker supervisor.conf /etc/supervisor/conf.d/supervisor.conf COPY --chown=timetracker:timetracker supervisor.conf /etc/supervisor/conf.d/supervisor.conf
COPY --chown=timetracker:timetracker entrypoint.sh / COPY --chown=timetracker:timetracker entrypoint.sh /
+14 -4
View File
@@ -25,12 +25,22 @@ init:
server: server:
uv run python -Wa manage.py runserver 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: dev:
@pnpm concurrently \ @pnpm concurrently \
--names "Django,Tailwind" \ --names "Django,Tailwind,TS" \
--prefix-colors "blue,green" \ --prefix-colors "blue,green,magenta" \
"uv run python -Wa manage.py runserver" \ "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: caddy:
@@ -85,7 +95,7 @@ format:
format-check: format-check:
uv run ruff format --check uv run ruff format --check
check: lint format-check test check: lint format-check ts-check test
date: date:
uv run python -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))' 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, randomid,
render, render,
) )
from common.components.custom_elements import SessionTimestampButtons, register_element
from common.components.date_range_picker import ( from common.components.date_range_picker import (
DateRangeCalendar, DateRangeCalendar,
DateRangeField, DateRangeField,
@@ -47,7 +48,6 @@ from common.components.primitives import (
H1, H1,
A, A,
AddForm, AddForm,
Button,
ButtonGroup, ButtonGroup,
Checkbox, Checkbox,
CsrfInput, CsrfInput,
@@ -67,6 +67,7 @@ from common.components.primitives import (
SimpleTable, SimpleTable,
Span, Span,
StaticScript, StaticScript,
StyledButton,
TableHeader, TableHeader,
TableRow, TableRow,
TableTd, TableTd,
@@ -76,6 +77,7 @@ from common.components.primitives import (
Tr, Tr,
Ul, Ul,
YearPicker, YearPicker,
custom_element_builder,
paginated_table_content, paginated_table_content,
) )
from common.components.search_select import ( from common.components.search_select import (
@@ -91,6 +93,9 @@ from common.utils import truncate
__all__ = [ __all__ = [
"truncate", "truncate",
"BaseComponent", "BaseComponent",
"register_element",
"SessionTimestampButtons",
"custom_element_builder",
"Element", "Element",
"Fragment", "Fragment",
"Media", "Media",
@@ -104,7 +109,7 @@ __all__ = [
"randomid", "randomid",
"A", "A",
"AddForm", "AddForm",
"Button", "StyledButton",
"ButtonGroup", "ButtonGroup",
"Checkbox", "Checkbox",
"CsrfInput", "CsrfInput",
+10
View File
@@ -235,6 +235,16 @@ class Element(Node):
children = [children] children = [children]
self.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: def collect_media(self) -> Media:
media = self.media media = self.media
for child in self.children: 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``. ``games/static/js/date_range_picker.js``.
""" """
from common.components.core import Element, HTMLAttribute, Media, Node, Safe from common.components.core import Element, HTMLAttribute, Media, Node, Safe
from common.components.primitives import Div, Input, Span from common.components.primitives import Div, Input, Span
from common.time import DatePartSpec, date_parts 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 return values
def _segment_input( def _segment_input(*, part: DatePartSpec, side: str, label: str, value: str) -> Node:
*, part: DatePartSpec, side: str, label: str, value: str
) -> Node:
side_label = "from" if side == "min" else "to" side_label = "from" if side == "min" else "to"
return Input( return Input(
attributes=[ attributes=[
+92 -123
View File
@@ -209,131 +209,100 @@ def PurchasePrice(purchase) -> Node:
) )
def GameStatusSelector(game, game_statuses, csrf_token: str) -> Node: _SELECTOR_MENU_CLASS = (
"""Alpine.js dropdown to change a game's status.""" "absolute top-[105%] left-0 w-full whitespace-nowrap z-10 text-sm "
options_html = "\n".join( "font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-t-none "
f"<template x-if=\"status == '{value}'\">" "border border-gray-200 dark:border-gray-700"
f"{GameStatus(status=value, children=[label], display='flex')}" )
f"</template>" _SELECTOR_TOGGLE_CLASS = (
for value, label in game_statuses "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 "
list_items = "\n".join( "dark:hover:text-white dark:hover:bg-gray-700 hover:cursor-pointer"
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 ' _SELECTOR_OPTION_CLASS = (
f'dark:focus:ring-blue-500 dark:focus:text-white rounded-sm no-underline! border-0!" ' "block w-full text-left px-4 py-2 rounded-sm cursor-pointer "
f":class=\"{{'font-bold': status === '{value}'}}\">" "hover:bg-gray-700 hover:text-white dark:hover:bg-gray-700 "
f"{GameStatus(status=value, children=[label], display='flex', class_='text-slate-300')}" "dark:hover:text-white border-0"
f"</a></li>" )
for value, label in game_statuses
)
return Safe(f"""
<div class="flex gap-2 items-center" def GameStatusSelector(game, game_statuses, csrf_token: str) -> Node:
x-data="{{ """Light-DOM custom element; behavior in ts/elements/game-status-selector.ts."""
status: '{game.status}', from common.components.core import Element
status_display: '{game.get_status_display()}', from common.components.custom_elements import _GameStatusSelector, GameStatusSelectorProps
open: false, from common.components.primitives import Li, Ul
saving: false,
setStatus(newStatus, newStatusDisplay) {{ options = [
this.status = newStatus; Li()[
this.status_display = newStatusDisplay; Element(
this.saving = true; "button",
fetchWithHtmxTriggers(`/api/games/{game.id}/status`, {{ [
method: 'PATCH', ("type", "button"),
headers: {{ ("data-option", ""),
'Content-Type': 'application/json', ("data-value", str(value)),
'X-CSRFToken': '{csrf_token}' ("class", _SELECTOR_OPTION_CLASS),
}}, ],
body: JSON.stringify({{ status: newStatus }}) GameStatus(status=value, children=[label], display="flex"),
}}) )
.then(() => {{ ]
document.body.dispatchEvent(new CustomEvent('status-changed')); for value, label in game_statuses
}}) ]
.catch(() => {{ current_label = Span(data_label="")[
console.error('Failed to update status'); GameStatus(
}}) status=game.status,
.finally(() => this.saving = false); children=[game.get_status_display()],
}} display="flex",
}}"> )
{_dropdown_button_html(options_html, list_items)} ]
</div> 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: def SessionDeviceSelector(session, session_devices, csrf_token: str) -> Node:
"""Alpine.js dropdown to change a session's device.""" """Light-DOM custom element; behavior in ts/elements/session-device-selector.ts."""
device_id = session.device_id or "null" from common.components.core import Element
device_name = (session.device.name if session.device else "Unknown").replace( from common.components.custom_elements import _SessionDeviceSelector, SessionDeviceSelectorProps
"'", "\\'" from common.components.primitives import Li, Ul
)
current_name = session.device.name if session.device else "Unknown"
list_items = "\n".join( options = [
f'<li><a href="#" @click.prevent.stop="setDevice({d.id}, \'{d.name.replace(chr(39), chr(92) + chr(39))}\'); open = false;" ' Li()[
f'class="block px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 ' Element(
f'dark:focus:ring-blue-500 dark:focus:text-white rounded-sm no-underline! border-0!" ' "button",
f":class=\"{{'font-bold': deviceId === {d.id}}}\">{d.name}</a></li>" [
for d in session_devices ("type", "button"),
) ("data-option", ""),
("data-value", str(device.id)),
return Safe(f""" ("class", _SELECTOR_OPTION_CLASS),
<div class="flex gap-2 items-center" ],
x-data="{{ children=[device.name],
originalDeviceId: {device_id}, )
originalDeviceName: '{device_name}', ]
deviceId: {device_id}, for device in session_devices
deviceName: '{device_name}', ]
open: false, toggle = Element(
saving: false, "button",
setDevice(newDeviceId, newDeviceName) {{ [("type", "button"), ("data-toggle", ""), ("class", _SELECTOR_TOGGLE_CLASS)],
this.deviceId = newDeviceId; Span(class_="flex flex-row gap-4 justify-between items-center")[
this.deviceName = newDeviceName; Span(data_label="")[current_name], Icon("arrowdown")
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>"
) )
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 "" return ""
def _enum_filter( def _enum_filter(field_name: str, options, choice: FilterChoice, *, nullable) -> Node:
field_name: str, options, choice: FilterChoice, *, nullable
) -> Node:
"""A FilterSelect over a small, fully pre-rendered option set (enum field). """A FilterSelect over a small, fully pre-rendered option set (enum field).
Enum fields are single-valued, so no M2M modifiers (all/only are 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.middleware.csrf import get_token
from django.templatetags.static import static from django.templatetags.static import static
from django.urls import reverse
from django.utils.html import conditional_escape from django.utils.html import conditional_escape
from django.utils.safestring import SafeText, mark_safe from django.utils.safestring import SafeText, mark_safe
@@ -25,6 +24,7 @@ from common.components.core import (
Safe, Safe,
as_attributes, as_attributes,
as_children, as_children,
collect_media,
randomid, randomid,
) )
from common.icons import get_icon 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. # tag name is data, not a separate class/function body. Add a tag = one line.
def _html_element(tag_name: str): def _attrs_from_kwargs(attrs: dict[str, object]) -> list[HTMLAttribute]:
"""Build a generic element builder for ``tag_name`` (the whitelist factory).""" """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( def element(
attributes: Attributes | None = None, attributes: Attributes | None = None,
children: Children = None, children: Children = None,
**attrs: object,
) -> Element: ) -> 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.__name__ = element.__qualname__ = tag_name[:1].upper() + tag_name[1:]
element.__doc__ = f"Builder for the <{tag_name}> element." element.__doc__ = f"Builder for the <{tag_name}> element."
return element return element
A = _html_element("a")
Button = _html_element("button")
Div = _html_element("div") Div = _html_element("div")
P = _html_element("p") P = _html_element("p")
Ul = _html_element("ul") Ul = _html_element("ul")
@@ -186,35 +219,7 @@ def PopoverTruncated(
return input_string return input_string
def A( def StyledButton(
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(
attributes: Attributes | None = None, attributes: Attributes | None = None,
children: Children = None, children: Children = None,
size: str = "base", size: str = "base",
@@ -227,8 +232,9 @@ def Button(
title: str = "", title: str = "",
onclick: str = "", onclick: str = "",
name: str = "", name: str = "",
**attrs: object,
) -> Element: ) -> Element:
attributes = as_attributes(attributes) attributes = as_attributes(attributes) + _attrs_from_kwargs(attrs)
children = children or [] children = children or []
# Separate custom class from other generic attributes # Separate custom class from other generic attributes
@@ -650,7 +656,7 @@ def AddForm(
children=[ children=[
CsrfInput(request), CsrfInput(request),
field_markup, field_markup,
Div(children=[Button(submit_attrs, "Submit", type="submit")]), Div(children=[StyledButton(submit_attrs, "Submit", type="submit")]),
Div( Div(
[("class", "submit-button-container")], [("class", "submit-button-container")],
[additional_row] if additional_row else [], [additional_row] if additional_row else [],
@@ -971,15 +977,26 @@ def SimpleTable(
columns = columns or [] columns = columns or []
rows = rows 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 = "" header_html = ""
if header_action: 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( columns_html = "".join(
f'<th scope="col" class="px-6 py-3">{conditional_escape(col)}</th>' f'<th scope="col" class="px-6 py-3">{conditional_escape(col)}</th>'
for col in columns 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 = "" pagination_html = ""
if page_obj and elided_page_range: if page_obj and elided_page_range:
@@ -995,7 +1012,8 @@ def SimpleTable(
f"<tr>{columns_html}</tr></thead>" f"<tr>{columns_html}</tr></thead>"
'<tbody class="dark:divide-y max-sm:[&_td:not(:first-child):not(:last-child)]:hidden">' '<tbody class="dark:divide-y max-sm:[&_td:not(:first-child):not(:last-child)]:hidden">'
f"{rows_html}</tbody></table></div>" 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.contrib.messages import get_messages
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.middleware.csrf import get_token
from django.templatetags.static import static from django.templatetags.static import static
from django.urls import reverse from django.urls import reverse
from django.utils.html import conditional_escape 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 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. """Top navigation bar.
Static chrome, so it's a single ``Safe`` node wrapping its markup rather 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> <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>
<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> </li>
</ul> </ul>
</div> </div>
@@ -309,6 +313,7 @@ def Page(
today_played=counts["today_played"], today_played=counts["today_played"],
last_7_played=counts["last_7_played"], last_7_played=counts["last_7_played"],
current_year=year, current_year=year,
csrf_token=get_token(request),
) )
messages = [ messages = [
+1
View File
@@ -12,6 +12,7 @@ services:
- PUID=${PUID:-1000} - PUID=${PUID:-1000}
- PGID=${PGID:-100} - PGID=${PGID:-100}
- DATA_DIR=${DATA_DIR:-/home/timetracker/app/data} - DATA_DIR=${DATA_DIR:-/home/timetracker/app/data}
- CREATE_DEFAULT_SUPERUSER=${CREATE_DEFAULT_SUPERUSER:-false}
ports: ports:
- "${TIMETRACKER_EXTERNAL_PORT:-8000}:8000" - "${TIMETRACKER_EXTERNAL_PORT:-8000}:8000"
volumes: 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. # synchronous operations inside the async context safely.
os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true") os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true")
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def browser_type_launch_args(browser_type_launch_args): 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 # 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}))" ".dispatchEvent(new Event('submit', {cancelable: true}))"
) )
parsed = _filter_from_url(page.url) parsed = _filter_from_url(page.url)
assert parsed == { assert parsed == {"date_refunded": {"value": "2025-06-30", "modifier": "LESS_THAN"}}
"date_refunded": {"value": "2025-06-30", "modifier": "LESS_THAN"}
}
@pytest.mark.django_db @pytest.mark.django_db
+23 -25
View File
@@ -1,8 +1,4 @@
"""End-to-end Playwright test for the RangeSlider JS synchronization, cross-over, and clamping behavior. """End-to-end Playwright test for the RangeSlider JS synchronization, cross-over, and clamping behavior."""
"""
import json
import urllib.parse
import pytest import pytest
from django.http import HttpResponse from django.http import HttpResponse
@@ -41,17 +37,17 @@ urlpatterns = [
@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e") @override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
def test_range_slider_crossover_min_higher_than_max(live_server, page): def test_range_slider_crossover_min_higher_than_max(live_server, page):
page.goto(live_server.url + "/test-range-slider/") page.goto(live_server.url + "/test-range-slider/")
# 1. Start with known state: Min is empty, Max is empty # 1. Start with known state: Min is empty, Max is empty
min_input = page.locator('input[name="filter-session-count-min"]') min_input = page.locator('input[name="filter-session-count-min"]')
max_input = page.locator('input[name="filter-session-count-max"]') max_input = page.locator('input[name="filter-session-count-max"]')
# 2. Type "20" into max input # 2. Type "20" into max input
max_input.fill("20") max_input.fill("20")
# 3. Type "50" into min input (which is higher than 20) # 3. Type "50" into min input (which is higher than 20)
min_input.fill("50") min_input.fill("50")
# 4. Max input should have automatically synchronized/snapped to 50 # 4. Max input should have automatically synchronized/snapped to 50
assert max_input.input_value() == "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") @override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
def test_range_slider_crossover_max_less_than_min(live_server, page): def test_range_slider_crossover_max_less_than_min(live_server, page):
page.goto(live_server.url + "/test-range-slider/") page.goto(live_server.url + "/test-range-slider/")
min_input = page.locator('input[name="filter-session-count-min"]') min_input = page.locator('input[name="filter-session-count-min"]')
max_input = page.locator('input[name="filter-session-count-max"]') max_input = page.locator('input[name="filter-session-count-max"]')
# 1. Type "50" into min input # 1. Type "50" into min input
min_input.fill("50") min_input.fill("50")
# 2. Type "30" into max input (which is less than 50) # 2. Type "30" into max input (which is less than 50)
max_input.fill("30") max_input.fill("30")
# 3. Min input should have automatically synchronized/snapped to 30 # 3. Min input should have automatically synchronized/snapped to 30
assert min_input.input_value() == "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") @override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
def test_range_slider_strict_bounds_clamping_on_blur(live_server, page): def test_range_slider_strict_bounds_clamping_on_blur(live_server, page):
page.goto(live_server.url + "/test-range-slider/") page.goto(live_server.url + "/test-range-slider/")
min_input = page.locator('input[name="filter-session-count-min"]') min_input = page.locator('input[name="filter-session-count-min"]')
max_input = page.locator('input[name="filter-session-count-max"]') max_input = page.locator('input[name="filter-session-count-max"]')
# 1. Type value higher than dataMax (100 is max, type "150") # 1. Type value higher than dataMax (100 is max, type "150")
max_input.fill("150") max_input.fill("150")
max_input.blur() # triggers "change" event max_input.blur() # triggers "change" event
assert max_input.input_value() == "100" assert max_input.input_value() == "100"
# 2. Type value lower than dataMin (0 is min, type "-20") # 2. Type value lower than dataMin (0 is min, type "-20")
min_input.fill("-20") min_input.fill("-20")
min_input.blur() # triggers "change" event min_input.blur() # triggers "change" event
assert min_input.input_value() == "0" 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") @override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
def test_range_slider_empty_max_thumb_does_not_jump_to_beginning(live_server, page): def test_range_slider_empty_max_thumb_does_not_jump_to_beginning(live_server, page):
page.goto(live_server.url + "/test-range-slider/") page.goto(live_server.url + "/test-range-slider/")
# Locate handles # 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) # Initially, max_input is empty, so handle should sit at 100% (far right)
style = max_handle.get_attribute("style") style = max_handle.get_attribute("style")
assert "left:100%" in style or "left: 100%" in style assert "left:100%" in style or "left: 100%" in style
# Set min to 50 # Set min to 50
min_input = page.locator('input[name="filter-session-count-min"]') min_input = page.locator('input[name="filter-session-count-min"]')
min_input.fill("50") min_input.fill("50")
# Max handle should STILL stay at 100% since max input is still empty (defaults to max_value) # Max handle should STILL stay at 100% since max input is still empty (defaults to max_value)
style = max_handle.get_attribute("style") style = max_handle.get_attribute("style")
assert "left:100%" in style or "left: 100%" in style assert "left:100%" in style or "left: 100%" in style
+21 -17
View File
@@ -38,9 +38,7 @@ def prefilled_bar_view(request):
"value": "Switch", "value": "Switch",
"modifier": "INCLUDES", "modifier": "INCLUDES",
}, },
"group": { "group": {"modifier": "IS_NULL"},
"modifier": "IS_NULL"
}
} }
) )
return HttpResponse(_bar_page(filter_json=filter_json)) 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") @override_settings(ROOT_URLCONF="e2e.test_string_filter_e2e")
def test_string_filter_defaults_and_toggles(live_server, page): def test_string_filter_defaults_and_toggles(live_server, page):
page.goto(live_server.url + "/test-string-filter-empty/") page.goto(live_server.url + "/test-string-filter-empty/")
# 1. Verify text inputs are active by default and modifier "is" (EQUALS) is checked # 1. Verify text inputs are active by default and modifier "is" (EQUALS) is checked
name_input = page.locator('input[name="filter-name"]') name_input = page.locator('input[name="filter-name"]')
assert name_input.is_enabled() assert name_input.is_enabled()
is_radio = page.locator('input[name="filter-name-modifier"][value="EQUALS"]') is_radio = page.locator('input[name="filter-name-modifier"][value="EQUALS"]')
assert is_radio.is_checked() assert is_radio.is_checked()
# 2. Enter values, click "includes" (INCLUDES), and submit # 2. Enter values, click "includes" (INCLUDES), and submit
name_input.fill("PlayStation") 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() includes_radio.click()
with page.expect_navigation(): with page.expect_navigation():
page.evaluate( page.evaluate(
"document.getElementById('filter-bar-form')" "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 = page.locator('input[name="filter-name"]')
name_input.fill("Xbox") name_input.fill("Xbox")
# Click "is null" # Click "is null"
is_null_radio = page.locator('input[name="filter-name-modifier"][value="IS_NULL"]') is_null_radio = page.locator('input[name="filter-name-modifier"][value="IS_NULL"]')
is_null_radio.click() is_null_radio.click()
# Verification of interactive disabling # Verification of interactive disabling
assert not name_input.is_enabled() assert not name_input.is_enabled()
assert name_input.input_value() == "" assert name_input.input_value() == ""
with page.expect_navigation(): with page.expect_navigation():
page.evaluate( page.evaluate(
"document.getElementById('filter-bar-form')" "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") @override_settings(ROOT_URLCONF="e2e.test_string_filter_e2e")
def test_string_filter_prefilled_states(live_server, page): def test_string_filter_prefilled_states(live_server, page):
page.goto(live_server.url + "/test-string-filter-prefilled/") page.goto(live_server.url + "/test-string-filter-prefilled/")
name_input = page.locator('input[name="filter-name"]') name_input = page.locator('input[name="filter-name"]')
group_input = page.locator('input[name="filter-group"]') group_input = page.locator('input[name="filter-group"]')
# Verifies name matches "Switch" and "includes" is checked # Verifies name matches "Switch" and "includes" is checked
assert name_input.input_value() == "Switch" assert name_input.input_value() == "Switch"
assert name_input.is_enabled() 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 # Verifies group is empty, disabled, and "is null" is checked
assert group_input.input_value() == "" assert group_input.input_value() == ""
assert not group_input.is_enabled() 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 @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"]') name_input = page.locator('input[name="filter-name"]')
is_null_radio = page.locator('input[name="filter-name-modifier"][value="IS_NULL"]') is_null_radio = page.locator('input[name="filter-name-modifier"][value="IS_NULL"]')
# 1. Click "is null" -> disables input # 1. Click "is null" -> disables input
is_null_radio.click() is_null_radio.click()
assert not name_input.is_enabled() assert not name_input.is_enabled()
# 2. Click "is null" again to deselect/uncheck -> should re-enable the text input # 2. Click "is null" again to deselect/uncheck -> should re-enable the text input
is_null_radio.click() is_null_radio.click()
assert name_input.is_enabled() assert name_input.is_enabled()
+12
View File
@@ -20,4 +20,16 @@ chown "$PUID:$PGID" /var/log/supervisor
python manage.py migrate python manage.py migrate
python manage.py collectstatic --clear --no-input 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 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 from games.models import PlayEvent
event_q = criterion.to_q("note") 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) 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): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('games', '0017_add_filter_preset'), ("games", "0017_add_filter_preset"),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name='session', model_name="session",
name='timestamp_start', name="timestamp_start",
field=models.DateTimeField(db_index=True, verbose_name='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 { .items-start {
align-items: flex-start; align-items: flex-start;
} }
.items-stretch {
align-items: stretch;
}
.justify-between { .justify-between {
justify-content: space-between; justify-content: space-between;
} }
@@ -1963,17 +1966,10 @@
border-top-right-radius: var(--radius-lg); border-top-right-radius: var(--radius-lg);
border-bottom-right-radius: var(--radius-lg); border-bottom-right-radius: var(--radius-lg);
} }
.rounded-tr-md {
border-top-right-radius: var(--radius-md);
}
.rounded-b { .rounded-b {
border-bottom-right-radius: var(--radius); border-bottom-right-radius: var(--radius);
border-bottom-left-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 {
border-style: var(--tw-border-style); border-style: var(--tw-border-style);
border-width: 1px; border-width: 1px;
@@ -1982,10 +1978,6 @@
border-style: var(--tw-border-style); border-style: var(--tw-border-style);
border-width: 0px; border-width: 0px;
} }
.border-0\! {
border-style: var(--tw-border-style) !important;
border-width: 0px !important;
}
.border-2 { .border-2 {
border-style: var(--tw-border-style); border-style: var(--tw-border-style);
border-width: 2px; border-width: 2px;
@@ -2774,9 +2766,6 @@
.line-through { .line-through {
text-decoration-line: line-through; text-decoration-line: line-through;
} }
.no-underline\! {
text-decoration-line: none !important;
}
.underline { .underline {
text-decoration-line: 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\:bg-green-500 {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@@ -4200,17 +4196,6 @@
text-underline-offset: 4px; 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-right {
& td:last-child { & td:last-child {
text-align: right; 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 django.urls import reverse
from common.components import ( from common.components import (
Fragment,
A, A,
AddForm, AddForm,
Button,
ButtonGroup, ButtonGroup,
Icon,
paginated_table_content,
DeviceFilterBar, DeviceFilterBar,
Fragment,
Icon,
StyledButton,
paginated_table_content,
) )
from common.layout import render_page from common.layout import render_page
from common.time import dateformat, local_strftime 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) devices, page_obj, elided_page_range = paginate(request, devices)
data = { data = {
"header_action": A([], Button([], "Add device"), url_name="games:add_device"), "header_action": A(href=reverse("games:add_device"))[
StyledButton()["Add device"]
],
"columns": [ "columns": [
"Name", "Name",
"Type", "Type",
+76 -79
View File
@@ -8,19 +8,18 @@ from django.middleware.csrf import get_token
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.template.defaultfilters import date as date_filter from django.template.defaultfilters import date as date_filter
from django.urls import reverse from django.urls import reverse
from django.utils.safestring import SafeText, mark_safe from django.utils.safestring import SafeText
from common.components import ( from common.components import (
Fragment,
H1, H1,
A, A,
AddForm, AddForm,
Button,
ButtonGroup, ButtonGroup,
CsrfInput, CsrfInput,
Div, Div,
Element, Element,
FilterBar, FilterBar,
Fragment,
GameStatus, GameStatus,
GameStatusSelector, GameStatusSelector,
Icon, Icon,
@@ -35,11 +34,11 @@ from common.components import (
Safe, Safe,
SearchField, SearchField,
SimpleTable, SimpleTable,
StyledButton,
Ul, Ul,
paginated_table_content, paginated_table_content,
) )
from common.components.primitives import Li, P, Span, Strong from common.components.primitives import Li, P, Span, Strong
from common.icons import get_icon
from common.layout import render_page from common.layout import render_page
from common.time import ( from common.time import (
dateformat, dateformat,
@@ -91,12 +90,11 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
data = { data = {
"header_action": Div( "header_action": Div(
children=[ class_="flex justify-between",
SearchField(search_string=search_string), )[
A([], Button([], "Add game"), url_name="games:add_game"), SearchField(search_string=search_string),
], A(href=reverse("games:add_game"))[StyledButton()["Add game"]],
attributes=[("class", "flex justify-between")], ],
),
"columns": [ "columns": [
"Name", "Name",
"Sort Name", "Sort Name",
@@ -173,7 +171,7 @@ def add_game(request: HttpRequest) -> HttpResponse:
AddForm( AddForm(
form, form,
request=request, request=request,
additional_row=Button( additional_row=StyledButton(
[], [],
"Submit & Create Purchase", "Submit & Create Purchase",
color="gray", color="gray",
@@ -249,14 +247,14 @@ def _delete_game_confirmation_modal(
Div( Div(
[("class", "items-center mt-5")], [("class", "items-center mt-5")],
[ [
Button( StyledButton(
[("class", "w-full")], [("class", "w-full")],
"Delete", "Delete",
color="red", color="red",
size="lg", size="lg",
type="submit", type="submit",
), ),
Button( StyledButton(
[("class", "mt-0 w-full")], [("class", "mt-0 w-full")],
"Cancel", "Cancel",
color="gray", 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>', "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 }"> _PLAYED_BTN = (
<span class="uppercase">Played</span> "px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 "
<div class="inline-flex rounded-md shadow-2xs" role="group" x-data="{ played: @@PLAYED_COUNT@@ }"> "hover:bg-gray-100 hover:text-blue-700 dark:bg-gray-800 dark:border-gray-700 "
<a href="@@ADD_PE@@"> "dark:text-white dark:hover:bg-gray-700 hover:cursor-pointer"
<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 _PLAYED_MENU = (
</button> "absolute top-full -left-px w-auto whitespace-nowrap z-10 text-sm font-medium "
</a> "bg-gray-800/20 backdrop-blur-lg rounded-md rounded-tl-none border "
<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"> "border-gray-200 dark:border-gray-700"
@@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>"""
def _played_row(game: Game, request: HttpRequest) -> Node: def _played_row(game: Game, request: HttpRequest) -> Node:
"""The 'Played N times' control with its Alpine.js dropdown.""" """'Played N times' control as a custom element (ts/elements/play-event-row.ts)."""
replacements = { from common.components import Element
"@@PLAYED_COUNT@@": str(game.playevents.count()), from common.components.custom_elements import _PlayEventRow
"@@ADD_PE@@": reverse("games:add_playevent"), from common.components.primitives import Button
"@@ARROWDOWN@@": get_icon("arrowdown"),
"@@ADD_PE_FOR_GAME@@": reverse("games:add_playevent_for_game", args=[game.id]), played: int = 0
"@@API_CREATE@@": reverse("api-1.0.0:create_playevent"), played = game.playevents.count()
"@@CSRF@@": get_token(request),
"@@GAME_ID@@": str(game.id), count_button = A(href=reverse("games:add_playevent"))[
} Button(class_=_PLAYED_BTN + " rounded-s-lg")[
html = _PLAYED_ROW_TEMPLATE Span(data_count="")[str(played)], " times"
for token, value in replacements.items(): ]
html = html.replace(token, value) ]
return Safe(html) 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: 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( header_action = Div(
children=[ children=[
A( A(href=reverse("games:add_session"))[
url_name="games:add_session", StyledButton(icon=True, color="blue", size="xs")[Icon("plus")]
children=Button(icon=True, size="xs", children=[Icon("play"), "LOG"]), ],
),
A( A(
href=reverse( href=reverse(
"games:list_sessions_start_session_from_session", "games:list_sessions_start_session_from_session",
@@ -698,7 +695,7 @@ def _sessions_section(game: Game, request: HttpRequest) -> SafeText:
children=Popover( children=Popover(
popover_content=last_session.game.name, popover_content=last_session.game.name,
children=[ children=[
Button( StyledButton(
icon=True, icon=True,
color="gray", color="gray",
size="xs", 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" "<script>\n"
" function getSessionCount() {\n" " function getSessionCount() {\n"
" return document.getElementById('session-count')" " 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 django.urls import reverse
from common.components import ( from common.components import (
Fragment,
A, A,
AddForm, AddForm,
Button,
ButtonGroup, ButtonGroup,
Fragment,
Icon, Icon,
paginated_table_content,
PlatformFilterBar, PlatformFilterBar,
StyledButton,
paginated_table_content,
) )
from common.layout import render_page from common.layout import render_page
from common.time import dateformat, local_strftime 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) platforms, page_obj, elided_page_range = paginate(request, platforms)
data = { data = {
"header_action": A( "header_action": A(href=reverse("games:add_platform"))[
[], Button([], "Add platform"), url_name="games:add_platform" StyledButton()["Add platform"]
), ],
"columns": [ "columns": [
"Name", "Name",
"Icon", "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.shortcuts import get_object_or_404
from django.urls import reverse from django.urls import reverse
from common.components import ( from common.components import (
Fragment,
A, A,
AddForm, AddForm,
Button,
ButtonGroup, ButtonGroup,
Fragment,
Icon, Icon,
ModuleScript, ModuleScript,
paginated_table_content,
PlayEventFilterBar, PlayEventFilterBar,
StyledButton,
paginated_table_content,
) )
from common.layout import render_page from common.layout import render_page
from common.time import dateformat, format_duration, local_strftime from common.time import dateformat, format_duration, local_strftime
@@ -87,9 +86,9 @@ def create_playevent_tabledata(
for row in row_list for row in row_list
] ]
return { return {
"header_action": A( "header_action": A(href=reverse("games:add_playevent"))[
[], Button([], "Add play event"), url_name="games:add_playevent" StyledButton()["Add play event"]
), ],
"columns": list(filtered_column_list), "columns": list(filtered_column_list),
"rows": filtered_row_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 django.views.decorators.http import require_POST
from common.components import ( from common.components import (
Fragment,
A, A,
AddForm, AddForm,
Button,
ButtonGroup, ButtonGroup,
CsrfInput, CsrfInput,
Div, Div,
Element, Element,
Fragment,
GameLink, GameLink,
Icon, Icon,
LinkedPurchase, LinkedPurchase,
@@ -30,6 +29,7 @@ from common.components import (
Node, Node,
PriceConverted, PriceConverted,
PurchasePrice, PurchasePrice,
StyledButton,
TableRow, TableRow,
paginated_table_content, paginated_table_content,
) )
@@ -110,9 +110,9 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
purchases, page_obj, elided_page_range = paginate(request, purchases) purchases, page_obj, elided_page_range = paginate(request, purchases)
data = { data = {
"header_action": A( "header_action": A(href=reverse("games:add_purchase"))[
[], Button([], "Add purchase"), url_name="games:add_purchase" StyledButton()["Add purchase"]
), ],
"columns": [ "columns": [
"Name", "Name",
"Type", "Type",
@@ -153,7 +153,7 @@ def _purchase_additional_row() -> SafeText:
Td(), Td(),
Td( Td(
children=[ children=[
Button( StyledButton(
[], [],
"Submit & Create Session", "Submit & Create Session",
color="gray", color="gray",
@@ -319,14 +319,14 @@ def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> Node:
Div( Div(
[("class", "items-center mt-5")], [("class", "items-center mt-5")],
[ [
Button( StyledButton(
[("class", "w-full")], [("class", "w-full")],
"Refund", "Refund",
color="blue", color="blue",
size="lg", size="lg",
type="submit", type="submit",
), ),
Button( StyledButton(
[("class", "mt-0 w-full")], [("class", "mt-0 w-full")],
"Cancel", "Cancel",
color="gray", color="gray",
+23 -38
View File
@@ -11,12 +11,11 @@ from django.utils import timezone
from django.utils.safestring import SafeText, mark_safe from django.utils.safestring import SafeText, mark_safe
from common.components import ( from common.components import (
Fragment,
A, A,
AddForm, AddForm,
Button,
ButtonGroup, ButtonGroup,
Div, Div,
Fragment,
Icon, Icon,
ModuleScript, ModuleScript,
NameWithIcon, NameWithIcon,
@@ -25,6 +24,8 @@ from common.components import (
Safe, Safe,
SearchField, SearchField,
SessionDeviceSelector, SessionDeviceSelector,
SessionTimestampButtons,
StyledButton,
paginated_table_content, paginated_table_content,
) )
from common.components.primitives import Span, Td, Tr from common.components.primitives import Span, Td, Tr
@@ -76,13 +77,13 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
Div( Div(
children=[ children=[
A( A(
url_name="games:add_session", href=reverse("games:add_session"),
children=Button( )[
StyledButton(
icon=True, icon=True,
size="xs", size="xs",
children=[Icon("play"), "LOG"], )[Icon("play"), "LOG"]
), ],
),
A( A(
href=reverse( href=reverse(
"games:list_sessions_start_session_from_session", "games:list_sessions_start_session_from_session",
@@ -91,7 +92,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
children=Popover( children=Popover(
popover_content=last_session.game.name, popover_content=last_session.game.name,
children=[ children=[
Button( StyledButton(
icon=True, icon=True,
color="gray", color="gray",
size="xs", size="xs",
@@ -208,32 +209,20 @@ def _session_fields(form) -> Fragment:
this_side = "start" if field.name == "timestamp_start" else "end" this_side = "start" if field.name == "timestamp_start" else "end"
other_side = "end" if field.name == "timestamp_start" else "start" other_side = "end" if field.name == "timestamp_start" else "start"
children.append( children.append(
Span( SessionTimestampButtons(
attributes=[ class_="form-row-button-group flex-row gap-3 justify-start mt-3",
( hx_boost="false",
"class", )[
"form-row-button-group flex-row gap-3 justify-start mt-3", StyledButton(data_target=field.name, data_type="now", size="xs")[
), "Set to now"
("hx-boost", "false"),
], ],
children=[ StyledButton(data_target=field.name, data_type="toggle", size="xs")[
Button( "Toggle text"
[("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="copy", size="xs")[
f"Copy {this_side} value to {other_side}"
],
]
) )
rows.append(Div(children=children)) rows.append(Div(children=children))
return Fragment(*rows, separator="\n") return Fragment(*rows, separator="\n")
@@ -265,9 +254,7 @@ def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
request, request,
AddForm(form, request=request, fields=_session_fields(form), submit_class=""), AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
title="Add New Session", title="Add New Session",
scripts=mark_safe( scripts=mark_safe(ModuleScript("search_select.js")),
ModuleScript("search_select.js") + ModuleScript("add_session.js")
),
) )
@@ -282,9 +269,7 @@ def edit_session(request: HttpRequest, session_id: int) -> HttpResponse:
request, request,
AddForm(form, request=request, fields=_session_fields(form), submit_class=""), AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
title="Edit Session", title="Edit Session",
scripts=mark_safe( scripts=mark_safe(ModuleScript("search_select.js")),
ModuleScript("search_select.js") + ModuleScript("add_session.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.template.defaultfilters import floatformat
from django.urls import reverse from django.urls import reverse
from django.utils.html import conditional_escape from django.utils.html import conditional_escape
from common.components import ( from common.components import (
A, A,
Div, Div,
@@ -100,10 +101,9 @@ def _year_nav(year, year_range, url_template) -> Node:
else "text-body hover:text-heading underline decoration-dotted" else "text-body hover:text-heading underline decoration-dotted"
) )
alltime_btn = A( alltime_btn = A(
url_name="games:stats_alltime", href=reverse("games:stats_alltime"),
attributes=[("class", alltime_classes)], class_=alltime_classes,
children=["All-time stats"], )["All-time stats"]
)
picker = YearPicker( picker = YearPicker(
year=year_int, year=year_int,
available_years=tuple(year_range or []), available_years=tuple(year_range or []),
+3 -3
View File
@@ -7,10 +7,10 @@ from django.utils.safestring import SafeText
from common.components import ( from common.components import (
A, A,
AddForm, AddForm,
Button,
CsrfInput, CsrfInput,
Div, Div,
Element, Element,
StyledButton,
paginated_table_content, paginated_table_content,
) )
from common.components.primitives import P from common.components.primitives import P
@@ -79,12 +79,12 @@ def _delete_statuschange_content(statuschange, request: HttpRequest) -> SafeText
P( P(
children=["Are you sure you want to delete this status change?"], children=["Are you sure you want to delete this status change?"],
), ),
Button( StyledButton(
[("class", "w-full")], "Delete", color="red", type="submit", size="lg" [("class", "w-full")], "Delete", color="red", type="submit", size="lg"
), ),
A( A(
[("class", "")], [("class", "")],
Button([("class", "w-full")], "Cancel", color="gray"), StyledButton([("class", "w-full")], "Cancel", color="gray"),
href=reverse("games:view_game", args=[statuschange.game.id]), href=reverse("games:view_game", args=[statuschange.game.id]),
), ),
], ],
+2 -1
View File
@@ -4,7 +4,8 @@
"@tailwindcss/typography": "^0.5.13", "@tailwindcss/typography": "^0.5.13",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"npm-check-updates": "^16.14.20", "npm-check-updates": "^16.14.20",
"tailwindcss": "^4.1.18" "tailwindcss": "^4.1.18",
"typescript": "^5.6.0"
}, },
"dependencies": { "dependencies": {
"@tailwindcss/cli": "^4.1.18", "@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 django.utils.safestring import SafeText, mark_safe
from common import components 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 # Component builders return lazy ``Node`` objects; these tests assert on rendered
# HTML, so node-returning calls are wrapped in ``str(...)`` at the call site # 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")) result = str(components.A([], "x", href="/literal/path"))
self.assertIn('href="/literal/path"', result) 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): def test_a_no_url_or_href(self):
result = str(components.A([], "link")) result = str(components.A([], "link"))
self.assertIn("<a>link</a>", result) self.assertIn("<a>link</a>", result)
self.assertNotIn("href=", 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): def test_button_returns_safe_text(self):
result = str(components.Button([], "click")) result = str(components.StyledButton([], "click"))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
self.assertIn("<button", result) self.assertIn("<button", result)
def test_button_default_colors(self): def test_button_default_colors(self):
result = str(components.Button([], "click")) result = str(components.StyledButton([], "click"))
self.assertIn("text-white bg-brand", result) self.assertIn("text-white bg-brand", result)
def test_name_with_icon_no_link(self): def test_name_with_icon_no_link(self):
@@ -269,7 +256,7 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
def test_component_output_starts_with_tag(self): def test_component_output_starts_with_tag(self):
for label, html in [ for label, html in [
("A", str(components.A(href="/foo", children=["link"]))), ("A", str(components.A(href="/foo", children=["link"]))),
("Button", str(components.Button([], "click"))), ("Button", str(components.StyledButton([], "click"))),
("Div", str(components.Div([], ["hello"]))), ("Div", str(components.Div([], ["hello"]))),
("Input", str(components.Input())), ("Input", str(components.Input())),
("ButtonGroup", str(components.ButtonGroup([]))), ("ButtonGroup", str(components.ButtonGroup([]))),
@@ -294,7 +281,7 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
def test_button_with_icon_children_not_escaped(self): def test_button_with_icon_children_not_escaped(self):
result = str( result = str(
components.Button( components.StyledButton(
icon=True, icon=True,
size="xs", size="xs",
children=[components.Icon("play"), "LOG"], children=[components.Icon("play"), "LOG"],
@@ -307,7 +294,7 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
components.Popover( components.Popover(
popover_content="test tooltip", popover_content="test tooltip",
children=[ children=[
components.Button( components.StyledButton(
icon=True, icon=True,
color="gray", color="gray",
size="xs", size="xs",
@@ -923,6 +910,7 @@ class ComponentPrimitivesTest(SimpleTestCase):
class PrimitiveWidgetsTest(SimpleTestCase): class PrimitiveWidgetsTest(SimpleTestCase):
def test_mixin_applies_widget_to_boolean_fields_only(self): def test_mixin_applies_widget_to_boolean_fields_only(self):
from django import forms from django import forms
from games.forms import PrimitiveCheckboxWidget, PrimitiveWidgetsMixin from games.forms import PrimitiveCheckboxWidget, PrimitiveWidgetsMixin
class DummyForm(PrimitiveWidgetsMixin, forms.Form): 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('name="filter-refunded"', purchase_html)
self.assertIn('value="true"', purchase_html) self.assertIn('value="true"', purchase_html)
self.assertIn('value="false"', 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.assertTrue(_parse_bool_nullable({"field": {"value": "1"}}, "field"))
self.assertFalse(_parse_bool_nullable({"field": {"value": "false"}}, "field")) self.assertFalse(_parse_bool_nullable({"field": {"value": "false"}}, "field"))
self.assertFalse(_parse_bool_nullable({"field": {"value": "0"}}, "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): def test_mastered_not_checked_by_default(self):
html = str(FilterBar(filter_json="")) 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 (
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 '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): def test_mastered_checked_when_filtered(self):
html = str( html = str(
@@ -784,7 +790,7 @@ class TestExpandedFiltersAgainstDB:
from games.filters import SessionFilter from games.filters import SessionFilter
from games.models import Session from games.models import Session
data = self._setup_entities() self._setup_entities()
# Test duration_total_hours equals 4 # Test duration_total_hours equals 4
sf_tot = SessionFilter.from_json( sf_tot = SessionFilter.from_json(
@@ -808,7 +814,7 @@ class TestExpandedFiltersAgainstDB:
from games.filters import PurchaseFilter from games.filters import PurchaseFilter
from games.models import Purchase from games.models import Purchase
data = self._setup_entities() self._setup_entities()
pf = PurchaseFilter.from_json( pf = PurchaseFilter.from_json(
{ {
+43
View File
@@ -174,5 +174,48 @@ class RealComponentMediaTest(unittest.TestCase):
self.assertIn("range_slider.js", media.js) 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+10 -2
View File
@@ -139,7 +139,7 @@ class RenderedPagesTest(TestCase):
def test_add_session_form_has_timestamp_helpers(self): def test_add_session_form_has_timestamp_helpers(self):
html = self.get("games:add_session").content.decode() html = self.get("games:add_session").content.decode()
self.assertIn("add_session.js", html) self.assertIn("session-timestamp-buttons", html)
for marker in [ for marker in [
"Set to now", "Set to now",
"Toggle text", "Toggle text",
@@ -168,7 +168,7 @@ class RenderedPagesTest(TestCase):
"Platform", "Platform",
'id="history-container"', 'id="history-container"',
"status-changed from:body", "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 'hx-target="#global-modal-container"', # delete trigger
"Purchases", "Purchases",
"Sessions", "Sessions",
@@ -179,6 +179,14 @@ class RenderedPagesTest(TestCase):
self.assertNoEscapedTags(html) self.assertNoEscapedTags(html)
self.assertEqual(html.count("<div"), html.count("</div>")) 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): def test_view_game_empty_sections(self):
"""A game with no sessions/purchases/etc shows the empty messages.""" """A game with no sessions/purchases/etc shows the empty messages."""
lonely = Game.objects.create(name="Lonely Game", platform=self.platform) 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"]
}