Compare commits

..

2 Commits

Author SHA1 Message Date
lukas b62a0f689a Update allowed builders for pnpm
Django CI/CD / test (push) Successful in 49s
Django CI/CD / build-and-push (push) Failing after 1m7s
2026-06-08 08:37:10 +02:00
lukas c75133d9c4 Update uv.lock security 2026-06-08 08:37:10 +02:00
107 changed files with 2648 additions and 20967 deletions
+14 -44
View File
@@ -1,51 +1,21 @@
# =============================================================================
# Django application settings (read by timetracker/config.py)
#
# Resolution priority, highest first:
# SECRET_KEY__FILE -> env var -> .env -> settings.ini -> built-in default
# See docs/configuration.md for the full reference.
# =============================================================================
# Docker registry URL (used in docker-compose.yml)
REGISTRY_URL=registry.kucharczyk.xyz
# Turn DEBUG off in production. Defaults on for local development.
# (The old PROD=1 variable still works but is deprecated; prefer DEBUG.)
DEBUG=false
# Secret key. Required in production; an insecure default is used in DEBUG.
# For Docker/K8s secrets, point SECRET_KEY__FILE at a mounted file instead.
SECRET_KEY=change-me-to-a-long-random-string
# SECRET_KEY__FILE=/run/secrets/timetracker_secret_key
# Public URL of the site. Derives ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS.
APP_URL=https://tracker.kucharczyk.xyz
# Optional explicit overrides (comma-separated). When set they win over APP_URL.
# Useful behind a reverse proxy, e.g. ALLOWED_HOSTS=*
# ALLOWED_HOSTS=*
# CSRF_TRUSTED_ORIGINS=https://tracker.kucharczyk.xyz
# Container timezone.
# Container timezone
TZ=Europe/Prague
# Directory holding the SQLite database (defaults to the project root).
DATA_DIR=/home/timetracker/app/data
# =============================================================================
# Container / entrypoint-only settings (read by entrypoint.sh, NOT by Django)
# =============================================================================
# User/group IDs the container process runs as.
# User/group IDs for container (used in entrypoint.sh)
PUID=1000
PGID=100
# Create an admin/admin superuser on startup (for initial setup only).
CREATE_DEFAULT_SUPERUSER=false
# =============================================================================
# docker-compose-only settings (compose file substitution, not the app)
# =============================================================================
# Docker registry URL (used in docker-compose.yml).
REGISTRY_URL=registry.kucharczyk.xyz
# External port mapping.
# External port mapping
TIMETRACKER_EXTERNAL_PORT=8000
# Django production mode (set to "1" for production)
PROD=1
# Database directory (defaults to project root)
DATA_DIR=/home/timetracker/app/data
# CSRF trusted origins
CSRF_TRUSTED_ORIGINS=https://tracker.kucharczyk.xyz
-62
View File
@@ -1,62 +0,0 @@
name: Django CI/CD
on:
push:
paths-ignore: [ 'README.md' ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: false
python-version: "3.14"
- name: Install dependencies
run: uv sync --frozen
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install pnpm and JS dependencies
run: corepack enable && pnpm install --frozen-lockfile --ignore-scripts
- name: Build TypeScript
run: make ts
- name: Install Playwright browsers
run: uv run playwright install --with-deps chromium
- name: Run Migrations
run: uv run python manage.py migrate
- name: Run Tests
run: uv run --with pytest-django pytest
build-and-push:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Set Version
run: echo "VERSION_NUMBER=1.7.0" >> $GITHUB_ENV
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
registry.kucharczyk.xyz/timetracker:latest
registry.kucharczyk.xyz/timetracker:${{ env.VERSION_NUMBER }}
-86
View File
@@ -1,86 +0,0 @@
name: Staging deployment
on:
push:
branches-ignore: [main]
delete:
jobs:
deploy:
if: github.event_name == 'push'
runs-on: ubuntu-latest
env:
BRANCH: ${{ github.ref_name }}
steps:
- uses: actions/checkout@v4
- name: Compute staging name
run: |
SLUG=$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g; s/-+/-/g; s/^-//; s/-$//' | cut -c1-40)
echo "SLUG=${SLUG}" >> "$GITHUB_ENV"
echo "HOST=tracker-${SLUG}.home.arpa" >> "$GITHUB_ENV"
# Per-staging secret so each instance has its own key, decoupling it
# from prod even though the database is seeded from a prod snapshot.
echo "STAGING_SECRET_KEY=staging-${SLUG}-$(head -c16 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9')" >> "$GITHUB_ENV"
- name: Build image
run: docker build -t "timetracker:staging-${SLUG}" .
- name: Deploy staging container
run: |
docker rm -f "timetracker-staging-${SLUG}" 2>/dev/null || true
docker run -d --name "timetracker-staging-${SLUG}" \
--network docker-compose-templates_public \
-e TZ=Europe/Prague \
-e PUID=1000 \
-e PGID=100 \
-e DATA_DIR=/home/timetracker/app/data \
-e STAGING=true \
-e "SECRET_KEY=${STAGING_SECRET_KEY}" \
-e "APP_URL=https://${HOST}" \
-v "timetracker-staging-${SLUG}:/home/timetracker/app/data" \
-l "caddy=${HOST}" \
-l 'caddy.reverse_proxy={{ upstreams 8000 }}' \
-l xyz.kucharczyk.staging=timetracker \
-l "xyz.kucharczyk.staging.branch=${BRANCH}" \
--restart unless-stopped \
"timetracker:staging-${SLUG}"
- name: Summary
run: echo "Deployed to https://${HOST}" >> "$GITHUB_STEP_SUMMARY"
- name: Comment staging URL on PR
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
auth="Authorization: token ${GITHUB_TOKEN}"
api="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}"
pr=$(curl -fsS -H "$auth" "${api}/pulls?state=open&limit=50" \
| jq -r --arg branch "$BRANCH" '.[] | select(.head.ref == $branch) | .number' | head -n1)
if [ -z "$pr" ]; then
echo "No open PR for branch '${BRANCH}', skipping comment"
exit 0
fi
body="Staging deployment: https://${HOST}"
if curl -fsS -H "$auth" "${api}/issues/${pr}/comments" \
| jq -e --arg body "$body" 'any(.[]; .body == $body)' >/dev/null; then
echo "Staging URL already commented on PR #${pr}"
exit 0
fi
curl -fsS -X POST -H "$auth" -H 'Content-Type: application/json' \
-d "$(jq -n --arg body "$body" '{body: $body}')" \
"${api}/issues/${pr}/comments" >/dev/null
echo "Commented staging URL on PR #${pr}"
teardown:
if: github.event_name == 'delete' && github.event.ref_type == 'branch'
runs-on: ubuntu-latest
env:
BRANCH: ${{ github.event.ref }}
steps:
- name: Remove staging container, volume, and image
run: |
SLUG=$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g; s/-+/-/g; s/^-//; s/-$//' | cut -c1-40)
docker rm -f "timetracker-staging-${SLUG}" 2>/dev/null || true
docker volume rm "timetracker-staging-${SLUG}" 2>/dev/null || true
docker rmi "timetracker:staging-${SLUG}" 2>/dev/null || true
-14
View File
@@ -19,20 +19,6 @@ jobs:
- name: Install dependencies
run: uv sync --frozen
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install pnpm and JS dependencies
run: corepack enable && pnpm install --frozen-lockfile --ignore-scripts
- name: Build TypeScript
run: make ts
- name: Install Playwright browsers
run: uv run playwright install --with-deps chromium
- name: Run Migrations
run: uv run python manage.py migrate
-99
View File
@@ -1,99 +0,0 @@
name: Staging deployment
on:
push:
branches-ignore: [main]
delete:
concurrency:
group: staging-${{ github.event.ref }}
cancel-in-progress: true
jobs:
deploy:
if: github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
pull-requests: write
env:
BRANCH: ${{ github.ref_name }}
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
steps:
- uses: actions/checkout@v4
- name: Compute staging name
run: |
SLUG=$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g; s/-+/-/g; s/^-//; s/-$//' | cut -c1-30)
APP="timetracker-staging-${SLUG}"
echo "SLUG=${SLUG}" >> "$GITHUB_ENV"
echo "APP=${APP}" >> "$GITHUB_ENV"
echo "HOST=${APP}.fly.dev" >> "$GITHUB_ENV"
- name: Set up flyctl
uses: superfly/flyctl-actions/setup-flyctl@master
- name: Create app if missing
run: |
if ! flyctl status --app "$APP" >/dev/null 2>&1; then
flyctl apps create "$APP" --org personal
fi
- name: Set staging secrets
run: |
# Per-app SECRET_KEY so each staging instance is independent and no
# session cookie is shared across instances or with production.
SECRET_KEY="staging-${SLUG}-$(head -c16 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9')"
# APP_URL derives both ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS.
flyctl secrets set --app "$APP" --stage \
"SECRET_KEY=${SECRET_KEY}" \
"APP_URL=https://${HOST}"
- name: Deploy
run: flyctl deploy --app "$APP" --config fly.staging.toml --remote-only --yes
- name: Summary
run: echo "Deployed to https://${HOST}" >> "$GITHUB_STEP_SUMMARY"
- name: Comment staging URL on PR
uses: actions/github-script@v7
with:
script: |
const host = process.env.HOST;
const branch = process.env.BRANCH;
const body = `Staging deployment: https://${host}`;
const { owner, repo } = context.repo;
const pulls = await github.rest.pulls.list({
owner, repo, state: "open", head: `${owner}:${branch}`,
});
const pr = pulls.data[0];
if (!pr) {
core.info(`No open PR for branch '${branch}', skipping comment`);
return;
}
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: pr.number,
});
if (comments.some((comment) => comment.body === body)) {
core.info(`Staging URL already commented on PR #${pr.number}`);
return;
}
await github.rest.issues.createComment({
owner, repo, issue_number: pr.number, body,
});
core.info(`Commented staging URL on PR #${pr.number}`);
teardown:
if: github.event_name == 'delete' && github.event.ref_type == 'branch'
runs-on: ubuntu-latest
env:
BRANCH: ${{ github.event.ref }}
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
steps:
- name: Set up flyctl
uses: superfly/flyctl-actions/setup-flyctl@master
- name: Destroy staging app
run: |
SLUG=$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g; s/-+/-/g; s/^-//; s/-$//' | cut -c1-30)
APP="timetracker-staging-${SLUG}"
flyctl apps destroy "$APP" --yes 2>/dev/null || true
+1 -10
View File
@@ -4,20 +4,11 @@ __pycache__
.venv/
node_modules
package-lock.json
pnpm-lock.yaml
db.sqlite3
data/
/static/
dist/
.DS_Store
.python-version
# Local configuration (may contain secrets); examples are committed instead
.env
/settings.ini
.direnv
.hermes/
# Build artifacts: generated in CI/Docker assets stage, not committed
/games/static/base.css
/games/static/js/dist/
/ts/generated/
+19 -44
View File
@@ -35,7 +35,6 @@ games/ — Django app: models, views, templates, forms, signals, tasks,
common/ — Shared utilities: time formatting, component system, criteria, layout, icons
timetracker/ — Django project: settings, URL root, ASGI/WSGI
tests/ — Pytest tests
e2e/ — Playwright browser tests (run via `make test-e2e`)
contrib/ — One-off scripts (exchange rate import)
docs/ — Additional documentation
```
@@ -58,15 +57,15 @@ docs/ — Additional documentation
### Key patterns
**Layout system** (`common/layout.py`): Views call `render_page(request, content, title=...)` instead of Django's `render()`. This assembles a full HTML document via `Page()` — analogous to FastHTML's `fast_app()`. `Page()` handles the `<head>`, navbar, toast container, FOUC-prevention script, and **JS includes**: it calls `collect_media(content)` to gather every component's declared `Media` and emits the `<script>` tags automatically — so views do **not** pass `scripts=` for component-owned JS. The `scripts=` argument remains only for page-specific glue not owned by a reusable component (e.g. the add-form helper `add_*.js`). The navbar shows today's/last-7-days playtime from the `model_counts` context processor.
**Layout system** (`common/layout.py`): Views call `render_page(request, content, title=...)` instead of Django's `render()`. This assembles a full HTML document via `Page()` — analogous to FastHTML's `fast_app()`. `Page()` handles the `<head>`, navbar, toast container, JS includes, and FOUC-prevention script. The navbar shows today's playtime and last-7-days playtime from the `model_counts` context processor.
**Component system** (`common/components/`): a FastHTML-style **lazy node tree**. Components are `Node` objects that render to HTML only when asked (`str(node)` / `Page()`), so `Page()` can walk a finished tree and collect each component's JS. Split into submodules re-exported via `common/components/__init__.py`:
**Component system** (`common/components/`): Pure-Python HTML builders, split into four submodules re-exported via `common/components/__init__.py`:
- **`core.py`** — the node layer. `Node` (base; `__html__`/`__str__` return a `SafeString`), `Element` (the single class for *any* HTML element), `Safe` (wraps pre-rendered/trusted HTML), `Fragment` (ordered children, no wrapper tag — use instead of `str(a)+str(b)`), `BaseComponent` (base for higher-level components: implement `render()`, declare `media`), and `Media` (declarative JS deps with order-preserving dedup merge; `collect_media()` sums them over a tree, `node.with_media(...)` attaches them). `_render_element()` is `@lru_cache`-memoized (4096). Attribute values are always escaped. **Children: every string child is escaped — `SafeText`/`mark_safe` included; only `Node` children (so `Safe`) render unescaped.** Trusted pre-rendered HTML must be wrapped in `Safe(...)`, never passed as a safe string. `randomid()` generates stable hash-based IDs.
- **`primitives.py`** — Generic HTML. Plain leaf builders (`Div`, `Span`, `P`, `Ul`, `Li`, `Strong`, `Label`, `Template`, `Td`, `Tr`, `Th`) are **generated from a whitelist** via the `_html_element(tag)` factory over `Element` — not hand-written per tag. Builders that add classes/behaviour are written out: `A()`, `Button()`, `ButtonGroup()`, `Input()`, `Checkbox()`, `Radio()`, `Pill()`, `Icon()`, `Popover()`, `PopoverTruncated()`, `SearchField()`, `H1()`, `Modal()`, `SimpleTable()`, `TableRow()`, `TableTd()`, `TableHeader()`, `paginated_table_content()`, `AddForm()`, `YearPicker()` (declares datepicker media), `CsrfInput()`/`ModuleScript()`/`StaticScript()` (script-tag string helpers used by `Page()`).
- **`core.py`** — `Component(tag_name, attributes, children)`: the fundamental builder. `_render_element()` is `@lru_cache`-memoized (4096 entries, always active). Attribute values are always HTML-escaped; children are escaped unless they are `SafeText`. `randomid()` generates stable hash-based IDs.
- **`primitives.py`** — Generic HTML: `A()`, `Button()` (with color/size/icon params), `ButtonGroup()`, `Div()`, `Span()`, `Label()`, `Input()`, `Icon()`, `Popover()`, `PopoverTruncated()`, `SearchField()`, `H1()`, `Modal()`, `SimpleTable()`, `TableRow()`, `TableTd()`, `TableHeader()`, `paginated_table_content()`, `AddForm()`, `Pill()`, `CsrfInput()`, `ModuleScript()`
- **`domain.py`** — Domain-specific: `GameLink()`, `GameStatus()` (colored dot + label), `GameStatusSelector()` (Alpine.js PATCH dropdown), `SessionDeviceSelector()` (Alpine.js PATCH dropdown), `LinkedPurchase()`, `NameWithIcon()`, `PriceConverted()`, `PurchasePrice()`
- **`filters.py`** — Filter UI: `FilterBar()`, `SessionFilterBar()`, `PurchaseFilterBar()` (built from `FilterSelect` widgets)
- **`search_select.py`** — `SearchSelect()` (form combobox) + `FilterSelect()` (include/exclude filter combobox with pinned Any/None modifiers) + `SearchSelectOption`, all built on a shared `_combobox_shell`; wired by `games/static/js/search_select.js`
- **`filters.py`** — Filter UI: `FilterBar()`, `SessionFilterBar()`, `PurchaseFilterBar()`, `SelectableFilter()` (clickable include/exclude chips)
- **`search_select.py`** — `SearchSelect()` + `SearchSelectOption`: search-as-you-type dropdown with removable pill selection, wired by `games/static/js/search_select.js`
**Filter system** (`games/filters.py` + `common/criteria.py`): Stash-inspired structured filtering.
@@ -114,47 +113,30 @@ Only a small number of HTML templates remain (platform icon snippets and partial
### Frontend stack
- **HTMX** (`games/static/js/htmx.min.js`) — partial page updates
- **Alpine.js** (vendored: `alpine.min.js`, `alpine-mask.min.js`) — reactive dropdowns (`GameStatusSelector`, `SessionDeviceSelector`), toast store
- **Flowbite** (vendored: `flowbite.min.js`; `datepicker.umd.js` for the stats YearPicker) — navbar collapse, dropdown toggles
- **Alpine.js** (CDN) — reactive dropdowns (`GameStatusSelector`, `SessionDeviceSelector`), toast store
- **Flowbite** (CDN) — navbar collapse, dropdown toggles
- **Tailwind CSS** — utility classes, compiled from `common/input.css``games/static/base.css`
- All third-party JS is served locally from `games/static/js/` (no CDNs), so pages and browser tests work offline
- **Custom JS** in `games/static/js/`:
- `toast.js` — Alpine.js toast store (listens for `show-toast` HTMX event); also defines `window.fetchWithHtmxTriggers`
- `search_select.js` — SearchSelect/FilterSelect widgets (search-as-you-type, pills, include/exclude filter mode)
- `utils.js` — shared ES-module helpers (`onSwap`, `toISOUTCString`, …)
- **Widget initialization**: widget JS registers with `onSwap(selector, initializeElement)` from `utils.js` — a port of FastHTML's `proc_htmx` built on `htmx.onLoad`. It runs the initializer once per matching element, on initial page load and inside every htmx-swapped fragment. Never hand-roll `DOMContentLoaded`/`htmx:afterSwap` listeners with per-element guard flags.
### Interactive components: custom elements + TypeScript
New interactive components are **custom elements**, not inline JS in Python. A component that needs behavior emits a semantic tag via `custom_element("tag", Props(...))` (light DOM, server-rendered inner markup built with the htpy-style node builders). Behavior lives in `ts/elements/<tag>.ts` (TypeScript, vanilla DOM, `customElements.define`); the native `connectedCallback` replaces `onSwap` (it fires on parse *and* htmx swap). The server↔client contract is one Python `TypedDict` per element registered with `register_element(...)` in `common/components/custom_elements.py`; `manage.py gen_element_types` codegens `ts/generated/props.ts` (interface + attribute reader) so renaming a prop fails `tsc`.
- **Build:** `tsc` per-module (`tsconfig.json`) compiles `ts/``games/static/js/dist/` (build-only, gitignored). `make ts` = codegen + compile; `make ts-check` (in `make check`) = codegen + `tsc --noEmit`; `make dev` runs `tsc --watch`. The Docker image builds CSS + TS in a Node stage. Run `make ts` after editing any `.ts` so e2e/local serving sees fresh output.
- **htpy-style markup:** generic builders take kwargs attributes and `[]` children — `Div(class_="x", hx_get="/y")[child1, child2]` (`class_``class`, `hx_get``hx-get`, `True`→bare attr, `False`/`None`→omitted). Still a walkable `Element` tree, so `Media` bubbles.
- **Do NOT** author HTML/JS as Python f-strings or add new inline Alpine `x-data` blobs. Alpine remains only for trivial pre-existing toggles (toast store, etc.).
- **Tables collect cell media:** `SimpleTable` stringifies cells, so it explicitly `collect_media`s its rows/header and re-attaches it — a custom element in a table cell still gets its `<script>` emitted by `Page()`.
- `toast.js` — Alpine.js toast store (listens for `show-toast` HTMX event)
- `selectable_filter.js` — SelectableFilter widget interaction
- `search_select.js` — SearchSelect widget (search-as-you-type, pills)
- `utils.js` — shared helpers (e.g., `fetchWithHtmxTriggers`)
### Deployment
Docker-based: multi-stage Dockerfile (uv builder → Node assets stage → 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.
**Package manager (pnpm):** front-end deps use **pnpm**, not npm. The pnpm version is pinned in `package.json`'s `packageManager` field and provisioned via **Corepack** (bundled with Node) — the Docker assets stage runs `corepack enable` rather than `npm install -g pnpm`. To bump pnpm, update the `packageManager` field; local, CI, and Docker all follow it. pnpm disables dependency lifecycle scripts by default (opt in via `pnpm.onlyBuiltDependencies`), so the project is unaffected by npm v12's install-script changes.
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.
### Database
SQLite with WAL journal mode. Connection timeout 20s. The `DATA_DIR` setting controls the database file location and is read consistently by both `settings.py` and `entrypoint.sh` (same env var + matching default). Migrations live in `games/migrations/`. There are `GeneratedField`s on the models — these are computed by the database engine and cannot be written from application code.
SQLite with WAL journal mode. Connection timeout 20s. The `DATA_DIR` env var controls the database file location. Migrations live in `games/migrations/`. There are `GeneratedField`s on the models — these are computed by the database engine and cannot be written from application code.
### Configuration
All configurable Django settings are read through `config()` in `timetracker/config.py`, never via bare `os.environ` in `settings.py`. Full reference: `docs/configuration.md`.
- **Resolution priority** (highest first): `NAME__FILE` (opt-in file secret) → `NAME` env var → `.env``settings.ini` (`[timetracker]` section) → in-code default. Missing + no default = `ImproperlyConfigured`.
- `config(name, *, default, cast, allow_file, required_in_prod)`: `cast` handles `bool`/`list`/`int`/`Path`/callable; `allow_file=True` honors `NAME__FILE` (contents `.strip()`-ed); `required_in_prod=True` hard-fails when missing and DEBUG is off.
- `DEBUG` defaults `True` (dev), turned off with `DEBUG=false`. `PROD` is a **deprecated alias** kept for one release.
- `SECRET_KEY` is required in production (insecure default only in DEBUG); supports `SECRET_KEY__FILE`.
- `APP_URL` derives `ALLOWED_HOSTS` and `CSRF_TRUSTED_ORIGINS` when those aren't set explicitly; the two are never merged (different security checks) and each can be overridden directly.
- `TIME_ZONE` reads `TZ` (defaults `Europe/Prague` in debug, `UTC` in prod).
- Django Admin, Debug Toolbar, and `django_extensions` are only available in `DEBUG` mode.
- **Container/entrypoint-only** flags (`PUID`, `PGID`, `CREATE_DEFAULT_SUPERUSER`, `STAGING`, `LOAD_SAMPLE_DATA`) live in `entrypoint.sh`, not the Python config — they are bootstrap concerns, not Django settings.
- `DEBUG` is `True` unless `PROD` env var is set
- `TIME_ZONE` defaults to `Europe/Prague` in debug, otherwise reads `TZ` env var (default `UTC`)
- Django Admin, Debug Toolbar, and `django_extensions` are only available in `DEBUG` mode
- `CSRF_TRUSTED_ORIGINS` is parsed from a comma-separated env var
- `DATA_DIR` env var sets the SQLite database location (defaults to `BASE_DIR`)
- django-q2 cluster: 1 worker, 60s timeout, 120s retry, ORM broker
### Testing
@@ -174,20 +156,13 @@ Tests live in `tests/`. Run with `make test` or `uv run --with pytest-django pyt
Pytest settings are in `pyproject.toml` under `[tool.pytest.ini_options]` (`DJANGO_SETTINGS_MODULE = "timetracker.settings"`).
**Browser/E2E tests** live in `e2e/` and run with `make test-e2e` (`pytest-playwright` driving a real Chromium against pytest-django's `live_server`). `e2e/conftest.py` sets `DJANGO_ALLOW_ASYNC_UNSAFE` and prefers a system Chrome/Chromium; otherwise install browsers once via `uv run playwright install chromium`. All JS (including Alpine/Flowbite) is vendored in `games/static/js/`, so the tests run fully offline. Note that a bare `pytest` (`make test`) collects `e2e/` too, so it needs a browser as well. Key files: `test_widgets_e2e.py` (onSwap initialization lifecycle, FilterSelect/RangeSlider/add-purchase behavior), `test_search_select_e2e.py` (single-select edge cases on a synthetic page).
## Conventions for AI assistants
- **Never write to `GeneratedField`s** (`duration_calculated`, `duration_total`, `price_per_game`, `days_to_finish`). They are computed by the database.
- **Name variables with complete words** — readable, unabbreviated identifiers in both Python and JavaScript (e.g. `template` not `tpl`, `event` not `e`, `element` not `el`, `removeButton` not `removeBtn`, `option`/`value` not single letters in loops). This applies to new code and to code you touch.
- **Use `render_page()` not `render()`** for all full-page HTTP responses. Import from `common.layout`.
- **Build UI with Python components** from `common.components`, not raw HTML strings or Django templates. `SafeText` children pass through unescaped; plain strings are auto-escaped.
- **Components are nodes; use the named builders** — build with `Div()`, `Span()`, `Element("tag", ...)`, etc., which return `Node` objects. For a tag with no builder, add it to the whitelist in `primitives.py` (one line) or use `Element("tag", attrs, children)`. Use `Fragment(a, b, ...)` to group siblings (never `str(a)+str(b)`, which flattens the tree and drops media). Wrap trusted pre-rendered HTML in `Safe(html)` (the `mark_safe` analogue).
- **JS-bearing components declare `Media`, they don't rely on the view** — give a component `class Media: js = (...)` (a `BaseComponent`) or `return node.with_media(Media(js=...))`. `Page()` collects and emits it. Never re-add `scripts=ModuleScript(...)` threading in a view for a component that can declare its own dependency.
- **Filter views** accept `?filter=<JSON>` (structured) and fall back to `?search_string=` (free-text). New filter criteria go in `games/filters.py`; new criterion types go in `common/criteria.py`.
- **Read settings via `config()`** — new Django settings go through `config()` from `timetracker/config.py`, never bare `os.environ.get` in `settings.py`. Declare `cast`/`allow_file`/`required_in_prod` explicitly. Container-bootstrap flags belong in `entrypoint.sh`, not the Python config. See `docs/configuration.md`.
- **Signals handle side-effects** — do not manually recalculate `Game.playtime` or `Purchase.num_purchases`; the signals in `games/signals.py` do this on save/delete.
- **Button colors**: `blue` (primary action), `red` (destructive), `gray` (secondary), `green` (positive). Icon buttons use `icon=True`.
- **Inline Alpine.js** is used for client-side reactivity in domain components (`GameStatusSelector`, `SessionDeviceSelector`). The pattern is `x-data="{...}"` with `fetchWithHtmxTriggers()` for PATCH API calls.
- **Platform icons** are SVG snippets in `games/templates/icons/<slug>.html`. Add new ones there and reference them by slug in `Platform.icon`.
- **Name compound types explicitly** — if a `tuple`, `dict`, or other compound value is passed between functions or appears in multiple signatures, give it a named type (`TypedDict`, `NamedTuple`, or a `type` alias) rather than repeating the structural annotation. This applies even to small types used in only a few places; the name carries intent that the structure cannot. Examples: `LabeledOption = tuple[str, str]` instead of repeating `tuple[str, str]` for (value, label) pairs; `RangeValues(min, max)` instead of `tuple[str, str]` for range bounds.
-23
View File
@@ -15,25 +15,6 @@ COPY . .
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev
# Codegen the TypeScript prop contracts (needs Django); tsc compiles them in
# the assets stage below.
RUN uv run python manage.py gen_element_types
# Front-end assets: Tailwind CSS + the TypeScript custom elements. Built here so
# the compiled output ships in the image (dist/ is build-only, not committed).
FROM node:22-bookworm-slim AS assets
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
# Corepack ships with Node and activates the pnpm version pinned in
# package.json's "packageManager" field — no npm bootstrap needed.
RUN corepack enable && 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
@@ -63,10 +44,6 @@ WORKDIR /home/timetracker/app
COPY --from=builder --chown=timetracker:timetracker /home/timetracker/app /home/timetracker/app
# Built front-end assets from the Node stage (Tailwind CSS + compiled TS).
COPY --from=assets --chown=timetracker:timetracker /app/games/static/base.css /home/timetracker/app/games/static/base.css
COPY --from=assets --chown=timetracker:timetracker /app/games/static/js/dist /home/timetracker/app/games/static/js/dist
COPY --chown=timetracker:timetracker Caddyfile /etc/caddy/Caddyfile
COPY --chown=timetracker:timetracker supervisor.conf /etc/supervisor/conf.d/supervisor.conf
COPY --chown=timetracker:timetracker entrypoint.sh /
+4 -20
View File
@@ -22,25 +22,12 @@ init:
pnpm install
$(MAKE) loadplatforms
server:
uv run python -Wa manage.py runserver
gen-element-types:
uv run python manage.py gen_element_types
ts: gen-element-types
pnpm exec tsc
ts-check: gen-element-types
pnpm exec tsc --noEmit
dev:
@pnpm concurrently \
--names "Django,Tailwind,TS" \
--prefix-colors "blue,green,magenta" \
--names "Django,Tailwind" \
--prefix-colors "blue,green" \
"uv run python -Wa manage.py runserver" \
"pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css --watch" \
"pnpm exec tsc --watch"
"pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css --watch"
caddy:
@@ -80,9 +67,6 @@ uv.lock: pyproject.toml
test: uv.lock
uv run --with pytest-django pytest
test-e2e: uv.lock
uv run pytest e2e/
lint:
uv run ruff check
@@ -95,7 +79,7 @@ format:
format-check:
uv run ruff format --check
check: lint format-check ts-check test
check: lint format-check test
date:
uv run python -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))'
+37 -93
View File
@@ -4,25 +4,45 @@ Split into core / primitives / domain / filters submodules; this package
re-exports the public API so ``from common.components import X`` keeps working.
"""
from common.utils import truncate
from common.components.core import (
BaseComponent,
Element,
Fragment,
Component,
HTMLAttribute,
HTMLTag,
Media,
Node,
Safe,
_render_element,
collect_media,
randomid,
render,
)
from common.components.custom_elements import SessionTimestampButtons, register_element
from common.components.date_range_picker import (
DateRangeCalendar,
DateRangeField,
DateRangePicker,
from common.components.primitives import (
A,
AddForm,
Button,
ButtonGroup,
CsrfInput,
Div,
ExternalScript,
H1,
Icon,
Input,
Modal,
ModuleScript,
Pill,
Popover,
PopoverTruncated,
SearchField,
SimpleTable,
Span,
Label,
TableHeader,
TableRow,
TableTd,
YearPicker,
paginated_table_content,
)
from common.components.search_select import (
SearchSelect,
SearchSelectOption,
searchselect_selected,
)
from common.components.domain import (
GameLink,
@@ -36,82 +56,23 @@ from common.components.domain import (
_resolve_name_with_icon,
)
from common.components.filters import (
DeviceFilterBar,
FilterBar,
PlatformFilterBar,
PlayEventFilterBar,
PurchaseFilterBar,
SelectableFilter,
SessionFilterBar,
StringFilter,
)
from common.components.primitives import (
H1,
A,
AddForm,
ButtonGroup,
Checkbox,
CsrfInput,
Div,
ExternalScript,
Icon,
Input,
Label,
Li,
Modal,
ModuleScript,
Pill,
Popover,
PopoverTruncated,
Radio,
SearchField,
SimpleTable,
Span,
StaticScript,
StyledButton,
TableHeader,
TableRow,
TableTd,
Td,
Template,
Th,
Tr,
Ul,
YearPicker,
custom_element_builder,
paginated_table_content,
)
from common.components.search_select import (
DEFAULT_PREFETCH,
FilterSelect,
LabeledOption,
SearchSelect,
SearchSelectOption,
searchselect_selected,
)
from common.utils import truncate
__all__ = [
"truncate",
"BaseComponent",
"register_element",
"SessionTimestampButtons",
"custom_element_builder",
"Element",
"Fragment",
"Media",
"Node",
"Safe",
"collect_media",
"render",
"Component",
"HTMLAttribute",
"HTMLTag",
"_render_element",
"randomid",
"A",
"AddForm",
"StyledButton",
"Button",
"ButtonGroup",
"Checkbox",
"CsrfInput",
"Div",
"ExternalScript",
@@ -123,27 +84,16 @@ __all__ = [
"Pill",
"Popover",
"PopoverTruncated",
"Radio",
"SearchField",
"DEFAULT_PREFETCH",
"FilterSelect",
"LabeledOption",
"SearchSelect",
"SearchSelectOption",
"searchselect_selected",
"SimpleTable",
"Span",
"StaticScript",
"Label",
"Li",
"Td",
"Th",
"Tr",
"Ul",
"TableHeader",
"TableRow",
"TableTd",
"Template",
"YearPicker",
"paginated_table_content",
"GameLink",
@@ -155,14 +105,8 @@ __all__ = [
"PurchasePrice",
"SessionDeviceSelector",
"_resolve_name_with_icon",
"DateRangeCalendar",
"DateRangeField",
"DateRangePicker",
"FilterBar",
"PurchaseFilterBar",
"SelectableFilter",
"SessionFilterBar",
"DeviceFilterBar",
"PlatformFilterBar",
"PlayEventFilterBar",
"StringFilter",
]
+27 -306
View File
@@ -1,20 +1,6 @@
"""Node layer: the lazy component tree, its renderer, and media collection.
A FastHTML-style model. Everything renderable is a :class:`Node`. The single
:class:`Element` class represents *any* HTML element (tag + attrs + children);
named builders like ``Div`` / ``Span`` are generated from a whitelist rather
than hand-written per tag (see ``primitives.py``). Higher-level, behaviour- or
media-bearing components subclass :class:`BaseComponent` and implement
``render()`` returning a node subtree.
Nodes are *lazy*: they hold structure and render to HTML only when asked
(``str(node)`` / ``node.__html__()`` / :func:`render`). This is what lets
``Page()`` walk a finished tree and collect every component's declared JS
(:class:`Media`) instead of each view threading ``scripts=`` by hand.
"""
"""Escaping core: the Component builder and its memoised renderer."""
import hashlib
from collections.abc import Sequence
from functools import lru_cache
from django.utils.html import escape
@@ -24,181 +10,24 @@ from django.utils.safestring import SafeText, mark_safe
HTMLAttribute = tuple[str, str | int | bool]
# Type for a builder's ``attributes`` parameter. Covariant ``Sequence`` so a
# caller's ``list[tuple[str, str]]`` is accepted (a plain ``list[HTMLAttribute]``
# would be invariant and reject it). Locals that get ``.append()``-ed should
# stay a concrete ``list[HTMLAttribute]``.
Attributes = Sequence[HTMLAttribute]
HTMLTag = str
# ── Media: declarative JS dependencies ──────────────────────────────────────
def _dedup(*sequences: tuple[str, ...]) -> tuple[str, ...]:
"""First-seen dedup that preserves declaration order across sequences."""
seen: dict[str, None] = {}
for sequence in sequences:
for item in sequence:
seen.setdefault(item, None)
return tuple(seen)
class Media:
"""A component's JS dependencies, modelled on ``django.forms.Media``.
``js`` are static ES-module filenames (rendered as ``ModuleScript``);
``js_external`` are vendored UMD / classic bundles (rendered as
``StaticScript``). Addition merges with first-seen, order-preserving dedup,
so a page that uses a component many times emits each script once.
"""
__slots__ = ("js", "js_external")
def __init__(
self,
js: tuple[str, ...] | list[str] = (),
js_external: tuple[str, ...] | list[str] = (),
) -> None:
self.js = tuple(js)
self.js_external = tuple(js_external)
def __add__(self, other: "Media | None") -> "Media":
if not other:
return self
return Media(
_dedup(self.js, other.js),
_dedup(self.js_external, other.js_external),
)
def __radd__(self, other: "Media | None") -> "Media":
# Supports ``sum(medias, Media())`` and ``0 + media``.
if not other or other == 0:
return self
return other.__add__(self)
def __bool__(self) -> bool:
return bool(self.js or self.js_external)
def __eq__(self, other: object) -> bool:
return (
isinstance(other, Media)
and self.js == other.js
and self.js_external == other.js_external
)
def __hash__(self) -> int:
return hash((self.js, self.js_external))
def __repr__(self) -> str:
return f"Media(js={self.js!r}, js_external={self.js_external!r})"
# ── Node tree ────────────────────────────────────────────────────────────────
class Node:
"""Base class for everything renderable to HTML."""
# Declared dependencies. Class-level default is shared and empty; concrete
# components override with their own ``Media(...)``.
media: Media = Media()
def _render(self) -> str:
raise NotImplementedError
def collect_media(self) -> Media:
"""Total media of this node and its subtree."""
return self.media
def with_media(self, media: Media) -> "Node":
"""Attach JS dependencies to this node and return it (for fluent use).
Lets a function-built node declare its media without becoming a full
``BaseComponent`` subclass: ``return Div(...).with_media(Media(js=...))``.
"""
self.media = self.media + media
return self
# A node's rendered output is always safe HTML by construction (Element
# escapes unsafe children; Safe wraps trusted markup; Fragment escapes plain
# strings). So both `__html__` (Django's conditional_escape hook) and
# `__str__` return a SafeString — this is what keeps ``str(node)`` safe when
# fed back into a child list or template, matching the old SafeText shims.
def __html__(self) -> SafeText:
return mark_safe(self._render())
def __str__(self) -> SafeText:
return mark_safe(self._render())
# A renderable child is a node or a string. Strings are ALWAYS escaped (a string
# is untrusted text — ``SafeText``/``mark_safe`` is escaped too); trusted
# pre-rendered HTML must be a ``Safe`` node. ``Children`` is the type for a
# builder's ``children``
# parameter: a sequence of child nodes/strings, a bare string, or nothing. The
# sequence is a covariant ``Sequence`` so ``list[Element]`` / ``list[Node]`` are
# accepted (a plain ``list[str]`` would be invariant and reject them). A single
# bare ``Node`` is accepted only by ``Element`` itself (which wraps it); the
# higher-level builders take ``Children``.
Child = Node | str
Children = Sequence[Child] | Node | str | None
def as_children(children: Children) -> list[Child]:
"""Normalise a builder's ``children`` argument to a flat list.
Accepts ``None`` (→ empty), a single node/string (→ one-element list), or a
sequence of them. Lets builders drop the ``children if isinstance(children,
list) else [children]`` dance and get a properly typed ``list[Child]``.
"""
if children is None:
return []
if isinstance(children, (str, Node)):
return [children]
return list(children)
def as_attributes(attributes: "Attributes | None") -> list[HTMLAttribute]:
"""Normalise an ``attributes`` argument to a mutable ``list[HTMLAttribute]``.
Builders take a covariant ``Attributes`` (so callers can pass a
``list[tuple[str, str]]``) but often append to or concatenate the value;
this turns it into a concrete list they can mutate.
"""
return list(attributes) if attributes else []
def _child_key(child: object) -> tuple[str, bool]:
"""Normalise a child to a ``(text, is_safe)`` pair.
Only :class:`Node` children render unescaped — that includes :class:`Safe`,
the one sanctioned way to put trusted pre-rendered HTML into the tree. Every
*string* child is escaped, ``SafeText``/``mark_safe`` included: a string is
always treated as untrusted text, so trusted markup must be wrapped in
``Safe(...)`` rather than smuggled in as a safe string. ``is_safe`` is part
of the render cache key so a safe ``"<b>"`` and an unsafe ``"<b>"`` never
collide.
"""
if isinstance(child, Node):
return (child._render(), True)
if isinstance(child, str):
return (child, False)
return (str(child), False)
@lru_cache(maxsize=4096)
def _render_element(
tag_name: str,
attrs_key: tuple[tuple[str, str], ...],
children_key: tuple[tuple[str, bool], ...],
) -> str:
"""Pure, memoized HTML builder. Identical (tag, attrs, children) render once.
"""Pure, memoized HTML builder behind `Component`.
``attrs_key`` is (name, stringified value) pairs (values always escaped);
``children_key`` is (text, is_safe) pairs (safe passes through, else escaped).
Inputs are fully hashable and fully determine the output, so identical
elements are rendered once. `attrs_key` is (name, stringified value) pairs
(attribute values are always escaped). `children_key` is (child, is_safe)
pairs: SafeText children pass through, plain strings are escaped. The
`is_safe` flag is part of the key on purpose — otherwise a safe ``"<b>"``
and an unsafe ``"<b>"`` (equal as strings) would collide and one would
render with the wrong escaping.
"""
children_blob = "\n".join(
child if is_safe else escape(child) for child, is_safe in children_key
@@ -212,132 +41,24 @@ def _render_element(
return f"<{tag_name}{attributes_blob}>{children_blob}</{tag_name}>"
class Element(Node):
"""Any HTML element: a tag name, attributes and children.
Children may be other nodes, ``SafeText``, or plain strings (escaped).
Rendering goes through the memoized :func:`_render_element`.
"""
def __init__(
self,
tag_name: str,
attributes: Attributes | None = None,
children: "Children | Node" = None,
) -> None:
if not tag_name:
raise ValueError("tag_name is required.")
self.tag_name = tag_name
self.attributes = attributes or []
if children is None:
children = []
elif isinstance(children, (str, Node)):
children = [children]
self.children = children
def __getitem__(self, children: "Children | Node") -> "Element":
"""htpy-style children: ``Div(class_="x")[child1, child2]``.
Returns an Element with the same tag/attributes/media and these
children, so the tree stays walkable (Media still bubbles)."""
items = children if isinstance(children, tuple) else (children,)
clone = Element(self.tag_name, self.attributes, list(items))
clone.media = self.media
return clone
def collect_media(self) -> Media:
media = self.media
for child in self.children:
if isinstance(child, Node):
media = media + child.collect_media()
return media
def _render(self) -> str:
attrs_key = tuple((name, str(value)) for name, value in self.attributes)
children_key = tuple(_child_key(child) for child in self.children)
return _render_element(self.tag_name, attrs_key, children_key)
class Safe(Node):
"""A node wrapping pre-rendered, trusted HTML (the ``mark_safe`` analogue).
Used as the migration bridge for components still built from f-strings:
they return ``Safe(html)`` and declare their ``media`` explicitly rather
than atomising their markup into a node tree up front.
"""
def __init__(self, html: object, media: Media | None = None) -> None:
self._html = str(html)
if media is not None:
self.media = media
def _render(self) -> str:
return self._html
class Fragment(Node):
"""An ordered group of children with no wrapping tag.
Replaces ``mark_safe(str(a) + str(b))`` / ``"\\n".join(...)`` composition,
so media still bubbles up from the grouped children.
"""
def __init__(self, *children: object, separator: str = "") -> None:
self.children = [c for c in children if c is not None and c != ""]
self.separator = separator
def collect_media(self) -> Media:
media = Media()
for child in self.children:
if isinstance(child, Node):
media = media + child.collect_media()
return media
def _render(self) -> str:
parts = []
for child in self.children:
text, is_safe = _child_key(child)
parts.append(text if is_safe else escape(text))
return self.separator.join(parts)
class BaseComponent(Node):
"""Base for higher-level components: implement ``render()`` returning a node
subtree and declare ``media`` (a :class:`Media`).
``render()`` is called once and memoized; ``collect_media()`` returns this
component's own media merged with the rendered subtree's.
"""
def render(self) -> Node:
raise NotImplementedError
def _tree(self) -> Node:
cached = getattr(self, "_tree_cache", None)
if cached is None:
cached = self.render()
self._tree_cache = cached
return cached
def _render(self) -> str:
return self._tree()._render()
def collect_media(self) -> Media:
return self.media + self._tree().collect_media()
def render(node: "Node | str") -> SafeText:
"""Render a node (or pass a string through) to safe HTML."""
if isinstance(node, Node):
return mark_safe(node._render())
return mark_safe(str(node))
def collect_media(node: "Node | str") -> Media:
"""Collect the media of a node tree (empty for a bare string)."""
if isinstance(node, Node):
return node.collect_media()
return Media()
def Component(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
tag_name: str = "",
) -> SafeText:
"""Render an HTML element. Attribute values are always escaped; children are
escaped unless they are `SafeText` (so nested components pass through),
preventing accidental HTML injection. Rendering is memoized via
`_render_element`."""
attributes = attributes or []
children = children or []
if not tag_name:
raise ValueError("tag_name is required.")
if isinstance(children, str):
children = [children]
attrs_key = tuple((name, str(value)) for name, value in attributes)
children_key = tuple((child, isinstance(child, SafeText)) for child in children)
return mark_safe(_render_element(tag_name, attrs_key, children_key))
def randomid(seed: str = "", content: str = "", length: int = 10) -> str:
-127
View File
@@ -1,127 +0,0 @@
"""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")
-354
View File
@@ -1,354 +0,0 @@
"""DateRangePicker: a segmented date-range input with a calendar popup.
``DateRangePicker`` composes two parts:
- ``DateRangeField`` — the visible widget, styled as a single input. Each
date is split into per-part segments (``DD``/``MM``/``YYYY``, ordered by
``common.time.dateformat_hyphenated``) that the user fills digit by digit,
plus a calendar icon that opens the popup.
- ``DateRangeCalendar`` — the popup: a preset column (today, yesterday,
last 7 days, …), a month grid rendered client-side, and a
Cancel / Clear / Select footer.
The committed value lives in two hidden ISO-date inputs named
``{input_name_prefix}-min`` / ``{input_name_prefix}-max`` — the same contract
as the older ``DateRangeFilter``, so ``filter_bar.js`` serializes either
widget into a ``DateCriterion`` unchanged. All behaviour is wired by
``games/static/js/date_range_picker.js``.
"""
from common.components.core import Element, HTMLAttribute, Media, Node, Safe
from common.components.primitives import Div, Input, Span
from common.time import DatePartSpec, date_parts
# Wired by date_range_picker.js.
_DATE_RANGE_MEDIA = Media(js=("date_range_picker.js",))
_FIELD_CONTAINER_CLASS = (
"flex items-center gap-0.5 w-full rounded-base border border-default-medium "
"bg-neutral-secondary-medium text-sm text-heading p-1.5 cursor-text "
"focus-within:ring-1 focus-within:ring-brand focus-within:border-brand"
)
# The segments must not stand out from the container: transparent background,
# no border, and only a subtle highlight when active (focused).
_SEGMENT_INPUT_CLASS = (
"bg-transparent border-0 p-0 text-center text-sm text-heading "
"placeholder:text-body rounded-xs focus:outline-none focus:ring-0 "
"focus:bg-brand/30 caret-transparent"
)
_SEGMENT_WIDTH_CLASSES = {2: "w-[2.5ch]", 4: "w-[4.5ch]"}
_CALENDAR_ICON_SVG = (
'<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" '
'stroke="currentColor" aria-hidden="true">'
'<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.5'
"A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5"
"A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5"
'A2.25 2.25 0 0 1 21 11.25v7.5"/>'
"</svg>"
)
_PRESET_OPTIONS: list[tuple[str, str]] = [
("today", "Today"),
("yesterday", "Yesterday"),
("last_7_days", "Last 7 days"),
("last_30_days", "Last 30 days"),
("this_month", "This month"),
("last_month", "Last month"),
("this_year", "This year"),
]
_PRESET_BUTTON_CLASS = (
"px-3 py-1.5 text-sm text-start text-body hover:text-heading "
"hover:bg-neutral-tertiary-medium rounded-base cursor-pointer whitespace-nowrap"
)
_NAV_BUTTON_CLASS = (
"p-1.5 text-body hover:text-heading hover:bg-neutral-tertiary-medium "
"rounded-base cursor-pointer"
)
_FOOTER_BUTTON_CLASS = (
"px-3 py-1.5 text-sm font-medium rounded-base cursor-pointer "
"text-heading bg-neutral-secondary-medium border border-default-medium "
"hover:bg-neutral-tertiary-medium"
)
_FOOTER_SELECT_BUTTON_CLASS = (
"px-3 py-1.5 text-sm font-medium rounded-base cursor-pointer "
"text-white bg-brand border border-transparent hover:bg-brand-strong"
)
def _iso_part_values(iso_value: str, parts: list[DatePartSpec]) -> dict[str, str]:
"""Split an ISO ``YYYY-MM-DD`` string into per-part initial values.
Returns an empty mapping for empty/malformed input so a bad stored filter
renders as empty segments instead of crashing."""
if not iso_value:
return {}
pieces = iso_value.split("-")
if len(pieces) != 3:
return {}
year, month, day = pieces
values = {"year": year, "month": month, "day": day}
if any(not values[part.name].isdigit() for part in parts):
return {}
return values
def _segment_input(*, part: DatePartSpec, side: str, label: str, value: str) -> Node:
side_label = "from" if side == "min" else "to"
return Input(
attributes=[
("inputmode", "numeric"),
("autocomplete", "off"),
("maxlength", str(part.length)),
("placeholder", part.placeholder),
("value", value),
("data-date-part", part.name),
("data-date-side", side),
("aria-label", f"{label} {side_label} {part.name}"),
(
"class",
f"{_SEGMENT_INPUT_CLASS} "
f"{_SEGMENT_WIDTH_CLASSES.get(part.length, 'w-[4.5ch]')}",
),
],
)
def _segment_group(*, side: str, label: str, iso_value: str) -> Node:
"""One date's worth of segments (``DD - MM - YYYY``) for a range side."""
parts = date_parts()
initial_values = _iso_part_values(iso_value, parts)
children: list[Node] = []
for index, part in enumerate(parts):
if index > 0:
children.append(
Span(
attributes=[("class", "text-body select-none")],
children=["-"],
)
)
children.append(
_segment_input(
part=part,
side=side,
label=label,
value=initial_values.get(part.name, ""),
)
)
return Span(
attributes=[
("class", "flex items-center gap-0.5"),
("data-date-range-side", side),
],
children=children,
)
def DateRangeField(
*,
label: str,
input_name_prefix: str,
min_value: str = "",
max_value: str = "",
) -> Node:
"""The visible half of the DateRangePicker: a single-input-looking
container holding two segmented dates, a calendar toggle, and the two
hidden ISO inputs (``{prefix}-min`` / ``{prefix}-max``) that carry the
committed value to ``filter_bar.js``."""
min_input_id = f"{input_name_prefix}-min"
max_input_id = f"{input_name_prefix}-max"
return Div(
attributes=[
("class", _FIELD_CONTAINER_CLASS),
("data-date-range-field", ""),
],
children=[
Input(
type="hidden",
attributes=[
("name", min_input_id),
("id", min_input_id),
("value", min_value),
("data-date-range-hidden", "min"),
],
),
Input(
type="hidden",
attributes=[
("name", max_input_id),
("id", max_input_id),
("value", max_value),
("data-date-range-hidden", "max"),
],
),
_segment_group(side="min", label=label, iso_value=min_value),
Span(
attributes=[("class", "text-body select-none px-0.5")],
children=[""],
),
_segment_group(side="max", label=label, iso_value=max_value),
Element(
"button",
attributes=[
("type", "button"),
("data-date-range-calendar-toggle", ""),
("aria-label", f"Open {label} calendar"),
(
"class",
"ms-auto p-1 text-body hover:text-heading rounded "
"cursor-pointer shrink-0",
),
],
children=[Safe(_CALENDAR_ICON_SVG)],
),
],
)
def _calendar_nav_button(direction: str, arrow: str, label: str) -> Node:
return Element(
"button",
attributes=[
("type", "button"),
(f"data-date-range-{direction}", ""),
("aria-label", label),
("class", _NAV_BUTTON_CLASS),
],
children=[arrow],
)
def _footer_button(action: str, label: str, button_class: str) -> Node:
return Element(
"button",
attributes=[
("type", "button"),
(f"data-date-range-{action}", ""),
("class", button_class),
],
children=[label],
)
def DateRangeCalendar(*, input_name_prefix: str) -> Node:
"""The popup half of the DateRangePicker: preset column, month grid
(filled client-side into ``[data-date-range-grid]``), and the
Cancel / Clear / Select footer. Hidden until the calendar toggle opens it."""
preset_buttons = [
Element(
"button",
attributes=[
("type", "button"),
("data-date-range-preset", preset_value),
("class", _PRESET_BUTTON_CLASS),
],
children=[preset_label],
)
for preset_value, preset_label in _PRESET_OPTIONS
]
return Div(
attributes=[
(
"class",
"hidden absolute z-20 top-full start-0 mt-1 flex "
"rounded-base border border-default-medium "
"bg-neutral-secondary-medium shadow-lg",
),
("data-date-range-calendar", ""),
("data-input-name-prefix", input_name_prefix),
],
children=[
Div(
attributes=[
(
"class",
"flex flex-col gap-0.5 p-2 border-e border-default-medium",
),
("data-date-range-presets", ""),
],
children=preset_buttons,
),
Div(
attributes=[("class", "p-2")],
children=[
Div(
attributes=[
("class", "flex items-center justify-between gap-2"),
],
children=[
_calendar_nav_button("prev", "", "Previous month"),
Span(
attributes=[
("class", "text-sm font-medium text-heading"),
("data-date-range-month-label", ""),
],
),
_calendar_nav_button("next", "", "Next month"),
],
),
Div(
attributes=[
("class", "grid grid-cols-7 gap-y-0.5 mt-1"),
("data-date-range-grid", ""),
],
),
Div(
attributes=[
(
"class",
"flex justify-end gap-2 mt-2 pt-2 border-t "
"border-default-medium",
),
],
children=[
_footer_button("cancel", "Cancel", _FOOTER_BUTTON_CLASS),
_footer_button("clear", "Clear", _FOOTER_BUTTON_CLASS),
_footer_button(
"select", "Select", _FOOTER_SELECT_BUTTON_CLASS
),
],
),
],
),
],
)
def DateRangePicker(
*,
label: str,
input_name_prefix: str,
min_value: str = "",
max_value: str = "",
) -> Node:
"""A date-range widget: segmented manual entry plus a calendar popup.
Drop-in replacement for ``DateRangeFilter`` — exposes the same hidden
``{prefix}-min`` / ``{prefix}-max`` ISO inputs, so the filter-bar
serializer needs no changes. ``min_value`` / ``max_value`` are ISO
``YYYY-MM-DD`` strings used to prefill both the segments and the hidden
inputs."""
attributes: list[HTMLAttribute] = [
("class", "date-range-picker relative"),
("data-date-range-picker", ""),
("data-input-name-prefix", input_name_prefix),
]
return Div(
attributes=attributes,
children=[
DateRangeField(
label=label,
input_name_prefix=input_name_prefix,
min_value=min_value,
max_value=max_value,
),
DateRangeCalendar(input_name_prefix=input_name_prefix),
],
).with_media(_DATE_RANGE_MEDIA)
+145 -111
View File
@@ -4,8 +4,9 @@ from typing import Any
from django.template.defaultfilters import floatformat
from django.urls import reverse
from django.utils.safestring import SafeText, mark_safe
from common.components.core import Children, Node, Safe, as_children
from common.components.core import Component, HTMLTag
from common.components.primitives import (
A,
Div,
@@ -20,23 +21,25 @@ from games.models import Game, Purchase, Session
def GameLink(
game_id: int,
name: str = "",
children: Children = None,
) -> Node:
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
"""Link to a game's detail page. Uses children (slot) if provided, otherwise name."""
from django.urls import reverse
display = as_children(children) or [name]
children = children or []
display = children if children else [name]
link = reverse("games:view_game", args=[game_id])
return Span(
attributes=[("class", "truncate-container")],
children=[
A(
href=link,
Component(
tag_name="a",
attributes=[
("href", link),
("class", "underline decoration-slate-500 sm:decoration-2"),
],
children=display,
children=display if isinstance(display, list) else [display],
),
],
)
@@ -52,11 +55,11 @@ _STATUS_COLORS = {
def GameStatus(
children: Children = None,
children: list[HTMLTag] | HTMLTag | None = None,
status: str = "u",
display: str = "",
class_: str = "",
) -> Node:
) -> SafeText:
"""Colored status dot with label. Status codes: u/p/f/a/r."""
children = children or []
outer_class = (
@@ -74,13 +77,13 @@ def GameStatus(
return Span(
attributes=[("class", outer_class)],
children=[dot] + as_children(children),
children=[dot] + (children if isinstance(children, list) else [children]),
)
def PriceConverted(
children: Children = None,
) -> Node:
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
"""Wrap content in a span that indicates the price was converted."""
children = children or []
return Span(
@@ -88,11 +91,11 @@ def PriceConverted(
("title", "Price is a result of conversion and rounding."),
("class", "decoration-dotted underline"),
],
children=as_children(children),
children=children if isinstance(children, list) else [children],
)
def LinkedPurchase(purchase: Purchase) -> Node:
def LinkedPurchase(purchase: Purchase) -> SafeText:
link = reverse("games:view_purchase", args=[int(purchase.id)])
link_content = ""
popover_content = ""
@@ -129,7 +132,7 @@ def LinkedPurchase(purchase: Purchase) -> Node:
),
PopoverTruncated(
input_string=link_content,
popover_content=Safe(popover_content),
popover_content=mark_safe(popover_content),
popover_if_not_truncated=popover_if_not_truncated,
),
],
@@ -143,7 +146,7 @@ def NameWithIcon(
session: Session | None = None,
linkify: bool = True,
emulated: bool = False,
) -> Node:
) -> SafeText:
_name, platform, final_emulated, create_link, link = _resolve_name_with_icon(
name, game, session, linkify
)
@@ -201,7 +204,7 @@ def _resolve_name_with_icon(
return _name, platform, final_emulated, create_link, link
def PurchasePrice(purchase) -> Node:
def PurchasePrice(purchase) -> SafeText:
return Popover(
popover_content=f"{floatformat(purchase.price)} {purchase.price_currency}",
wrapped_content=f"{floatformat(purchase.converted_price)} {purchase.converted_currency}",
@@ -209,100 +212,131 @@ def PurchasePrice(purchase) -> Node:
)
_SELECTOR_MENU_CLASS = (
"absolute top-[105%] left-0 w-full whitespace-nowrap z-10 text-sm "
"font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-t-none "
"border border-gray-200 dark:border-gray-700"
)
_SELECTOR_TOGGLE_CLASS = (
"relative px-4 py-2 text-sm font-medium bg-white border border-gray-200 "
"rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 "
"dark:hover:text-white dark:hover:bg-gray-700 hover:cursor-pointer"
)
_SELECTOR_OPTION_CLASS = (
"block w-full text-left px-4 py-2 rounded-sm cursor-pointer "
"hover:bg-gray-700 hover:text-white dark:hover:bg-gray-700 "
"dark:hover:text-white border-0"
)
def GameStatusSelector(game, game_statuses, csrf_token: str) -> Node:
"""Light-DOM custom element; behavior in ts/elements/game-status-selector.ts."""
from common.components.core import Element
from common.components.custom_elements import _GameStatusSelector, GameStatusSelectorProps
from common.components.primitives import Li, Ul
options = [
Li()[
Element(
"button",
[
("type", "button"),
("data-option", ""),
("data-value", str(value)),
("class", _SELECTOR_OPTION_CLASS),
],
GameStatus(status=value, children=[label], display="flex"),
)
]
def GameStatusSelector(game, game_statuses, csrf_token: str) -> SafeText:
"""Alpine.js dropdown to change a game's status."""
options_html = "\n".join(
f"<template x-if=\"status == '{value}'\">"
f"{GameStatus(status=value, children=[label], display='flex')}"
f"</template>"
for value, label in game_statuses
]
current_label = Span(data_label="")[
GameStatus(
status=game.status,
children=[game.get_status_display()],
display="flex",
)
list_items = "\n".join(
f"<li><a href=\"#\" @click.prevent.stop=\"setStatus('{value}', '{label}'); open = false;\" "
f'class="block px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 '
f'dark:focus:ring-blue-500 dark:focus:text-white rounded-sm no-underline! border-0!" '
f":class=\"{{'font-bold': status === '{value}'}}\">"
f"{GameStatus(status=value, children=[label], display='flex', class_='text-slate-300')}"
f"</a></li>"
for value, label in game_statuses
)
return mark_safe(f"""
<div class="flex gap-2 items-center"
x-data="{{
status: '{game.status}',
status_display: '{game.get_status_display()}',
open: false,
saving: false,
setStatus(newStatus, newStatusDisplay) {{
this.status = newStatus;
this.status_display = newStatusDisplay;
this.saving = true;
fetchWithHtmxTriggers(`/api/games/{game.id}/status`, {{
method: 'PATCH',
headers: {{
'Content-Type': 'application/json',
'X-CSRFToken': '{csrf_token}'
}},
body: JSON.stringify({{ status: newStatus }})
}})
.then(() => {{
document.body.dispatchEvent(new CustomEvent('status-changed'));
}})
.catch(() => {{
console.error('Failed to update status');
}})
.finally(() => this.saving = false);
}}
}}">
{_dropdown_button_html(options_html, list_items)}
</div>
""")
def SessionDeviceSelector(session, session_devices, csrf_token: str) -> SafeText:
"""Alpine.js dropdown to change a session's device."""
device_id = session.device_id or "null"
device_name = (session.device.name if session.device else "Unknown").replace(
"'", "\\'"
)
list_items = "\n".join(
f'<li><a href="#" @click.prevent.stop="setDevice({d.id}, \'{d.name.replace(chr(39), chr(92) + chr(39))}\'); open = false;" '
f'class="block px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 '
f'dark:focus:ring-blue-500 dark:focus:text-white rounded-sm no-underline! border-0!" '
f":class=\"{{'font-bold': deviceId === {d.id}}}\">{d.name}</a></li>"
for d in session_devices
)
return mark_safe(f"""
<div class="flex gap-2 items-center"
x-data="{{
originalDeviceId: {device_id},
originalDeviceName: '{device_name}',
deviceId: {device_id},
deviceName: '{device_name}',
open: false,
saving: false,
setDevice(newDeviceId, newDeviceName) {{
this.deviceId = newDeviceId;
this.deviceName = newDeviceName;
this.saving = true;
fetchWithHtmxTriggers(`/api/session/{session.id}/device`, {{
method: 'PATCH',
headers: {{
'Content-Type': 'application/json',
'X-CSRFToken': '{csrf_token}'
}},
body: JSON.stringify({{ device_id: newDeviceId }})
}})
.then((res) => {{
document.body.dispatchEvent(new CustomEvent('device-changed'));
}})
.catch(() => {{
this.deviceName = this.originalDeviceName;
this.deviceId = this.originalDeviceId;
console.error('Failed to update device');
}})
.finally(() => this.saving = false);
}}
}}">
{
_dropdown_button_html(
'<span x-text="deviceName"></span>' + str(Icon("arrowdown")), list_items
)
]
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")
],
}
</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 _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:
"""Light-DOM custom element; behavior in ts/elements/session-device-selector.ts."""
from common.components.core import Element
from common.components.custom_elements import _SessionDeviceSelector, SessionDeviceSelectorProps
from common.components.primitives import Li, Ul
current_name = session.device.name if session.device else "Unknown"
options = [
Li()[
Element(
"button",
[
("type", "button"),
("data-option", ""),
("data-value", str(device.id)),
("class", _SELECTOR_OPTION_CLASS),
],
children=[device.name],
)
]
for device in session_devices
]
toggle = Element(
"button",
[("type", "button"), ("data-toggle", ""), ("class", _SELECTOR_TOGGLE_CLASS)],
Span(class_="flex flex-row gap-4 justify-between items-center")[
Span(data_label="")[current_name], Icon("arrowdown")
],
)
menu = Div(data_menu="", hidden=True, class_=_SELECTOR_MENU_CLASS)[Ul()[*options]]
dropdown = Div(
data_dropdown="", class_="inline-flex rounded-md shadow-2xs relative"
)[toggle, menu]
return _SessionDeviceSelector(session_id=session.id, csrf=csrf_token)[
Div(class_="flex gap-2 items-center")[dropdown]
]
+488 -1042
View File
File diff suppressed because it is too large Load Diff
+172 -288
View File
@@ -1,34 +1,15 @@
"""Generic HTML primitives (no domain knowledge).
Generic leaf elements (``Div``, ``Span``, ``Td`` …) are *not* hand-written one
per tag: they are generated from a whitelist via :func:`_html_element`, each a
thin builder over the single :class:`Element` node class. Only elements that add
classes or behaviour (``Button``, ``Pill``, ``Checkbox`` …) are written out.
Everything returns a :class:`Node`; string-built widgets return :class:`Safe`.
"""
"""Generic HTML primitives (no domain knowledge)."""
from django.middleware.csrf import get_token
from django.templatetags.static import static
from django.urls import reverse
from django.utils.html import conditional_escape
from django.utils.safestring import SafeText, mark_safe
from common.components.core import (
Attributes,
Child,
Children,
Element,
Fragment,
HTMLAttribute,
Media,
Node,
Safe,
as_attributes,
as_children,
collect_media,
randomid,
)
from common.icons import get_icon
from common.utils import truncate
from common.components.core import Component, HTMLAttribute, HTMLTag, randomid
_COLOR_CLASSES = {
"blue": "text-white bg-brand box-border border border-transparent hover:bg-brand-strong focus:ring-4 focus:ring-brand-medium",
@@ -47,79 +28,18 @@ _SIZE_CLASSES = {
}
# ── Generic leaf elements ────────────────────────────────────────────────────
# A whitelist of plain tags, each turned into a builder over `Element`. The
# tag name is data, not a separate class/function body. Add a tag = one line.
def _attrs_from_kwargs(attrs: dict[str, object]) -> list[HTMLAttribute]:
"""Translate htpy-style attribute kwargs to (name, value) pairs.
``class_`` -> ``class`` (trailing underscore stripped); ``hx_get`` ->
``hx-get`` (inner underscores to hyphens); ``True`` -> bare attribute;
``False`` / ``None`` -> omitted."""
result: list[HTMLAttribute] = []
for key, value in attrs.items():
if value is None or value is False:
continue
name = key.rstrip("_").replace("_", "-")
result.append((name, name if value is True else value)) # type: ignore[arg-type]
return result
def custom_element_builder(tag_name: str):
"""Create a tag builder for a custom element with auto-attached Media.
The module path follows the convention ``ts/elements/<tag>.ts`` →
``dist/elements/<tag>.js``.
"""
return _html_element(tag_name, Media(js=(f"dist/elements/{tag_name}.js",)))
def _html_element(tag_name: str, media: Media | None = None):
"""Build a generic element builder for ``tag_name`` (the whitelist factory).
If ``media`` is provided, every node created by the builder will carry it
(used for custom elements whose compiled JS must be loaded automatically).
"""
def element(
attributes: Attributes | None = None,
children: Children = None,
**attrs: object,
) -> Element:
merged = as_attributes(attributes) + _attrs_from_kwargs(attrs)
node = Element(tag_name, merged, children)
return node.with_media(media) if media else node
element.__name__ = element.__qualname__ = tag_name[:1].upper() + tag_name[1:]
element.__doc__ = f"Builder for the <{tag_name}> element."
return element
A = _html_element("a")
Button = _html_element("button")
Div = _html_element("div")
P = _html_element("p")
Ul = _html_element("ul")
Li = _html_element("li")
Strong = _html_element("strong")
Span = _html_element("span")
Label = _html_element("label")
Template = _html_element("template")
Td = _html_element("td")
Tr = _html_element("tr")
Th = _html_element("th")
def _popover_html(
id: str,
popover_content: Child,
popover_content: str,
wrapped_content: str = "",
wrapped_classes: str = "",
slot: "Node | str" = "",
) -> Node:
"""Generate popover HTML. Single source of truth for popover structure."""
slot: str = "",
) -> SafeText:
"""Generate popover HTML using Component(tag_name=...).
Single source of truth for popover HTML structure.
Used by Popover() and the python_popover template tag bridge.
"""
display_content = wrapped_content if wrapped_content else slot
span = Span(
@@ -137,7 +57,8 @@ def _popover_html(
"dark:bg-purple-800"
)
div = Div(
div = Component(
tag_name="div",
attributes=[
("data-popover", ""),
("id", id),
@@ -145,12 +66,13 @@ def _popover_html(
("class", popover_tooltip_class),
],
children=[
Div(
Component(
tag_name="div",
attributes=[("class", "px-3 py-2")],
children=[popover_content],
),
Div(attributes=[("data-popper-arrow", "")]),
Safe( # nosec — intentional HTML comment for Tailwind JIT
Component(tag_name="div", attributes=[("data-popper-arrow", "")]),
mark_safe( # nosec — intentional HTML comment for Tailwind JIT
"<!-- for Tailwind CSS to generate decoration-dotted CSS "
"from Python component -->"
),
@@ -160,24 +82,24 @@ def _popover_html(
],
)
return Fragment(span, div, separator="\n")
return mark_safe(span + "\n" + div)
def Popover(
popover_content: Child,
popover_content: str,
wrapped_content: str = "",
wrapped_classes: str = "",
children: Children = None,
attributes: Attributes | None = None,
children: list[HTMLTag] | None = None,
attributes: list[HTMLAttribute] | None = None,
id: str = "",
) -> Node:
children = as_children(children)
) -> str:
children = children or []
if not wrapped_content and not children:
raise ValueError("One of wrapped_content or children is required.")
if not id:
id = randomid(content=f"{wrapped_content}:{popover_content}:{wrapped_classes}")
slot = Fragment(*children, separator="\n") if children else ""
slot = mark_safe("\n".join(children))
return _popover_html(
id=id,
popover_content=popover_content,
@@ -189,12 +111,12 @@ def Popover(
def PopoverTruncated(
input_string: str,
popover_content: Child = "",
popover_content: str = "",
popover_if_not_truncated: bool = False,
length: int = 30,
ellipsis: str = "",
endpart: str = "",
) -> "Node | str":
) -> str:
"""
Returns `input_string` truncated after `length` of characters
and displays the untruncated text in a popover HTML element.
@@ -219,9 +141,37 @@ def PopoverTruncated(
return input_string
def StyledButton(
attributes: Attributes | None = None,
children: Children = None,
def A(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
url_name: str | None = None,
href: str | None = None,
) -> SafeText:
"""
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 = attributes or []
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 Component(
tag_name="a", attributes=attributes + additional_attributes, children=children
)
def Button(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
size: str = "base",
icon: bool = False,
color: str = "blue",
@@ -232,9 +182,8 @@ def StyledButton(
title: str = "",
onclick: str = "",
name: str = "",
**attrs: object,
) -> Element:
attributes = as_attributes(attributes) + _attrs_from_kwargs(attrs)
) -> SafeText:
attributes = attributes or []
children = children or []
# Separate custom class from other generic attributes
@@ -278,8 +227,8 @@ def StyledButton(
button_attrs.append(("name", name))
button_attrs.extend(other_attrs)
return Element(
"button",
return Component(
tag_name="button",
attributes=button_attrs,
children=children,
)
@@ -321,7 +270,7 @@ def _button_group_button(
title: str = "",
hx_get: str = "",
hx_target: str = "",
) -> Element:
) -> SafeText:
"""Generate a single button-group button (inner <button> inside <a>)."""
color_classes = _GROUP_BUTTON_COLORS.get(color, _GROUP_BUTTON_COLORS["gray"])
@@ -338,8 +287,8 @@ def _button_group_button(
)
)
button = Element(
"button",
button = Component(
tag_name="button",
attributes=[
("type", "button"),
("title", title),
@@ -348,10 +297,10 @@ def _button_group_button(
children=[slot],
)
return Element("a", attributes=a_attrs, children=[button])
return Component(tag_name="a", attributes=a_attrs, children=[button])
def ButtonGroup(buttons: list[dict] | None = None) -> Element:
def ButtonGroup(buttons: list[dict] | None = None) -> SafeText:
"""Generate a button group div.
Each button dict accepts: href, slot (required), color, title, hx_get, hx_target.
@@ -359,7 +308,7 @@ def ButtonGroup(buttons: list[dict] | None = None) -> Element:
for conditional buttons (e.g., end-session only when session is active).
"""
buttons = buttons or []
children: list[Node] = []
children: list[SafeText] = []
for btn in buttons:
if not btn or not btn.get("slot"):
continue
@@ -374,84 +323,50 @@ def ButtonGroup(buttons: list[dict] | None = None) -> Element:
)
)
return Div(
return Component(
tag_name="div",
attributes=[("class", "inline-flex rounded-md shadow-xs"), ("role", "group")],
children=children,
)
def Div(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
attributes = attributes or []
children = children or []
return Component(tag_name="div", attributes=attributes, children=children)
def Input(
type: str = "text",
attributes: Attributes | None = None,
children: Children = None,
) -> Element:
attributes = as_attributes(attributes)
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
attributes = attributes or []
children = children or []
return Element("input", attributes=attributes + [("type", type)], children=children)
def Checkbox(
name: str,
label: str | None = None,
checked: bool = False,
value: str = "1",
attributes: Attributes | None = None,
) -> Node:
"""A filter-agnostic Checkbox component."""
attributes = as_attributes(attributes)
input_attrs = [
("name", name),
("value", value),
(
"class",
"rounded border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand",
),
] + attributes
if checked:
input_attrs.append(("checked", "true"))
input_el = Input(type="checkbox", attributes=input_attrs)
if label is None:
return input_el
return Label(
attributes=[
("class", "flex items-center gap-2 text-sm text-heading cursor-pointer")
],
children=[input_el, label],
return Component(
tag_name="input", attributes=attributes + [("type", type)], children=children
)
def Radio(
name: str,
label: str | None = None,
checked: bool = False,
value: str = "",
attributes: Attributes | None = None,
) -> Node:
"""A filter-agnostic Radio component."""
attributes = as_attributes(attributes)
input_attrs = [
("name", name),
("value", value),
(
"class",
"rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand",
),
] + attributes
if checked:
input_attrs.append(("checked", "true"))
def Span(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
attributes = attributes or []
children = children or []
return Component(tag_name="span", attributes=attributes, children=children)
input_el = Input(type="radio", attributes=input_attrs)
if label is None:
return input_el
return Label(
attributes=[
("class", "flex items-center gap-1 text-sm text-heading cursor-pointer")
],
children=[input_el, label],
)
def Label(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
attributes = attributes or []
children = children or []
return Component(tag_name="label", attributes=attributes, children=children)
# Inline Tailwind utilities for Pill (mirrors the .sf-tag / .sf-remove rules in
@@ -471,36 +386,26 @@ def Pill(
value: str = "",
removable: bool = False,
extra_class: str = "",
label_slot: bool = False,
attributes: Attributes | None = None,
) -> Node:
attributes: list[HTMLAttribute] | None = None,
) -> SafeText:
"""A small label pill, optionally removable (× button).
Styling is inline Tailwind utilities; ``data-pill`` / ``data-pill-remove``
are JS hooks only (no CSS attached). ``value`` (when set) becomes
``data-value``; extra ``attributes`` are appended to the outer span.
``label_slot=True`` wraps the label in a ``<span data-search-select-label>`` so JS can
fill it when cloning the pill from a server-rendered ``<template>`` (keeps the
markup single-sourced — see ``search_select.py``).
"""
attributes = as_attributes(attributes)
attributes = attributes or []
pill_class = f"{_PILL_CLASS} {extra_class}".strip()
pill_attrs: list[HTMLAttribute] = [("class", pill_class), ("data-pill", "")]
if value != "":
pill_attrs.append(("data-value", str(value)))
pill_attrs.extend(attributes)
label_child: "Node | str" = (
Span(attributes=[("data-search-select-label", "")], children=[label])
if label_slot
else label
)
children: list["Node | str"] = [label_child]
children: list[HTMLTag] = [label]
if removable:
children.append(
Element(
"button",
Component(
tag_name="button",
attributes=[
("type", "button"),
("data-pill-remove", ""),
@@ -511,15 +416,12 @@ def Pill(
)
)
return Span(attributes=pill_attrs, children=children)
return Component(tag_name="span", attributes=pill_attrs, children=children)
def CsrfInput(request) -> Node:
"""Hidden CSRF input, equivalent to the `{% csrf_token %}` template tag.
Returns a ``Safe`` node (not a safe string): it is always used as a tree
child, and only nodes render unescaped now."""
return Safe(
def CsrfInput(request) -> SafeText:
"""Hidden CSRF input, equivalent to the `{% csrf_token %}` template tag."""
return mark_safe(
f'<input type="hidden" name="csrfmiddlewaretoken" value="{get_token(request)}">'
)
@@ -536,22 +438,11 @@ def ExternalScript(url: str) -> SafeText:
return mark_safe(f'<script src="{url}"></script>')
def StaticScript(filename: str) -> SafeText:
"""A plain (classic, non-module) `<script src=...>` tag for a static JS
file — for vendored UMD bundles, which break inside module scope."""
return mark_safe(f'<script src="{static("js/" + filename)}"></script>')
# Media for the Flowbite-datepicker year picker (vendored UMD bundle). Declared
# on the YearPicker node so Page() loads it wherever a YearPicker appears.
_YEAR_PICKER_MEDIA = Media(js_external=("datepicker.umd.js",))
def YearPicker(
year: int | None = None,
available_years: tuple[int, ...] = (),
url_template: str = "",
) -> Node:
) -> SafeText:
"""A Flowbite-datepicker year picker.
`year` is the selected year, or ``None`` for the all-time view (the empty
@@ -560,8 +451,8 @@ def YearPicker(
placeholder, substituted with the chosen year in JS (keeps this component
decoupled from the project's URL names).
The Flowbite-datepicker UMD bundle is declared as ``media`` on the returned
node, so ``Page()`` loads it automatically.
The Flowbite-datepicker UMD bundle is *not* loaded here — the view hoists it
via ``render_page(scripts=...)``.
"""
label = str(year) if year is not None else "Choose a year"
selected = str(year) if year is not None else ""
@@ -572,8 +463,7 @@ def YearPicker(
"hover:bg-neutral-tertiary-medium focus:ring-4 focus:ring-brand-medium"
)
years_csv = ",".join(str(y) for y in available_years)
return Safe(
f"""<div class="relative inline-block" x-data="{{ pickerOpen: false }}"
return mark_safe(f"""<div class="relative inline-block" x-data="{{ pickerOpen: false }}"
@keydown.escape.window="pickerOpen = false">
<button type="button"
x-on:click="pickerOpen = !pickerOpen; $refs.pickerInput._pickerInstance && ($refs.pickerInput._pickerInstance.active ? $refs.pickerInput._pickerInstance.hide() : $refs.pickerInput._pickerInstance.show())"
@@ -626,19 +516,17 @@ document.addEventListener('DOMContentLoaded', () => {{
picker.update();
}}
}});
</script>""",
media=_YEAR_PICKER_MEDIA,
)
</script>""")
def AddForm(
form,
*,
request,
fields: Node | SafeText | str | None = None,
additional_row: Node | SafeText | str = "",
fields: SafeText | str | None = None,
additional_row: SafeText | str = "",
submit_class: str = "mt-3",
) -> Node:
) -> SafeText:
"""Page body for the generic add/edit form (Python equivalent of add.html).
`fields` overrides the default ``form.as_div()`` field markup (used by the
@@ -647,16 +535,16 @@ def AddForm(
is applied to the main Submit button (the session form passes "" to match
its original markup).
"""
field_markup = fields if fields is not None else Safe(form.as_div())
field_markup = fields if fields is not None else mark_safe(form.as_div())
submit_attrs = [("class", submit_class)] if submit_class else []
inner_form = Element(
"form",
inner_form = Component(
tag_name="form",
attributes=[("method", "post"), ("enctype", "multipart/form-data")],
children=[
CsrfInput(request),
field_markup,
Div(children=[StyledButton(submit_attrs, "Submit", type="submit")]),
Div(children=[Button(submit_attrs, "Submit", type="submit")]),
Div(
[("class", "submit-button-container")],
[additional_row] if additional_row else [],
@@ -679,10 +567,10 @@ def SearchField(
search_string: str = "",
id: str = "search_string",
placeholder: str = "Search",
) -> Element:
) -> SafeText:
"""Generate a search form with icon, input field, and submit button."""
return Element(
"form",
return Component(
tag_name="form",
attributes=[("class", "max-w-md")],
children=[
Label(
@@ -692,10 +580,11 @@ def SearchField(
],
children=["Search"],
),
Div(
Component(
tag_name="div",
attributes=[("class", "relative")],
children=[
Safe(
mark_safe(
'<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">'
'<svg class="w-4 h-4 text-body" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" '
'fill="none" viewBox="0 0 24 24">'
@@ -703,9 +592,10 @@ def SearchField(
'd="m21 21-3.5-3.5M17 10a7 7 0 1 1-14 0 7 7 0 0 1 14 0Z"/>'
"</svg></div>"
),
Input(
type="search",
Component(
tag_name="input",
attributes=[
("type", "search"),
("id", id),
("name", id),
("value", search_string),
@@ -720,8 +610,8 @@ def SearchField(
("required", ""),
],
),
Element(
"button",
Component(
tag_name="button",
attributes=[
("type", "submit"),
(
@@ -742,13 +632,13 @@ def SearchField(
def H1(
children: Children = None,
children: list[HTMLTag] | HTMLTag | None = None,
badge: str = "",
) -> Element:
) -> SafeText:
"""Heading with optional badge count."""
children = children or []
heading_class = "mb-4 text-3xl font-extrabold leading-none tracking-tight text-gray-900 dark:text-white"
badge_html: Node | str = ""
badge_html = ""
if badge:
heading_class = "flex items-center " + heading_class
@@ -763,20 +653,22 @@ def H1(
children=[badge],
)
return Element(
"h1",
return Component(
tag_name="h1",
attributes=[("class", heading_class)],
children=as_children(children) + ([badge_html] if badge_html else []),
children=(children if isinstance(children, list) else [children])
+ ([badge_html] if badge_html else []),
)
def Modal(
modal_id: str,
children: Children = None,
) -> Node:
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
"""Modal overlay with container. Content (form, buttons) goes in children."""
children = children or []
return Div(
outer = Component(
tag_name="div",
attributes=[
("id", modal_id),
(
@@ -786,7 +678,8 @@ def Modal(
),
],
children=[
Div(
Component(
tag_name="div",
attributes=[
(
"class",
@@ -794,24 +687,26 @@ def Modal(
"shadow-lg/50 rounded-md bg-white dark:bg-gray-900",
),
],
children=as_children(children),
children=(children if isinstance(children, list) else [children]),
),
],
)
return mark_safe(str(outer))
def TableTd(
children: Children = None,
) -> Element:
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
"""Styled table cell."""
children = children or []
return Td(
return Component(
tag_name="td",
attributes=[("class", "px-6 py-4 min-w-20-char max-w-20-char")],
children=as_children(children),
children=children if isinstance(children, list) else [children],
)
def TableRow(data: dict | list | None = None) -> Element:
def TableRow(data: dict | list | None = None) -> SafeText:
"""Generate a <tr> from a row data dict or list.
Dict form: {"row_id": "...", "cell_data": [...], "hx_trigger": ..., ...}
@@ -846,11 +741,12 @@ def TableRow(data: dict | list | None = None) -> Element:
if data.get("hx_swap"):
tr_attrs.append(("hx-swap", data["hx_swap"]))
cell_elements: list[Node] = []
cell_elements: list[SafeText] = []
for i, cell in enumerate(cells):
if i == 0:
cell_elements.append(
Th(
Component(
tag_name="th",
attributes=[
("scope", "row"),
(
@@ -865,23 +761,23 @@ def TableRow(data: dict | list | None = None) -> Element:
else:
cell_elements.append(TableTd(children=[cell]))
return Tr(attributes=tr_attrs, children=cell_elements)
return Component(tag_name="tr", attributes=tr_attrs, children=cell_elements)
def Icon(
name: str,
attributes: Attributes | None = None,
) -> Node:
return Safe(get_icon(name))
attributes: list[HTMLAttribute] | None = None,
) -> SafeText:
return mark_safe(get_icon(name))
def TableHeader(
children: Children = None,
) -> Element:
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
"""Table caption."""
children = children or []
return Element(
"caption",
return Component(
tag_name="caption",
attributes=[
(
"class",
@@ -889,7 +785,7 @@ def TableHeader(
"text-gray-900 bg-white dark:text-white dark:bg-gray-900",
),
],
children=as_children(children),
children=children if isinstance(children, list) else [children],
)
@@ -968,41 +864,30 @@ def _pagination_nav(page_obj, elided_page_range, request) -> str:
def SimpleTable(
columns: list[str] | None = None,
rows: list | None = None,
header_action: Child | None = None,
header_action: SafeText | str | None = None,
page_obj=None,
elided_page_range=None,
request=None,
) -> Node:
) -> SafeText:
"""Paginated table. Python equivalent of the old simple_table.html."""
columns = columns or []
rows = rows or []
# Rows/header are stringified into the table markup, so their components'
# declared Media would be lost; collect it from the nodes first and attach
# it to the returned node so Page() still emits each cell component's JS
# (e.g. a <game-status-selector> in a cell).
media = Media()
header_html = ""
if header_action:
header_node = TableHeader(children=[header_action])
header_html = str(header_node)
media = media + collect_media(header_node)
header_html = str(TableHeader(children=[header_action]))
columns_html = "".join(
f'<th scope="col" class="px-6 py-3">{conditional_escape(col)}</th>'
for col in columns
)
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)
rows_html = "".join(str(TableRow(data=row)) for row in rows)
pagination_html = ""
if page_obj and elided_page_range:
pagination_html = _pagination_nav(page_obj, elided_page_range, request)
return Safe(
return mark_safe(
'<div class="shadow-md" hx-boost="false">'
'<div class="relative overflow-x-auto sm:rounded-t-lg">'
'<table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400">'
@@ -1012,8 +897,7 @@ def SimpleTable(
f"<tr>{columns_html}</tr></thead>"
'<tbody class="dark:divide-y max-sm:[&_td:not(:first-child):not(:last-child)]:hidden">'
f"{rows_html}</tbody></table></div>"
f"{pagination_html}</div>",
media=media,
f"{pagination_html}</div>"
)
@@ -1023,7 +907,7 @@ def paginated_table_content(
page_obj=None,
elided_page_range=None,
request=None,
) -> Node:
) -> SafeText:
"""Standard list-page body: a max-width Div wrapping a SimpleTable.
`data` is the table dict with keys ``columns``, ``rows`` and
+56 -433
View File
@@ -7,26 +7,15 @@ hidden ``<input>`` so an existing ``ModelMultipleChoiceField`` keeps validating.
This module imports only from ``common.components`` — it has no Django-forms or
``games`` knowledge. Styling is inline Tailwind utilities; behavioural hooks are
``data-*`` attributes wired up by ``games/static/js/search_select.js``.
Option sourcing follows two axes. *Population*: options are either rendered
inline up front (``options=``, no ``search_url``) or fetched from ``search_url``.
*Completeness*: without a ``search_url`` the inline set is the whole dataset and
filtering is purely client-side; with a ``search_url`` the loaded rows are a
window, so the JS filters the loaded rows instantly on each keystroke while
issuing a debounced server request for the rest. ``prefetch`` (rows to load on
first open, ``0`` = none) seeds that window so the panel is populated before the
user types.
"""
from collections.abc import Callable, Iterable
from typing import TypedDict
from django.utils.safestring import SafeText
from common.components.core import Attributes, Element, HTMLAttribute, Media, Node
from common.components.primitives import Div, Input, Pill, Span, Template
# Both comboboxes are wired by search_select.js.
_SEARCH_SELECT_MEDIA = Media(js=("search_select.js",))
from common.components.core import Component, HTMLAttribute
from common.components.primitives import Pill
class SearchSelectOption(TypedDict):
@@ -35,97 +24,28 @@ class SearchSelectOption(TypedDict):
data: dict[str, str] # becomes data-* attrs on the row / pill
# A lightweight (value, label) pair used wherever only those two fields are
# needed — e.g. filter pill lists and modifier pseudo-options. The richer
# SearchSelectOption adds a ``data`` dict for extra row attributes.
LabeledOption = tuple[str, str]
# The pills and the search box share one flex-wrap row (with padding) so the
# widget reads as a single clickable field; the pills wrapper uses `contents`
# so its pills/hidden inputs flow as direct participants of that row, inline
# with the search input. The options panel is absolute, so it sits outside the
# flex flow. (border omitted intentionally — see if it's needed later.)
_CONTAINER_CLASS = (
"relative flex flex-wrap items-center gap-1 p-2 "
"rounded-base bg-neutral-secondary-medium"
)
# removed border and border-default-medium, see later if it's needed
_CONTAINER_CLASS = "relative rounded-base bg-neutral-secondary-medium"
# The pills and the search box share one flex-wrap row so the widget reads as a
# single field; the pills wrapper uses `contents` so its pills/hidden inputs
# flow as direct participants of that row, inline with the search input.
_FIELD_CLASS = "flex flex-wrap items-center gap-1 p-2"
_PILLS_CLASS = "contents"
_SEARCH_CLASS = (
"flex-1 min-w-[8rem] border-0 bg-transparent text-sm text-heading "
"focus:ring-0 focus:outline-hidden placeholder:text-body"
)
# top-full anchors the panel to the container's bottom edge: as an absolutely
# positioned child of the flex field, its static position would otherwise be
# centered by items-center and overlap the search box.
_OPTIONS_CLASS = (
"absolute z-10 top-full left-0 right-0 mt-1 overflow-y-auto "
"border border-default-medium rounded-base bg-neutral-secondary-medium shadow-lg"
)
_OPTION_ROW_CLASS = (
"px-3 py-2 text-sm text-heading cursor-pointer "
"hover:bg-brand/15 data-[search-select-highlighted]:bg-brand/15"
"absolute z-10 left-0 right-0 mt-1 overflow-y-auto border border-default-medium "
"rounded-base bg-neutral-secondary-medium shadow-lg"
)
_OPTION_ROW_CLASS = "px-3 py-2 text-sm text-heading cursor-pointer hover:bg-brand/15"
_NO_RESULTS_CLASS = "px-3 py-2 text-sm italic text-body hidden"
# Approximate rendered height of one option row (px-3 py-2 text-sm) in rem,
# used to derive the panel's max-height from items_visible.
_ROW_HEIGHT_REM = 2.25
# Default number of rows to fetch on first focus when a search_url is set.
# Shared by filter and form widgets so the dropdown is populated for keyboard
# navigation as soon as the user opens it.
DEFAULT_PREFETCH = 20
# ── FilterSelect styling ───────────────────────────────────────────────────
# Inline class strings (ported verbatim from the retired SelectableFilter CSS)
# so the filter combobox is fully self-styled — nothing in input.css. JS-added
# rows/pills are cloned from server-rendered <template>s, so these strings live
# only here — never duplicated in search_select.js. The keyboard-highlighted
# state is expressed via Tailwind `data-[search-select-highlighted]` and
# `group-data-[search-select-highlighted]` variants on the row/label/button
# classes below; the JS only toggles the data attribute on the row.
_FILTER_INCLUDE_PILL_CLASS = (
"inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded "
"bg-brand/15 text-heading"
)
_FILTER_EXCLUDE_PILL_CLASS = (
"inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded "
"bg-red-500/15 text-red-600 line-through decoration-red-400"
)
_FILTER_MODIFIER_PILL_CLASS = (
"inline-flex items-center px-2 py-0.5 text-sm rounded "
"bg-amber-500/15 text-amber-600 cursor-pointer"
)
_FILTER_PILL_REMOVE_CLASS = "ml-1 text-body hover:text-heading font-bold cursor-pointer"
_FILTER_OPTION_ROW_CLASS = (
"group flex items-center justify-between px-2 py-1 rounded text-sm "
"hover:bg-neutral-secondary-strong cursor-pointer "
"data-[search-select-highlighted]:bg-brand "
"data-[search-select-highlighted]:outline data-[search-select-highlighted]:outline-1 "
"data-[search-select-highlighted]:outline-brand-strong"
)
_FILTER_OPTION_LABEL_CLASS = (
"truncate text-body group-data-[search-select-highlighted]:text-white"
)
_FILTER_OPTION_BUTTONS_CLASS = "flex gap-1 ml-2 shrink-0"
# text-body keeps the +/ readable on dark backgrounds; hover:border-brand-strong
# keeps the edge visible against the brand hover fill. When the row is the
# keyboard-highlighted one its bg is brand, so the button text/border switch
# to white and the hover fill shifts to brand-strong for contrast.
_FILTER_ACTION_BUTTON_CLASS = (
"w-5 h-5 flex items-center justify-center text-xs font-bold rounded text-body "
"border border-brand "
"hover:bg-brand hover:text-white hover:border-brand-strong "
"group-data-[search-select-highlighted]:text-white "
"group-data-[search-select-highlighted]:border-white "
"group-data-[search-select-highlighted]:hover:bg-brand-strong "
"group-data-[search-select-highlighted]:hover:border-white"
)
_FILTER_MODIFIER_ROW_CLASS = (
"px-2 py-1 text-sm text-body hover:bg-neutral-secondary-strong cursor-pointer"
)
def _normalize_option(option) -> SearchSelectOption:
"""Coerce a dict option or a ``(value, label)`` tuple into the TypedDict."""
@@ -143,82 +63,27 @@ def _data_attributes(data: dict[str, str]) -> list[HTMLAttribute]:
return [(f"data-{key}", str(value)) for key, value in data.items()]
def _hidden_input(name: str, value) -> Node:
return Input(type="hidden", attributes=[("name", name), ("value", str(value))])
def _hidden_input(name: str, value) -> SafeText:
return Component(
tag_name="input",
attributes=[("type", "hidden"), ("name", name), ("value", str(value))],
)
def _label_slot(text: str, *, extra_class: str = "") -> Node:
"""A ``<span data-search-select-label>`` holding a row/pill's visible label. JS fills this
one node when cloning the shape from a ``<template>``, so labels are the only
thing the JS sets — all classes and structure stay server-side."""
attributes: list[HTMLAttribute] = [("data-search-select-label", "")]
if extra_class:
attributes.append(("class", extra_class))
return Span(attributes=attributes, children=[text])
# A placeholder option for rendering template prototypes (JS overwrites it).
_BLANK_OPTION: SearchSelectOption = {"value": "", "label": "", "data": {}}
def _option_row(option: SearchSelectOption) -> Node:
return Div(
def _option_row(option: SearchSelectOption) -> SafeText:
return Component(
tag_name="div",
attributes=[
("data-search-select-option", ""),
("data-ss-option", ""),
("data-value", str(option["value"])),
("data-label", option["label"]),
("class", _OPTION_ROW_CLASS),
*_data_attributes(option["data"]),
],
children=[_label_slot(option["label"])],
children=[option["label"]],
)
def _combobox_shell(
*,
container_attributes: Attributes,
pills: Node,
search_attributes: Attributes,
options_children: list[Node],
always_visible: bool,
items_visible: int,
templates: list[Node] | None = None,
) -> Node:
"""Assemble the shared, domain-agnostic combobox skeleton.
Every combobox built on top of this shell has the same three regions in the
same order: the ``pills`` region, the search box, and the options panel (which
always carries a trailing no-results node). Callers supply the already-built
``pills`` region, the ``search_attributes`` for the text box, the
``options_children`` (value rows plus any pinned pseudo-options), the
``container_attributes`` that carry the widget's identity and behaviour flags,
and any ``templates`` (inert ``<template>`` prototypes the JS clones for
dynamically-added rows/pills). The shell knows nothing about how individual
rows or pills look.
"""
search = Input(attributes=search_attributes)
no_results = Div(
attributes=[
("data-search-select-no-results", ""),
("class", _NO_RESULTS_CLASS),
],
children=["No results"],
)
options_class = _OPTIONS_CLASS if always_visible else _OPTIONS_CLASS + " hidden"
options_panel = Div(
attributes=[
("data-search-select-options", ""),
("style", f"max-height: {items_visible * _ROW_HEIGHT_REM:.2f}rem"),
("class", options_class),
],
children=[*options_children, no_results],
)
children: list[Node] = [pills, search, options_panel, *(templates or [])]
return Div(attributes=container_attributes, children=children)
def SearchSelect(
*,
name: str,
@@ -229,22 +94,21 @@ def SearchSelect(
always_visible: bool = False,
items_visible: int = 5,
items_scroll: int = 10,
prefetch: int = 0,
placeholder: str = "Search…",
id: str = "",
sync_url: bool = False,
autofocus: bool = False,
) -> Node:
) -> SafeText:
"""Render the search-select widget. See module docstring for the contract."""
selected = [_normalize_option(option) for option in (selected or [])]
options = [_normalize_option(option) for option in (options or [])]
selected = [_normalize_option(o) for o in (selected or [])]
options = [_normalize_option(o) for o in (options or [])]
# ── Pills + their hidden inputs (the submitted channel) ──
# Multi-select renders a removable Pill per value; single-select renders no
# pill — the committed label shows inside the search box instead, with a
# lone hidden input carrying the value. Both keep the hidden input(s) inside
# `[data-search-select-pills]` so the JS reads/writes values uniformly.
pills_children: list[Node] = []
# `[data-ss-pills]` so the JS reads/writes values uniformly.
pills_children: list[SafeText] = []
search_value = ""
if multi_select:
for option in selected:
@@ -253,7 +117,6 @@ def SearchSelect(
option["label"],
value=str(option["value"]),
removable=True,
label_slot=True,
attributes=_data_attributes(option["data"]),
)
)
@@ -263,14 +126,16 @@ def SearchSelect(
pills_children.append(_hidden_input(name, option["value"]))
search_value = option["label"]
pills = Div(
attributes=[("data-search-select-pills", ""), ("class", _PILLS_CLASS)],
pills = Component(
tag_name="div",
attributes=[("data-ss-pills", ""), ("class", _PILLS_CLASS)],
children=pills_children,
)
# ── Search box (NO name — the query is never submitted) ──
search_attrs: list[HTMLAttribute] = [
("data-search-select-search", ""),
("data-ss-search", ""),
("type", "text"),
("placeholder", placeholder),
("autocomplete", "off"),
("class", _SEARCH_CLASS),
@@ -279,29 +144,27 @@ def SearchSelect(
search_attrs.append(("autofocus", ""))
if search_value:
search_attrs.append(("value", search_value))
search = Component(tag_name="input", attributes=search_attrs)
# ── Options panel (pre-rendered only when there is no search_url) ──
option_rows = [_option_row(option) for option in options] if not search_url else []
option_rows = [_option_row(o) for o in options] if not search_url else []
no_results = Component(
tag_name="div",
attributes=[("data-ss-no-results", ""), ("class", _NO_RESULTS_CLASS)],
children=["No results"],
)
options_class = _OPTIONS_CLASS if always_visible else _OPTIONS_CLASS + " hidden"
options_panel = Component(
tag_name="div",
attributes=[
("data-ss-options", ""),
("style", f"max-height: {items_visible * _ROW_HEIGHT_REM:.2f}rem"),
("class", options_class),
],
children=[*option_rows, no_results],
)
# ── Templates the JS clones: a row when results are fetched, a pill when
# multi-select adds chosen items. ──
templates: list[Node] = []
if search_url:
templates.append(
Template(
attributes=[("data-search-select-template", "row")],
children=[_option_row(_BLANK_OPTION)],
)
)
if multi_select:
templates.append(
Template(
attributes=[("data-search-select-template", "pill")],
children=[Pill("", value="", removable=True, label_slot=True)],
)
)
container_attributes: list[HTMLAttribute] = [
container_attrs: list[HTMLAttribute] = [
("data-search-select", ""),
("data-name", name),
("data-search-url", search_url),
@@ -309,259 +172,19 @@ def SearchSelect(
("data-always-visible", "true" if always_visible else "false"),
("data-items-visible", str(items_visible)),
("data-items-scroll", str(items_scroll)),
("data-prefetch", str(prefetch)),
("data-sync-url", "true" if sync_url else "false"),
("class", _CONTAINER_CLASS),
]
if id:
container_attributes.append(("id", id))
container_attrs.append(("id", id))
return _combobox_shell(
container_attributes=container_attributes,
pills=pills,
search_attributes=search_attrs,
options_children=option_rows,
always_visible=always_visible,
items_visible=items_visible,
templates=templates,
).with_media(_SEARCH_SELECT_MEDIA)
def _filter_remove_button() -> Node:
return Element(
"button",
attributes=[
("type", "button"),
("data-pill-remove", ""),
("class", _FILTER_PILL_REMOVE_CLASS),
("aria-label", "Remove"),
],
children=["×"],
return Component(
tag_name="div",
attributes=container_attrs,
children=[pills, search, options_panel],
)
def _filter_value_pill(option: SearchSelectOption, kind: str) -> Node:
"""An include (✓) or exclude (✗) value pill. ``kind`` is "include"/"exclude"."""
symbol = "" if kind == "include" else ""
css = (
_FILTER_INCLUDE_PILL_CLASS if kind == "include" else _FILTER_EXCLUDE_PILL_CLASS
)
return Span(
attributes=[
("class", css),
("data-pill", ""),
("data-value", str(option["value"])),
("data-label", option["label"]),
("data-search-select-type", kind),
*_data_attributes(option["data"]),
],
children=[f"{symbol} ", _label_slot(option["label"]), _filter_remove_button()],
)
def _filter_modifier_pill(modifier_value: str, label: str) -> Node:
"""The lone, sticky modifier pill (e.g. "(Any)"/"(None)")."""
return Span(
attributes=[
("class", _FILTER_MODIFIER_PILL_CLASS),
("data-pill", ""),
("data-search-select-modifier", modifier_value),
],
children=[_label_slot(label), _filter_remove_button()],
)
def _filter_action_button(action: str, symbol: str, title: str) -> Node:
return Element(
"button",
attributes=[
("type", "button"),
("data-search-select-action", action),
("class", _FILTER_ACTION_BUTTON_CLASS),
("title", title),
],
children=[symbol],
)
def _filter_option_row(value: str | int, label: str) -> Node:
"""A value row with include (+) and exclude () buttons."""
return Div(
attributes=[
("data-search-select-option", ""),
("data-value", str(value)),
("data-label", label),
("class", _FILTER_OPTION_ROW_CLASS),
],
children=[
_label_slot(label, extra_class=_FILTER_OPTION_LABEL_CLASS),
Span(
attributes=[("class", _FILTER_OPTION_BUTTONS_CLASS)],
children=[
_filter_action_button("include", "+", "Include"),
_filter_action_button("exclude", "", "Exclude"),
],
),
],
)
def _filter_modifier_row(modifier_value: str, label: str) -> Node:
"""A pinned pseudo-option row. It carries no ``data-search-select-option`` so the text
filter never hides it — modifiers stay visible at the top of the panel."""
return Div(
attributes=[
("data-search-select-modifier-option", modifier_value),
("data-label", label),
("class", _FILTER_MODIFIER_ROW_CLASS),
],
children=[label],
)
def FilterSelect(
*,
field_name: str,
options: list[LabeledOption | SearchSelectOption] | None = None,
included: list[LabeledOption | SearchSelectOption] | None = None,
excluded: list[LabeledOption | SearchSelectOption] | None = None,
modifier: str = "",
modifier_options: list[LabeledOption] | None = None,
search_url: str = "",
prefetch: int = 0,
items_visible: int = 6,
items_scroll: int = 10,
placeholder: str = "Search…",
id: str = "",
free_text: bool = False,
) -> Node:
"""Include/exclude filter combobox built on the shared ``_combobox_shell``.
Like ``SearchSelect`` but each value row carries +/ buttons that add an
*include* (✓) or *exclude* (✗) pill, plus an optional set of pinned
``modifier_options`` (e.g. ``[("NOT_NULL", "(Any)"), ("IS_NULL", "(None)")]``)
rendered above the value rows. Presence modifiers (NOT_NULL / IS_NULL) are
mutually exclusive with value pills. Non-presence modifiers (INCLUDES_ALL /
INCLUDES_ONLY) coexist with value pills — they govern how the include set
matches and are only surfaced for many-to-many fields. State is read from
the DOM into the filter JSON by ``readSearchSelect`` (filter mode) — nothing
is submitted by ``name``.
``included``/``excluded`` are resolved options (value + label) so pills show
labels even when the value rows come from ``search_url``. ``options``
pre-renders the value rows for the complete-set (no ``search_url``) case.
``free_text`` turns the widget into a typed-pill input: there is no backing
option list, the JS builds an ephemeral option row from whatever the user
types so the +/ buttons (and Enter) commit the typed string itself as an
include / exclude pill.
"""
options = [_normalize_option(option) for option in (options or [])]
included = [_normalize_option(option) for option in (included or [])]
excluded = [_normalize_option(option) for option in (excluded or [])]
modifier_options = modifier_options or []
active_modifier_label = ""
for modifier_value, label in modifier_options:
if modifier_value == modifier:
active_modifier_label = label
break
# ── Pills: modifier pill (if active), then include/exclude value pills ──
# Presence modifiers (NOT_NULL / IS_NULL) are mutually exclusive with value
# pills — but the stored state guarantees they never coexist, so we render
# both channels unconditionally. Non-presence modifiers (INCLUDES_ALL /
# INCLUDES_ONLY) coexist with value pills and render side by side.
pills_children: list[Node] = []
if active_modifier_label:
pills_children.append(_filter_modifier_pill(modifier, active_modifier_label))
for option in included:
pills_children.append(_filter_value_pill(option, "include"))
for option in excluded:
pills_children.append(_filter_value_pill(option, "exclude"))
pills = Div(
attributes=[("data-search-select-pills", ""), ("class", _PILLS_CLASS)],
children=pills_children,
)
# ── Search box (NO name — the query is never submitted) ──
search_attributes: list[HTMLAttribute] = [
("data-search-select-search", ""),
("placeholder", placeholder),
("autocomplete", "off"),
("class", _SEARCH_CLASS),
]
# ── Options: pinned modifier rows, then value rows (pre-rendered only when
# there is no search_url; otherwise the JS fetches them) ──
modifier_rows = [
_filter_modifier_row(value, label) for value, label in modifier_options
]
value_rows = (
[_filter_option_row(option["value"], option["label"]) for option in options]
if not search_url
else []
)
# ── Templates the JS clones: include/exclude pills (added on click), the
# modifier pill (when modifiers exist), and a value row (when fetched). ──
templates: list[Node] = [
Template(
attributes=[("data-search-select-template", "pill-include")],
children=[_filter_value_pill(_BLANK_OPTION, "include")],
),
Template(
attributes=[("data-search-select-template", "pill-exclude")],
children=[_filter_value_pill(_BLANK_OPTION, "exclude")],
),
]
if modifier_options:
templates.append(
Template(
attributes=[("data-search-select-template", "pill-modifier")],
children=[_filter_modifier_pill("", "")],
)
)
if search_url or free_text:
templates.append(
Template(
attributes=[("data-search-select-template", "row")],
children=[_filter_option_row("", "")],
)
)
container_attributes: list[HTMLAttribute] = [
("data-search-select", ""),
("data-search-select-mode", "filter"),
("data-name", field_name),
("data-search-url", search_url),
("data-multi", "true"),
("data-always-visible", "false"),
("data-items-visible", str(items_visible)),
("data-items-scroll", str(items_scroll)),
("data-prefetch", str(prefetch)),
("data-sync-url", "false"),
("class", _CONTAINER_CLASS),
]
if free_text:
container_attributes.append(("data-search-select-free-text", "true"))
if modifier:
container_attributes.append(("data-modifier", modifier))
if id:
container_attributes.append(("id", id))
return _combobox_shell(
container_attributes=container_attributes,
pills=pills,
search_attributes=search_attributes,
options_children=[*modifier_rows, *value_rows],
always_visible=False,
items_visible=items_visible,
templates=templates,
).with_media(_SEARCH_SELECT_MEDIA)
def searchselect_selected(
values: list,
resolver: Callable[[list], Iterable[SearchSelectOption]],
@@ -573,4 +196,4 @@ def searchselect_selected(
"""
if not values:
return []
return [_normalize_option(option) for option in resolver(values)]
return [_normalize_option(o) for o in resolver(values)]
+58 -93
View File
@@ -30,7 +30,6 @@ class Modifier(str, Enum):
INCLUDES = "INCLUDES"
EXCLUDES = "EXCLUDES"
INCLUDES_ALL = "INCLUDES_ALL"
INCLUDES_ONLY = "INCLUDES_ONLY"
IS_NULL = "IS_NULL"
NOT_NULL = "NOT_NULL"
MATCHES_REGEX = "MATCHES_REGEX"
@@ -72,7 +71,6 @@ class Modifier(str, Enum):
cls.INCLUDES,
cls.EXCLUDES,
cls.INCLUDES_ALL,
cls.INCLUDES_ONLY,
cls.IS_NULL,
cls.NOT_NULL,
]
@@ -269,109 +267,76 @@ class BoolCriterion(_Criterion):
@dataclass
class _SetCriterion(_Criterion):
"""Shared base for set-membership criteria (``MultiCriterion`` /
``ChoiceCriterion``).
Two orthogonal channels, mirroring Stash's modifier model:
- ``value`` is the *include* set. The ``modifier`` governs how it matches:
- ``INCLUDES`` — in ``value`` (match *any*); ``EQUALS`` is an alias.
- ``INCLUDES_ALL`` — related to *all* of ``value`` (meaningful for
many-to-many fields, e.g. a purchase's games).
- ``EXCLUDES`` — in none of ``value`` (match *none*); ``NOT_EQUALS`` is an
alias.
- ``excludes`` is an *always-orthogonal* negative: it contributes
``AND NOT IN (excludes)`` for every (non-presence) modifier, never
swapped into the include set. An exclude-only criterion therefore means
"everything except ``excludes``".
Empty lists contribute no constraint. ``IS_NULL`` / ``NOT_NULL`` test
presence and ignore both lists.
The logic lives entirely here so the two subclasses (which differ only in
their value type) cannot drift.
"""
value: list = field(default_factory=list)
excludes: list = field(default_factory=list)
modifier: Modifier = Modifier.INCLUDES
def to_q(self, field_name: str) -> Q:
modifier = self.modifier
if modifier == Modifier.IS_NULL:
return Q(**{f"{field_name}__isnull": True})
if modifier == Modifier.NOT_NULL:
return Q(**{f"{field_name}__isnull": False})
# The modifier governs only the include set; ``excludes`` is an orthogonal
# AND'd negative applied for every (non-presence) modifier.
q = self._value_q(field_name)
if self.excludes:
q &= ~Q(**{f"{field_name}__in": self.excludes})
return q
def _value_q(self, field_name: str) -> Q:
"""Build the Q for the include (``value``) set, per the modifier."""
modifier = self.modifier
if modifier in (Modifier.INCLUDES, Modifier.EQUALS):
return Q(**{f"{field_name}__in": self.value}) if self.value else Q()
if modifier in (Modifier.EXCLUDES, Modifier.NOT_EQUALS):
return ~Q(**{f"{field_name}__in": self.value}) if self.value else Q()
if modifier in (Modifier.INCLUDES_ALL, Modifier.INCLUDES_ONLY):
# INCLUDES_ALL ("related to all of these") and INCLUDES_ONLY
# ("related to exactly these, nothing else") are only meaningful
# for many-to-many fields. A naive Q(field=a) & Q(field=b)
# collapses to a single join requiring one through-row to equal
# both values (impossible), so the generic criterion layer cannot
# build a correct Q. M2M callers must supply their own Q builder
# at the filter level — see PurchaseFilter._games_to_q for the
# chained-subquery pattern.
assert False, (
f"{modifier} requires a filter-level Q builder for M2M fields. "
"See PurchaseFilter._games_to_q for the chained-subquery pattern."
)
raise ValueError(f"Unsupported modifier {modifier} for {type(self).__name__}")
@classmethod
def from_json(cls, data: dict | None) -> Self | None:
result = super().from_json(data)
if result is None:
return None
# Labels embedded as {id, label} dicts are display-only; strip to bare ids
# so the querying layer stays clean and typed.
result.value = [
item["id"] if isinstance(item, dict) else item for item in result.value
]
result.excludes = [
item["id"] if isinstance(item, dict) else item for item in result.excludes
]
return result
@dataclass
class MultiCriterion(_SetCriterion):
"""Filter on a many-to-many or ForeignKey relationship by ID list.
All modifier logic (including ``INCLUDES_ALL`` and ``EXCLUDES``) lives in
``_SetCriterion``; this subclass only refines the value type.
"""
class MultiCriterion(_Criterion):
"""Filter on a many-to-many or ForeignKey relationship by ID list."""
value: list[int] = field(default_factory=list)
excludes: list[int] = field(default_factory=list)
modifier: Modifier = Modifier.INCLUDES
def to_q(self, field_name: str) -> Q:
m = self.modifier
if m == Modifier.INCLUDES:
q = Q(**{f"{field_name}__in": self.value})
if self.excludes:
q &= ~Q(**{f"{field_name}__in": self.excludes})
return q
if m == Modifier.EXCLUDES:
return ~Q(**{f"{field_name}__in": self.value})
if m == Modifier.INCLUDES_ALL:
q = Q()
for v in self.value:
q &= Q(**{field_name: v})
return q
if m == Modifier.IS_NULL:
return Q(**{f"{field_name}__isnull": True})
if m == Modifier.NOT_NULL:
return Q(**{f"{field_name}__isnull": False})
raise ValueError(f"Unsupported modifier {m} for multi field")
@dataclass
class ChoiceCriterion(_SetCriterion):
class ChoiceCriterion(_Criterion):
"""Filter on a choice/enum field with multi-select include/exclude.
Used by FilterSelect widgets for status, ownership_type, etc. Shares all
modifier logic with ``MultiCriterion`` via ``_SetCriterion``.
Used by SelectableFilter widgets for status, ownership_type, etc.
Supports INCLUDES, EXCLUDES, EQUALS, IS_NULL, NOT_NULL modifiers.
"""
value: list[str] = field(default_factory=list)
excludes: list[str] = field(default_factory=list)
modifier: Modifier = Modifier.INCLUDES
def to_q(self, field_name: str) -> Q:
m = self.modifier
if m == Modifier.INCLUDES:
q = Q()
if self.value:
q &= Q(**{f"{field_name}__in": self.value})
if self.excludes:
q &= ~Q(**{f"{field_name}__in": self.excludes})
return q
if m == Modifier.EXCLUDES:
q = Q()
if self.value:
q &= ~Q(**{f"{field_name}__in": self.value})
if self.excludes:
q &= Q(**{f"{field_name}__in": self.excludes})
return q
if m == Modifier.EQUALS:
q = Q()
if self.value:
q &= Q(**{f"{field_name}__in": self.value})
if self.excludes:
q &= ~Q(**{f"{field_name}__in": self.excludes})
return q
if m == Modifier.NOT_EQUALS:
return ~Q(**{f"{field_name}__in": self.value})
if m == Modifier.IS_NULL:
return Q(**{f"{field_name}__isnull": True})
if m == Modifier.NOT_NULL:
return Q(**{f"{field_name}__isnull": False})
raise ValueError(f"Unsupported modifier {m} for choice field")
# ── OperatorFilter base ────────────────────────────────────────────────────
+50 -2
View File
@@ -206,8 +206,11 @@ textarea:disabled {
label {
@apply mb-2.5 text-sm font-medium text-heading;
}
input:not([type="checkbox"]):not([data-search-select-search]) {
@apply mb-3 bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand block w-full px-3 py-2.5 shadow-xs placeholder:text-body;
input:not([type="checkbox"]) {
@apply mb-3 bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand block w-full px-3 py-2.5 shadow-xs placeholder:text-body;
}
input[type="checkbox"] {
@apply w-4 h-4 border border-default-medium rounded-xs bg-neutral-secondary-medium focus:ring-2 focus:ring-brand-soft;
}
select {
@apply w-full px-3 py-2.5 bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand shadow-xs placeholder:text-body;
@@ -229,3 +232,48 @@ textarea:disabled {
}
}
/* SelectableFilter widget styling */
.sf-container {
@apply border border-default-medium rounded-base bg-neutral-secondary-medium;
}
.sf-selected {
@apply flex flex-wrap gap-1 p-2 min-h-[2rem];
}
.sf-tag {
@apply inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded bg-brand/15 text-heading;
}
.sf-tag.sf-excluded {
@apply bg-red-500/15 text-red-600 line-through decoration-red-400;
}
.sf-remove {
@apply ml-1 text-body hover:text-heading font-bold cursor-pointer;
}
.sf-modifier-tag {
@apply inline-flex items-center px-2 py-0.5 text-sm rounded bg-amber-500/15 text-amber-600 cursor-pointer;
}
.sf-search {
@apply block w-full border-0 border-t border-default-medium bg-transparent text-sm text-heading p-2;
&:focus {
@apply ring-0 outline-hidden;
}
}
.sf-options {
@apply max-h-40 overflow-y-auto p-1 text-body;
}
.sf-option {
@apply flex items-center justify-between px-2 py-1 rounded text-sm hover:bg-neutral-secondary-strong cursor-pointer;
}
.sf-option-label {
@apply truncate;
}
.sf-option-buttons {
@apply flex gap-1 ml-2 shrink-0;
}
.sf-btn-include,
.sf-btn-exclude {
@apply w-5 h-5 flex items-center justify-center text-xs font-bold rounded border border-default-medium hover:bg-brand hover:text-white hover:border-brand;
}
.sf-modifier-option {
@apply px-2 py-1 text-sm text-body hover:bg-neutral-secondary-strong cursor-pointer;
}
+13 -45
View File
@@ -8,11 +8,9 @@ it hoists shared `<head>` content (the `_HEADERS` block, analogous to
"""
import json
from typing import TYPE_CHECKING
from django.contrib.messages import get_messages
from django.http import HttpRequest, HttpResponse
from django.middleware.csrf import get_token
from django.templatetags.static import static
from django.urls import reverse
from django.utils.html import conditional_escape
@@ -21,9 +19,6 @@ from django_htmx.jinja import django_htmx_script
from games.templatetags.version import version, version_date
if TYPE_CHECKING:
from common.components import Node
# Static head script that sets the dark/light class before paint (avoids FOUC).
_THEME_FOUC_SCRIPT = """<script>
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
@@ -187,16 +182,10 @@ def _main_script(mastered: bool) -> str:
return _MAIN_SCRIPT_A + ("true" if mastered else "false") + _MAIN_SCRIPT_B
def Navbar(*, today_played: str, last_7_played: str, current_year: int, csrf_token: str) -> "Node":
"""Top navigation bar.
Static chrome, so it's a single ``Safe`` node wrapping its markup rather
than a hand-built element tree — trusted HTML belongs in a ``Safe`` node,
not a ``mark_safe`` string."""
from common.components import Safe
def Navbar(*, today_played: str, last_7_played: str, current_year: int) -> SafeText:
"""Top navigation bar."""
logo = static("icons/schedule.png")
return Safe(f"""<nav class="bg-neutral-primary-soft border-b border-default">
return mark_safe(f"""<nav class="bg-neutral-primary-soft border-b border-default">
<div class="max-w-(--breakpoint-xl) flex flex-wrap items-center justify-between mx-auto p-4">
<a href="{reverse("games:index")}"
class="flex items-center space-x-3 rtl:space-x-reverse">
@@ -271,10 +260,7 @@ def Navbar(*, today_played: str, last_7_played: str, current_year: int, csrf_tok
<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>
<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>
<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>
</li>
</ul>
</div>
@@ -283,37 +269,22 @@ def Navbar(*, today_played: str, last_7_played: str, current_year: int, csrf_tok
def Page(
content: "Node | SafeText | str",
content: SafeText | str,
*,
request: HttpRequest,
title: str = "",
scripts: "Node | SafeText | str" = "",
scripts: SafeText | str = "",
mastered: bool = False,
) -> SafeText:
"""Assemble a full HTML document around `content` (the fast_app equivalent).
Scripts are collected from `content`'s component tree: every component
declares its JS via `Media`, and `collect_media` gathers (deduped) the union
for the whole page. The `scripts` argument remains for page-specific glue
that isn't owned by a reusable component (e.g. the add-form helpers).
"""
from common.components import ModuleScript, StaticScript, collect_media
"""Assemble a full HTML document around `content` (the fast_app equivalent)."""
from games.views.general import global_current_year, model_counts
media = collect_media(content)
collected_scripts = "".join(
[str(ModuleScript(name)) for name in media.js]
+ [str(StaticScript(name)) for name in media.js_external]
)
all_scripts = collected_scripts + (str(scripts) if scripts else "")
counts = model_counts(request)
year = global_current_year(request)["global_current_year"]
navbar = Navbar(
today_played=counts["today_played"],
last_7_played=counts["last_7_played"],
current_year=year,
csrf_token=get_token(request),
)
messages = [
@@ -338,12 +309,9 @@ def Page(
f' <script src="{static("js/htmx-redirect-toast.js")}"></script>\n'
f" {django_htmx_script(nonce=None)}\n"
f' <link rel="stylesheet" href="{static("base.css")}" />\n'
# Vendored bundles (flowbite 2.4.1, alpinejs/@alpinejs/mask 3.15.12) —
# served locally so pages work offline (and in browser tests). The mask
# plugin must load before Alpine core; both stay deferred.
f' <script src="{static("js/flowbite.min.js")}"></script>\n'
f' <script defer src="{static("js/alpine-mask.min.js")}"></script>\n'
f' <script defer src="{static("js/alpine.min.js")}"></script>\n'
' <script src="https://cdn.jsdelivr.net/npm/flowbite@2.4.1/dist/flowbite.min.js"></script>\n'
' <script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/mask@3.x.x/dist/cdn.min.js"></script>\n'
' <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>\n'
f" {_THEME_FOUC_SCRIPT}\n"
" </head>\n"
)
@@ -357,7 +325,7 @@ def Page(
f' <div id="main-container" class="flex flex-1 flex-col pt-8 pb-16">{content}</div>\n'
f' <span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{version()} ({version_date()})</span>\n'
" </div>\n"
f" {all_scripts}\n"
f" {scripts}\n"
f" {_main_script(mastered)}\n"
" <!-- hx-swap-oob makes sure the modal gets removed upon any HTMX response -->\n"
' <div id="global-modal-container" hx-swap-oob="true"></div>\n'
@@ -371,10 +339,10 @@ def Page(
def render_page(
request: HttpRequest,
content: "Node | SafeText | str",
content: SafeText | str,
*,
title: str = "",
scripts: "Node | SafeText | str" = "",
scripts: SafeText | str = "",
mastered: bool = False,
status: int = 200,
) -> HttpResponse:
-26
View File
@@ -1,43 +1,17 @@
import re
from datetime import date, datetime, timedelta
from typing import NamedTuple
from django.utils import timezone
from common.utils import generate_split_ranges
dateformat: str = "%d/%m/%Y"
dateformat_hyphenated: str = "%d-%m-%Y"
datetimeformat: str = "%d/%m/%Y %H:%M"
timeformat: str = "%H:%M"
durationformat: str = "%2.1H hours"
durationformat_manual: str = "%H hours"
class DatePartSpec(NamedTuple):
"""One date part (day/month/year) of a hyphenated date format."""
name: str
placeholder: str
length: int
_DATE_PART_SPECS: dict[str, DatePartSpec] = {
"%d": DatePartSpec("day", "DD", 2),
"%m": DatePartSpec("month", "MM", 2),
"%Y": DatePartSpec("year", "YYYY", 4),
}
def date_parts(format_string: str = dateformat_hyphenated) -> list[DatePartSpec]:
"""Split a hyphenated strftime date format into its ordered parts.
``"%d-%m-%Y"`` becomes ``[day, month, year]`` specs, each carrying the
placeholder text (``DD``/``MM``/``YYYY``) and digit length shown by the
DateRangeField segments."""
return [_DATE_PART_SPECS[directive] for directive in format_string.split("-")]
def _safe_timedelta(duration: timedelta | int | None):
if duration is None:
return timedelta(0)
+1 -4
View File
@@ -7,11 +7,8 @@ services:
dockerfile: Dockerfile
container_name: timetracker
environment:
- DEBUG=false
- TZ=Europe/Prague
# APP_URL drives ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS unless overridden.
# Behind your own reverse proxy you may also set ALLOWED_HOSTS=* directly.
- APP_URL=https://tracker.kucharczyk.xyz
- CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz"
user: "1000"
# volumes:
# - "db:/home/timetracker/app/src/timetracker/db.sqlite3"
+2 -6
View File
@@ -1,21 +1,17 @@
---
services:
timetracker:
image: ${REGISTRY_URL:-registry.kucharczyk.xyz}/timetracker:latest
image: ${REGISTRY_URL:-registry.kucharczyk.xyz}/timetracker:1.7.0
build:
context: .
dockerfile: Dockerfile
container_name: timetracker
environment:
- DEBUG=${DEBUG:-false}
- SECRET_KEY=${SECRET_KEY}
- TZ=${TZ:-Europe/Prague}
# APP_URL drives ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS unless overridden.
- APP_URL=${APP_URL:-http://localhost:8000}
- CSRF_TRUSTED_ORIGINS=https://tracker.kucharczyk.xyz
- PUID=${PUID:-1000}
- PGID=${PGID:-100}
- DATA_DIR=${DATA_DIR:-/home/timetracker/app/data}
- CREATE_DEFAULT_SUPERUSER=${CREATE_DEFAULT_SUPERUSER:-false}
ports:
- "${TIMETRACKER_EXTERNAL_PORT:-8000}:8000"
volumes:
-124
View File
@@ -1,124 +0,0 @@
# Configuration
All configurable Django settings are read through a single helper,
`config()` in [`timetracker/config.py`](../timetracker/config.py). It resolves
each value from a fixed chain of sources so the same setting can come from an
environment variable, a `.env` file, an `.ini` file, or a built-in default —
without any per-setting special-casing in `settings.py`.
## Resolution priority
For a setting named `NAME`, the first source that provides a value wins:
| Priority | Source | Notes |
|---------:|--------|-------|
| 1 | `NAME__FILE` env var | Path to a file; its *stripped* contents are the value. Opt-in per setting (`allow_file=True`). For Docker/Kubernetes secrets. |
| 2 | `NAME` env var | A real process environment variable. |
| 3 | `.env` file | `KEY=value` lines (see [.env syntax](#env-syntax)). |
| 4 | `settings.ini` file | The `[timetracker]` section, parsed with `configparser`. |
| 5 | `default` | The in-code fallback in `settings.py`. |
If no source supplies a value and no `default` is defined, startup fails with
`ImproperlyConfigured` rather than silently using an empty value.
**Worked example.** With `VALUE` set in the environment *and* in `.env` *and*
in `settings.ini`, the environment variable wins. Remove it and `.env` wins;
remove that and `settings.ini` wins; remove that and the code default applies.
## Settings reference
| Setting | Cast | Default | `__FILE`? | Description |
|---------|------|---------|:---------:|-------------|
| `SECRET_KEY` | str | insecure dev key | yes | Django secret key. **Required in production** (DEBUG off) — a missing value is a hard error, not a silent insecure fallback. |
| `DEBUG` | bool | `true` (dev) | no | Debug mode. Turn **off** in production. Defaults on for local development. |
| `APP_URL` | str | `http://localhost:8000` | no | Public URL of the site. Derives `ALLOWED_HOSTS` and `CSRF_TRUSTED_ORIGINS` when those are not set explicitly. |
| `ALLOWED_HOSTS` | list | derived from `APP_URL` | no | Comma-separated hostnames. Overrides the `APP_URL` derivation. |
| `CSRF_TRUSTED_ORIGINS` | list | derived from `APP_URL` | no | Comma-separated full origins (`https://host`). Overrides the `APP_URL` derivation. |
| `TZ` | str | `Europe/Prague` (dev) / `UTC` (prod) | no | Time zone. |
| `DATA_DIR` | path | project root | no | Directory holding the SQLite database. Also read by `entrypoint.sh`. |
`cast` understands `bool` (`true/1/yes/on``True`), `list` (comma-separated,
whitespace-trimmed, empty items dropped), `int`, `Path`, or any callable.
## APP_URL, ALLOWED_HOSTS and CSRF
`ALLOWED_HOSTS` and `CSRF_TRUSTED_ORIGINS` guard different things — the `Host`
header versus cross-origin requests — so they are **never merged**. For the
common case you set only `APP_URL` and both are derived:
```
APP_URL=https://tracker.example.com
# -> ALLOWED_HOSTS = ["tracker.example.com"]
# -> CSRF_TRUSTED_ORIGINS = ["https://tracker.example.com"]
```
Power users override either independently. A typical reverse-proxy setup:
```
ALLOWED_HOSTS=*
CSRF_TRUSTED_ORIGINS=https://tracker.example.com
```
## Secrets and `__FILE`
Secret managers (Docker secrets, Kubernetes) mount secrets as files. For any
setting that opts in (currently `SECRET_KEY`), point a `*__FILE` variable at
the mounted path:
```
SECRET_KEY__FILE=/run/secrets/timetracker_secret_key
```
The file contents are read and `.strip()`-ed. The strip matters: editors and
`echo` often append a trailing newline, and a stray `\n` inside `SECRET_KEY`
would silently invalidate every signed cookie/token when the file is recreated
without it.
## .env syntax
```dotenv
# full-line comment
KEY=value
export KEY=value # optional leading "export"
QUOTED="value with spaces" # surrounding quotes are stripped
SINGLE='also fine'
WITH_HASH="a # b" # '#' inside quotes is literal
INLINE=value # trailing comment after an unquoted value is dropped
```
Deliberately **not** supported (documented limits, not bugs):
- variable interpolation (`${OTHER}`)
- multiline values
File locations default to `.env` and `settings.ini` at the project root and
can be moved with the `ENV_FILE` / `INI_FILE` environment variables. Missing
files are ignored, so env-only deployments need neither. A `.env` file used by
`docker-compose` for `${VAR}` substitution is the same file Django reads in
local development; inside the container, real environment variables apply.
See [`.env.example`](../.env.example) and
[`settings.ini.example`](../settings.ini.example) for starting points.
## Container / entrypoint-only variables
These are consumed by [`entrypoint.sh`](../entrypoint.sh) during container
bootstrap, **not** by Django. They are intentionally not part of the Python
config — moving them there would buy nothing and force a bash↔Python bridge.
| Variable | Default | Purpose |
|----------|---------|---------|
| `PUID` / `PGID` | `1000` / `100` | uid/gid the container process runs as. |
| `DATA_DIR` | `/home/timetracker/app/data` | Database directory. Shared with Django via the same env var + matching default. |
| `CREATE_DEFAULT_SUPERUSER` | `false` | Create an `admin`/`admin` superuser on first start. |
| `STAGING` | `false` | Scrub copied sessions / django-q schedule on staging. |
| `LOAD_SAMPLE_DATA` | `false` | Seed sample fixtures when the database is empty. |
## Migrating from the old config
- `PROD=1``DEBUG=false`. `PROD` still works as a **deprecated alias** for
one release and emits a `DeprecationWarning`.
- `ALLOWED_HOSTS` is now configurable (it was previously hard-coded to `*`).
After upgrading, set `APP_URL` (or `ALLOWED_HOSTS` explicitly) or the host
will be rejected. Reverse-proxy deployments that relied on `*` should set
`ALLOWED_HOSTS=*`.
-51
View File
@@ -1,51 +0,0 @@
# 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,485 +0,0 @@
# Boolean Filters Overhaul Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Overhaul the boolean criterion filters from a single checkbox (representing True/Not set) to a 2-radio-button UI representing True, False, and Unset states across all filter bars.
**Architecture:**
1. Generalize `_filter_checkbox` into a filter-agnostic `Checkbox` component and introduce a `Radio` component in `common/components/primitives.py`.
2. Implement a nullable boolean filter JSON parsing helper `_parse_bool_nullable` and a component helper `_filter_boolean_radio` in `common/components/filters.py`.
3. Update `GameFilterBar`, `SessionFilterBar`, and `PurchaseFilterBar` in `common/components/filters.py` to leverage these new helpers.
4. Enhance `games/static/js/filter_bar.js` with deselectable radio toggling behavior and updated checked-radio state serialization.
**Tech Stack:** Python, Django, vanilla JavaScript, HTML.
---
### Task 1: Generalize Checkbox and Introduce Radio in Primitives
**Files:**
- Modify: `common/components/primitives.py`
- [ ] **Step 1: Write the failing test for the new Checkbox and Radio primitives**
Create a new test class `ComponentPrimitivesTest` in `tests/test_components.py` (or verify where to append) to check the output of `Checkbox` and `Radio`.
Add the following code to `tests/test_components.py`:
```python
from common.components.primitives import Checkbox, Radio
class ComponentPrimitivesTest(SimpleTestCase):
def test_checkbox_primitive(self):
html = Checkbox(name="test-check", label="Accept Terms", checked=True, value="yes")
self.assertIn('type="checkbox"', html)
self.assertIn('name="test-check"', html)
self.assertIn('value="yes"', html)
self.assertIn('checked="true"', html)
self.assertIn("Accept Terms", html)
def test_radio_primitive(self):
html = Radio(name="test-radio", label="Option A", checked=False, value="A")
self.assertIn('type="radio"', html)
self.assertIn('name="test-radio"', html)
self.assertIn('value="A"', html)
self.assertNotIn('checked="true"', html)
self.assertIn("Option A", html)
```
- [ ] **Step 2: Run test to verify it fails**
Run: `pytest tests/test_components.py -k ComponentPrimitivesTest`
Expected output: Failures/errors due to `Checkbox` and `Radio` not being defined/imported.
- [ ] **Step 3: Implement Checkbox and Radio in `common/components/primitives.py`**
Open `common/components/primitives.py` and find the other basic primitives (e.g. `Input`, `Label`). Add the following implementations and ensure they are exported / added to imports/exports:
```python
def Checkbox(
name: str,
label: str,
checked: bool = False,
value: str = "1",
attributes: list[HTMLAttribute] | None = None,
) -> SafeText:
"""A filter-agnostic Checkbox component."""
attributes = attributes or []
input_attrs = [
("name", name),
("value", value),
("class", "rounded border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand"),
] + attributes
if checked:
input_attrs.append(("checked", "true"))
return Label(
attributes=[("class", "flex items-center gap-2 text-sm text-heading cursor-pointer")],
children=[
Input(type="checkbox", attributes=input_attrs),
label,
],
)
def Radio(
name: str,
label: str,
checked: bool = False,
value: str = "",
attributes: list[HTMLAttribute] | None = None,
) -> SafeText:
"""A filter-agnostic Radio component."""
attributes = attributes or []
input_attrs = [
("name", name),
("value", value),
("class", "rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand"),
] + attributes
if checked:
input_attrs.append(("checked", "true"))
return Label(
attributes=[("class", "flex items-center gap-1.5 text-sm text-heading cursor-pointer")],
children=[
Input(type="radio", attributes=input_attrs),
label,
],
)
```
- [ ] **Step 4: Run test to verify it passes**
Run: `pytest tests/test_components.py -k ComponentPrimitivesTest`
Expected output: `2 passed`
- [ ] **Step 5: Commit**
Run:
```bash
git add common/components/primitives.py tests/test_components.py
git commit -m "refactor: generalize Checkbox and add Radio primitive component"
```
---
### Task 2: Implement Filter Parsers & Helpers in filters.py
**Files:**
- Modify: `common/components/filters.py`
- Modify: `tests/test_filter_helpers.py`
- [ ] **Step 1: Write failing unit tests for `_parse_bool_nullable` in `tests/test_filter_helpers.py`**
Add a new test class `ParseBoolNullableTest` to `tests/test_filter_helpers.py`:
```python
from common.components.filters import _parse_bool_nullable
class ParseBoolNullableTest(SimpleTestCase):
def test_missing_key(self):
self.assertIsNone(_parse_bool_nullable({}, "field"))
def test_null_value(self):
self.assertIsNone(_parse_bool_nullable({"field": None}, "field"))
self.assertIsNone(_parse_bool_nullable({"field": {}}, "field"))
def test_boolean_values(self):
self.assertTrue(_parse_bool_nullable({"field": {"value": True}}, "field"))
self.assertFalse(_parse_bool_nullable({"field": {"value": False}}, "field"))
def test_string_values(self):
self.assertTrue(_parse_bool_nullable({"field": {"value": "true"}}, "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": "0"}}, "field"))
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `pytest tests/test_filter_helpers.py -k ParseBoolNullableTest`
Expected output: Failures/errors due to `_parse_bool_nullable` not found.
- [ ] **Step 3: Implement `_parse_bool_nullable` and `_filter_boolean_radio` in `common/components/filters.py`**
1. Import `Checkbox` and `Radio` from `common.components.primitives` at the top of `common/components/filters.py`.
2. Define `_FILTER_RADIO_CLASS` and add `_parse_bool_nullable`.
3. Create `_filter_boolean_radio`.
4. Refactor `_filter_checkbox` to use `Checkbox` instead of raw `Label` and `Input`.
Code to implement:
```python
_FILTER_RADIO_CLASS = (
"rounded-full border-default-medium bg-neutral-secondary-medium "
"text-brand focus:ring-brand"
)
def _parse_bool_nullable(existing: dict, key: str) -> bool | None:
"""Extract a nullable boolean value from a filter criterion."""
if key not in existing:
return None
field = existing[key]
if not isinstance(field, dict):
return None
val = field.get("value")
if val is None:
return None
if isinstance(val, str):
if val.lower() in ("true", "1", "yes"):
return True
if val.lower() in ("false", "0", "no"):
return False
return bool(val)
def _filter_checkbox(name: str, label: str, checked: bool) -> SafeText:
"""Thin adapter mapping legacy checkbox filters to the generalized Checkbox primitive."""
return Checkbox(name=name, label=label, checked=checked)
def _filter_boolean_radio(name: str, label: str, value: bool | None) -> SafeText:
"""Renders a filter-specific boolean radio button group with 'True' and 'False' options."""
return Div(
attributes=[("class", "flex flex-col gap-1")],
children=[
Span(
attributes=[("class", _FILTER_LABEL_CLASS)],
children=[label],
),
Div(
attributes=[("class", "flex items-center gap-4 h-9")],
children=[
Radio(name=name, label="True", checked=value is True, value="true"),
Radio(name=name, label="False", checked=value is False, value="false"),
],
),
],
)
```
- [ ] **Step 4: Run unit tests to verify they pass**
Run: `pytest tests/test_filter_helpers.py`
Expected output: All helper tests passed (including `ParseBoolNullableTest`).
- [ ] **Step 5: Commit**
Run:
```bash
git add common/components/filters.py tests/test_filter_helpers.py
git commit -m "feat: implement _parse_bool_nullable and _filter_boolean_radio helper"
```
---
### Task 3: Replace Single Checkboxes with Radio Groups in Filter Bars
**Files:**
- Modify: `common/components/filters.py`
- [ ] **Step 1: Update GameFilterBar**
In `common/components/filters.py` inside `GameFilterBar`:
1. Parse using `_parse_bool_nullable` instead of `_parse_bool` for:
- `mastered_value`
- `purchase_refunded_value`
- `purchase_infinite_value`
- `session_emulated_value`
2. Update the fields list to replace `_filter_checkbox` with `_filter_boolean_radio`, changing the wrapper div to have `gap-6` for better horizontal radio button spacing.
Code snippet modification:
```python
# Parsing:
mastered_value = _parse_bool_nullable(existing, "mastered")
# ...
purchase_refunded_value = _parse_bool_nullable(existing, "purchase_refunded")
purchase_infinite_value = _parse_bool_nullable(existing, "purchase_infinite")
session_emulated_value = _parse_bool_nullable(existing, "session_emulated")
# Rendering (in fields):
Div(
attributes=[("class", "flex items-end gap-6 mb-4 flex-wrap")],
children=[
_filter_boolean_radio("filter-mastered", "Mastered", mastered_value),
_filter_boolean_radio(
"filter-purchase-refunded", "Refunded", purchase_refunded_value
),
_filter_boolean_radio(
"filter-purchase-infinite", "Infinite", purchase_infinite_value
),
_filter_boolean_radio(
"filter-session-emulated", "Emulated", session_emulated_value
),
],
),
```
- [ ] **Step 2: Update SessionFilterBar**
In `common/components/filters.py` inside `SessionFilterBar`:
1. Parse using `_parse_bool_nullable` for:
- `emulated_value`
- `is_active_value`
2. Update the fields to replace `_filter_checkbox` with `_filter_boolean_radio`.
Code snippet modification:
```python
# Parsing:
emulated_value = _parse_bool_nullable(existing, "emulated")
is_active_value = _parse_bool_nullable(existing, "is_active")
# Rendering (in fields):
Div(
attributes=[("class", "flex gap-6 mb-4")],
children=[
_filter_boolean_radio("filter-emulated", "Emulated", emulated_value),
_filter_boolean_radio("filter-active", "Active", is_active_value),
],
),
```
- [ ] **Step 3: Update PurchaseFilterBar**
In `common/components/filters.py` inside `PurchaseFilterBar`:
1. Parse using `_parse_bool_nullable` for:
- `is_refunded_value`
- `infinite_value`
- `needs_price_update_value`
2. Update the fields to replace `_filter_checkbox` with `_filter_boolean_radio`.
Code snippet modification:
```python
# Parsing:
is_refunded_value = _parse_bool_nullable(existing, "is_refunded")
infinite_value = _parse_bool_nullable(existing, "infinite")
needs_price_update_value = _parse_bool_nullable(existing, "needs_price_update")
# Rendering (in fields):
Div(
attributes=[("class", "flex flex-col items-start gap-4 mb-4")],
children=[
_filter_boolean_radio(
"filter-refunded", "Refunded", is_refunded_value
),
_filter_boolean_radio("filter-infinite", "Infinite", infinite_value),
_filter_boolean_radio(
"filter-needs-price-update",
"Needs Price Update",
needs_price_update_value,
),
],
),
```
- [ ] **Step 4: Run component tests to verify output**
Run: `pytest tests/test_filter_bars.py`
Expected output: Since we only changed the internal input type from checkbox to radio but kept the `name="..."` attribute intact, the tests asserting name occurrences should still pass!
- [ ] **Step 5: Commit**
Run:
```bash
git add common/components/filters.py
git commit -m "feat: replace single boolean checkboxes with radio groups in all FilterBars"
```
---
### Task 4: Frontend Behavior and Serialization in JS
**Files:**
- Modify: `games/static/js/filter_bar.js`
- [ ] **Step 1: Update Radio Serialization in `buildFilterJSON`**
In `games/static/js/filter_bar.js`, locate the `// 2. Boolean Fields (Checkboxes)` section.
Update the loop to check for `:checked` radio options:
```javascript
// 2. Boolean Fields (Radio Button Groups)
var booleanFields = [
{ name: "filter-mastered", key: "mastered" },
{ name: "filter-emulated", key: "emulated" },
{ name: "filter-active", key: "is_active" },
{ name: "filter-refunded", key: "is_refunded" },
{ name: "filter-infinite", key: "infinite" },
{ name: "filter-needs-price-update", key: "needs_price_update" },
{ name: "filter-purchase-refunded", key: "purchase_refunded" },
{ name: "filter-purchase-infinite", key: "purchase_infinite" },
{ name: "filter-session-emulated", key: "session_emulated" }
];
booleanFields.forEach(function (bf) {
var el = form.querySelector('[name="' + bf.name + '"]:checked');
if (el) {
var val = el.value === "true";
filter[bf.key] = criterion(val, null, "EQUALS");
}
});
```
- [ ] **Step 2: Add click-to-deselect functionality for radios**
In `games/static/js/filter_bar.js`, add `setupDeselectableRadios` and call it inside `DOMContentLoaded`:
```javascript
/**
* Enable deselect-on-click behavior for filter radio buttons.
*/
function setupDeselectableRadios() {
document.querySelectorAll('input[type="radio"]').forEach(function (radio) {
radio.addEventListener('click', function (e) {
if (this.wasChecked) {
this.checked = false;
this.wasChecked = false;
this.dispatchEvent(new Event('change', { bubbles: true }));
} else {
var name = this.getAttribute('name');
if (name) {
document.querySelectorAll('input[type="radio"][name="' + name + '"]').forEach(function (r) {
r.wasChecked = false;
});
}
this.wasChecked = true;
}
});
if (radio.checked) {
radio.wasChecked = true;
}
});
}
```
Locate the `document.addEventListener("DOMContentLoaded", ...)` callback at the bottom of the file and update it:
```javascript
document.addEventListener("DOMContentLoaded", function () {
injectSearchInputs();
setupDeselectableRadios();
loadPresets();
});
```
- [ ] **Step 3: Run existing frontend / component tests to verify no syntax errors or simple breaks**
Run: `pytest tests/test_filter_bars.py`
Expected output: PASS
- [ ] **Step 4: Commit**
Run:
```bash
git add games/static/js/filter_bar.js
git commit -m "feat: add click-to-deselect behavior and update checked-radio serialization in JS"
```
---
### Task 5: Add Comprehensive Test Coverage & Verification
**Files:**
- Modify: `tests/test_filter_bars.py`
- [ ] **Step 1: Write explicit tests for boolean radio elements in filter bars**
Add a test case checking that the filter bars output `type="radio"` and contain `value="true"` and `value="false"` for boolean fields:
In `tests/test_filter_bars.py`, add the following test method:
```python
def test_boolean_fields_render_as_radio_groups(self):
"""Boolean fields must render as radio groups with True/False choices."""
from common.components import FilterBar, SessionFilterBar, PurchaseFilterBar
# 1. Games Filter Bar
games_html = str(FilterBar(filter_json=""))
self.assertIn('type="radio"', games_html)
self.assertIn('name="filter-mastered"', games_html)
self.assertIn('value="true"', games_html)
self.assertIn('value="false"', games_html)
# 2. Session Filter Bar
session_html = str(SessionFilterBar(filter_json=""))
self.assertIn('type="radio"', session_html)
self.assertIn('name="filter-emulated"', session_html)
self.assertIn('value="true"', session_html)
self.assertIn('value="false"', session_html)
# 3. Purchase Filter Bar
purchase_html = str(PurchaseFilterBar(filter_json=""))
self.assertIn('type="radio"', purchase_html)
self.assertIn('name="filter-refunded"', purchase_html)
self.assertIn('value="true"', purchase_html)
self.assertIn('value="false"', purchase_html)
```
- [ ] **Step 2: Run pytest to verify all tests (including new ones) pass**
Run: `pytest`
Expected output: `356 passed` (including the new test case).
- [ ] **Step 3: Commit final tests**
Run:
```bash
git add tests/test_filter_bars.py
git commit -m "test: add explicit radio group and True/False choice checks for boolean fields"
```
@@ -1,662 +0,0 @@
# Comprehensive Filters Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement a comprehensive suite of backend filter classes and filter field expansions across all 6 main models (Game, Session, Purchase, Device, Platform, PlayEvent) using a subquery-based cross-entity approach.
**Architecture:** We will implement missing filter classes (`DeviceFilter`, `PlatformFilter`, `PlayEventFilter`) in `games/filters.py`. We will extend all filters to support powerful, deeply linked "cross-entity" subqueries (e.g. `GameFilter.session_filter` or `PlatformFilter.game_filter`) which builds robust `Q` objects without causing duplicate join rows in list queries.
**Tech Stack:** Django, Python dataclasses, Pytest.
---
### Task 1: Implement New Filter Classes (Device, Platform, PlayEvent)
**Files:**
- Modify: `games/filters.py`
- Test: `tests/test_filters.py`
- [ ] **Step 1: Implement DeviceFilter, PlatformFilter, and PlayEventFilter**
Add the three new operator filters to `games/filters.py`. Ensure we import all necessary criterion types and add the `parse_device_filter`, `parse_platform_filter`, and `parse_playevent_filter` helper functions at the end of the file.
```python
# Insert new filter imports and classes in games/filters.py
@dataclass
class DeviceFilter(OperatorFilter):
"""Filter for the Device model."""
AND: DeviceFilter | None = None
OR: DeviceFilter | None = None
NOT: DeviceFilter | None = None
name: StringCriterion | None = None
type: ChoiceCriterion | None = None
created_at: StringCriterion | None = None
# Free-text search
search: StringCriterion | None = None
# Cross-entity: Devices that have sessions matching these criteria
session_filter: SessionFilter | None = None
def to_q(self) -> Q:
q = Q()
if self.name is not None:
q &= self.name.to_q("name")
if self.type is not None:
q &= self.type.to_q("type")
if self.created_at is not None:
q &= self.created_at.to_q("created_at")
# Free-text search
if self.search is not None and self.search.value:
search_q = (
Q(name__icontains=self.search.value)
| Q(type__icontains=self.search.value)
)
if self.search.modifier == Modifier.EXCLUDES:
search_q = ~search_q
q &= search_q
# Cross-entity filter: session_filter
if self.session_filter is not None:
from games.models import Session
session_q = self.session_filter.to_q()
matching_ids = Session.objects.filter(session_q).values_list("device_id", flat=True)
q &= Q(id__in=matching_ids)
sub = self.sub_filter()
if sub is not None:
if self.AND is not None:
q &= sub.to_q()
elif self.OR is not None:
q |= sub.to_q()
elif self.NOT is not None:
q &= ~sub.to_q()
return q
@dataclass
class PlatformFilter(OperatorFilter):
"""Filter for the Platform model."""
AND: PlatformFilter | None = None
OR: PlatformFilter | None = None
NOT: PlatformFilter | None = None
name: StringCriterion | None = None
group: StringCriterion | None = None
icon: StringCriterion | None = None
created_at: StringCriterion | None = None
# Free-text search
search: StringCriterion | None = None
# Cross-entity
game_filter: GameFilter | None = None
purchase_filter: PurchaseFilter | None = None
def to_q(self) -> Q:
q = Q()
if self.name is not None:
q &= self.name.to_q("name")
if self.group is not None:
q &= self.group.to_q("group")
if self.icon is not None:
q &= self.icon.to_q("icon")
if self.created_at is not None:
q &= self.created_at.to_q("created_at")
# Free-text search
if self.search is not None and self.search.value:
search_q = (
Q(name__icontains=self.search.value)
| Q(group__icontains=self.search.value)
)
if self.search.modifier == Modifier.EXCLUDES:
search_q = ~search_q
q &= search_q
# Cross-entity filter: game_filter
if self.game_filter is not None:
from games.models import Game
game_q = self.game_filter.to_q()
matching_ids = Game.objects.filter(game_q).values_list("platform_id", flat=True)
q &= Q(id__in=matching_ids)
# Cross-entity filter: purchase_filter
if self.purchase_filter is not None:
from games.models import Purchase
purchase_q = self.purchase_filter.to_q()
matching_ids = Purchase.objects.filter(purchase_q).values_list("platform_id", flat=True)
q &= Q(id__in=matching_ids)
sub = self.sub_filter()
if sub is not None:
if self.AND is not None:
q &= sub.to_q()
elif self.OR is not None:
q |= sub.to_q()
elif self.NOT is not None:
q &= ~sub.to_q()
return q
@dataclass
class PlayEventFilter(OperatorFilter):
"""Filter for the PlayEvent model."""
AND: PlayEventFilter | None = None
OR: PlayEventFilter | None = None
NOT: PlayEventFilter | None = None
game: MultiCriterion | None = None # filters on game_id
started: StringCriterion | None = None # date string
ended: StringCriterion | None = None # date string
days_to_finish: IntCriterion | None = None
note: StringCriterion | None = None
created_at: StringCriterion | None = None
# Free-text search
search: StringCriterion | None = None
# Cross-entity: PlayEvents for games matching these criteria
game_filter: GameFilter | None = None
def to_q(self) -> Q:
q = Q()
if self.game is not None:
q &= self.game.to_q("game_id")
if self.started is not None:
q &= self.started.to_q("started")
if self.ended is not None:
q &= self.ended.to_q("ended")
if self.days_to_finish is not None:
q &= self.days_to_finish.to_q("days_to_finish")
if self.note is not None:
q &= self.note.to_q("note")
if self.created_at is not None:
q &= self.created_at.to_q("created_at")
# Free-text search
if self.search is not None and self.search.value:
search_q = (
Q(game__name__icontains=self.search.value)
| Q(note__icontains=self.search.value)
)
if self.search.modifier == Modifier.EXCLUDES:
search_q = ~search_q
q &= search_q
# Cross-entity filter: game_filter
if self.game_filter is not None:
from games.models import Game
game_q = self.game_filter.to_q()
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
q &= Q(game_id__in=matching_ids)
sub = self.sub_filter()
if sub is not None:
if self.AND is not None:
q &= sub.to_q()
elif self.OR is not None:
q |= sub.to_q()
elif self.NOT is not None:
q &= ~sub.to_q()
return q
# Add to convenience helpers section:
def parse_device_filter(json_str: str) -> DeviceFilter | None:
return filter_from_json(DeviceFilter, json_str)
def parse_platform_filter(json_str: str) -> PlatformFilter | None:
return filter_from_json(PlatformFilter, json_str)
def parse_playevent_filter(json_str: str) -> PlayEventFilter | None:
return filter_from_json(PlayEventFilter, json_str)
```
- [ ] **Step 2: Run existing tests to verify everything compiles**
Run: `pytest tests/test_filters.py -v`
Expected: All existing tests PASS without issues.
---
### Task 2: Expand SessionFilter (Duration Fields + Cross-Entity)
**Files:**
- Modify: `games/filters.py:SessionFilter`
- Test: `tests/test_filters.py`
- [ ] **Step 1: Refactor SessionFilter and add new duration fields & device_filter**
Modify `SessionFilter` to replace `duration_minutes: IntCriterion` with `duration_total_minutes`, `duration_manual_minutes`, and `duration_calculated_minutes`. Add `device_filter: DeviceFilter`.
Update `to_q()` inside `SessionFilter` to map duration fields correctly to their respective GeneratedFields (`duration_total`, `duration_calculated`) or manual field (`duration_manual`). Use standard Python `timedelta` logic.
```python
# Inside SessionFilter class:
duration_total_minutes: IntCriterion | None = None
duration_manual_minutes: IntCriterion | None = None
duration_calculated_minutes: IntCriterion | None = None
# Cross-entity: sessions for devices matching these criteria
device_filter: DeviceFilter | None = None
```
```python
# Helper inside SessionFilter or refactored:
def _duration_to_q(self, c: IntCriterion, field: str) -> Q:
from datetime import timedelta
q = Q()
td_val = timedelta(minutes=c.value)
m = c.modifier
if m == Modifier.EQUALS:
q &= Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(minutes=c.value + 1),
}
)
elif m == Modifier.NOT_EQUALS:
q &= ~Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(minutes=c.value + 1),
}
)
elif m == Modifier.GREATER_THAN:
q &= Q(**{f"{field}__gt": td_val})
elif m == Modifier.LESS_THAN:
q &= Q(**{f"{field}__lt": td_val})
elif m == Modifier.BETWEEN and c.value2 is not None:
lo = timedelta(minutes=min(c.value, c.value2))
hi = timedelta(minutes=max(c.value, c.value2))
q &= Q(**{f"{field}__gte": lo, f"{f_field}__lte": hi})
elif m == Modifier.NOT_BETWEEN and c.value2 is not None:
lo = timedelta(minutes=min(c.value, c.value2))
hi = timedelta(minutes=max(c.value, c.value2))
q &= Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
elif m == Modifier.IS_NULL:
q &= Q(**{f"{field}": timedelta(0)})
elif m == Modifier.NOT_NULL:
q &= ~Q(**{f"{field}": timedelta(0)})
return q
```
Then in `to_q()` inside `SessionFilter`:
```python
if self.duration_total_minutes is not None:
q &= self._duration_to_q(self.duration_total_minutes, "duration_total")
if self.duration_manual_minutes is not None:
q &= self._duration_to_q(self.duration_manual_minutes, "duration_manual")
if self.duration_calculated_minutes is not None:
q &= self._duration_to_q(self.duration_calculated_minutes, "duration_calculated")
# Cross-entity filter: device_filter
if self.device_filter is not None:
from games.models import Device
device_q = self.device_filter.to_q()
matching_ids = Device.objects.filter(device_q).values_list("id", flat=True)
q &= Q(device_id__in=matching_ids)
```
- [ ] **Step 2: Run tests to verify compiles correctly**
Run: `pytest tests/test_filters.py -v`
Expected: PASS (existing tests may need updating if they referenced `duration_minutes`).
---
### Task 3: Expand PurchaseFilter (Original Currency, Infinite, Needs Price Update, Converted Currency)
**Files:**
- Modify: `games/filters.py:PurchaseFilter`
- Test: `tests/test_filters.py`
- [ ] **Step 1: Add new fields to PurchaseFilter and platform_filter**
Expand `PurchaseFilter` with `infinite: BoolCriterion`, `needs_price_update: BoolCriterion`, `converted_currency: StringCriterion`, and `platform_filter: PlatformFilter`.
```python
# Inside PurchaseFilter class:
infinite: BoolCriterion | None = None
needs_price_update: BoolCriterion | None = None
converted_currency: StringCriterion | None = None
# Cross-entity
platform_filter: PlatformFilter | None = None
```
Update `to_q()` inside `PurchaseFilter`:
```python
if self.infinite is not None:
q &= self.infinite.to_q("infinite")
if self.needs_price_update is not None:
q &= self.needs_price_update.to_q("needs_price_update")
if self.converted_currency is not None:
q &= self.converted_currency.to_q("converted_currency")
# Cross-entity filter: platform_filter
if self.platform_filter is not None:
from games.models import Platform
platform_q = self.platform_filter.to_q()
matching_ids = Platform.objects.filter(platform_q).values_list("id", flat=True)
q &= Q(platform_id__in=matching_ids)
```
- [ ] **Step 2: Verify test suite continues to pass**
Run: `pytest tests/test_filters.py -v`
Expected: PASS
---
### Task 4: Expand GameFilter (Has Purchases, Has PlayEvents, Session Stats, Cross-Entity)
**Files:**
- Modify: `games/filters.py:GameFilter`
- Test: `tests/test_filters.py`
- [ ] **Step 1: Expand GameFilter with session stats, purchase/playevent existence, and cross-entity filters**
Add fields and cross-entity filters to `GameFilter`:
```python
# Inside GameFilter class:
has_purchases: BoolCriterion | None = None
has_playevents: BoolCriterion | None = None
session_count: IntCriterion | None = None
session_average: IntCriterion | None = None # average in minutes
# Cross-entity filters
session_filter: SessionFilter | None = None
purchase_filter: PurchaseFilter | None = None
playevent_filter: PlayEventFilter | None = None
platform_filter: PlatformFilter | None = None
```
Update `to_q()` inside `GameFilter`.
For existence and session stats filters, we use Subqueries to avoid complex inline annotations during the generic filter generation (which is much cleaner and less bug-prone):
```python
if self.has_purchases is not None:
from games.models import Purchase
purchased_ids = Purchase.objects.values_list("games__id", flat=True).distinct()
if self.has_purchases.value:
q &= Q(id__in=purchased_ids)
else:
q &= ~Q(id__in=purchased_ids)
if self.has_playevents is not None:
from games.models import PlayEvent
played_ids = PlayEvent.objects.values_list("game_id", flat=True).distinct()
if self.has_playevents.value:
q &= Q(id__in=played_ids)
else:
q &= ~Q(id__in=played_ids)
if self.session_count is not None:
from games.models import Game
from django.db.models import Count
matching_ids = Game.objects.annotate(s_count=Count("sessions")).filter(self.session_count.to_q("s_count")).values_list("id", flat=True)
q &= Q(id__in=matching_ids)
if self.session_average is not None:
from games.models import Game, Session
from django.db.models import Avg, F, ExpressionWrapper, DurationField
# Compute average session total duration.
# Avg returns an interval/duration type, so we can convert it to minutes in Python or do duration comparisons directly.
# To match the criterion easily, we can filter Game objects using Avg:
matching_ids = Game.objects.annotate(s_avg=Avg("sessions__duration_total")).filter(self._playtime_to_q_for_field(self.session_average, "s_avg")).values_list("id", flat=True)
q &= Q(id__in=matching_ids)
# Cross-entity filters
if self.session_filter is not None:
from games.models import Session
session_q = self.session_filter.to_q()
matching_ids = Session.objects.filter(session_q).values_list("game_id", flat=True)
q &= Q(id__in=matching_ids)
if self.purchase_filter is not None:
from games.models import Purchase
purchase_q = self.purchase_filter.to_q()
matching_ids = Purchase.objects.filter(purchase_q).values_list("games__id", flat=True)
q &= Q(id__in=matching_ids)
if self.playevent_filter is not None:
from games.models import PlayEvent
playevent_q = self.playevent_filter.to_q()
matching_ids = PlayEvent.objects.filter(playevent_q).values_list("game_id", flat=True)
q &= Q(id__in=matching_ids)
if self.platform_filter is not None:
from games.models import Platform
platform_q = self.platform_filter.to_q()
matching_ids = Platform.objects.filter(platform_q).values_list("id", flat=True)
q &= Q(platform_id__in=matching_ids)
```
Add a helper `_playtime_to_q_for_field` in `GameFilter` that works exactly like `_playtime_to_q` but accepts a customized field name (e.g. `s_avg`):
```python
@staticmethod
def _playtime_to_q_for_field(c: IntCriterion, field: str) -> Q:
from datetime import timedelta
m = c.modifier
td_val = timedelta(minutes=c.value)
if m == Modifier.EQUALS:
return Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(minutes=c.value + 1),
}
)
if m == Modifier.NOT_EQUALS:
return ~Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(minutes=c.value + 1),
}
)
if m == Modifier.GREATER_THAN:
return Q(**{f"{field}__gt": td_val})
if m == Modifier.LESS_THAN:
return Q(**{f"{field}__lt": td_val})
if m == Modifier.BETWEEN and c.value2 is not None:
lo = timedelta(minutes=min(c.value, c.value2))
hi = timedelta(minutes=max(c.value, c.value2))
return Q(**{f"{field}__gte": lo, f"{field}__lte": hi})
if m == Modifier.NOT_BETWEEN and c.value2 is not None:
lo = timedelta(minutes=min(c.value, c.value2))
hi = timedelta(minutes=max(c.value, c.value2))
return Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
if m == Modifier.IS_NULL:
return Q(**{f"{field}": timedelta(0)})
if m == Modifier.NOT_NULL:
return ~Q(**{f"{field}": timedelta(0)})
return Q()
```
- [ ] **Step 2: Update existing `_playtime_to_q` to delegate to `_playtime_to_q_for_field`**
```python
@staticmethod
def _playtime_to_q(c: IntCriterion) -> Q:
return GameFilter._playtime_to_q_for_field(c, "playtime")
```
---
### Task 5: Add Exhaustive DB Tests for the Expanded and New Filters
**Files:**
- Modify: `tests/test_filters.py`
- [ ] **Step 1: Write DB-backed unit tests for the new filter behaviors**
Add comprehensive test cases inside `tests/test_filters.py` covering:
- New cross-entity filters (e.g. Platform -> Game -> Session -> Device chain).
- Session total vs manual vs calculated duration filters.
- Game session stats (`session_count`, `session_average`) and presence flags (`has_purchases`, `has_playevents`).
- Device, Platform, and PlayEvent specific filters.
```python
# Add test class at the end of tests/test_filters.py:
@pytest.mark.django_db
class TestExpandedFiltersAgainstDB:
def _setup_entities(self):
from games.models import Game, Platform, Device, Session, Purchase, PlayEvent
import datetime
from datetime import timedelta
# 1. Platform & Game
plat, _ = Platform.objects.get_or_create(name="Retro Console", group="Nintendo", icon="retro")
game, _ = Game.objects.get_or_create(name="Super Mario World", defaults={"platform": plat, "status": "f"})
game2, _ = Game.objects.get_or_create(name="Zelda", defaults={"platform": plat, "status": "u"})
# 2. Device & Session
dev, _ = Device.objects.get_or_create(name="Super Famicom", type="Console")
# Session 1: total 40 minutes (30 calc, 10 manual)
s1 = Session.objects.create(
game=game,
device=dev,
timestamp_start=datetime.datetime(2026, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc),
timestamp_end=datetime.datetime(2026, 6, 1, 12, 30, 0, tzinfo=datetime.timezone.utc),
duration_manual=timedelta(minutes=10)
)
# 3. Purchase
pur = Purchase.objects.create(
platform=plat,
date_purchased=datetime.date(2026, 1, 1),
infinite=True,
price=49.99,
price_currency="JPY",
converted_price=45.00,
converted_currency="USD",
needs_price_update=False
)
pur.games.add(game)
# 4. PlayEvent
pe = PlayEvent.objects.create(
game=game,
started=datetime.date(2026, 6, 1),
ended=datetime.date(2026, 6, 2),
note="Completed 100%"
)
return {
"plat": plat,
"game": game,
"game2": game2,
"dev": dev,
"s1": s1,
"pur": pur,
"pe": pe
}
def test_device_filter_and_cross_entity(self):
from games.filters import DeviceFilter, SessionFilter
from games.models import Device
data = self._setup_entities()
# Find devices that have sessions on "Super Mario World"
df = DeviceFilter.from_json({
"session_filter": {
"game_filter": {
"name": {"value": "Super Mario World", "modifier": "EQUALS"}
}
}
})
results = list(Device.objects.filter(df.to_q()))
assert data["dev"] in results
def test_platform_filter_and_cross_entity(self):
from games.filters import PlatformFilter, GameFilter
from games.models import Platform
data = self._setup_entities()
# Find platforms with games that are finished
pf = PlatformFilter.from_json({
"game_filter": {
"status": {"value": ["f"], "modifier": "INCLUDES"}
}
})
results = list(Platform.objects.filter(pf.to_q()))
assert data["plat"] in results
def test_session_filter_duration_splits(self):
from games.filters import SessionFilter
from games.models import Session
data = self._setup_entities()
# Test duration_total_minutes equals 40
sf_tot = SessionFilter.from_json({
"duration_total_minutes": {"value": 40, "modifier": "EQUALS"}
})
assert Session.objects.filter(sf_tot.to_q()).count() == 1
# Test duration_manual_minutes equals 10
sf_man = SessionFilter.from_json({
"duration_manual_minutes": {"value": 10, "modifier": "EQUALS"}
})
assert Session.objects.filter(sf_man.to_q()).count() == 1
# Test duration_calculated_minutes equals 30
sf_calc = SessionFilter.from_json({
"duration_calculated_minutes": {"value": 30, "modifier": "EQUALS"}
})
assert Session.objects.filter(sf_calc.to_q()).count() == 1
def test_purchase_filter_new_fields(self):
from games.filters import PurchaseFilter
from games.models import Purchase
data = self._setup_entities()
pf = PurchaseFilter.from_json({
"infinite": {"value": True, "modifier": "EQUALS"},
"needs_price_update": {"value": False, "modifier": "EQUALS"},
"converted_currency": {"value": "USD", "modifier": "EQUALS"}
})
assert Purchase.objects.filter(pf.to_q()).count() == 1
def test_game_filter_stats_and_existence(self):
from games.filters import GameFilter
from games.models import Game
data = self._setup_entities()
# has_purchases = True
gf_pur = GameFilter.from_json({
"has_purchases": {"value": True, "modifier": "EQUALS"}
})
assert data["game"] in list(Game.objects.filter(gf_pur.to_q()))
assert data["game2"] not in list(Game.objects.filter(gf_pur.to_q()))
# session_count = 1
gf_cnt = GameFilter.from_json({
"session_count": {"value": 1, "modifier": "EQUALS"}
})
assert data["game"] in list(Game.objects.filter(gf_cnt.to_q()))
```
- [ ] **Step 2: Run all unit tests to confirm success**
Run: `pytest tests/test_filters.py -v`
Expected: ALL tests pass perfectly.
@@ -1,577 +0,0 @@
# Frontend Filters Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement a comprehensive frontend filter bar interface for all 6 list views (Games, Sessions, Purchases, Devices, Platforms, PlayEvents) with specific field controls, simple cross-entity toggles, and full JSON preset support.
**Architecture:** We will extend existing components in `common/components/filters.py` and implement new filter bars (`DeviceFilterBar`, `PlatformFilterBar`, `PlayEventFilterBar`). We will update the views in `games/views/` to parse standard filter JSON from `request.GET.get('filter')`, apply them to querysets, render the filter bars, and export them in `common/components/__init__.py`.
**Tech Stack:** Django, Python dataclasses, Pytest.
---
### Task 1: Update existing FilterBars in `common/components/filters.py`
**Files:**
- Modify: `common/components/filters.py`
- [ ] **Step 1: Add new fields to GameFilterBar**
Add checkboxes for `has_purchases`, `has_playevents` and RangeSliders for `session_count`, `session_average`.
```python
# Inside common/components/filters.py: FilterBar()
# Parse new values
has_purchases_value = _parse_bool(existing, "has_purchases")
has_playevents_value = _parse_bool(existing, "has_playevents")
session_count_min, session_count_max = _parse_range(existing, "session_count")
session_avg_min, session_avg_max = _parse_range(existing, "session_average")
# Add components to fields:
# 1. Under status and platform, add the checkboxes for purchases/playevents
# 2. Add RangeSliders for session count and average
```
Code change to apply in `FilterBar`:
```python
fields = [
Component(
tag_name="div",
attributes=[("class", _FILTER_GRID_CLASS)],
children=[
_filter_field(
"Status",
_enum_filter(
"status",
status_options,
status_choice,
nullable=not Game._meta.get_field("status").has_default(),
),
),
_filter_field(
"Platform",
_model_filter(
"platform",
platform_choice,
search_url="/api/platforms/search",
nullable=Game._meta.get_field("platform").null,
),
),
],
),
RangeSlider(
label="Year",
input_name_prefix="filter-year",
min_value=year_min,
max_value=year_max,
range_min=year_range_min,
range_max=year_range_max,
min_placeholder="e.g. 2020",
max_placeholder="e.g. 2024",
),
Component(
tag_name="div",
attributes=[("class", "flex items-end gap-4 mb-4")],
children=[
_filter_checkbox("filter-mastered", "Mastered", mastered_value),
_filter_checkbox("filter-has-purchases", "Has Purchases", has_purchases_value),
_filter_checkbox("filter-has-playevents", "Has Play Events", has_playevents_value),
],
),
RangeSlider(
label="Playtime",
input_name_prefix="filter-playtime",
min_value=playtime_min,
max_value=playtime_max,
range_min=0,
range_max=playtime_range_max,
step="1",
min_placeholder="e.g. 1",
max_placeholder="e.g. 100",
),
RangeSlider(
label="Session Count",
input_name_prefix="filter-session-count",
min_value=session_count_min,
max_value=session_count_max,
range_min=0,
range_max=100,
step="1",
min_placeholder="e.g. 1",
max_placeholder="e.g. 50",
),
RangeSlider(
label="Average Session Duration (mins)",
input_name_prefix="filter-session-average",
min_value=session_avg_min,
max_value=session_avg_max,
range_min=0,
range_max=240,
step="1",
min_placeholder="e.g. 10",
max_placeholder="e.g. 120",
),
]
```
- [ ] **Step 2: Update SessionFilterBar to support split duration fields**
Replace old `duration_minutes` RangeSlider with split total, manual, and calculated duration RangeSliders.
```python
# Inside common/components/filters.py: SessionFilterBar()
dur_tot_min, dur_tot_max = _parse_range(existing, "duration_total_minutes")
dur_man_min, dur_man_max = _parse_range(existing, "duration_manual_minutes")
dur_calc_min, dur_calc_max = _parse_range(existing, "duration_calculated_minutes")
# Inside fields array, replace RangeSlider "Duration" with:
RangeSlider(
label="Total Duration (mins)",
input_name_prefix="filter-duration-total-minutes",
min_value=dur_tot_min,
max_value=dur_tot_max,
range_min=0,
range_max=duration_range_max * 60, # Range sliders use minutes now
step="1",
min_placeholder="e.g. 30",
max_placeholder="e.g. 180",
),
RangeSlider(
label="Manual Duration (mins)",
input_name_prefix="filter-duration-manual-minutes",
min_value=dur_man_min,
max_value=dur_man_max,
range_min=0,
range_max=240,
step="1",
min_placeholder="e.g. 10",
max_placeholder="e.g. 120",
),
RangeSlider(
label="Calculated Duration (mins)",
input_name_prefix="filter-duration-calculated-minutes",
min_value=dur_calc_min,
max_value=dur_calc_max,
range_min=0,
range_max=duration_range_max * 60,
step="1",
min_placeholder="e.g. 30",
max_placeholder="e.g. 180",
),
```
- [ ] **Step 3: Update PurchaseFilterBar to support original and converted currencies and infinite flag**
Add Checkboxes `infinite`, `needs_price_update` and currency StringCriterion text field / Choice options.
```python
# Inside common/components/filters.py: PurchaseFilterBar()
infinite_value = _parse_bool(existing, "infinite")
needs_price_update_value = _parse_bool(existing, "needs_price_update")
price_currency_value = existing.get("price_currency", {}).get("value", "")
converted_currency_value = existing.get("converted_currency", {}).get("value", "")
# Expand fields component array with:
Component(
tag_name="div",
attributes=[("class", "flex gap-4 mb-4")],
children=[
_filter_checkbox("filter-refunded", "Refunded", is_refunded_value),
_filter_checkbox("filter-infinite", "Infinite", infinite_value),
_filter_checkbox("filter-needs-price-update", "Needs Price Update", needs_price_update_value),
],
),
```
Add currency text filters (as primitive `Input` controls for string criteria):
```python
Component(
tag_name="div",
attributes=[("class", _FILTER_GRID_CLASS)],
children=[
_filter_field(
"Original Currency",
Component(
tag_name="input",
attributes=[
("type", "text"),
("name", "filter-price_currency"),
("value", price_currency_value),
("placeholder", "e.g. USD, EUR"),
("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"),
],
),
),
_filter_field(
"Converted Currency",
Component(
tag_name="input",
attributes=[
("type", "text"),
("name", "filter-converted_currency"),
("value", converted_currency_value),
("placeholder", "e.g. USD, EUR"),
("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"),
],
),
),
],
),
```
---
### Task 2: Create New FilterBars in `common/components/filters.py`
**Files:**
- Modify: `common/components/filters.py`
- [ ] **Step 1: Implement DeviceFilterBar, PlatformFilterBar, and PlayEventFilterBar**
Append these three new filter bar components to `common/components/filters.py`:
```python
def DeviceFilterBar(
filter_json="", preset_list_url="", preset_save_url=""
) -> SafeText:
"""Collapsible filter bar for the Device list."""
from games.models import Device
existing = _filter_parse(filter_json)
type_options = Device.DEVICE_TYPES
type_choice = _filter_get_choice(existing, "type")
fields = [
Component(
tag_name="div",
attributes=[("class", _FILTER_GRID_CLASS)],
children=[
_filter_field(
"Device Type",
_enum_filter(
"type",
type_options,
type_choice,
nullable=True,
),
),
],
),
]
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
def PlatformFilterBar(
filter_json="", preset_list_url="", preset_save_url=""
) -> SafeText:
"""Collapsible filter bar for the Platform list."""
existing = _filter_parse(filter_json)
name_value = existing.get("name", {}).get("value", "")
group_value = existing.get("group", {}).get("value", "")
fields = [
Component(
tag_name="div",
attributes=[("class", _FILTER_GRID_CLASS)],
children=[
_filter_field(
"Platform Name",
Component(
tag_name="input",
attributes=[
("type", "text"),
("name", "filter-name"),
("value", name_value),
("placeholder", "e.g. Nintendo Switch"),
("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"),
],
),
),
_filter_field(
"Platform Group",
Component(
tag_name="input",
attributes=[
("type", "text"),
("name", "filter-group"),
("value", group_value),
("placeholder", "e.g. Nintendo"),
("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"),
],
),
),
],
),
]
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
def PlayEventFilterBar(
filter_json="", preset_list_url="", preset_save_url=""
) -> SafeText:
"""Collapsible filter bar for the PlayEvent list."""
from games.models import PlayEvent
existing = _filter_parse(filter_json)
game_choice = _filter_get_choice(existing, "game")
days_min, days_max = _parse_range(existing, "days_to_finish")
fields = [
Component(
tag_name="div",
attributes=[("class", _FILTER_GRID_CLASS)],
children=[
_filter_field(
"Game",
_model_filter(
"game",
game_choice,
search_url="/api/games/search",
nullable=False,
),
),
],
),
RangeSlider(
label="Days to Finish",
input_name_prefix="filter-days-to-finish",
min_value=days_min,
max_value=days_max,
range_min=0,
range_max=365,
step="1",
min_placeholder="e.g. 1",
max_placeholder="e.g. 30",
),
]
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
```
- [ ] **Step 2: Export new FilterBars in `common/components/__init__.py`**
Modify: `common/components/__init__.py` to import and expose `DeviceFilterBar`, `PlatformFilterBar`, and `PlayEventFilterBar`.
```python
# Import section:
from common.components.filters import (
FilterBar,
PurchaseFilterBar,
SessionFilterBar,
DeviceFilterBar,
PlatformFilterBar,
PlayEventFilterBar,
)
# In __all__:
"FilterBar",
"PurchaseFilterBar",
"SessionFilterBar",
"DeviceFilterBar",
"PlatformFilterBar",
"PlayEventFilterBar",
```
---
### Task 3: Integrate FilterBars into `Device`, `Platform`, and `PlayEvent` views
**Files:**
- Modify: `games/views/device.py`
- Modify: `games/views/platform.py`
- Modify: `games/views/playevent.py`
- [ ] **Step 1: Integrate FilterBar in `list_devices` in `games/views/device.py`**
Import and parse the filter, apply to queryset, instantiate `DeviceFilterBar`, prepend it to the output page content.
```python
# At top of games/views/device.py:
from django.utils.safestring import mark_safe
from common.components import DeviceFilterBar, ModuleScript
from games.filters import parse_device_filter
# Inside list_devices(request):
devices = Device.objects.order_by("-created_at")
filter_json = request.GET.get("filter", "")
if filter_json:
device_filter = parse_device_filter(filter_json)
if device_filter is not None:
devices = devices.filter(device_filter.to_q())
devices, page_obj, elided_page_range = paginate(request, devices)
# ... create data dict ...
# Prepend the filter bar above table:
filter_bar = DeviceFilterBar(
filter_json=filter_json,
preset_list_url=reverse("games:list_presets") + "?mode=devices",
preset_save_url=reverse("games:save_preset") + "?mode=devices",
)
content = mark_safe(str(filter_bar) + str(content))
return render_page(
request,
content,
title="Manage devices",
scripts=ModuleScript("range_slider.js")
+ ModuleScript("search_select.js")
+ ModuleScript("filter_bar.js"),
)
```
- [ ] **Step 2: Integrate FilterBar in `list_platforms` in `games/views/platform.py`**
Import and parse the filter, apply to platform queryset, instantiate platform filter bar, prepend to page content.
```python
# At top of games/views/platform.py:
from django.utils.safestring import mark_safe
from common.components import PlatformFilterBar, ModuleScript
from games.filters import parse_platform_filter
# Inside list_platforms(request):
platforms = Platform.objects.order_by("name")
filter_json = request.GET.get("filter", "")
if filter_json:
platform_filter = parse_platform_filter(filter_json)
if platform_filter is not None:
platforms = platforms.filter(platform_filter.to_q())
platforms, page_obj, elided_page_range = paginate(request, platforms)
# ... create data dict ...
filter_bar = PlatformFilterBar(
filter_json=filter_json,
preset_list_url=reverse("games:list_presets") + "?mode=platforms",
preset_save_url=reverse("games:save_preset") + "?mode=platforms",
)
content = mark_safe(str(filter_bar) + str(content))
return render_page(
request,
content,
title="Manage platforms",
scripts=ModuleScript("range_slider.js")
+ ModuleScript("search_select.js")
+ ModuleScript("filter_bar.js"),
)
```
- [ ] **Step 3: Integrate FilterBar in `list_playevents` in `games/views/playevent.py`**
Import and parse the filter, apply to playevent queryset, instantiate playevent filter bar, prepend to page content.
```python
# At top of games/views/playevent.py:
from django.utils.safestring import mark_safe
from common.components import PlayEventFilterBar
from games.filters import parse_playevent_filter
# Inside list_playevents(request):
playevents = PlayEvent.objects.order_by("-created_at")
filter_json = request.GET.get("filter", "")
if filter_json:
playevent_filter = parse_playevent_filter(filter_json)
if playevent_filter is not None:
playevents = playevents.filter(playevent_filter.to_q())
playevents, page_obj, elided_page_range = paginate(request, playevents)
# ... create data ...
filter_bar = PlayEventFilterBar(
filter_json=filter_json,
preset_list_url=reverse("games:list_presets") + "?mode=playevents",
preset_save_url=reverse("games:save_preset") + "?mode=playevents",
)
content = mark_safe(str(filter_bar) + str(content))
return render_page(
request,
content,
title="Manage play events",
scripts=ModuleScript("range_slider.js")
+ ModuleScript("search_select.js")
+ ModuleScript("filter_bar.js"),
)
```
---
### Task 4: Support new preset modes in Preset View/Model
Ensure FilterPreset allows `devices` and `platforms` modes.
**Files:**
- Modify: `games/models.py`
- Modify: `games/views/filter_presets.py`
- [ ] **Step 1: Expand FilterPreset mode choices**
Verify or expand `MODE_CHOICES` inside `FilterPreset` model in `games/models.py`.
```python
# Inside FilterPreset class:
MODE_CHOICES = [
("games", "Games"),
("sessions", "Sessions"),
("purchases", "Purchases"),
("playevents", "Play Events"),
("devices", "Devices"),
("platforms", "Platforms"),
]
```
---
### Task 5: Add Render Tests for new FilterBars
**Files:**
- Modify: `tests/test_filter_bars.py`
- [ ] **Step 1: Write tests to verify new FilterBars render correctly**
Add test cases in `tests/test_filter_bars.py`:
```python
def test_device_filter_bar(self):
from common.components import DeviceFilterBar
html = str(
DeviceFilterBar(
filter_json="",
preset_list_url="/presets/devices/list",
preset_save_url="/presets/devices/save",
)
)
self._assert_shell(html, "/presets/devices/list", "/presets/devices/save")
def test_platform_filter_bar(self):
from common.components import PlatformFilterBar
html = str(
PlatformFilterBar(
filter_json="",
preset_list_url="/presets/platforms/list",
preset_save_url="/presets/platforms/save",
)
)
self._assert_shell(html, "/presets/platforms/list", "/presets/platforms/save")
def test_playevent_filter_bar(self):
from common.components import PlayEventFilterBar
html = str(
PlayEventFilterBar(
filter_json="",
preset_list_url="/presets/playevents/list",
preset_save_url="/presets/playevents/save",
)
)
self._assert_shell(html, "/presets/playevents/list", "/presets/playevents/save")
```
- [ ] **Step 2: Run all test suites to confirm complete success**
Run: `pytest tests/test_filter_bars.py -v`
Expected: ALL filter bar render tests pass.
@@ -1,177 +0,0 @@
# Unify Form Checkboxes Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Unify all Django form checkboxes across the codebase by routing them through our new Python `Checkbox` primitive.
**Architecture:**
1. Modify `Checkbox` and `Radio` primitives in `common/components/primitives.py` to support headless (label-less) rendering when `label` is `None`, so they can be injected into Django's native `form.as_div()` rendering without duplicating labels.
2. Create a `PrimitiveCheckboxWidget` in `games/forms.py` that extends `forms.CheckboxInput` but renders using our `Checkbox` Python component.
3. Create a `PrimitiveWidgetsMixin` in `games/forms.py` that automatically applies the `PrimitiveCheckboxWidget` to all `forms.BooleanField` instances in a form. Add this mixin to all ModelForms.
**Tech Stack:** Python, Django Forms, HTML.
---
### Task 1: Update Primitives for Headless Rendering
**Files:**
- Modify: `common/components/primitives.py`
- Modify: `tests/test_components.py`
- [ ] **Step 1: Write a failing test for headless rendering**
In `tests/test_components.py`, add a test to `ComponentPrimitivesTest`:
```python
def test_checkbox_headless(self):
html = Checkbox(name="test-headless", label=None, checked=True)
self.assertNotIn('<label', html)
self.assertIn('<input', html)
self.assertIn('type="checkbox"', html)
self.assertIn('name="test-headless"', html)
```
- [ ] **Step 2: Run test to verify it fails**
Run: `pytest tests/test_components.py -k test_checkbox_headless`
Expected: Fail because `Checkbox` currently requires `label` as a `str` and always renders a `Label` wrapper.
- [ ] **Step 3: Update `Checkbox` and `Radio` in `common/components/primitives.py`**
Update the function signatures to accept `label: str | None = None` and selectively return only the `Input` if `label` is missing.
```python
def Checkbox(
name: str,
label: str | None = None,
checked: bool = False,
value: str = "1",
attributes: list[HTMLAttribute] | None = None,
) -> SafeText:
"""A filter-agnostic Checkbox component."""
attributes = attributes or []
input_attrs = [
("name", name),
("value", value),
("class", "rounded border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand"),
] + attributes
if checked:
input_attrs.append(("checked", "true"))
input_el = Input(type="checkbox", attributes=input_attrs)
if label is None:
return input_el
return Label(
attributes=[("class", "flex items-center gap-2 text-sm text-heading cursor-pointer")],
children=[input_el, label],
)
def Radio(
name: str,
label: str | None = None,
checked: bool = False,
value: str = "",
attributes: list[HTMLAttribute] | None = None,
) -> SafeText:
"""A filter-agnostic Radio component."""
attributes = attributes or []
input_attrs = [
("name", name),
("value", value),
("class", "rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand"),
] + attributes
if checked:
input_attrs.append(("checked", "true"))
input_el = Input(type="radio", attributes=input_attrs)
if label is None:
return input_el
return Label(
attributes=[("class", "flex items-center gap-1.5 text-sm text-heading cursor-pointer")],
children=[input_el, label],
)
```
- [ ] **Step 4: Run test to verify it passes**
Run: `pytest tests/test_components.py -k ComponentPrimitivesTest`
Expected: PASS
- [ ] **Step 5: Commit**
Run:
```bash
git add common/components/primitives.py tests/test_components.py
git commit -m "refactor: allow Checkbox and Radio primitives to render headlessly without labels"
```
---
### Task 2: Create Django Widget Adapter and Mixin
**Files:**
- Modify: `games/forms.py`
- [ ] **Step 1: Write the Widget and Mixin implementations**
At the top of `games/forms.py`, import `Checkbox` and implement `PrimitiveCheckboxWidget` and `PrimitiveWidgetsMixin`.
```python
from common.components.primitives import Checkbox
class PrimitiveCheckboxWidget(forms.CheckboxInput):
"""Adapts Django's CheckboxInput to use our Checkbox component."""
def render(self, name, value, attrs=None, renderer=None):
final_attrs = self.build_attrs(self.attrs, attrs)
checked = self.check_test(value)
attributes = [(k, str(v)) for k, v in final_attrs.items() if k not in ("type", "name", "value", "checked")]
# Django uses boolean values differently for checkboxes, we omit value if empty
return str(Checkbox(
name=name,
label=None,
checked=checked,
value=str(value) if value else "1",
attributes=attributes
))
class PrimitiveWidgetsMixin:
"""Automatically applies primitive custom widgets to native Django form fields."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field_name, field in self.fields.items():
if isinstance(field, forms.BooleanField):
field.widget = PrimitiveCheckboxWidget()
# Maintain the field's explicit required status (usually False for booleans)
```
- [ ] **Step 2: Apply the Mixin to all Forms**
In `games/forms.py`, update all the ModelForm classes to inherit from `PrimitiveWidgetsMixin` as the **first** base class (before `forms.ModelForm`).
Example:
```python
class SessionForm(PrimitiveWidgetsMixin, forms.ModelForm):
# ...
class PurchaseForm(PrimitiveWidgetsMixin, forms.ModelForm):
# ...
class GameForm(PrimitiveWidgetsMixin, forms.ModelForm):
# ...
class PlatformForm(PrimitiveWidgetsMixin, forms.ModelForm):
# ...
class DeviceForm(PrimitiveWidgetsMixin, forms.ModelForm):
# ...
class PlayEventForm(PrimitiveWidgetsMixin, forms.ModelForm):
# ...
class GameStatusChangeForm(PrimitiveWidgetsMixin, forms.ModelForm):
# ...
```
- [ ] **Step 3: Test Django Form Rendering**
Run the full test suite to ensure forms still validate properly and render without error.
Run: `pytest`
Expected: PASS
- [ ] **Step 4: Commit**
Run:
```bash
git add games/forms.py
git commit -m "feat: replace all form BooleanFields with PrimitiveCheckboxWidget via mixin"
```
File diff suppressed because it is too large Load Diff
@@ -1,197 +0,0 @@
# Design Spec: Boolean Filters Overhaul (Approach A with Reusable Primitives)
Expose a two-radio-button UI for all boolean filters to allow selecting "True" (Yes), "False" (No), or leaving the filter "Unset" (Not set).
## 1. Architectural Changes
### 1.1 Backend Primitives & Components
We will extract the `_filter_checkbox` rendering logic from `common/components/filters.py` and generalize it into a reusable, filter-agnostic `Checkbox` component in `common/components/primitives.py`. We will also add a corresponding `Radio` component.
#### In `common/components/primitives.py`:
```python
def Checkbox(
name: str,
label: str,
checked: bool = False,
value: str = "1",
attributes: list[HTMLAttribute] | None = None,
) -> SafeText:
"""A filter-agnostic Checkbox component."""
attributes = attributes or []
input_attrs = [
("name", name),
("value", value),
("class", "rounded border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand"),
] + attributes
if checked:
input_attrs.append(("checked", "true"))
return Label(
attributes=[("class", "flex items-center gap-2 text-sm text-heading cursor-pointer")],
children=[
Input(type="checkbox", attributes=input_attrs),
label,
],
)
def Radio(
name: str,
label: str,
checked: bool = False,
value: str = "",
attributes: list[HTMLAttribute] | None = None,
) -> SafeText:
"""A filter-agnostic Radio component."""
attributes = attributes or []
input_attrs = [
("name", name),
("value", value),
("class", "rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand"),
] + attributes
if checked:
input_attrs.append(("checked", "true"))
return Label(
attributes=[("class", "flex items-center gap-1.5 text-sm text-heading cursor-pointer")],
children=[
Input(type="radio", attributes=input_attrs),
label,
],
)
```
#### In `common/components/filters.py`:
We will import `Checkbox` and `Radio` from `common.components.primitives`. We will redefine `_filter_checkbox` as a thin adapter pointing to our new generalized `Checkbox` component (preserving any backward compatibility), and we will create a new helper `_filter_boolean_radio` using `Radio`:
```python
_FILTER_RADIO_CLASS = (
"rounded-full border-default-medium bg-neutral-secondary-medium "
"text-brand focus:ring-brand"
)
def _filter_checkbox(name: str, label: str, checked: bool) -> SafeText:
"""Thin adapter mapping legacy checkbox filters to the generalized Checkbox primitive."""
return Checkbox(name=name, label=label, checked=checked)
def _filter_boolean_radio(name: str, label: str, value: bool | None) -> SafeText:
"""Renders a filter-specific boolean radio button group with 'True' and 'False' options."""
return Div(
attributes=[("class", "flex flex-col gap-1")],
children=[
Span(
attributes=[("class", _FILTER_LABEL_CLASS)],
children=[label],
),
Div(
attributes=[("class", "flex items-center gap-4 h-9")],
children=[
Radio(name=name, label="True", checked=value is True, value="true"),
Radio(name=name, label="False", checked=value is False, value="false"),
],
),
],
)
```
### 1.2 Parsing Filter JSON (Backend)
We will introduce a robust parsing function in `common/components/filters.py` to distinguish `True`, `False`, and `None` (unset):
```python
def _parse_bool_nullable(existing: dict, key: str) -> bool | None:
"""Extract a nullable boolean value from a filter criterion."""
if key not in existing:
return None
field = existing[key]
if not isinstance(field, dict):
return None
val = field.get("value")
if val is None:
return None
if isinstance(val, str):
if val.lower() in ("true", "1", "yes"):
return True
if val.lower() in ("false", "0", "no"):
return False
return bool(val)
```
### 1.3 UI Overhauls in Filter Bars
We will update the following filter bars to use `_parse_bool_nullable` and `_filter_boolean_radio`:
1. **GameFilterBar:** `mastered`, `purchase_refunded`, `purchase_infinite`, `session_emulated`.
2. **SessionFilterBar:** `emulated`, `is_active`.
3. **PurchaseFilterBar:** `is_refunded`, `infinite`, `needs_price_update`.
---
## 2. Frontend JS Changes (`games/static/js/filter_bar.js`)
### 2.1 Deselectable Radios Behavior
To support resetting filters back to "Unset" without resetting the whole form, we add click behavior that unchecks an already checked radio button when clicked.
```javascript
function setupDeselectableRadios() {
document.querySelectorAll('input[type="radio"]').forEach(function (radio) {
radio.addEventListener('click', function (e) {
if (this.wasChecked) {
this.checked = false;
this.wasChecked = false;
this.dispatchEvent(new Event('change', { bubbles: true }));
} else {
var name = this.getAttribute('name');
if (name) {
document.querySelectorAll('input[type="radio"][name="' + name + '"]').forEach(function (r) {
r.wasChecked = false;
});
}
this.wasChecked = true;
}
});
if (radio.checked) {
radio.wasChecked = true;
}
});
}
```
We will call `setupDeselectableRadios()` during `DOMContentLoaded`.
### 2.2 Serializing Radio States
Update `buildFilterJSON(form)` to collect checked radios from boolean field groups:
```javascript
// 2. Boolean Fields (Radio Button Groups)
var booleanFields = [
{ name: "filter-mastered", key: "mastered" },
{ name: "filter-emulated", key: "emulated" },
{ name: "filter-active", key: "is_active" },
{ name: "filter-refunded", key: "is_refunded" },
{ name: "filter-infinite", key: "infinite" },
{ name: "filter-needs-price-update", key: "needs_price_update" },
{ name: "filter-purchase-refunded", key: "purchase_refunded" },
{ name: "filter-purchase-infinite", key: "purchase_infinite" },
{ name: "filter-session-emulated", key: "session_emulated" }
];
booleanFields.forEach(function (bf) {
var el = form.querySelector('[name="' + bf.name + '"]:checked');
if (el) {
var val = el.value === "true";
filter[bf.key] = criterion(val, null, "EQUALS");
}
});
```
---
## 3. Testing Strategy
1. **Unit Tests (`tests/test_filter_helpers.py`):**
- Add test coverage for `_parse_bool_nullable` covering `None`, `True`, `False`, strings, missing keys, etc.
2. **Component Tests (`tests/test_filter_bars.py`):**
- Update tests where the filters render checkbox elements to assert that radio groups are rendered instead (with "True" and "False" radio buttons).
3. **Integration and End-to-End Tests:**
- Execute the test suite using `pytest` to ensure that all 355 tests continue to pass and reflect the updated UI structure perfectly.
@@ -1,157 +0,0 @@
# HTML + JS component authoring — design
**Date:** 2026-06-13
**Status:** Approved (design); pending implementation plan
**Branch context:** follows the lazy node-tree component system (`Element`/`Safe`/`Fragment`/`Media`) and the `Children`/`Attributes` typing work.
## Problem
Trusted HTML and JavaScript are authored as Python f-strings in several places. Two distinct pains:
- **HTML-as-string** — `Navbar`, `_TOAST_CONTAINER`, the played-row markup skeleton, and the generally verbose `Element("div", attributes=[...], children=[...])` call shape.
- **JS-in-string** — the genuinely ugly ones: `GameStatusSelector` (~70 lines) and `SessionDeviceSelector` (~50 lines) inline an Alpine `x-data="{...}"` blob with `fetchWithHtmxTriggers`, server-value interpolation (`{game.status}`), **and** `{{ }}` brace-doubling throughout; `_PLAYED_ROW_TEMPLATE` dodges the brace collision entirely by switching to `@@TOKEN@@` placeholders + a `.replace()` loop.
You cannot node-tree JavaScript, so the JS pain needs a different answer than the HTML pain. The newer widgets (`search_select`, `range_slider`, `filter_bar`) already moved behavior into real `.js` files wired by `onSwap` + `data-*` attributes; the Alpine selectors are the holdouts that still inline their JS.
## Goal
Establish the *right* way to author interactive, server-rendered components in this codebase, and convert a few exemplars to prove it. North-star principle:
> The server never writes a line of JavaScript. The server↔client boundary is a typed, declarative contract. Behavior lives in real, tooled TypeScript files.
## Decisions (locked during brainstorming)
| Decision | Choice |
| --- | --- |
| HTML authoring | **htpy-*style* sugar on the existing `Element`** (not the htpy library) — keeps `Media`/`collect_media`, no build step |
| JS runtime model | **Custom Elements** (Web Components), light DOM |
| Server↔client contract | **Typed contract + codegen** (one Python `Props` type → generated TS interface + reader) |
| JS language | **TypeScript** (real `.ts`, compiled) |
| Build tool | **`tsc` per-module** (no bundler) — preserves per-component `Media` loading |
| Alpine, for converted components | **Retired** — behavior rewritten as vanilla TS in the element class |
| Exemplars | **`GameStatusSelector` + `SessionDeviceSelector` + played-row** |
| Compiled output | **Build-only, gitignored** (produced by `make` + Docker) |
| Existing hand-written `.js` | **Left as-is**, migrated to TS later |
## Architecture
Three independent layers composing through one typed seam:
```
Python (server) TypeScript (client)
───────────────── ───────────────────
htpy-style Element ──renders──► <game-status-selector ──connectedCallback──► game-status-selector.ts
+ Media (kept) game-id="3" status="f"> (vanilla DOM behavior)
│ ▲
└── GameStatusSelectorProps ─codegen─┘ generated props.ts (interface + typed reader)
(one Python type = the whole server↔client contract)
```
- **Layer 1 — htpy-style HTML** removes HTML-string / verbose-`Element` ugliness, pure Python, no build, `Media` untouched.
- **Layer 2 — Custom Elements (TS)** removes JS-string ugliness; behavior in real typed modules with a native lifecycle.
- **Layer 3 — Typed contract codegen** makes the seam type-safe in both languages from a single Python source.
### Layer 1 — htpy-style sugar on `Element`
Additive only. Existing `Element("div", attributes=[...], children=[...])` and `Div([("class","x")], "hi")` keep working.
- **Attributes as kwargs:** `Div(class_="card", hx_get="/x", disabled=True)`. Translation: trailing `_` stripped (`class_``class`); inner `_``-` (`hx_get``hx-get`, `data_id``data-id`); `True`→bare attribute, `False`/`None`→omitted.
- **Children via `[]`:** `Div(class_="card")[H1["Title"], body]`. `Element.__getitem__` normalizes through the existing `as_children` and returns an `Element` carrying the same attributes and media.
The result is still a walkable `Element` tree, so `collect_media` / `Media` are unaffected. This is the "htpy feel on our own node so the asset system survives" decision.
Example:
```python
Div(class_="flex gap-2 items-center")[
Icon("play"),
Span(class_="label")[name],
]
```
### Layer 2 — Custom Elements (TypeScript, light DOM)
- Python builder emits a **semantic tag**: `Element("game-status-selector", attrs).with_media(Media(js=("dist/elements/game-status-selector.js",)))`.
- **Light DOM** (no shadow root — Tailwind's global classes must apply). The server renders the inner markup (htpy-style); the element enhances it.
- **Native lifecycle replaces `onSwap`:** `connectedCallback()` fires when the browser parses or htmx-swaps the element in; `disconnectedCallback()` provides free teardown. No init registry, no guard flags.
- Behavior is **vanilla TS** — the element class owns its state (dropdown open/closed, PATCH-on-select via `fetchWithHtmxTriggers`). Alpine retired for these three.
- Source `ts/elements/<tag>.ts` → compiled `games/static/js/dist/elements/<tag>.js`, loaded only on pages that use it (via `Media`).
### Layer 3 — Typed contract (one Python type → the whole seam)
Each element declares its props once, in Python:
```python
class GameStatusSelectorProps(TypedDict):
game_id: int
status: str
csrf: str
```
- The **Python builder** takes these typed args and serializes them to kebab-case attributes (`game-id="3"`).
- **Codegen** reads the registered Props types and emits, per component, into `ts/generated/props.ts`:
- an **interface**`GameStatusSelectorProps { gameId: number; status: string; csrf: string }`, and
- a **typed reader**`readGameStatusSelectorProps(el): GameStatusSelectorProps` that pulls and parses attributes (`Number(el.getAttribute("game-id"))`, etc.).
- The element imports the generated reader. The entire server↔client boundary is generated from one Python type: rename `game_id` in Python, regenerate, and `tsc` fails until the element updates. Drift is caught at build time; no hand-written `getAttribute` soup, no silent attr-name drift.
Type map: `int`/`float``number`, `str``string`, `bool``boolean`. Field `game_id` → attr `game-id` → TS prop `gameId`. Reader parsing follows the type (number → `Number(...)`, bool → presence / `=== "true"`, string → `getAttribute(...) ?? ""`).
## Toolchain (`tsc` per-module, build-only)
Layout:
```
ts/
elements/game-status-selector.ts # hand-written element classes
generated/props.ts # codegen output (gitignored)
globals.d.ts # ambient: window.fetchWithHtmxTriggers, htmx
tsconfig.json # strict, ES2022, lib [ES2022, DOM, DOM.Iterable]
# rootDir: ts/ → outDir: games/static/js/dist/
```
- **`games/static/js/dist/` is the only compiled output**, trivially gitignored, never colliding with hand-written `.js`. `Media` references `dist/elements/...`.
- **package.json**: add `typescript` devDep; scripts `build:ts` (`tsc -p tsconfig.json`), `watch:ts` (`tsc -p tsconfig.json --watch`).
- **Makefile**: `make ts` = codegen → `tsc`; `make dev` also runs `tsc --watch` (beside Django runserver + Tailwind watch); `make check` gains `tsc --noEmit` as a drift gate.
- **.gitignore**: `games/static/js/dist/`, `ts/generated/`.
- **Docker**: add a `make ts` step in the image build (npm already present for Tailwind); compiled JS baked into the image. Runtime stays offline.
- **TS lint/format**: deferred — `tsc --strict` is the only gate for now.
### Codegen mechanics
- A registry maps `tag → Props type` (e.g. a decorator `@element("game-status-selector", GameStatusSelectorProps)` on the Python builder, collected into a module-level registry).
- A Django management command (or script) imports the registry and writes `ts/generated/props.ts` (interface + reader per component).
- **Ordering:** codegen runs before `tsc` (the generated file is a `tsc` input). CI runs codegen then `tsc --noEmit`, so Python/TS drift fails the build. No committed generated artifact to diff against — `tsc` failing on drift is the gate.
## Exemplar conversions
1. **`GameStatusSelector``<game-status-selector game-id status csrf>`** — Python builds the light-DOM htpy-style; `game-status-selector.ts` wires the dropdown toggle + click→PATCH `/api/games/{id}/status` via `fetchWithHtmxTriggers` with CSRF, and updates the displayed status. Deletes the ~70-line f-string + brace-doubling.
2. **`SessionDeviceSelector``<session-device-selector>`** — same shape; PATCH `/api/session/{id}/device`.
3. **played-row → `<play-event-row>`** (non-Alpine) — deletes `_PLAYED_ROW_TEMPLATE` and the `@@TOKEN@@` / `.replace()` hack; Python builds markup htpy-style; `play-event-row.ts` owns the dropdown + add-playthrough POST. URLs are server-reversed and passed as attributes. Proves the pattern is not Alpine-only.
## Testing
- **Python**: builders render the correct tag + attributes (extend `test_components` / `test_rendered_pages`); assert no f-string remnants remain.
- **Type-check**: `tsc --noEmit` in `make check` — type errors, including contract drift, fail CI.
- **e2e (Playwright)**: real Chromium upgrades the custom elements natively; port/extend the existing widget-e2e pattern for all three (open dropdown → select → PATCH → DOM updates).
## Risks and mitigations
1. **Element module must be loaded before its tag appears.** Full-page render loads the module via `Media`; htmx row-swaps reuse the already-defined element. Constraint to document: a fragment response that introduces a brand-new element type must include that element's `Media`. (Same limitation class as today's "`onSwap` needs the script present.")
2. **A build step is now required** for `make dev` and Docker. One-time wiring, mitigated by Make/Docker integration.
3. **First TypeScript in the repo** — adds `typescript`, `tsconfig.json`, a Docker build step. Scoped to `ts/`; existing `.js` untouched.
4. **CSRF/PATCH parity** — the vanilla TS must replicate the Alpine version's fetch/CSRF/`HX-Trigger` behavior; it reuses the existing `fetchWithHtmxTriggers`; e2e guards it.
5. **Codegen ↔ build ordering** — codegen must precede `tsc`; encoded in `make ts`.
## Out of scope (YAGNI)
- Migrating the existing hand-written `.js` to TypeScript (later, incrementally).
- Bundling / minification of app JS.
- Shadow DOM / scoped styles.
- A general island / props-blob hydration runtime (custom elements cover these three).
- TS lint/format tooling (prettier/eslint).
## Future on-ramps (not now)
- **More custom elements**: migrate the remaining `onSwap` widgets to custom elements once the pattern is proven.
- **Existing `.js` → TS**: incremental, file by file (`tsc` checks mixed projects).
- The typed contract already positions the boundary for full type-safety as more client code becomes TS.
-1
View File
@@ -1 +0,0 @@
# e2e tests package
-22
View File
@@ -1,22 +0,0 @@
import os
import shutil
import pytest
# Playwright runs an async event loop in the background, which triggers
# Django's async safety checks when running synchronous tests. This allows
# synchronous operations inside the async context safely.
os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true")
@pytest.fixture(scope="session")
def browser_type_launch_args(browser_type_launch_args):
# Try to find a system-installed Google Chrome or Chromium to bypass Nix/NixOS shared library issues
for browser_name in ["google-chrome-stable", "google-chrome", "chromium", "chrome"]:
path = shutil.which(browser_name)
if path:
return {
**browser_type_launch_args,
"executable_path": path,
}
# Fallback to default Playwright behavior
return browser_type_launch_args
-112
View File
@@ -1,112 +0,0 @@
"""End-to-end Playwright test for boolean radio filter serialization and deselect behavior.
Covers:
1. Selecting True/False serializes the boolean field as True/False.
2. Unsetting/unchecking a radio button by clicking on it again, which deselects it, omitting the field from JSON.
"""
import json
import urllib.parse
import pytest
from django.http import HttpResponse
from django.test import override_settings
from django.urls import path
from common.components import FilterBar
def _bar_page(filter_json: str = "") -> str:
return f"""<!DOCTYPE html>
<html>
<head>
<title>Boolean filter E2E</title>
<script src="/static/js/htmx.min.js"></script>
<script src="/static/js/range_slider.js" type="module"></script>
<script src="/static/js/search_select.js" type="module"></script>
<script src="/static/js/filter_bar.js" type="module"></script>
</head>
<body>
{FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
</body>
</html>"""
def empty_bar_view(request):
return HttpResponse(_bar_page())
urlpatterns = [
path("test-boolean-filter/", empty_bar_view),
]
def _filter_from_url(url: str) -> dict:
"""Extract and parse the ?filter=... query param from a URL."""
query = urllib.parse.urlparse(url).query
params = urllib.parse.parse_qs(query)
raw = params.get("filter", [""])[0]
return json.loads(raw) if raw else {}
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_boolean_filter_e2e")
def test_no_selection_omits_boolean_filters(live_server, page):
page.goto(live_server.url + "/test-boolean-filter/")
with page.expect_navigation():
page.evaluate(
"document.getElementById('filter-bar-form')"
".dispatchEvent(new Event('submit', {cancelable: true}))"
)
parsed = _filter_from_url(page.url)
assert "mastered" not in parsed
assert "purchase_refunded" not in parsed
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_boolean_filter_e2e")
def test_select_true_and_false_serializes_correctly(live_server, page):
page.goto(live_server.url + "/test-boolean-filter/")
# Select "True" for Mastered
# Under PurchaseFilterBar: "filter-mastered" is the mastered radio name.
# The true radio has value="true", false radio has value="false"
true_radio = page.locator('input[name="filter-mastered"][value="true"]')
true_radio.click()
# Select "False" for Refunded (filter-purchase-refunded)
false_radio = page.locator('input[name="filter-purchase-refunded"][value="false"]')
false_radio.click()
with page.expect_navigation():
page.evaluate(
"document.getElementById('filter-bar-form')"
".dispatchEvent(new Event('submit', {cancelable: true}))"
)
parsed = _filter_from_url(page.url)
assert parsed.get("mastered") == {"value": True, "modifier": "EQUALS"}
assert parsed.get("purchase_refunded") == {"value": False, "modifier": "EQUALS"}
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_boolean_filter_e2e")
def test_click_to_deselect_radio_works(live_server, page):
page.goto(live_server.url + "/test-boolean-filter/")
true_radio = page.locator('input[name="filter-mastered"][value="true"]')
# First click checks it
true_radio.click()
assert true_radio.is_checked()
# Second click deselects it
true_radio.click()
assert not true_radio.is_checked()
with page.expect_navigation():
page.evaluate(
"document.getElementById('filter-bar-form')"
".dispatchEvent(new Event('submit', {cancelable: true}))"
)
parsed = _filter_from_url(page.url)
assert "mastered" not in parsed
-84
View File
@@ -1,84 +0,0 @@
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
-167
View File
@@ -1,167 +0,0 @@
"""End-to-end Playwright test for the date-range filter widget's JS submit path.
Covers the one layer the Django-Client tests in ``test_rendered_pages.py``
cannot reach: ``filter_bar.js`` reading the two ``<input type="date">``
elements, building a ``DateCriterion`` JSON object, and navigating the
browser to ``?filter=<encoded>``.
The native ``<input type="date">`` path is exercised through the Refunded
field — the Purchased field now uses the DateRangePicker component, covered
by ``test_date_range_picker_e2e.py``.
Renders the bar at its own custom URL so the test doesn't need to auth
against the real app — the bar's JS doesn't care what route serves it.
"""
import json
import urllib.parse
import pytest
from django.http import HttpResponse
from django.test import override_settings
from django.urls import path
from common.components import PurchaseFilterBar
def _bar_page(filter_json: str = "") -> str:
return f"""<!DOCTYPE html>
<html>
<head>
<title>Date filter E2E</title>
<script src="/static/js/htmx.min.js"></script>
<script src="/static/js/range_slider.js" type="module"></script>
<script src="/static/js/search_select.js" type="module"></script>
<script src="/static/js/filter_bar.js" type="module"></script>
</head>
<body>
{PurchaseFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
</body>
</html>"""
def empty_bar_view(request):
return HttpResponse(_bar_page())
def prefilled_bar_view(request):
filter_json = json.dumps(
{
"date_refunded": {
"value": "2024-03-15",
"value2": "2024-09-20",
"modifier": "BETWEEN",
}
}
)
return HttpResponse(_bar_page(filter_json))
urlpatterns = [
path("test-date-filter/", empty_bar_view),
path("test-date-filter-prefilled/", prefilled_bar_view),
]
def _filter_from_url(url: str) -> dict:
"""Extract and parse the ?filter=... query param from a URL."""
query = urllib.parse.urlparse(url).query
params = urllib.parse.parse_qs(query)
raw = params.get("filter", [""])[0]
return json.loads(raw) if raw else {}
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e")
def test_both_dates_serializes_as_between(live_server, page):
page.goto(live_server.url + "/test-date-filter/")
page.locator('input[name="filter-date-refunded-min"]').fill("2024-01-01")
page.locator('input[name="filter-date-refunded-max"]').fill("2024-12-31")
with page.expect_navigation():
page.evaluate(
"document.getElementById('filter-bar-form')"
".dispatchEvent(new Event('submit', {cancelable: true}))"
)
parsed = _filter_from_url(page.url)
assert parsed == {
"date_refunded": {
"value": "2024-01-01",
"value2": "2024-12-31",
"modifier": "BETWEEN",
}
}
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e")
def test_min_only_serializes_as_greater_than(live_server, page):
page.goto(live_server.url + "/test-date-filter/")
page.locator('input[name="filter-date-refunded-min"]').fill("2024-06-15")
with page.expect_navigation():
page.evaluate(
"document.getElementById('filter-bar-form')"
".dispatchEvent(new Event('submit', {cancelable: true}))"
)
parsed = _filter_from_url(page.url)
assert parsed == {
"date_refunded": {"value": "2024-06-15", "modifier": "GREATER_THAN"}
}
# value2 must not be present when there's no upper bound.
assert "value2" not in parsed["date_refunded"]
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e")
def test_max_only_serializes_as_less_than(live_server, page):
page.goto(live_server.url + "/test-date-filter/")
page.locator('input[name="filter-date-refunded-max"]').fill("2025-06-30")
with page.expect_navigation():
page.evaluate(
"document.getElementById('filter-bar-form')"
".dispatchEvent(new Event('submit', {cancelable: true}))"
)
parsed = _filter_from_url(page.url)
assert parsed == {"date_refunded": {"value": "2025-06-30", "modifier": "LESS_THAN"}}
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e")
def test_empty_inputs_omit_date_criterion(live_server, page):
"""No date typed → the filter JSON simply has no date_purchased /
date_refunded keys (vs. an empty-string crash)."""
page.goto(live_server.url + "/test-date-filter/")
with page.expect_navigation():
page.evaluate(
"document.getElementById('filter-bar-form')"
".dispatchEvent(new Event('submit', {cancelable: true}))"
)
parsed = _filter_from_url(page.url)
assert "date_purchased" not in parsed
assert "date_refunded" not in parsed
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e")
def test_prefilled_bar_reflects_existing_filter_in_inputs(live_server, page):
"""A bar rendered with a BETWEEN filter_json pre-fills the inputs and
re-submits the same bounds unchanged."""
page.goto(live_server.url + "/test-date-filter-prefilled/")
assert (
page.locator('input[name="filter-date-refunded-min"]').input_value()
== "2024-03-15"
)
assert (
page.locator('input[name="filter-date-refunded-max"]').input_value()
== "2024-09-20"
)
with page.expect_navigation():
page.evaluate(
"document.getElementById('filter-bar-form')"
".dispatchEvent(new Event('submit', {cancelable: true}))"
)
parsed = _filter_from_url(page.url)
assert parsed["date_refunded"] == {
"value": "2024-03-15",
"value2": "2024-09-20",
"modifier": "BETWEEN",
}
-326
View File
@@ -1,326 +0,0 @@
"""End-to-end Playwright tests for the DateRangePicker component.
Exercises the behaviour layers the rendering tests cannot reach
(``date_range_picker.js``): segmented digit entry with right-to-left
placeholder fill and auto-advance, Backspace reverting a part, the calendar
popup's anchor-style range picking, presets, the Cancel / Clear / Select
footer, and the ``filter_bar.js`` serialization of the hidden ISO inputs
into a ``DateCriterion``.
Like the other filter-bar e2e modules, the bar is served from its own
minimal URLconf (no auth, no CSS) — the JS only cares about the DOM.
"""
import datetime
import json
import urllib.parse
import pytest
from django.http import HttpResponse
from django.test import override_settings
from common.components import PurchaseFilterBar
from django.urls import path
def _bar_page(filter_json: str = "") -> str:
return f"""<!DOCTYPE html>
<html>
<head>
<title>Date range picker E2E</title>
<script src="/static/js/htmx.min.js"></script>
<script src="/static/js/range_slider.js" type="module"></script>
<script src="/static/js/search_select.js" type="module"></script>
<script src="/static/js/date_range_picker.js" defer></script>
<script src="/static/js/filter_bar.js" type="module"></script>
</head>
<body>
{PurchaseFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
</body>
</html>"""
def empty_bar_view(request):
return HttpResponse(_bar_page())
def prefilled_bar_view(request):
filter_json = json.dumps(
{
"date_purchased": {
"value": "2024-03-15",
"value2": "2024-09-20",
"modifier": "BETWEEN",
}
}
)
return HttpResponse(_bar_page(filter_json))
urlpatterns = [
path("test-date-range-picker/", empty_bar_view),
path("test-date-range-picker-prefilled/", prefilled_bar_view),
]
PICKER = '[data-date-range-picker][data-input-name-prefix="filter-date-purchased"]'
POPUP = PICKER + " [data-date-range-calendar]"
HIDDEN_MIN = 'input[name="filter-date-purchased-min"]'
HIDDEN_MAX = 'input[name="filter-date-purchased-max"]'
def _segment(page, side: str, part: str):
return page.locator(
f'{PICKER} input[data-date-side="{side}"][data-date-part="{part}"]'
)
def _day_cell(page, iso_date: str):
return page.locator(
f'{PICKER} [data-date-range-grid] button[data-date="{iso_date}"]'
)
def _popup_is_open(page) -> bool:
return "hidden" not in (page.locator(POPUP).get_attribute("class") or "")
def _submit_filter_bar(page):
with page.expect_navigation():
page.evaluate(
"document.getElementById('filter-bar-form')"
".dispatchEvent(new Event('submit', {cancelable: true}))"
)
def _filter_from_url(url: str) -> dict:
query = urllib.parse.urlparse(url).query
params = urllib.parse.parse_qs(query)
raw = params.get("filter", [""])[0]
return json.loads(raw) if raw else {}
# ── Segmented manual entry ──────────────────────────────────────────────────
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_typing_fills_parts_and_serializes_between(live_server, page):
"""Digits flow through the parts (DD → MM → YYYY → DD …) with
auto-advance, ending in a BETWEEN criterion on submit."""
page.goto(live_server.url + "/test-date-range-picker/")
_segment(page, "min", "day").click()
page.keyboard.type("1503202420092024")
assert page.locator(HIDDEN_MIN).input_value() == "2024-03-15"
assert page.locator(HIDDEN_MAX).input_value() == "2024-09-20"
_submit_filter_bar(page)
parsed = _filter_from_url(page.url)
assert parsed == {
"date_purchased": {
"value": "2024-03-15",
"value2": "2024-09-20",
"modifier": "BETWEEN",
}
}
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_placeholder_fills_from_the_right(live_server, page):
"""Typing 19 into the YYYY part shows YYY1 then YY19."""
page.goto(live_server.url + "/test-date-range-picker/")
year_segment = _segment(page, "min", "year")
year_segment.click()
page.keyboard.press("1")
assert year_segment.input_value() == "YYY1"
page.keyboard.press("9")
assert year_segment.input_value() == "YY19"
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_min_side_only_serializes_greater_than(live_server, page):
page.goto(live_server.url + "/test-date-range-picker/")
_segment(page, "min", "day").click()
page.keyboard.type("15062024")
_submit_filter_bar(page)
parsed = _filter_from_url(page.url)
assert parsed == {
"date_purchased": {"value": "2024-06-15", "modifier": "GREATER_THAN"}
}
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_backspace_reverts_part_to_placeholder(live_server, page):
page.goto(live_server.url + "/test-date-range-picker/")
_segment(page, "min", "day").click()
page.keyboard.type("15032024")
assert page.locator(HIDDEN_MIN).input_value() == "2024-03-15"
month_segment = _segment(page, "min", "month")
month_segment.click()
page.keyboard.press("Backspace")
assert month_segment.input_value() == ""
# An incomplete date no longer commits to the hidden input.
assert page.locator(HIDDEN_MIN).input_value() == ""
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_only_numbers_can_be_typed(live_server, page):
page.goto(live_server.url + "/test-date-range-picker/")
day_segment = _segment(page, "min", "day")
day_segment.click()
page.keyboard.type("ab-/")
assert day_segment.input_value() == ""
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_invalid_calendar_date_does_not_commit(live_server, page):
"""31-02-2024 fills all parts but is not a real date — no hidden value."""
page.goto(live_server.url + "/test-date-range-picker/")
_segment(page, "min", "day").click()
page.keyboard.type("31022024")
assert page.locator(HIDDEN_MIN).input_value() == ""
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_clicking_container_activates_first_part(live_server, page):
page.goto(live_server.url + "/test-date-range-picker/")
page.locator(PICKER + " [data-date-range-field]").click(position={"x": 5, "y": 5})
focused = page.evaluate(
"document.activeElement.getAttribute('data-date-part') + ':' +"
"document.activeElement.getAttribute('data-date-side')"
)
assert focused == "day:min"
# ── Calendar popup ──────────────────────────────────────────────────────────
def _open_calendar(page):
page.locator(PICKER + " [data-date-range-calendar-toggle]").click()
def _current_month_iso(day_of_month: int) -> str:
today = datetime.date.today()
return today.replace(day=day_of_month).isoformat()
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_calendar_pick_range_then_select(live_server, page):
page.goto(live_server.url + "/test-date-range-picker/")
_open_calendar(page)
assert _popup_is_open(page)
first_pick = _current_month_iso(10)
second_pick = _current_month_iso(20)
_day_cell(page, first_pick).click()
assert page.locator(HIDDEN_MIN).input_value() == first_pick
assert page.locator(HIDDEN_MAX).input_value() == ""
_day_cell(page, second_pick).click()
assert page.locator(HIDDEN_MAX).input_value() == second_pick
page.locator(PICKER + " [data-date-range-select]").click()
assert not _popup_is_open(page)
_submit_filter_bar(page)
parsed = _filter_from_url(page.url)
assert parsed == {
"date_purchased": {
"value": first_pick,
"value2": second_pick,
"modifier": "BETWEEN",
}
}
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_picking_before_start_restarts_the_range(live_server, page):
"""With the StartDate anchored, picking an earlier date clears the range
and the clicked date becomes the new StartDate."""
page.goto(live_server.url + "/test-date-range-picker/")
_open_calendar(page)
_day_cell(page, _current_month_iso(20)).click()
_day_cell(page, _current_month_iso(10)).click()
assert page.locator(HIDDEN_MIN).input_value() == _current_month_iso(10)
assert page.locator(HIDDEN_MAX).input_value() == ""
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_completed_range_anchor_moves_to_end(live_server, page):
"""After both dates are picked the EndDate becomes the anchor, so a
further pick inside the range moves the StartDate."""
page.goto(live_server.url + "/test-date-range-picker/")
_open_calendar(page)
_day_cell(page, _current_month_iso(10)).click()
_day_cell(page, _current_month_iso(20)).click()
_day_cell(page, _current_month_iso(15)).click()
assert page.locator(HIDDEN_MIN).input_value() == _current_month_iso(15)
assert page.locator(HIDDEN_MAX).input_value() == _current_month_iso(20)
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_preset_fills_both_dates(live_server, page):
page.goto(live_server.url + "/test-date-range-picker/")
_open_calendar(page)
page.locator(PICKER + ' [data-date-range-preset="last_7_days"]').click()
today = datetime.date.today()
assert (
page.locator(HIDDEN_MIN).input_value()
== (today - datetime.timedelta(days=6)).isoformat()
)
assert page.locator(HIDDEN_MAX).input_value() == today.isoformat()
# Presets keep the popup open; Select commits and closes.
assert _popup_is_open(page)
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_clear_clears_dates_but_keeps_popup_open(live_server, page):
page.goto(live_server.url + "/test-date-range-picker/")
_open_calendar(page)
_day_cell(page, _current_month_iso(10)).click()
_day_cell(page, _current_month_iso(20)).click()
page.locator(PICKER + " [data-date-range-clear]").click()
assert page.locator(HIDDEN_MIN).input_value() == ""
assert page.locator(HIDDEN_MAX).input_value() == ""
assert _popup_is_open(page)
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_cancel_clears_dates_and_closes_popup(live_server, page):
page.goto(live_server.url + "/test-date-range-picker/")
_open_calendar(page)
_day_cell(page, _current_month_iso(10)).click()
_day_cell(page, _current_month_iso(20)).click()
page.locator(PICKER + " [data-date-range-cancel]").click()
assert page.locator(HIDDEN_MIN).input_value() == ""
assert page.locator(HIDDEN_MAX).input_value() == ""
assert not _popup_is_open(page)
# ── Prefill round-trip ──────────────────────────────────────────────────────
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
def test_prefilled_picker_round_trips_unchanged(live_server, page):
page.goto(live_server.url + "/test-date-range-picker-prefilled/")
assert _segment(page, "min", "day").input_value() == "15"
assert _segment(page, "min", "month").input_value() == "03"
assert _segment(page, "min", "year").input_value() == "2024"
assert _segment(page, "max", "day").input_value() == "20"
assert page.locator(HIDDEN_MIN).input_value() == "2024-03-15"
assert page.locator(HIDDEN_MAX).input_value() == "2024-09-20"
_submit_filter_bar(page)
parsed = _filter_from_url(page.url)
assert parsed["date_purchased"] == {
"value": "2024-03-15",
"value2": "2024-09-20",
"modifier": "BETWEEN",
}
-114
View File
@@ -1,114 +0,0 @@
"""End-to-end Playwright test for the RangeSlider JS synchronization, cross-over, and clamping behavior."""
import pytest
from django.http import HttpResponse
from django.test import override_settings
from django.urls import path
from common.components import FilterBar
def _bar_page(filter_json: str = "") -> str:
return f"""<!DOCTYPE html>
<html>
<head>
<title>Range Slider E2E</title>
<script src="/static/js/htmx.min.js"></script>
<script src="/static/js/range_slider.js" type="module"></script>
<script src="/static/js/search_select.js" type="module"></script>
<script src="/static/js/filter_bar.js" type="module"></script>
</head>
<body>
{FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
</body>
</html>"""
def empty_bar_view(request):
return HttpResponse(_bar_page())
urlpatterns = [
path("test-range-slider/", empty_bar_view),
]
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
def test_range_slider_crossover_min_higher_than_max(live_server, page):
page.goto(live_server.url + "/test-range-slider/")
# 1. Start with known state: Min is empty, Max is empty
min_input = page.locator('input[name="filter-session-count-min"]')
max_input = page.locator('input[name="filter-session-count-max"]')
# 2. Type "20" into max input
max_input.fill("20")
# 3. Type "50" into min input (which is higher than 20)
min_input.fill("50")
# 4. Max input should have automatically synchronized/snapped to 50
assert max_input.input_value() == "50"
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
def test_range_slider_crossover_max_less_than_min(live_server, page):
page.goto(live_server.url + "/test-range-slider/")
min_input = page.locator('input[name="filter-session-count-min"]')
max_input = page.locator('input[name="filter-session-count-max"]')
# 1. Type "50" into min input
min_input.fill("50")
# 2. Type "30" into max input (which is less than 50)
max_input.fill("30")
# 3. Min input should have automatically synchronized/snapped to 30
assert min_input.input_value() == "30"
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
def test_range_slider_strict_bounds_clamping_on_blur(live_server, page):
page.goto(live_server.url + "/test-range-slider/")
min_input = page.locator('input[name="filter-session-count-min"]')
max_input = page.locator('input[name="filter-session-count-max"]')
# 1. Type value higher than dataMax (100 is max, type "150")
max_input.fill("150")
max_input.blur() # triggers "change" event
assert max_input.input_value() == "100"
# 2. Type value lower than dataMin (0 is min, type "-20")
min_input.fill("-20")
min_input.blur() # triggers "change" event
assert min_input.input_value() == "0"
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
def test_range_slider_empty_max_thumb_does_not_jump_to_beginning(live_server, page):
page.goto(live_server.url + "/test-range-slider/")
# Locate handles
max_handle = page.locator(
'.range-handle-max[data-target="filter-session-count-max"]'
)
# Initially, max_input is empty, so handle should sit at 100% (far right)
style = max_handle.get_attribute("style")
assert "left:100%" in style or "left: 100%" in style
# Set min to 50
min_input = page.locator('input[name="filter-session-count-min"]')
min_input.fill("50")
# Max handle should STILL stay at 100% since max input is still empty (defaults to max_value)
style = max_handle.get_attribute("style")
assert "left:100%" in style or "left: 100%" in style
-109
View File
@@ -1,109 +0,0 @@
import pytest
from django.urls import path
from django.http import HttpResponse
from django.test import override_settings
from common.components import SearchSelect
def e2e_test_view(request):
html = f"""
<!DOCTYPE html>
<html>
<head>
<title>SearchSelect E2E Test</title>
<!-- search_select.js is an ES module and initializes via onSwap(),
which rides on htmx.onLoad — so htmx must be present. -->
<script src="/static/js/htmx.min.js"></script>
<script type="module" src="/static/js/search_select.js"></script>
</head>
<body>
<div style="padding: 50px;">
{
SearchSelect(
name="games",
selected=[{"value": "7", "label": "Game A", "data": {}}],
options=[
{"value": "7", "label": "Game A", "data": {}},
{"value": "8", "label": "Game B", "data": {}},
],
multi_select=False,
)
}
</div>
</body>
</html>
"""
return HttpResponse(html)
urlpatterns = [
path("test-search-select/", e2e_test_view),
]
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_search_select_e2e")
def test_search_select_backspace_clears_single_select(live_server, page):
# Enable console log forwarding
page.on("console", lambda msg: print(f"BROWSER CONSOLE: {msg.text}"))
page.goto(live_server.url + "/test-search-select/")
# Inject our event logger
page.evaluate("""() => {
const s = document.querySelector('input[data-search-select-search]');
const c = document.querySelector('[data-search-select]');
s.addEventListener('focus', () => console.log('JS-EVENT: focus, dirty=' + c._searchSelectDirty + ', value="' + s.value + '"'));
s.addEventListener('blur', () => console.log('JS-EVENT: blur, dirty=' + c._searchSelectDirty + ', value="' + s.value + '"'));
s.addEventListener('input', () => console.log('JS-EVENT: input, dirty=' + c._searchSelectDirty + ', value="' + s.value + '"'));
s.addEventListener('keydown', (e) => console.log('JS-EVENT: keydown ' + e.key + ', dirty=' + c._searchSelectDirty + ', value="' + s.value + '"'));
}""")
search_input = page.locator("input[data-search-select-search]")
assert search_input.input_value() == "Game A"
hidden_input = page.locator('input[name="games"]')
assert hidden_input.first.get_attribute("value") == "7"
# Focus the input
print("\n--- FOCUSING INPUT ---")
search_input.focus()
assert search_input.input_value() == ""
# Press Backspace using the raw keyboard API to avoid any high-level Playwright input simulation
print("\n--- PRESSING BACKSPACE ---")
page.keyboard.press("Backspace")
# Explicitly blur the input
print("\n--- BLURRING INPUT ---")
search_input.blur()
# Wait for blur microtasks/setTimeout to settle (120ms timeout in JS)
page.wait_for_timeout(200)
# After Backspace and blur, the input should remain empty (the selection is cleared)
assert search_input.input_value() == ""
assert hidden_input.count() == 0
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_search_select_e2e")
def test_search_select_typing_replaces_single_select(live_server, page):
page.goto(live_server.url + "/test-search-select/")
search_input = page.locator("input[data-search-select-search]")
search_input.focus()
assert search_input.input_value() == ""
search_input.type("X")
assert search_input.input_value() == "X"
search_input.blur()
page.wait_for_timeout(200)
assert search_input.input_value() == "Game A"
hidden_input = page.locator('input[name="games"]')
assert hidden_input.first.get_attribute("value") == "7"
-150
View File
@@ -1,150 +0,0 @@
"""End-to-end Playwright test for String multi-mode filter serialization, null-state toggling, and prefill behaviors."""
import json
import urllib.parse
import pytest
from django.http import HttpResponse
from django.test import override_settings
from django.urls import path
from common.components import PlatformFilterBar
def _bar_page(filter_json: str = "") -> str:
return f"""<!DOCTYPE html>
<html>
<head>
<title>String filter E2E</title>
<script src="/static/js/htmx.min.js"></script>
<script src="/static/js/range_slider.js" type="module"></script>
<script src="/static/js/search_select.js" type="module"></script>
<script src="/static/js/filter_bar.js" type="module"></script>
</head>
<body>
{PlatformFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
</body>
</html>"""
def empty_bar_view(request):
return HttpResponse(_bar_page())
def prefilled_bar_view(request):
filter_json = json.dumps(
{
"name": {
"value": "Switch",
"modifier": "INCLUDES",
},
"group": {"modifier": "IS_NULL"},
}
)
return HttpResponse(_bar_page(filter_json=filter_json))
urlpatterns = [
path("test-string-filter-empty/", empty_bar_view),
path("test-string-filter-prefilled/", prefilled_bar_view),
]
def _filter_from_url(url: str) -> dict:
query = urllib.parse.urlparse(url).query
params = urllib.parse.parse_qs(query)
raw = params.get("filter", [""])[0]
return json.loads(raw) if raw else {}
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_string_filter_e2e")
def test_string_filter_defaults_and_toggles(live_server, page):
page.goto(live_server.url + "/test-string-filter-empty/")
# 1. Verify text inputs are active by default and modifier "is" (EQUALS) is checked
name_input = page.locator('input[name="filter-name"]')
assert name_input.is_enabled()
is_radio = page.locator('input[name="filter-name-modifier"][value="EQUALS"]')
assert is_radio.is_checked()
# 2. Enter values, click "includes" (INCLUDES), and submit
name_input.fill("PlayStation")
includes_radio = page.locator(
'input[name="filter-name-modifier"][value="INCLUDES"]'
)
includes_radio.click()
with page.expect_navigation():
page.evaluate(
"document.getElementById('filter-bar-form')"
".dispatchEvent(new Event('submit', {cancelable: true}))"
)
parsed = _filter_from_url(page.url)
assert parsed["name"] == {"value": "PlayStation", "modifier": "INCLUDES"}
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_string_filter_e2e")
def test_string_filter_null_states(live_server, page):
page.goto(live_server.url + "/test-string-filter-empty/")
name_input = page.locator('input[name="filter-name"]')
name_input.fill("Xbox")
# Click "is null"
is_null_radio = page.locator('input[name="filter-name-modifier"][value="IS_NULL"]')
is_null_radio.click()
# Verification of interactive disabling
assert not name_input.is_enabled()
assert name_input.input_value() == ""
with page.expect_navigation():
page.evaluate(
"document.getElementById('filter-bar-form')"
".dispatchEvent(new Event('submit', {cancelable: true}))"
)
parsed = _filter_from_url(page.url)
assert parsed["name"] == {"modifier": "IS_NULL"}
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_string_filter_e2e")
def test_string_filter_prefilled_states(live_server, page):
page.goto(live_server.url + "/test-string-filter-prefilled/")
name_input = page.locator('input[name="filter-name"]')
group_input = page.locator('input[name="filter-group"]')
# Verifies name matches "Switch" and "includes" is checked
assert name_input.input_value() == "Switch"
assert name_input.is_enabled()
assert page.locator(
'input[name="filter-name-modifier"][value="INCLUDES"]'
).is_checked()
# Verifies group is empty, disabled, and "is null" is checked
assert group_input.input_value() == ""
assert not group_input.is_enabled()
assert page.locator(
'input[name="filter-group-modifier"][value="IS_NULL"]'
).is_checked()
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_string_filter_e2e")
def test_string_filter_deselect_re_enables(live_server, page):
page.goto(live_server.url + "/test-string-filter-empty/")
name_input = page.locator('input[name="filter-name"]')
is_null_radio = page.locator('input[name="filter-name-modifier"][value="IS_NULL"]')
# 1. Click "is null" -> disables input
is_null_radio.click()
assert not name_input.is_enabled()
# 2. Click "is null" again to deselect/uncheck -> should re-enable the text input
is_null_radio.click()
assert name_input.is_enabled()
-135
View File
@@ -1,135 +0,0 @@
"""Browser tests for widget JavaScript (search_select.js, range_slider.js,
add_purchase.js) and their onSwap() initialization lifecycle.
These run a real Chromium via pytest-playwright against pytest-django's
``live_server``. All JavaScript under test is served locally from
``games/static/js/`` (htmx, Alpine, Flowbite and the widget files are
vendored), so no network access is needed beyond the live server itself.
Browser binaries must be installed once: ``uv run playwright install chromium``.
"""
import pytest
from django.urls import reverse
from playwright.sync_api import Page, expect
@pytest.fixture
def authenticated_page(live_server, page: Page, django_user_model) -> Page:
django_user_model.objects.create_user(username="tester", password="secret123")
page.goto(f"{live_server.url}{reverse('login')}")
page.fill('input[name="username"]', "tester")
page.fill('input[name="password"]', "secret123")
page.click('input[type="submit"]')
page.wait_for_url(f"{live_server.url}/tracker**")
return page
def open_filter_bar(page: Page) -> None:
page.click("#filter-bar button:has-text('Filters')")
expect(page.locator("#filter-bar-body")).to_be_visible()
def status_filter_widget(page: Page):
return page.locator('[data-search-select][data-name="status"]')
def test_search_select_initializes_on_page_load(authenticated_page: Page, live_server):
"""Clicking into a FilterSelect search box opens its options panel —
proof that onSwap ran the widget initializer on the initial page load."""
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:list_games')}")
open_filter_bar(page)
widget = status_filter_widget(page)
widget.locator("[data-search-select-search]").click()
options_panel = widget.locator("[data-search-select-options]")
expect(options_panel).to_be_visible()
# The pinned "(Any)" modifier pseudo-option is rendered server-side and
# only becomes interactable through the initialized panel.
expect(
options_panel.locator("[data-search-select-modifier-option]").first
).to_have_text("(Any)")
def test_search_select_adds_include_pill(authenticated_page: Page, live_server):
"""Clicking an enum option row adds an include pill (full widget wiring)."""
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:list_games')}")
open_filter_bar(page)
widget = status_filter_widget(page)
widget.locator("[data-search-select-search]").click()
widget.locator('[data-search-select-option][data-label="Finished"]').click()
pill = widget.locator("[data-search-select-pills] [data-pill]")
expect(pill).to_have_count(1)
expect(pill).to_contain_text("Finished")
def test_range_slider_mode_toggle_fires_exactly_once(
authenticated_page: Page, live_server
):
"""One click on the mode toggle flips the slider from range to point mode
exactly once. Double-bound listeners (the old force-re-init bug) would
flip it twice, leaving data-mode unchanged."""
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:list_games')}")
open_filter_bar(page)
block = page.locator(".range-slider-block").first
slider = block.locator(".range-slider")
expect(slider).to_have_attribute("data-mode", "range")
block.locator(".range-mode-toggle").click()
expect(slider).to_have_attribute("data-mode", "point")
def test_widgets_initialize_inside_htmx_swapped_content(
authenticated_page: Page, live_server
):
"""Widgets arriving via an htmx swap initialize without a page load.
The filter bar is re-fetched and swapped in with htmx.ajax — fresh,
uninitialized DOM. The swapped-in FilterSelect must open its panel and the
swapped-in slider must toggle exactly once, proving the htmx:load half of
onSwap and the once-per-element guard."""
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:list_games')}")
page.evaluate(
"htmx.ajax('GET', window.location.pathname, "
"{target: '#filter-bar', select: '#filter-bar', swap: 'outerHTML'})"
)
# The swapped-in bar arrives collapsed again; opening it proves the swap
# happened and the fresh DOM is in place.
open_filter_bar(page)
widget = status_filter_widget(page)
widget.locator("[data-search-select-search]").click()
expect(widget.locator("[data-search-select-options]")).to_be_visible()
block = page.locator(".range-slider-block").first
slider = block.locator(".range-slider")
expect(slider).to_have_attribute("data-mode", "range")
block.locator(".range-mode-toggle").click()
expect(slider).to_have_attribute("data-mode", "point")
def test_add_purchase_type_toggles_disabled_fields(
authenticated_page: Page, live_server
):
"""add_purchase.js disables name/related-purchase while type is "game"
and re-enables them for other types."""
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:add_purchase')}")
name_input = page.locator("#id_name")
expect(name_input).to_be_disabled()
page.select_option("#id_type", "dlc")
expect(name_input).to_be_enabled()
page.select_option("#id_type", "game")
expect(name_input).to_be_disabled()
+2 -41
View File
@@ -1,16 +1,8 @@
#!/bin/bash
set -euo pipefail
# Container-bootstrap configuration. These variables are consumed only by this
# entrypoint, NOT by Django (see timetracker/config.py for the app settings):
# PUID/PGID — uid/gid the container process runs as
# DATA_DIR — writable dir for the SQLite database (kept in
# sync with Django via the same env var + default)
# CREATE_DEFAULT_SUPERUSER — create an admin/admin user on first start
# STAGING / LOAD_SAMPLE_DATA — staging-only data bootstrap (see below)
PUID=${PUID:-1000}
PGID=${PGID:-100}
DATA_DIR=${DATA_DIR:-/home/timetracker/app/data}
USERHOME=$(grep timetracker /etc/passwd | cut -d ":" -f6)
usermod -d "/root" timetracker
@@ -18,45 +10,14 @@ groupmod -o -g "$PGID" timetracker
usermod -o -u "$PUID" timetracker
usermod -d "${USERHOME}" timetracker
mkdir -p "$DATA_DIR" /var/log/supervisor
mkdir -p /home/timetracker/app/data /var/log/supervisor
chmod 755 /home/timetracker/app
chmod 755 /home/timetracker/app/.venv
chown "$PUID:$PGID" "$DATA_DIR"
chown "$PUID:$PGID" /home/timetracker/app/data
chown "$PUID:$PGID" /var/log/supervisor
python manage.py migrate
python manage.py collectstatic --clear --no-input
# Staging seeded from a production snapshot: remove copied sessions and the
# inherited django-q schedule/queue so staging neither shares prod's session
# cookies nor independently runs scheduled tasks (see issue #20).
if [ "${STAGING:-false}" = "true" ]; then
python manage.py scrub_staging
fi
# Public staging with a fresh database (e.g. Fly.io): load demo data instead
# of any production snapshot. Runs once while the games table is empty.
if [ "${LOAD_SAMPLE_DATA:-false}" = "true" ]; then
python manage.py shell -c "
from games.models import Game
from django.core.management import call_command
if not Game.objects.exists():
call_command('loaddata', 'sample.yaml')
print('Loaded sample data.')
"
fi
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" "$DATA_DIR"
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisor.conf
-29
View File
@@ -1,29 +0,0 @@
# Shared Fly.io configuration for ephemeral, per-branch GitHub staging deploys.
#
# The app name is NOT set here on purpose; each branch supplies its own via
# `flyctl deploy --app timetracker-staging-<slug>`. These instances run with a
# fresh database seeded from sample fixtures (never production data) and their
# own SECRET_KEY, so they are safe to expose on a public *.fly.dev hostname.
primary_region = "ams"
[build]
dockerfile = "Dockerfile"
[env]
DEBUG = "false"
TZ = "Europe/Prague"
DATA_DIR = "/home/timetracker/app/data"
LOAD_SAMPLE_DATA = "true"
CREATE_DEFAULT_SUPERUSER = "true"
[http_service]
internal_port = 8000
force_https = true
auto_stop_machines = "stop"
auto_start_machines = true
min_machines_running = 0
[[vm]]
size = "shared-cpu-1x"
memory = "512mb"
-15
View File
@@ -59,12 +59,6 @@ class GameOption(Schema): # mirrors SearchSelectOption
data: dict
class StringOption(Schema): # SearchSelectOption with a string value (e.g. group names)
value: str
label: str
data: dict
@game_router.get("/search", response=list[GameOption])
def search_games(request, q: str = "", limit: int = 10):
qs = Game.objects.select_related("platform").order_by("sort_name")
@@ -139,15 +133,6 @@ def search_platforms(request, q: str = "", limit: int = 10):
return [{"value": p.id, "label": p.name, "data": {}} for p in qs[:limit]]
@platform_router.get("/groups", response=list[StringOption])
def search_platform_groups(request, q: str = "", limit: int = 10):
qs = Platform.objects.exclude(group="")
if q:
qs = qs.filter(group__icontains=q)
groups = qs.values_list("group", flat=True).distinct().order_by("group")
return [{"value": group, "label": group, "data": {}} for group in groups[:limit]]
api.add_router("/playevent", playevent_router)
api.add_router("/games", game_router)
api.add_router("/devices", device_router)
+52 -630
View File
@@ -18,7 +18,6 @@ from django.db.models import Q
from common.criteria import (
BoolCriterion,
ChoiceCriterion,
DateCriterion,
FloatCriterion,
IntCriterion,
Modifier,
@@ -59,46 +58,15 @@ class GameFilter(OperatorFilter):
original_year_released: IntCriterion | None = None
wikidata: StringCriterion | None = None
platform: ChoiceCriterion | None = None # selectable filter widget
platform_group: MultiCriterion | None = None # platform__group__in
status: ChoiceCriterion | None = None # selectable filter widget
mastered: BoolCriterion | None = None
playtime_hours: IntCriterion | None = None # converted to timedelta on to_q()
playtime_minutes: IntCriterion | None = None # converted to timedelta on to_q()
created_at: StringCriterion | None = None # date string
updated_at: StringCriterion | None = None # date string
session_count: IntCriterion | None = None
session_average: IntCriterion | None = None # average in hours
purchase_count: IntCriterion | None = None # distinct purchases per game
playevent_count: IntCriterion | None = None # playevents per game
# Aggregate session durations (hours), summed across the game's sessions
manual_playtime_hours: IntCriterion | None = None
calculated_playtime_hours: IntCriterion | None = None
# Cross-entity: any session played on these devices / matching these flags
device: MultiCriterion | None = None # game has session on any of these devices
session_emulated: BoolCriterion | None = None # game has emulated session
# Cross-entity: matches against the game's purchases
purchase_refunded: BoolCriterion | None = None # game has refunded purchase
purchase_infinite: BoolCriterion | None = None # game has infinite purchase
purchase_price_total: FloatCriterion | None = None # sum of converted prices
purchase_price_any: FloatCriterion | None = None # any single purchase in range
purchase_type: ChoiceCriterion | None = None # game has purchase of type
purchase_ownership_type: ChoiceCriterion | None = None # by ownership
# Cross-entity: substring match against the game's playevent notes
playevent_note: StringCriterion | None = None
# Free-text search (combines name + sort_name + platform name)
search: StringCriterion | None = None
# Cross-entity filters
session_filter: SessionFilter | None = None
purchase_filter: PurchaseFilter | None = None
playevent_filter: PlayEventFilter | None = None
platform_filter: PlatformFilter | None = None
def to_q(self) -> Q:
q = Q()
@@ -119,183 +87,13 @@ class GameFilter(OperatorFilter):
q &= self.status.to_q("status")
if self.mastered is not None:
q &= self.mastered.to_q("mastered")
if self.playtime_hours is not None:
q &= self._playtime_to_q(self.playtime_hours)
if self.playtime_minutes is not None:
q &= self._playtime_to_q(self.playtime_minutes)
if self.created_at is not None:
q &= self.created_at.to_q("created_at")
if self.updated_at is not None:
q &= self.updated_at.to_q("updated_at")
if self.platform_group is not None:
q &= self.platform_group.to_q("platform__group")
if self.session_count is not None:
from django.db.models import Count
from games.models import Game
matching_ids = (
Game.objects.annotate(s_count=Count("sessions", distinct=True))
.filter(self.session_count.to_q("s_count"))
.values_list("id", flat=True)
)
q &= Q(id__in=matching_ids)
if self.session_average is not None:
from django.db.models import Avg
from games.models import Game
matching_ids = (
Game.objects.annotate(s_avg=Avg("sessions__duration_total"))
.filter(self._playtime_to_q_for_field(self.session_average, "s_avg"))
.values_list("id", flat=True)
)
q &= Q(id__in=matching_ids)
if self.purchase_count is not None:
from django.db.models import Count
from games.models import Game
matching_ids = (
Game.objects.annotate(p_count=Count("purchases", distinct=True))
.filter(self.purchase_count.to_q("p_count"))
.values_list("id", flat=True)
)
q &= Q(id__in=matching_ids)
if self.playevent_count is not None:
from django.db.models import Count
from games.models import Game
matching_ids = (
Game.objects.annotate(pe_count=Count("playevents", distinct=True))
.filter(self.playevent_count.to_q("pe_count"))
.values_list("id", flat=True)
)
q &= Q(id__in=matching_ids)
if self.manual_playtime_hours is not None:
from django.db.models import Sum
from games.models import Game
matching_ids = (
Game.objects.annotate(s_manual=Sum("sessions__duration_manual"))
.filter(
self._playtime_to_q_for_field(
self.manual_playtime_hours, "s_manual"
)
)
.values_list("id", flat=True)
)
q &= Q(id__in=matching_ids)
if self.calculated_playtime_hours is not None:
from django.db.models import Sum
from games.models import Game
matching_ids = (
Game.objects.annotate(s_calc=Sum("sessions__duration_calculated"))
.filter(
self._playtime_to_q_for_field(
self.calculated_playtime_hours, "s_calc"
)
)
.values_list("id", flat=True)
)
q &= Q(id__in=matching_ids)
if self.device is not None:
from games.models import Session
session_q = self.device.to_q("device_id")
matching_ids = Session.objects.filter(session_q).values_list(
"game_id", flat=True
)
q &= Q(id__in=matching_ids)
if self.session_emulated is not None:
from games.models import Session
emulated_ids = Session.objects.filter(
emulated=self.session_emulated.value
).values_list("game_id", flat=True)
if self.session_emulated.value:
q &= Q(id__in=emulated_ids)
else:
emulated_true_ids = Session.objects.filter(emulated=True).values_list(
"game_id", flat=True
)
q &= ~Q(id__in=emulated_true_ids)
if self.purchase_refunded is not None:
from games.models import Purchase
refunded_ids = Purchase.objects.filter(
date_refunded__isnull=False
).values_list("games__id", flat=True)
if self.purchase_refunded.value:
q &= Q(id__in=refunded_ids)
else:
q &= ~Q(id__in=refunded_ids)
if self.purchase_infinite is not None:
from games.models import Purchase
infinite_ids = Purchase.objects.filter(infinite=True).values_list(
"games__id", flat=True
)
if self.purchase_infinite.value:
q &= Q(id__in=infinite_ids)
else:
q &= ~Q(id__in=infinite_ids)
if self.purchase_price_total is not None:
from django.db.models import Sum
from games.models import Game
matching_ids = (
Game.objects.annotate(p_total=Sum("purchases__converted_price"))
.filter(self.purchase_price_total.to_q("p_total"))
.values_list("id", flat=True)
)
q &= Q(id__in=matching_ids)
if self.purchase_price_any is not None:
from games.models import Purchase
price_q = self.purchase_price_any.to_q("converted_price")
matching_ids = Purchase.objects.filter(price_q).values_list(
"games__id", flat=True
)
q &= Q(id__in=matching_ids)
if self.purchase_type is not None:
from games.models import Purchase
type_q = self.purchase_type.to_q("type")
matching_ids = Purchase.objects.filter(type_q).values_list(
"games__id", flat=True
)
q &= Q(id__in=matching_ids)
if self.purchase_ownership_type is not None:
from games.models import Purchase
ownership_q = self.purchase_ownership_type.to_q("ownership_type")
matching_ids = Purchase.objects.filter(ownership_q).values_list(
"games__id", flat=True
)
q &= Q(id__in=matching_ids)
if self.playevent_note is not None:
q &= self._playevent_note_to_q(self.playevent_note)
# ── free-text search (OR across multiple fields) ──
if self.search is not None and self.search.value:
search_q = (
@@ -307,43 +105,6 @@ class GameFilter(OperatorFilter):
search_q = ~search_q
q &= search_q
# Cross-entity filters
if self.session_filter is not None:
from games.models import Session
session_q = self.session_filter.to_q()
matching_ids = Session.objects.filter(session_q).values_list(
"game_id", flat=True
)
q &= Q(id__in=matching_ids)
if self.purchase_filter is not None:
from games.models import Purchase
purchase_q = self.purchase_filter.to_q()
matching_ids = Purchase.objects.filter(purchase_q).values_list(
"games__id", flat=True
)
q &= Q(id__in=matching_ids)
if self.playevent_filter is not None:
from games.models import PlayEvent
playevent_q = self.playevent_filter.to_q()
matching_ids = PlayEvent.objects.filter(playevent_q).values_list(
"game_id", flat=True
)
q &= Q(id__in=matching_ids)
if self.platform_filter is not None:
from games.models import Platform
platform_q = self.platform_filter.to_q()
matching_ids = Platform.objects.filter(platform_q).values_list(
"id", flat=True
)
q &= Q(platform_id__in=matching_ids)
# ── AND / OR / NOT sub-filters ──
sub = self.sub_filter()
if sub is not None:
@@ -358,34 +119,31 @@ class GameFilter(OperatorFilter):
@staticmethod
def _playtime_to_q(c: IntCriterion) -> Q:
return GameFilter._playtime_to_q_for_field(c, "playtime")
@staticmethod
def _playtime_to_q_for_field(c: IntCriterion, field: str) -> Q:
"""Convert hours-based criterion to a DurationField Q object.
"""Convert minutes-based criterion to a DurationField Q object.
Django stores DurationField as microseconds in SQLite, so we convert
hours → timedelta(microseconds=X) and use the appropriate lookups.
minutes → timedelta(microseconds=X) and use the appropriate lookups.
"""
from datetime import timedelta
from common.criteria import Modifier
m = c.modifier
td_val = timedelta(hours=c.value)
field = "playtime"
td_val = timedelta(minutes=c.value)
if m == Modifier.EQUALS:
return Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(hours=c.value + 1),
f"{field}__lt": timedelta(minutes=c.value + 1),
}
)
if m == Modifier.NOT_EQUALS:
return ~Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(hours=c.value + 1),
f"{field}__lt": timedelta(minutes=c.value + 1),
}
)
if m == Modifier.GREATER_THAN:
@@ -393,12 +151,12 @@ class GameFilter(OperatorFilter):
if m == Modifier.LESS_THAN:
return Q(**{f"{field}__lt": td_val})
if m == Modifier.BETWEEN and c.value2 is not None:
lo = timedelta(hours=min(c.value, c.value2))
hi = timedelta(hours=max(c.value, c.value2))
lo = timedelta(minutes=min(c.value, c.value2))
hi = timedelta(minutes=max(c.value, c.value2))
return Q(**{f"{field}__gte": lo, f"{field}__lte": hi})
if m == Modifier.NOT_BETWEEN and c.value2 is not None:
lo = timedelta(hours=min(c.value, c.value2))
hi = timedelta(hours=max(c.value, c.value2))
lo = timedelta(minutes=min(c.value, c.value2))
hi = timedelta(minutes=max(c.value, c.value2))
return Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
if m == Modifier.IS_NULL:
return Q(**{f"{field}": timedelta(0)})
@@ -406,17 +164,6 @@ class GameFilter(OperatorFilter):
return ~Q(**{f"{field}": timedelta(0)})
return Q()
@staticmethod
def _playevent_note_to_q(criterion: StringCriterion) -> Q:
"""Match games by substring / regex / null against their playevents' notes."""
from games.models import PlayEvent
event_q = criterion.to_q("note")
matching_ids = PlayEvent.objects.filter(event_q).values_list(
"game_id", flat=True
)
return Q(id__in=matching_ids)
# ── SessionFilter ──────────────────────────────────────────────────────────
@@ -433,10 +180,7 @@ class SessionFilter(OperatorFilter):
device: MultiCriterion | None = None # filters on device_id
emulated: BoolCriterion | None = None
note: StringCriterion | None = None
duration_hours: IntCriterion | None = None # on duration_total (legacy alias)
duration_total_hours: IntCriterion | None = None
duration_manual_hours: IntCriterion | None = None
duration_calculated_hours: IntCriterion | None = None
duration_minutes: IntCriterion | None = None # on duration_total
is_active: BoolCriterion | None = None # timestamp_end IS NULL
timestamp_start: StringCriterion | None = None # date string
timestamp_end: StringCriterion | None = None # date string
@@ -449,47 +193,6 @@ class SessionFilter(OperatorFilter):
# Cross-entity: sessions for games matching these criteria
game_filter: GameFilter | None = None
# Cross-entity: sessions for devices matching these criteria
device_filter: DeviceFilter | None = None
def _duration_to_q(self, c: IntCriterion, field: str) -> Q:
from datetime import timedelta
q = Q()
td_val = timedelta(hours=c.value)
m = c.modifier
if m == Modifier.EQUALS:
q &= Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(hours=c.value + 1),
}
)
elif m == Modifier.NOT_EQUALS:
q &= ~Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(hours=c.value + 1),
}
)
elif m == Modifier.GREATER_THAN:
q &= Q(**{f"{field}__gt": td_val})
elif m == Modifier.LESS_THAN:
q &= Q(**{f"{field}__lt": td_val})
elif m == Modifier.BETWEEN and c.value2 is not None:
lo = timedelta(hours=min(c.value, c.value2))
hi = timedelta(hours=max(c.value, c.value2))
q &= Q(**{f"{field}__gte": lo, f"{field}__lte": hi})
elif m == Modifier.NOT_BETWEEN and c.value2 is not None:
lo = timedelta(hours=min(c.value, c.value2))
hi = timedelta(hours=max(c.value, c.value2))
q &= Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
elif m == Modifier.IS_NULL:
q &= Q(**{f"{field}": timedelta(0)})
elif m == Modifier.NOT_NULL:
q &= ~Q(**{f"{field}": timedelta(0)})
return q
def to_q(self) -> Q:
from datetime import timedelta
@@ -503,16 +206,41 @@ class SessionFilter(OperatorFilter):
q &= self.emulated.to_q("emulated")
if self.note is not None:
q &= self.note.to_q("note")
if self.duration_hours is not None:
q &= self._duration_to_q(self.duration_hours, "duration_total")
if self.duration_total_hours is not None:
q &= self._duration_to_q(self.duration_total_hours, "duration_total")
if self.duration_manual_hours is not None:
q &= self._duration_to_q(self.duration_manual_hours, "duration_manual")
if self.duration_calculated_hours is not None:
q &= self._duration_to_q(
self.duration_calculated_hours, "duration_calculated"
)
if self.duration_minutes is not None:
c = self.duration_minutes
td_val = timedelta(minutes=c.value)
field = "duration_total"
m = c.modifier
if m == Modifier.EQUALS:
q &= Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(minutes=c.value + 1),
}
)
elif m == Modifier.NOT_EQUALS:
q &= ~Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(minutes=c.value + 1),
}
)
elif m == Modifier.GREATER_THAN:
q &= Q(**{f"{field}__gt": td_val})
elif m == Modifier.LESS_THAN:
q &= Q(**{f"{field}__lt": td_val})
elif m == Modifier.BETWEEN and c.value2 is not None:
lo = timedelta(minutes=min(c.value, c.value2))
hi = timedelta(minutes=max(c.value, c.value2))
q &= Q(**{f"{field}__gte": lo, f"{field}__lte": hi})
elif m == Modifier.NOT_BETWEEN and c.value2 is not None:
lo = timedelta(minutes=min(c.value, c.value2))
hi = timedelta(minutes=max(c.value, c.value2))
q &= Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
elif m == Modifier.IS_NULL:
q &= Q(**{f"{field}": timedelta(0)})
elif m == Modifier.NOT_NULL:
q &= ~Q(**{f"{field}": timedelta(0)})
if self.is_active is not None:
if self.is_active.value:
q &= Q(timestamp_end__isnull=True)
@@ -550,14 +278,6 @@ class SessionFilter(OperatorFilter):
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
q &= Q(game_id__in=matching_ids)
# Cross-entity filter: sessions for devices matching DeviceFilter
if self.device_filter is not None:
from games.models import Device
device_q = self.device_filter.to_q()
matching_ids = Device.objects.filter(device_q).values_list("id", flat=True)
q &= Q(device_id__in=matching_ids)
# AND / OR / NOT
sub = self.sub_filter()
if sub is not None:
@@ -585,8 +305,8 @@ class PurchaseFilter(OperatorFilter):
name: StringCriterion | None = None
platform: ChoiceCriterion | None = None # platform_id
games: ChoiceCriterion | None = None # games (M2M IDs)
date_purchased: DateCriterion | None = None
date_refunded: DateCriterion | None = None
date_purchased: StringCriterion | None = None # date string
date_refunded: StringCriterion | None = None # date string
is_refunded: BoolCriterion | None = None # date_refunded IS NOT NULL
price: FloatCriterion | None = None # on price field
converted_price: FloatCriterion | None = None
@@ -597,19 +317,12 @@ class PurchaseFilter(OperatorFilter):
created_at: StringCriterion | None = None
updated_at: StringCriterion | None = None
infinite: BoolCriterion | None = None
needs_price_update: BoolCriterion | None = None
converted_currency: StringCriterion | None = None
# Free-text search
search: StringCriterion | None = None
# Cross-entity: purchases for games matching these criteria
game_filter: GameFilter | None = None
# Cross-entity: purchases for platforms matching these criteria
platform_filter: PlatformFilter | None = None
def to_q(self) -> Q:
q = Q()
@@ -618,7 +331,7 @@ class PurchaseFilter(OperatorFilter):
if self.platform is not None:
q &= self.platform.to_q("platform_id")
if self.games is not None:
q &= self._games_to_q(self.games)
q &= self.games.to_q("games")
if self.date_purchased is not None:
q &= self.date_purchased.to_q("date_purchased")
if self.date_refunded is not None:
@@ -641,12 +354,6 @@ class PurchaseFilter(OperatorFilter):
q &= self.created_at.to_q("created_at")
if self.updated_at is not None:
q &= self.updated_at.to_q("updated_at")
if self.infinite is not None:
q &= self.infinite.to_q("infinite")
if self.needs_price_update is not None:
q &= self.needs_price_update.to_q("needs_price_update")
if self.converted_currency is not None:
q &= self.converted_currency.to_q("converted_currency")
# Free-text search
if self.search is not None and self.search.value:
@@ -667,279 +374,6 @@ class PurchaseFilter(OperatorFilter):
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
q &= Q(games__id__in=matching_ids)
# Cross-entity platform filter
if self.platform_filter is not None:
from games.models import Platform
platform_q = self.platform_filter.to_q()
matching_ids = Platform.objects.filter(platform_q).values_list(
"id", flat=True
)
q &= Q(platform_id__in=matching_ids)
sub = self.sub_filter()
if sub is not None:
if self.AND is not None:
q &= sub.to_q()
elif self.OR is not None:
q |= sub.to_q()
elif self.NOT is not None:
q &= ~sub.to_q()
return q
@staticmethod
def _games_to_q(criterion: ChoiceCriterion) -> Q:
"""Build the Q for the many-to-many ``games`` field.
``INCLUDES_ALL`` ("related to every selected game") and
``INCLUDES_ONLY`` ("related to exactly these, nothing else") cannot be
a single ``.filter(Q(games=a) & Q(games=b))`` — that collapses to one
join and would require a single link row to be both games. Instead
chain a filter per game so each gets its own join, then match by
``pk``. ``INCLUDES_ONLY`` additionally excludes purchases that have
any game outside the specified set.
``INCLUDES`` (plain "any") also uses a subquery instead of a raw
``games__in`` join because a single purchase linked to *n* of the
given games would appear *n* times in the result set (M2M join
duplicates).
The orthogonal ``excludes`` channel is applied as a negative,
consistent with every other modifier. All other modifiers delegate
to the criterion.
"""
# Empty value means no constraint; still apply excludes if any
if not criterion.value:
if criterion.excludes:
return ~Q(games__in=criterion.excludes)
return Q()
from games.models import Game, Purchase
if criterion.modifier in (Modifier.INCLUDES_ALL, Modifier.INCLUDES_ONLY):
subquery = Purchase.objects.all()
for game_id in criterion.value:
subquery = subquery.filter(games=game_id)
if criterion.modifier == Modifier.INCLUDES_ONLY:
extra_ids = Game.objects.exclude(id__in=criterion.value).values_list(
"id", flat=True
)
if extra_ids:
subquery = subquery.exclude(games__in=extra_ids)
q = Q(pk__in=subquery.values("pk"))
if criterion.excludes:
q &= ~Q(games__in=criterion.excludes)
return q
if criterion.modifier == Modifier.INCLUDES:
# Use subquery to avoid duplicate rows from M2M join
subquery = Purchase.objects.filter(games__in=criterion.value)
q = Q(pk__in=subquery.values("pk"))
if criterion.excludes:
q &= ~Q(games__in=criterion.excludes)
return q
return criterion.to_q("games")
# ── DeviceFilter ───────────────────────────────────────────────────────────
@dataclass
class DeviceFilter(OperatorFilter):
"""Filter for the Device model."""
AND: DeviceFilter | None = None
OR: DeviceFilter | None = None
NOT: DeviceFilter | None = None
name: StringCriterion | None = None
type: ChoiceCriterion | None = None
created_at: StringCriterion | None = None
# Free-text search
search: StringCriterion | None = None
# Cross-entity: Devices that have sessions matching these criteria
session_filter: SessionFilter | None = None
def to_q(self) -> Q:
q = Q()
if self.name is not None:
q &= self.name.to_q("name")
if self.type is not None:
q &= self.type.to_q("type")
if self.created_at is not None:
q &= self.created_at.to_q("created_at")
# Free-text search
if self.search is not None and self.search.value:
search_q = Q(name__icontains=self.search.value) | Q(
type__icontains=self.search.value
)
if self.search.modifier == Modifier.EXCLUDES:
search_q = ~search_q
q &= search_q
# Cross-entity filter: session_filter
if self.session_filter is not None:
from games.models import Session
session_q = self.session_filter.to_q()
matching_ids = Session.objects.filter(session_q).values_list(
"device_id", flat=True
)
q &= Q(id__in=matching_ids)
sub = self.sub_filter()
if sub is not None:
if self.AND is not None:
q &= sub.to_q()
elif self.OR is not None:
q |= sub.to_q()
elif self.NOT is not None:
q &= ~sub.to_q()
return q
# ── PlatformFilter ─────────────────────────────────────────────────────────
@dataclass
class PlatformFilter(OperatorFilter):
"""Filter for the Platform model."""
AND: PlatformFilter | None = None
OR: PlatformFilter | None = None
NOT: PlatformFilter | None = None
name: StringCriterion | None = None
group: StringCriterion | None = None
icon: StringCriterion | None = None
created_at: StringCriterion | None = None
# Free-text search
search: StringCriterion | None = None
# Cross-entity
game_filter: GameFilter | None = None
purchase_filter: PurchaseFilter | None = None
def to_q(self) -> Q:
q = Q()
if self.name is not None:
q &= self.name.to_q("name")
if self.group is not None:
q &= self.group.to_q("group")
if self.icon is not None:
q &= self.icon.to_q("icon")
if self.created_at is not None:
q &= self.created_at.to_q("created_at")
# Free-text search
if self.search is not None and self.search.value:
search_q = Q(name__icontains=self.search.value) | Q(
group__icontains=self.search.value
)
if self.search.modifier == Modifier.EXCLUDES:
search_q = ~search_q
q &= search_q
# Cross-entity filter: game_filter
if self.game_filter is not None:
from games.models import Game
game_q = self.game_filter.to_q()
matching_ids = Game.objects.filter(game_q).values_list(
"platform_id", flat=True
)
q &= Q(id__in=matching_ids)
# Cross-entity filter: purchase_filter
if self.purchase_filter is not None:
from games.models import Purchase
purchase_q = self.purchase_filter.to_q()
matching_ids = Purchase.objects.filter(purchase_q).values_list(
"platform_id", flat=True
)
q &= Q(id__in=matching_ids)
sub = self.sub_filter()
if sub is not None:
if self.AND is not None:
q &= sub.to_q()
elif self.OR is not None:
q |= sub.to_q()
elif self.NOT is not None:
q &= ~sub.to_q()
return q
# ── PlayEventFilter ────────────────────────────────────────────────────────
@dataclass
class PlayEventFilter(OperatorFilter):
"""Filter for the PlayEvent model."""
AND: PlayEventFilter | None = None
OR: PlayEventFilter | None = None
NOT: PlayEventFilter | None = None
game: MultiCriterion | None = None # filters on game_id
started: StringCriterion | None = None # date string
ended: StringCriterion | None = None # date string
days_to_finish: IntCriterion | None = None
note: StringCriterion | None = None
created_at: StringCriterion | None = None
# Free-text search
search: StringCriterion | None = None
# Cross-entity: PlayEvents for games matching these criteria
game_filter: GameFilter | None = None
def to_q(self) -> Q:
q = Q()
if self.game is not None:
q &= self.game.to_q("game_id")
if self.started is not None:
q &= self.started.to_q("started")
if self.ended is not None:
q &= self.ended.to_q("ended")
if self.days_to_finish is not None:
q &= self.days_to_finish.to_q("days_to_finish")
if self.note is not None:
q &= self.note.to_q("note")
if self.created_at is not None:
q &= self.created_at.to_q("created_at")
# Free-text search
if self.search is not None and self.search.value:
search_q = Q(game__name__icontains=self.search.value) | Q(
note__icontains=self.search.value
)
if self.search.modifier == Modifier.EXCLUDES:
search_q = ~search_q
q &= search_q
# Cross-entity filter: game_filter
if self.game_filter is not None:
from games.models import Game
game_q = self.game_filter.to_q()
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
q &= Q(game_id__in=matching_ids)
sub = self.sub_filter()
if sub is not None:
if self.AND is not None:
@@ -965,15 +399,3 @@ def parse_session_filter(json_str: str) -> SessionFilter | None:
def parse_purchase_filter(json_str: str) -> PurchaseFilter | None:
return filter_from_json(PurchaseFilter, json_str)
def parse_device_filter(json_str: str) -> DeviceFilter | None:
return filter_from_json(DeviceFilter, json_str)
def parse_platform_filter(json_str: str) -> PlatformFilter | None:
return filter_from_json(PlatformFilter, json_str)
def parse_playevent_filter(json_str: str) -> PlayEventFilter | None:
return filter_from_json(PlayEventFilter, json_str)
+38 -57
View File
@@ -1,90 +1,71 @@
- model: games.platform
pk: 1
fields:
name: Steam
group: PC
created_at: "2020-01-01T00:00:00Z"
- model: games.platform
pk: 3
fields:
name: Xbox Gamepass
group: PC
created_at: "2020-01-01T00:00:00Z"
- model: games.platform
pk: 4
fields:
name: Epic Games Store
group: PC
created_at: "2020-01-01T00:00:00Z"
- model: games.platform
pk: 5
fields:
name: Playstation 5
group: Playstation
created_at: "2020-01-01T00:00:00Z"
- model: games.platform
pk: 6
fields:
name: Playstation 4
group: Playstation
created_at: "2020-01-01T00:00:00Z"
- model: games.platform
pk: 7
fields:
name: Nintendo Switch
group: Nintendo
created_at: "2020-01-01T00:00:00Z"
- model: games.platform
pk: 8
fields:
name: Nintendo 3DS
group: Nintendo
created_at: "2020-01-01T00:00:00Z"
- model: games.game
pk: 1
fields:
name: Nioh 2
wikidata: Q67482292
created_at: "2021-02-13T00:00:00Z"
updated_at: "2021-02-13T00:00:00Z"
- model: games.game
pk: 2
fields:
name: Elden Ring
wikidata: Q64826862
created_at: "2022-02-24T00:00:00Z"
updated_at: "2022-02-24T00:00:00Z"
- model: games.game
pk: 3
fields:
name: Cyberpunk 2077
wikidata: Q3182559
created_at: "2020-12-07T00:00:00Z"
updated_at: "2020-12-07T00:00:00Z"
- model: games.purchase
pk: 1
fields:
games: [1]
game: 1
platform: 1
date_purchased: 2021-02-13
date_refunded: null
created_at: "2021-02-13T00:00:00Z"
updated_at: "2021-02-13T00:00:00Z"
- model: games.purchase
pk: 2
fields:
games: [2]
game: 2
platform: 1
date_purchased: 2022-02-24
date_refunded: null
created_at: "2022-02-24T00:00:00Z"
updated_at: "2022-02-24T00:00:00Z"
- model: games.purchase
pk: 3
fields:
games: [3]
game: 3
platform: 1
date_purchased: 2020-12-07
date_refunded: null
created_at: "2020-12-07T00:00:00Z"
updated_at: "2020-12-07T00:00:00Z"
- model: games.platform
pk: 1
fields:
name: Steam
group: PC
- model: games.platform
pk: 3
fields:
name: Xbox Gamepass
group: PC
- model: games.platform
pk: 4
fields:
name: Epic Games Store
group: PC
- model: games.platform
pk: 5
fields:
name: Playstation 5
group: Playstation
- model: games.platform
pk: 6
fields:
name: Playstation 4
group: Playstation
- model: games.platform
pk: 7
fields:
name: Nintendo Switch
group: Nintendo
- model: games.platform
pk: 8
fields:
name: Nintendo 3DS
group: Nintendo
+19 -64
View File
@@ -3,13 +3,10 @@ from django.db import transaction
from django.db.models import OuterRef, Subquery
from common.components import (
DEFAULT_PREFETCH,
SearchSelect,
SearchSelectOption,
render,
searchselect_selected,
)
from common.components.primitives import Checkbox
from games.models import (
Device,
Game,
@@ -27,42 +24,6 @@ custom_datetime_widget = forms.DateTimeInput(
autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
class PrimitiveCheckboxWidget(forms.CheckboxInput):
"""Adapts Django's CheckboxInput to use our Checkbox component."""
def render(self, name, value, attrs=None, renderer=None):
final_attrs = self.build_attrs(self.attrs, attrs)
checked = self.check_test(value)
attributes = [
(k, str(v))
for k, v in final_attrs.items()
if k not in ("type", "name", "value", "checked")
]
# Django uses boolean values differently for checkboxes, we omit value if empty
# render() returns a safe string (Django widgets must not be autoescaped).
return render(
Checkbox(
name=name,
label=None,
checked=checked,
value=str(value) if value else "1",
attributes=attributes,
)
)
class PrimitiveWidgetsMixin:
"""Automatically applies primitive custom widgets to native Django form fields."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field_name, field in self.fields.items():
if isinstance(field, forms.BooleanField):
field.widget = PrimitiveCheckboxWidget()
# Maintain the field's explicit required status (usually False for booleans)
class MultipleGameChoiceField(forms.ModelMultipleChoiceField):
def label_from_instance(self, obj) -> str:
return obj.search_label
@@ -114,7 +75,6 @@ class SearchSelectWidget(forms.Widget):
multi_select=False,
items_visible=5,
items_scroll=10,
prefetch=DEFAULT_PREFETCH,
always_visible=False,
placeholder="Search…",
attrs=None,
@@ -125,7 +85,6 @@ class SearchSelectWidget(forms.Widget):
self.multi_select = multi_select
self.items_visible = items_visible
self.items_scroll = items_scroll
self.prefetch = prefetch
self.always_visible = always_visible
self.placeholder = placeholder
@@ -140,22 +99,18 @@ class SearchSelectWidget(forms.Widget):
def render(self, name, value, attrs=None, renderer=None):
selected = searchselect_selected(self._values(value), self.options_resolver)
autofocus = bool((attrs or {}).get("autofocus"))
# Django widgets must return a safe string; the component is a node.
return render(
SearchSelect(
name=name,
selected=selected,
options=None,
search_url=self.search_url,
multi_select=self.multi_select,
items_visible=self.items_visible,
items_scroll=self.items_scroll,
prefetch=self.prefetch,
always_visible=self.always_visible,
placeholder=self.placeholder,
id=(attrs or {}).get("id", ""),
autofocus=autofocus,
)
return SearchSelect(
name=name,
selected=selected,
options=None,
search_url=self.search_url,
multi_select=self.multi_select,
items_visible=self.items_visible,
items_scroll=self.items_scroll,
always_visible=self.always_visible,
placeholder=self.placeholder,
id=(attrs or {}).get("id", ""),
autofocus=autofocus,
)
def value_from_datadict(self, data, files, name):
@@ -169,7 +124,7 @@ class SearchSelectMultiple(SearchSelectWidget):
return data.get(name)
class SessionForm(PrimitiveWidgetsMixin, forms.ModelForm):
class SessionForm(forms.ModelForm):
game = SingleGameChoiceField(
queryset=Game.objects.order_by("sort_name"),
widget=SearchSelectWidget(
@@ -253,7 +208,7 @@ class RelatedPurchaseChoiceField(forms.ModelChoiceField):
return name or obj.standardized_name
class PurchaseForm(PrimitiveWidgetsMixin, forms.ModelForm):
class PurchaseForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["platform"].queryset = Platform.objects.order_by("name")
@@ -346,7 +301,7 @@ class GameModelChoiceField(forms.ModelChoiceField):
return obj.sort_name
class GameForm(PrimitiveWidgetsMixin, forms.ModelForm):
class GameForm(forms.ModelForm):
platform = forms.ModelChoiceField(
queryset=Platform.objects.order_by("name"),
required=False,
@@ -370,7 +325,7 @@ class GameForm(PrimitiveWidgetsMixin, forms.ModelForm):
widgets = {"name": autofocus_input_widget}
class PlatformForm(PrimitiveWidgetsMixin, forms.ModelForm):
class PlatformForm(forms.ModelForm):
class Meta:
model = Platform
fields = [
@@ -381,14 +336,14 @@ class PlatformForm(PrimitiveWidgetsMixin, forms.ModelForm):
widgets = {"name": autofocus_input_widget}
class DeviceForm(PrimitiveWidgetsMixin, forms.ModelForm):
class DeviceForm(forms.ModelForm):
class Meta:
model = Device
fields = ["name", "type"]
widgets = {"name": autofocus_input_widget}
class PlayEventForm(PrimitiveWidgetsMixin, forms.ModelForm):
class PlayEventForm(forms.ModelForm):
game = SingleGameChoiceField(
queryset=Game.objects.order_by("sort_name"),
widget=SearchSelectWidget(
@@ -423,7 +378,7 @@ class PlayEventForm(PrimitiveWidgetsMixin, forms.ModelForm):
return session
class GameStatusChangeForm(PrimitiveWidgetsMixin, forms.ModelForm):
class GameStatusChangeForm(forms.ModelForm):
class Meta:
model = GameStatusChange
fields = [
@@ -1,21 +0,0 @@
"""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}"))
@@ -1,28 +0,0 @@
from django.contrib.sessions.models import Session
from django.core.management.base import BaseCommand
from django_q.models import OrmQ, Schedule, Task
class Command(BaseCommand):
help = (
"Remove copied production artifacts from a staging database seeded "
"from a production snapshot: clears authenticated sessions and the "
"django-q schedule/queue/results so staging does not share prod's "
"session cookies or independently run scheduled tasks."
)
def handle(self, *args, **kwargs):
sessions_deleted, _ = Session.objects.all().delete()
schedules_deleted, _ = Schedule.objects.all().delete()
tasks_deleted, _ = Task.objects.all().delete()
queued_deleted, _ = OrmQ.objects.all().delete()
self.stdout.write(
self.style.SUCCESS(
"Scrubbed staging database: "
f"{sessions_deleted} session(s), "
f"{schedules_deleted} schedule(s), "
f"{tasks_deleted} task result(s), "
f"{queued_deleted} queued task(s) removed."
)
)
@@ -4,14 +4,15 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0017_add_filter_preset"),
('games', '0017_add_filter_preset'),
]
operations = [
migrations.AlterField(
model_name="session",
name="timestamp_start",
field=models.DateTimeField(db_index=True, verbose_name="Start"),
model_name='session',
name='timestamp_start',
field=models.DateTimeField(db_index=True, verbose_name='Start'),
),
]
@@ -1,28 +0,0 @@
# 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,
),
),
]
-2
View File
@@ -501,8 +501,6 @@ class FilterPreset(models.Model):
("sessions", "Sessions"),
("purchases", "Purchases"),
("playevents", "Play Events"),
("devices", "Devices"),
("platforms", "Platforms"),
]
name = models.CharField(max_length=255)
+206 -287
View File
@@ -306,6 +306,7 @@
--color-neutral-tertiary: var(--color-gray-100);
--color-neutral-tertiary-medium: var(--color-gray-100);
--color-neutral-quaternary: var(--color-gray-200);
--color-brand-soft: var(--color-blue-100);
--color-brand: var(--color-blue-700);
--color-brand-medium: var(--color-blue-200);
--color-brand-strong: var(--color-blue-800);
@@ -466,9 +467,6 @@
}
}
@layer utilities {
.\@container {
container-type: inline-size;
}
.pointer-events-auto {
pointer-events: auto;
}
@@ -813,9 +811,6 @@
.static {
position: static;
}
.sticky {
position: sticky;
}
.inset-0 {
inset: calc(var(--spacing) * 0);
}
@@ -918,9 +913,6 @@
.ms-2\.5 {
margin-inline-start: calc(var(--spacing) * 2.5);
}
.ms-auto {
margin-inline-start: auto;
}
.me-2 {
margin-inline-end: calc(var(--spacing) * 2);
}
@@ -1293,9 +1285,6 @@
.ml-1 {
margin-left: calc(var(--spacing) * 1);
}
.ml-2 {
margin-left: calc(var(--spacing) * 2);
}
.ml-4 {
margin-left: calc(var(--spacing) * 4);
}
@@ -1481,9 +1470,6 @@
.h-8 {
height: calc(var(--spacing) * 8);
}
.h-9 {
height: calc(var(--spacing) * 9);
}
.h-10 {
height: calc(var(--spacing) * 10);
}
@@ -1582,12 +1568,6 @@
.w-5 {
width: calc(var(--spacing) * 5);
}
.w-5\/6 {
width: calc(5 / 6 * 100%);
}
.w-8 {
width: calc(var(--spacing) * 8);
}
.w-10 {
width: calc(var(--spacing) * 10);
}
@@ -1603,12 +1583,6 @@
.w-72 {
width: calc(var(--spacing) * 72);
}
.w-\[2\.5ch\] {
width: 2.5ch;
}
.w-\[4\.5ch\] {
width: 4.5ch;
}
.w-\[300px\] {
width: 300px;
}
@@ -1748,9 +1722,6 @@
.cursor-pointer {
cursor: pointer;
}
.cursor-text {
cursor: text;
}
.resize {
resize: both;
}
@@ -1763,9 +1734,6 @@
.grid-cols-1 {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
.grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid-cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@@ -1790,9 +1758,6 @@
.items-start {
align-items: flex-start;
}
.items-stretch {
align-items: stretch;
}
.justify-between {
justify-content: space-between;
}
@@ -1805,15 +1770,9 @@
.justify-start {
justify-content: flex-start;
}
.gap-0\.5 {
gap: calc(var(--spacing) * 0.5);
}
.gap-1 {
gap: calc(var(--spacing) * 1);
}
.gap-1\.5 {
gap: calc(var(--spacing) * 1.5);
}
.gap-2 {
gap: calc(var(--spacing) * 2);
}
@@ -1826,9 +1785,6 @@
.gap-5 {
gap: calc(var(--spacing) * 5);
}
.gap-6 {
gap: calc(var(--spacing) * 6);
}
.space-y-6 {
:where(& > :not(:last-child)) {
--tw-space-y-reverse: 0;
@@ -1857,9 +1813,6 @@
margin-inline-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-x-reverse)));
}
}
.gap-y-0\.5 {
row-gap: calc(var(--spacing) * 0.5);
}
.gap-y-4 {
row-gap: calc(var(--spacing) * 4);
}
@@ -1928,9 +1881,6 @@
.rounded-xl {
border-radius: var(--radius-xl);
}
.rounded-xs {
border-radius: var(--radius-xs);
}
.rounded-s-base {
border-start-start-radius: var(--radius-base);
border-end-start-radius: var(--radius-base);
@@ -1955,21 +1905,20 @@
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.rounded-l-lg {
border-top-left-radius: var(--radius-lg);
border-bottom-left-radius: var(--radius-lg);
}
.rounded-tl-none {
border-top-left-radius: 0;
}
.rounded-r-lg {
border-top-right-radius: var(--radius-lg);
border-bottom-right-radius: var(--radius-lg);
.rounded-tr-md {
border-top-right-radius: var(--radius-md);
}
.rounded-b {
border-bottom-right-radius: var(--radius);
border-bottom-left-radius: var(--radius);
}
.rounded-b-md {
border-bottom-right-radius: var(--radius-md);
border-bottom-left-radius: var(--radius-md);
}
.border {
border-style: var(--tw-border-style);
border-width: 1px;
@@ -1978,14 +1927,14 @@
border-style: var(--tw-border-style);
border-width: 0px;
}
.border-0\! {
border-style: var(--tw-border-style) !important;
border-width: 0px !important;
}
.border-2 {
border-style: var(--tw-border-style);
border-width: 2px;
}
.border-y {
border-block-style: var(--tw-border-style);
border-block-width: 1px;
}
.border-e {
border-inline-end-style: var(--tw-border-style);
border-inline-end-width: 1px;
@@ -2055,21 +2004,9 @@
.border-blue-200 {
border-color: var(--color-blue-200);
}
.border-blue-600 {
border-color: var(--color-blue-600);
}
.border-blue-700 {
border-color: var(--color-blue-700);
}
.border-brand {
border-color: var(--color-brand);
}
.border-brand\/70 {
border-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 70%, transparent);
@supports (color: color-mix(in lab, red, red)) {
border-color: color-mix(in oklab, var(--color-brand) 70%, transparent);
}
}
.border-default {
border-color: var(--color-default);
}
@@ -2137,12 +2074,6 @@
.bg-amber-50 {
background-color: var(--color-amber-50);
}
.bg-amber-500\/15 {
background-color: color-mix(in srgb, oklch(76.9% 0.188 70.08) 15%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-amber-500) 15%, transparent);
}
}
.bg-black\/70 {
background-color: color-mix(in srgb, #000 70%, transparent);
@supports (color: color-mix(in lab, red, red)) {
@@ -2161,24 +2092,12 @@
.bg-brand {
background-color: var(--color-brand);
}
.bg-brand\/10 {
background-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 10%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-brand) 10%, transparent);
}
}
.bg-brand\/15 {
background-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 15%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-brand) 15%, transparent);
}
}
.bg-brand\/30 {
background-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 30%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-brand) 30%, transparent);
}
}
.bg-dark-backdrop\/70 {
background-color: color-mix(in srgb, oklch(13% 0.028 261.692) 70%, transparent);
@supports (color: color-mix(in lab, red, red)) {
@@ -2191,9 +2110,6 @@
.bg-gray-100 {
background-color: var(--color-gray-100);
}
.bg-gray-200 {
background-color: var(--color-gray-200);
}
.bg-gray-400 {
background-color: var(--color-gray-400);
}
@@ -2263,12 +2179,6 @@
.bg-red-500 {
background-color: var(--color-red-500);
}
.bg-red-500\/15 {
background-color: color-mix(in srgb, oklch(63.7% 0.237 25.331) 15%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-red-500) 15%, transparent);
}
}
.bg-red-600 {
background-color: var(--color-red-600);
}
@@ -2351,9 +2261,6 @@
padding: 0 !important;
}
}
.p-0 {
padding: calc(var(--spacing) * 0);
}
.p-1 {
padding: calc(var(--spacing) * 1);
}
@@ -2378,9 +2285,6 @@
.p-6 {
padding: calc(var(--spacing) * 6);
}
.px-0\.5 {
padding-inline: calc(var(--spacing) * 0.5);
}
.px-2 {
padding-inline: calc(var(--spacing) * 2);
}
@@ -2480,9 +2384,6 @@
.text-right {
text-align: right;
}
.text-start {
text-align: start;
}
.align-middle {
vertical-align: middle;
}
@@ -2661,9 +2562,6 @@
.text-amber-500 {
color: var(--color-amber-500);
}
.text-amber-600 {
color: var(--color-amber-600);
}
.text-amber-800 {
color: var(--color-amber-800);
}
@@ -2676,9 +2574,6 @@
.text-blue-500 {
color: var(--color-blue-500);
}
.text-blue-600 {
color: var(--color-blue-600);
}
.text-blue-800 {
color: var(--color-blue-800);
}
@@ -2763,33 +2658,21 @@
.italic {
font-style: italic;
}
.line-through {
text-decoration-line: line-through;
.no-underline\! {
text-decoration-line: none !important;
}
.underline {
text-decoration-line: underline;
}
.decoration-red-400 {
text-decoration-color: var(--color-red-400);
}
.decoration-slate-500 {
text-decoration-color: var(--color-slate-500);
}
.decoration-dotted {
text-decoration-style: dotted;
}
.caret-transparent {
caret-color: transparent;
}
.opacity-0 {
opacity: 0%;
}
.opacity-40 {
opacity: 40%;
}
.opacity-50 {
opacity: 50%;
}
.opacity-100 {
opacity: 100%;
}
@@ -2822,13 +2705,6 @@
--tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.ring-2 {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.ring-brand-strong {
--tw-ring-color: var(--color-brand-strong);
}
.outline {
outline-style: var(--tw-outline-style);
outline-width: 1px;
@@ -2850,11 +2726,6 @@
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration));
}
.transition-all {
transition-property: all;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration));
}
.transition-opacity {
transition-property: opacity;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
@@ -2894,9 +2765,6 @@
.\[program\:qcluster\] {
program: qcluster;
}
.ring-inset {
--tw-ring-inset: inset;
}
.group-hover\:absolute {
&:is(:where(.group):hover *) {
@media (hover: hover) {
@@ -2997,16 +2865,6 @@
}
}
}
.group-data-\[search-select-highlighted\]\:border-white {
&:is(:where(.group)[data-search-select-highlighted] *) {
border-color: var(--color-white);
}
}
.group-data-\[search-select-highlighted\]\:text-white {
&:is(:where(.group)[data-search-select-highlighted] *) {
color: var(--color-white);
}
}
.placeholder\:text-body {
&::placeholder {
color: var(--color-body);
@@ -3038,22 +2896,6 @@
padding-top: calc(var(--spacing) * 0);
}
}
.focus-within\:border-brand {
&:focus-within {
border-color: var(--color-brand);
}
}
.focus-within\:ring-1 {
&:focus-within {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
}
.focus-within\:ring-brand {
&:focus-within {
--tw-ring-color: var(--color-brand);
}
}
.hover\:scale-110 {
&:hover {
@media (hover: hover) {
@@ -3071,13 +2913,6 @@
}
}
}
.hover\:border-brand-strong {
&:hover {
@media (hover: hover) {
border-color: var(--color-brand-strong);
}
}
}
.hover\:border-default {
&:hover {
@media (hover: hover) {
@@ -3085,13 +2920,6 @@
}
}
}
.hover\:border-gray-300 {
&:hover {
@media (hover: hover) {
border-color: var(--color-gray-300);
}
}
}
.hover\:border-green-600 {
&:hover {
@media (hover: hover) {
@@ -3106,13 +2934,6 @@
}
}
}
.hover\:bg-brand {
&:hover {
@media (hover: hover) {
background-color: var(--color-brand);
}
}
}
.hover\:bg-brand-strong {
&:hover {
@media (hover: hover) {
@@ -3151,13 +2972,6 @@
}
}
}
.hover\:bg-gray-700 {
&:hover {
@media (hover: hover) {
background-color: var(--color-gray-700);
}
}
}
.hover\:bg-green-500 {
&:hover {
@media (hover: hover) {
@@ -3179,13 +2993,6 @@
}
}
}
.hover\:bg-neutral-secondary-strong {
&:hover {
@media (hover: hover) {
background-color: var(--color-neutral-secondary-strong);
}
}
}
.hover\:bg-neutral-tertiary-medium {
&:hover {
@media (hover: hover) {
@@ -3305,24 +3112,6 @@
}
}
}
.group-data-\[search-select-highlighted\]\:hover\:border-white {
&:is(:where(.group)[data-search-select-highlighted] *) {
&:hover {
@media (hover: hover) {
border-color: var(--color-white);
}
}
}
}
.group-data-\[search-select-highlighted\]\:hover\:bg-brand-strong {
&:is(:where(.group)[data-search-select-highlighted] *) {
&:hover {
@media (hover: hover) {
background-color: var(--color-brand-strong);
}
}
}
}
.focus\:z-10 {
&:focus {
z-index: 10;
@@ -3333,14 +3122,6 @@
border-color: var(--color-brand);
}
}
.focus\:bg-brand\/30 {
&:focus {
background-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 30%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-brand) 30%, transparent);
}
}
}
.focus\:text-blue-700 {
&:focus {
color: var(--color-blue-700);
@@ -3430,36 +3211,6 @@
outline-style: none;
}
}
.data-\[search-select-highlighted\]\:bg-brand {
&[data-search-select-highlighted] {
background-color: var(--color-brand);
}
}
.data-\[search-select-highlighted\]\:bg-brand\/15 {
&[data-search-select-highlighted] {
background-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 15%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-brand) 15%, transparent);
}
}
}
.data-\[search-select-highlighted\]\:outline {
&[data-search-select-highlighted] {
outline-style: var(--tw-outline-style);
outline-width: 1px;
}
}
.data-\[search-select-highlighted\]\:outline-1 {
&[data-search-select-highlighted] {
outline-style: var(--tw-outline-style);
outline-width: 1px;
}
}
.data-\[search-select-highlighted\]\:outline-brand-strong {
&[data-search-select-highlighted] {
outline-color: var(--color-brand-strong);
}
}
.sm\:table-cell {
@media (width >= 40rem) {
display: table-cell;
@@ -3645,11 +3396,6 @@
max-width: var(--breakpoint-2xl);
}
}
.\@md\:grid-cols-4 {
@container (width >= 28rem) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.rtl\:rotate-180 {
&:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) {
rotate: 180deg;
@@ -3695,11 +3441,6 @@
border-color: var(--color-amber-700);
}
}
.dark\:border-blue-500 {
&:is(.dark *) {
border-color: var(--color-blue-500);
}
}
.dark\:border-blue-700 {
&:is(.dark *) {
border-color: var(--color-blue-700);
@@ -3735,11 +3476,6 @@
border-color: var(--color-red-700);
}
}
.dark\:border-transparent {
&:is(.dark *) {
border-color: transparent;
}
}
.dark\:bg-amber-900 {
&:is(.dark *) {
background-color: var(--color-amber-900);
@@ -4037,15 +3773,6 @@
}
}
}
.dark\:hover\:text-blue-500 {
&:is(.dark *) {
&:hover {
@media (hover: hover) {
color: var(--color-blue-500);
}
}
}
}
.dark\:hover\:text-gray-300 {
&:is(.dark *) {
&:hover {
@@ -4196,6 +3923,17 @@
text-underline-offset: 4px;
}
}
.\[\&_li\:first-of-type_a\]\:rounded-none {
& li:first-of-type a {
border-radius: 0;
}
}
.\[\&_li\:last-of-type_a\]\:rounded-t-none {
& li:last-of-type a {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
}
.\[\&_td\:last-child\]\:text-right {
& td:last-child {
text-align: right;
@@ -4534,7 +4272,7 @@ form input:disabled, select:disabled, textarea:disabled {
font-weight: var(--font-weight-medium);
color: var(--color-heading);
}
input:not([type="checkbox"]):not([data-search-select-search]) {
input:not([type="checkbox"]) {
margin-bottom: calc(var(--spacing) * 3);
display: block;
width: 100%;
@@ -4560,6 +4298,22 @@ form input:disabled, select:disabled, textarea:disabled {
--tw-ring-color: var(--color-brand);
}
}
input[type="checkbox"] {
height: calc(var(--spacing) * 4);
width: calc(var(--spacing) * 4);
border-radius: var(--radius-xs);
border-style: var(--tw-border-style);
border-width: 1px;
border-color: var(--color-default-medium);
background-color: var(--color-neutral-secondary-medium);
&:focus {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
&:focus {
--tw-ring-color: var(--color-brand-soft);
}
}
select {
width: 100%;
border-radius: var(--radius-base);
@@ -4627,6 +4381,171 @@ form input:disabled, select:disabled, textarea:disabled {
padding: calc(var(--spacing) * 4);
}
}
.sf-container {
border-radius: var(--radius-base);
border-style: var(--tw-border-style);
border-width: 1px;
border-color: var(--color-default-medium);
background-color: var(--color-neutral-secondary-medium);
}
.sf-selected {
display: flex;
min-height: 2rem;
flex-wrap: wrap;
gap: calc(var(--spacing) * 1);
padding: calc(var(--spacing) * 2);
}
.sf-tag {
display: inline-flex;
align-items: center;
gap: calc(var(--spacing) * 1);
border-radius: var(--radius);
background-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 15%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-brand) 15%, transparent);
}
padding-inline: calc(var(--spacing) * 2);
padding-block: calc(var(--spacing) * 0.5);
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
color: var(--color-heading);
}
.sf-tag.sf-excluded {
background-color: color-mix(in srgb, oklch(63.7% 0.237 25.331) 15%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-red-500) 15%, transparent);
}
color: var(--color-red-600);
text-decoration-line: line-through;
text-decoration-color: var(--color-red-400);
}
.sf-remove {
margin-left: calc(var(--spacing) * 1);
cursor: pointer;
--tw-font-weight: var(--font-weight-bold);
font-weight: var(--font-weight-bold);
color: var(--color-body);
&:hover {
@media (hover: hover) {
color: var(--color-heading);
}
}
}
.sf-modifier-tag {
display: inline-flex;
cursor: pointer;
align-items: center;
border-radius: var(--radius);
background-color: color-mix(in srgb, oklch(76.9% 0.188 70.08) 15%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-amber-500) 15%, transparent);
}
padding-inline: calc(var(--spacing) * 2);
padding-block: calc(var(--spacing) * 0.5);
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
color: var(--color-amber-600);
}
.sf-search {
display: block;
width: 100%;
border-style: var(--tw-border-style);
border-width: 0px;
border-top-style: var(--tw-border-style);
border-top-width: 1px;
border-color: var(--color-default-medium);
background-color: transparent;
padding: calc(var(--spacing) * 2);
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
color: var(--color-heading);
&:focus {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
--tw-outline-style: none;
outline-style: none;
@media (forced-colors: active) {
outline: 2px solid transparent;
outline-offset: 2px;
}
}
}
.sf-options {
max-height: calc(var(--spacing) * 40);
overflow-y: auto;
padding: calc(var(--spacing) * 1);
color: var(--color-body);
}
.sf-option {
display: flex;
cursor: pointer;
align-items: center;
justify-content: space-between;
border-radius: var(--radius);
padding-inline: calc(var(--spacing) * 2);
padding-block: calc(var(--spacing) * 1);
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
&:hover {
@media (hover: hover) {
background-color: var(--color-neutral-secondary-strong);
}
}
}
.sf-option-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sf-option-buttons {
margin-left: calc(var(--spacing) * 2);
display: flex;
flex-shrink: 0;
gap: calc(var(--spacing) * 1);
}
.sf-btn-include, .sf-btn-exclude {
display: flex;
height: calc(var(--spacing) * 5);
width: calc(var(--spacing) * 5);
align-items: center;
justify-content: center;
border-radius: var(--radius);
border-style: var(--tw-border-style);
border-width: 1px;
border-color: var(--color-default-medium);
font-size: var(--text-xs);
line-height: var(--tw-leading, var(--text-xs--line-height));
--tw-font-weight: var(--font-weight-bold);
font-weight: var(--font-weight-bold);
&:hover {
@media (hover: hover) {
border-color: var(--color-brand);
}
}
&:hover {
@media (hover: hover) {
background-color: var(--color-brand);
}
}
&:hover {
@media (hover: hover) {
color: var(--color-white);
}
}
}
.sf-modifier-option {
cursor: pointer;
padding-inline: calc(var(--spacing) * 2);
padding-block: calc(var(--spacing) * 1);
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
color: var(--color-body);
&:hover {
@media (hover: hover) {
background-color: var(--color-neutral-secondary-strong);
}
}
}
@layer base {
input:where([type='text']),input:where(:not([type])),input:where([type='email']),input:where([type='url']),input:where([type='password']),input:where([type='number']),input:where([type='date']),input:where([type='datetime-local']),input:where([type='month']),input:where([type='search']),input:where([type='tel']),input:where([type='time']),input:where([type='week']),select:where([multiple]),textarea,select {
appearance: none;
+6 -7
View File
@@ -1,4 +1,4 @@
import { disableElementsWhenTrue, onSwap } from "./utils.js";
import { getEl, disableElementsWhenTrue } from "./utils.js";
const RELATED_PURCHASE_URL = "/tracker/purchase/related-purchase-by-game";
@@ -11,7 +11,7 @@ document.addEventListener("search-select:change", (event) => {
const last = event.detail.last;
const platformId = last && last.data ? last.data.platform : "";
if (platformId) {
const platformEl = document.querySelector("#id_platform");
const platformEl = getEl("#id_platform");
if (platformEl) platformEl.value = platformId;
}
@@ -26,7 +26,7 @@ document.addEventListener("search-select:change", (event) => {
})
.then((html) => {
if (html === null) return;
const target = document.querySelector("#id_related_purchase");
const target = getEl("#id_related_purchase");
if (target) target.outerHTML = html;
});
});
@@ -38,9 +38,8 @@ function setupElementHandlers() {
]);
}
onSwap("#id_type", (typeSelect) => {
document.addEventListener("DOMContentLoaded", setupElementHandlers);
document.addEventListener("htmx:afterSwap", setupElementHandlers);
getEl("#id_type").addEventListener("change", () => {
setupElementHandlers();
typeSelect.addEventListener("change", () => {
setupElementHandlers();
});
});
+23
View File
@@ -0,0 +1,23 @@
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";
}
});
}
-1
View File
@@ -1 +0,0 @@
(()=>{function x(n){n.directive("mask",(e,{value:l,expression:r},{effect:s,evaluateLater:i,cleanup:u})=>{let p=()=>r,f="";queueMicrotask(()=>{if(["function","dynamic"].includes(l)){let o=i(r);s(()=>{p=t=>{let c;return n.dontAutoEvaluateFunctions(()=>{o(d=>{c=typeof d=="function"?d(t):d},{scope:{$input:t,$money:M.bind({el:e})}})}),c},a(e,!1)})}else a(e,!1);if(e._x_model){e._x_model.get()!==e.value&&(e._x_model.get()===null&&e.value===""||e._x_model.set(e.value));let o=e._x_forceModelUpdate;e._x_forceModelUpdate=t=>{t=String(t);let c=p(t);c&&c!=="false"&&(t=m(c,t)),f=t,o(t),e._x_model.set(t)}}});let g=new AbortController;u(()=>{g.abort()}),e.addEventListener("input",()=>a(e),{signal:g.signal,capture:!0}),e.addEventListener("blur",()=>a(e,!1),{signal:g.signal});function a(o,t=!0){let c=o.value,d=p(c);if(!d||d==="false")return!1;if(f.length-o.value.length===1)return f=o.value;let h=()=>{f=o.value=m(d,c)};t?v(o,d,()=>{h()}):h()}}).before("model")}function v(n,e,l){let r=n.selectionStart,s=n.value;l();let i=s.slice(0,r),u=m(e,i).length;n.setSelectionRange(u,u)}var _={9:/[0-9]/,a:/[a-zA-Z]/,"*":/[a-zA-Z0-9]/};function m(n,e){let l=0,r=0,s="";for(;l<n.length&&r<e.length;){let i=n[l],u=e[r];i in _?(_[i].test(u)&&(s+=u,l++),r++):(s+=i,l++,i===e[r]&&r++)}return s}function M(n,e=".",l,r=2){if(n==="-")return"-";if(/^\D+$/.test(n))return"9";l==null&&(l=e===","?".":",");let s=(f,g)=>{let a="",o=0;for(let t=f.length-1;t>=0;t--)f[t]!==g&&(o===3?(a=f[t]+g+a,o=0):a=f[t]+a,o++);return a},i=n.startsWith("-")?"-":"",u=n.replaceAll(new RegExp(`[^0-9\\${e}]`,"g"),""),p=Array.from({length:u.split(e)[0].length}).fill("9").join("");return p=`${i}${s(p,l)}`,r>0&&n.includes(e)&&(p+=`${e}`+"9".repeat(r)),queueMicrotask(()=>{this.el.value.endsWith(e)||this.el.value[this.el.selectionStart-1]===e&&this.el.setSelectionRange(this.el.selectionStart-1,this.el.selectionStart-1)}),p}document.addEventListener("alpine:init",()=>{window.Alpine.plugin(x)});})();
-5
View File
File diff suppressed because one or more lines are too long
-530
View File
@@ -1,530 +0,0 @@
/**
* DateRangePicker vanilla JavaScript implementation.
*
* Drives the DateRangePicker component (common/components/date_range_picker.py):
*
* - DateRangeField: segmented manual entry. Each date part (DD/MM/YYYY) is its
* own input; digits fill the placeholder from the right (YYYY YYY1 YY19
* Y198 1987), full parts auto-advance to the next one, and
* Backspace/Delete reverts the active part to its placeholder.
* - DateRangeCalendar: popup month grid with a preset column and a
* Cancel / Clear / Select footer. Picking works anchor-style: the first
* pick becomes the StartDate anchor, the second pick sets the EndDate and
* moves the anchor there so further picks adjust the StartDate. Picking on
* the wrong side of the anchor clears the range and restarts from the
* clicked date.
*
* The committed value lives in the two hidden ISO inputs ({prefix}-min /
* {prefix}-max) that filter_bar.js serializes into a DateCriterion.
*
* NB: class strings below are emitted verbatim so the Tailwind scanner picks
* them up keep them as plain literals.
*/
(function () {
"use strict";
var WEEKDAY_LABELS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
var WEEKDAY_CLASS =
"w-8 h-6 flex items-center justify-center text-xs text-body select-none";
var DAY_BASE_CLASS =
"date-range-day w-8 h-8 flex items-center justify-center text-sm " +
"text-heading cursor-pointer hover:bg-neutral-tertiary-medium";
var DAY_ROUNDED_CLASS = "rounded-base";
var DAY_OUTSIDE_MONTH_CLASS = "opacity-40";
var DAY_SELECTED_CLASS = "bg-brand text-white hover:bg-brand-strong";
var DAY_ANCHOR_CLASS =
"bg-brand text-white ring-2 ring-inset ring-brand-strong hover:bg-brand-strong";
// The three visual states of the date range track (the days between the
// two endpoints): outlined while picking the second date, filled once both
// are picked, muted when showing an already-committed range read-only.
var TRACK_OUTLINED_CLASS = "border-y border-brand/70 bg-brand/10";
var TRACK_FILLED_CLASS = "bg-brand/30";
var TRACK_MUTED_CLASS = "bg-brand/15";
// ── Date helpers (all local-time; values are ISO YYYY-MM-DD strings) ──
function padNumber(value, width) {
var text = String(value);
while (text.length < width) text = "0" + text;
return text;
}
function isoFromDate(dateObject) {
return (
padNumber(dateObject.getFullYear(), 4) +
"-" +
padNumber(dateObject.getMonth() + 1, 2) +
"-" +
padNumber(dateObject.getDate(), 2)
);
}
function dateFromIso(isoString) {
var pieces = isoString.split("-");
return new Date(
parseInt(pieces[0], 10),
parseInt(pieces[1], 10) - 1,
parseInt(pieces[2], 10)
);
}
function addDays(dateObject, dayCount) {
var copy = new Date(dateObject.getTime());
copy.setDate(copy.getDate() + dayCount);
return copy;
}
/** Validate a (year, month, day) triple as a real calendar date. */
function isoFromParts(year, month, day) {
var candidate = new Date(year, month - 1, day);
if (
candidate.getFullYear() !== year ||
candidate.getMonth() !== month - 1 ||
candidate.getDate() !== day
) {
return "";
}
return isoFromDate(candidate);
}
function presetRange(presetName) {
var today = new Date();
today.setHours(0, 0, 0, 0);
var yesterday = addDays(today, -1);
var year = today.getFullYear();
var month = today.getMonth();
switch (presetName) {
case "today":
return [today, today];
case "yesterday":
return [yesterday, yesterday];
case "last_7_days":
return [addDays(today, -6), today];
case "last_30_days":
return [addDays(today, -29), today];
case "this_month":
return [new Date(year, month, 1), new Date(year, month + 1, 0)];
case "last_month":
return [new Date(year, month - 1, 1), new Date(year, month, 0)];
case "this_year":
return [new Date(year, 0, 1), new Date(year, 11, 31)];
default:
return null;
}
}
// ── DateRangeField: segmented manual entry ──────────────────────────────
function segmentBuffer(segment) {
return segment.dataset.typedDigits || "";
}
function setSegmentBuffer(segment, buffer) {
segment.dataset.typedDigits = buffer;
if (buffer === "") {
segment.value = "";
return;
}
var placeholder = segment.getAttribute("placeholder");
// Fill the placeholder from the right: typing 19 into YYYY shows YY19.
segment.value = placeholder.slice(0, placeholder.length - buffer.length) + buffer;
}
function segmentsForSide(picker, side) {
return Array.prototype.slice.call(
picker.querySelectorAll('input[data-date-side="' + side + '"]')
);
}
/** Recompute one hidden ISO input from its side's segment buffers. */
function syncHiddenFromSegments(picker, side) {
var hidden = picker.querySelector(
'input[data-date-range-hidden="' + side + '"]'
);
var partValues = {};
var complete = true;
segmentsForSide(picker, side).forEach(function (segment) {
var buffer = segmentBuffer(segment);
if (buffer.length !== parseInt(segment.getAttribute("maxlength"), 10)) {
complete = false;
}
partValues[segment.dataset.datePart] = buffer;
});
var previousValue = hidden.value;
if (complete) {
hidden.value = isoFromParts(
parseInt(partValues.year, 10),
parseInt(partValues.month, 10),
parseInt(partValues.day, 10)
);
} else {
hidden.value = "";
}
return hidden.value !== previousValue;
}
/** Push an ISO value (or "") into a side's segments and hidden input. */
function setSideValue(picker, side, isoString) {
var hidden = picker.querySelector(
'input[data-date-range-hidden="' + side + '"]'
);
hidden.value = isoString;
var partValues = { year: "", month: "", day: "" };
if (isoString) {
var pieces = isoString.split("-");
partValues = { year: pieces[0], month: pieces[1], day: pieces[2] };
}
segmentsForSide(picker, side).forEach(function (segment) {
setSegmentBuffer(segment, partValues[segment.dataset.datePart]);
});
}
function initField(picker, calendarState) {
var field = picker.querySelector("[data-date-range-field]");
var segments = Array.prototype.slice.call(
picker.querySelectorAll("input[data-date-part]")
);
// Adopt server-rendered values (prefilled filter) as typed buffers.
segments.forEach(function (segment) {
if (segment.value) setSegmentBuffer(segment, segment.value);
});
// Clicking anywhere in the container that is not a date part activates
// the first date part.
field.addEventListener("mousedown", function (event) {
if (event.target.closest("input[data-date-part]")) return;
if (event.target.closest("[data-date-range-calendar-toggle]")) return;
event.preventDefault();
segments[0].focus();
});
segments.forEach(function (segment, segmentIndex) {
segment.addEventListener("keydown", function (event) {
if (event.key === "Tab") return; // native Tab / Shift+Tab navigation
if (event.key === "Enter") return; // let the filter form submit
if (event.key === "Backspace" || event.key === "Delete") {
event.preventDefault();
setSegmentBuffer(segment, "");
syncHiddenFromSegments(picker, segment.dataset.dateSide);
return;
}
if (event.ctrlKey || event.metaKey || event.altKey) return;
event.preventDefault();
if (!/^[0-9]$/.test(event.key)) return; // only numbers can be typed
var maximumLength = parseInt(segment.getAttribute("maxlength"), 10);
var buffer = segmentBuffer(segment);
// Typing into an already-full part starts it over.
buffer = buffer.length >= maximumLength ? event.key : buffer + event.key;
setSegmentBuffer(segment, buffer);
syncHiddenFromSegments(picker, segment.dataset.dateSide);
if (buffer.length === maximumLength && segmentIndex + 1 < segments.length) {
segments[segmentIndex + 1].focus();
}
});
// Swallow any input that bypassed keydown (e.g. IME/paste).
segment.addEventListener("input", function () {
setSegmentBuffer(segment, segmentBuffer(segment));
});
segment.addEventListener("focus", function () {
if (calendarState) calendarState.refreshFromField();
});
});
}
// ── DateRangeCalendar: popup month grid ────────────────────────────────
function createCalendarState(picker) {
var popup = picker.querySelector("[data-date-range-calendar]");
var grid = popup.querySelector("[data-date-range-grid]");
var monthLabel = popup.querySelector("[data-date-range-month-label]");
var today = new Date();
var state = {
open: false,
viewYear: today.getFullYear(),
viewMonth: today.getMonth(),
startIso: "",
endIso: "",
// The anchor is the fixed endpoint: "start" while picking the EndDate,
// "end" once the range is complete (further picks move the StartDate).
anchor: "",
hoverIso: "",
// True while showing a committed range the user has not edited yet —
// the track renders muted until the first pick.
readOnly: false,
};
function hiddenValue(side) {
return picker.querySelector(
'input[data-date-range-hidden="' + side + '"]'
).value;
}
state.refreshFromField = function () {
if (state.open) return;
state.startIso = hiddenValue("min");
state.endIso = hiddenValue("max");
};
function syncSelectionToField() {
setSideValue(picker, "min", state.startIso);
setSideValue(picker, "max", state.endIso);
}
function openPopup() {
state.startIso = hiddenValue("min");
state.endIso = hiddenValue("max");
state.anchor = state.startIso && state.endIso ? "end" : state.startIso ? "start" : "";
state.readOnly = Boolean(state.startIso && state.endIso);
state.hoverIso = "";
var focusDate = state.startIso ? dateFromIso(state.startIso) : new Date();
state.viewYear = focusDate.getFullYear();
state.viewMonth = focusDate.getMonth();
state.open = true;
popup.classList.remove("hidden");
render();
}
function closePopup() {
state.open = false;
state.hoverIso = "";
popup.classList.add("hidden");
}
function clearSelection() {
state.startIso = "";
state.endIso = "";
state.anchor = "";
state.hoverIso = "";
state.readOnly = false;
syncSelectionToField();
}
/**
* Anchor-style picking:
* - no selection: the pick becomes the StartDate anchor
* - anchor=start (picking EndDate): a pick on/after the StartDate
* completes the range and moves the anchor to the EndDate; a pick
* before it clears the range and restarts
* - anchor=end (adjusting StartDate): a pick on/before the EndDate
* moves the StartDate (extend/shorten); a pick after it clears the
* range and restarts from the clicked date
*/
function pickDate(isoString) {
state.readOnly = false;
if (!state.startIso) {
state.startIso = isoString;
state.anchor = "start";
} else if (state.anchor === "start" && !state.endIso) {
if (isoString >= state.startIso) {
state.endIso = isoString;
state.anchor = "end";
} else {
state.startIso = isoString;
state.endIso = "";
state.anchor = "start";
}
} else {
if (isoString <= state.endIso) {
state.startIso = isoString;
} else {
state.startIso = isoString;
state.endIso = "";
state.anchor = "start";
}
}
syncSelectionToField();
render();
}
function applyPreset(presetName) {
var range = presetRange(presetName);
if (!range) return;
state.startIso = isoFromDate(range[0]);
state.endIso = isoFromDate(range[1]);
state.anchor = "end";
state.readOnly = false;
state.viewYear = range[0].getFullYear();
state.viewMonth = range[0].getMonth();
syncSelectionToField();
render();
}
/** The (inclusive-exclusive of endpoints) track between the two range
* ends; while picking the second date the hovered day acts as the
* provisional other end. */
function trackBounds() {
if (state.startIso && state.endIso) {
return [state.startIso, state.endIso, state.readOnly ? TRACK_MUTED_CLASS : TRACK_FILLED_CLASS];
}
if (state.startIso && state.hoverIso && state.hoverIso !== state.startIso) {
var lower = state.hoverIso < state.startIso ? state.hoverIso : state.startIso;
var upper = state.hoverIso < state.startIso ? state.startIso : state.hoverIso;
return [lower, upper, TRACK_OUTLINED_CLASS];
}
return null;
}
function dayCellClass(isoString, inViewMonth) {
var classes = [DAY_BASE_CLASS];
var isStart = isoString === state.startIso;
var isEnd = isoString === state.endIso;
var isAnchor =
(state.anchor === "start" && isStart) || (state.anchor === "end" && isEnd);
var track = trackBounds();
var inTrack = track && isoString > track[0] && isoString < track[1];
if (inTrack) {
classes.push(track[2]);
} else {
classes.push(DAY_ROUNDED_CLASS);
}
if (isAnchor && !state.readOnly) {
classes.push(DAY_ANCHOR_CLASS);
} else if (isStart || isEnd) {
classes.push(DAY_SELECTED_CLASS);
} else if (!inViewMonth) {
classes.push(DAY_OUTSIDE_MONTH_CLASS);
}
return classes.join(" ");
}
function render() {
monthLabel.textContent = new Date(
state.viewYear,
state.viewMonth,
1
).toLocaleDateString(undefined, { month: "long", year: "numeric" });
grid.textContent = "";
WEEKDAY_LABELS.forEach(function (weekdayLabel) {
var headerCell = document.createElement("span");
headerCell.className = WEEKDAY_CLASS;
headerCell.textContent = weekdayLabel;
grid.appendChild(headerCell);
});
var firstOfMonth = new Date(state.viewYear, state.viewMonth, 1);
// Monday-first offset of the leading overflow days.
var leadingDays = (firstOfMonth.getDay() + 6) % 7;
var cellDate = addDays(firstOfMonth, -leadingDays);
for (var cellIndex = 0; cellIndex < 42; cellIndex++) {
var isoString = isoFromDate(cellDate);
var dayButton = document.createElement("button");
dayButton.type = "button";
dayButton.setAttribute("data-date", isoString);
dayButton.className = dayCellClass(
isoString,
cellDate.getMonth() === state.viewMonth
);
dayButton.textContent = String(cellDate.getDate());
grid.appendChild(dayButton);
cellDate = addDays(cellDate, 1);
}
}
// ── Wiring ──
picker
.querySelector("[data-date-range-calendar-toggle]")
.addEventListener("click", function () {
if (state.open) closePopup();
else openPopup();
});
grid.addEventListener("click", function (event) {
var dayButton = event.target.closest("button[data-date]");
if (dayButton) pickDate(dayButton.getAttribute("data-date"));
});
grid.addEventListener("mouseover", function (event) {
if (!state.startIso || state.endIso) return;
var dayButton = event.target.closest("button[data-date]");
if (!dayButton) return;
var hoveredIso = dayButton.getAttribute("data-date");
if (hoveredIso === state.hoverIso) return;
state.hoverIso = hoveredIso;
render();
});
popup
.querySelector("[data-date-range-prev]")
.addEventListener("click", function () {
state.viewMonth -= 1;
if (state.viewMonth < 0) {
state.viewMonth = 11;
state.viewYear -= 1;
}
render();
});
popup
.querySelector("[data-date-range-next]")
.addEventListener("click", function () {
state.viewMonth += 1;
if (state.viewMonth > 11) {
state.viewMonth = 0;
state.viewYear += 1;
}
render();
});
popup.querySelectorAll("[data-date-range-preset]").forEach(function (button) {
button.addEventListener("click", function () {
applyPreset(button.getAttribute("data-date-range-preset"));
});
});
// Cancel: close the popup and clear the selected dates.
popup
.querySelector("[data-date-range-cancel]")
.addEventListener("click", function () {
clearSelection();
closePopup();
});
// Clear: clear the selected dates but keep the popup open.
popup
.querySelector("[data-date-range-clear]")
.addEventListener("click", function () {
clearSelection();
render();
});
// Select: close the popup, keeping the selected dates.
popup
.querySelector("[data-date-range-select]")
.addEventListener("click", function () {
closePopup();
});
document.addEventListener("keydown", function (event) {
if (event.key === "Escape" && state.open) closePopup();
});
document.addEventListener("mousedown", function (event) {
if (state.open && !picker.contains(event.target)) closePopup();
});
return state;
}
function initPicker(picker) {
if (picker.dataset.dateRangePickerInitialized) return;
picker.dataset.dateRangePickerInitialized = "true";
var calendarState = createCalendarState(picker);
initField(picker, calendarState);
}
function initAllPickers() {
document.querySelectorAll("[data-date-range-picker]").forEach(initPicker);
}
window.initDateRangePickers = initAllPickers;
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initAllPickers);
} else {
initAllPickers();
}
})();
File diff suppressed because it is too large Load Diff
+120 -231
View File
@@ -4,8 +4,6 @@
* Handles form submission, preset loading/saving, and preset list rendering.
* No HTMX plain fetch() and window.location for all interactions.
*/
import { onSwap } from "./utils.js";
(function () {
"use strict";
@@ -32,24 +30,6 @@ import { onSwap } from "./utils.js";
return isNaN(val) ? "" : val;
}
/** Read a raw <input> value as string, or "" if not found. */
function stringValue(form, name) {
var el = form.querySelector('[name="' + name + '"]');
return el ? el.value : "";
}
/**
* Derive a range criterion ({value, value2?, modifier}) from a (min, max)
* pair, or null if both bounds are empty. Shared by the numeric-range and
* date-range serializers.
*/
function buildRangeCriterion(vMin, vMax) {
if (vMin !== "" && vMax !== "") return criterion(vMin, vMax, "BETWEEN");
if (vMin !== "") return criterion(vMin, null, "GREATER_THAN");
if (vMax !== "") return criterion(vMax, null, "LESS_THAN");
return null;
}
/** Read all checked checkboxes with a given name, returning an array of ints. */
function checkedValues(form, name) {
var els = form.querySelectorAll('[name="' + name + '"]:checked');
@@ -67,6 +47,11 @@ import { onSwap } from "./utils.js";
*/
function buildFilterJSON(form) {
var filter = {};
var yearMin = numberValue(form, "filter-year-min");
var yearMax = numberValue(form, "filter-year-max");
var playMin = numberValue(form, "filter-playtime-min");
var playMax = numberValue(form, "filter-playtime-max");
var mastered = form.querySelector('[name="filter-mastered"]');
// ── Search field ──
var searchInput = form.querySelector('[name="filter-search"]');
@@ -74,128 +59,104 @@ import { onSwap } from "./utils.js";
filter.search = { value: searchInput.value.trim(), modifier: "INCLUDES" };
}
// ── FilterSelect widgets (data-search-select-mode="filter") ──
// readSearchSelect serialises each into data-included/data-excluded/data-modifier.
readSearchSelect(form);
var widgets = form.querySelectorAll('[data-search-select][data-search-select-mode="filter"]');
widgets.forEach(function (widget) {
var field = widget.getAttribute("data-name");
var included = parseJSONAttr(widget, "data-included");
var excluded = parseJSONAttr(widget, "data-excluded");
// Two orthogonal axes: a presence modifier (NOT_NULL/IS_NULL) from the
// pinned (Any)/(None) pseudo-options clears the value set and has no
// values; the non-presence modifier (INCLUDES_ALL/INCLUDES_ONLY) governs
// how the include set matches. When neither is set the implicit default
// is INCLUDES ("any"). Must match Python _PRESENCE_MODIFIERS.
var modifier = widget.getAttribute("data-modifier");
var IS_PRESENCE = modifier === "NOT_NULL" || modifier === "IS_NULL";
if (IS_PRESENCE) {
filter[field] = { modifier: modifier };
} else if (included.length > 0 || excluded.length > 0) {
// All filter pills carry {id, label}; store them as-is so the filter
// URL and saved presets are self-describing (Stash-style).
// ── Generic SelectableFilter widgets ──
readSelectableFilters(form);
var widgets = form.querySelectorAll("[data-selectable-filter]");
widgets.forEach(function (w) {
var field = w.getAttribute("data-selectable-filter");
var inc = parseJSONAttr(w, "data-included");
var exc = parseJSONAttr(w, "data-excluded");
var mod = w.getAttribute("data-modifier");
if (mod === "NOT_NULL" || mod === "IS_NULL") {
filter[field] = { modifier: mod };
} else if (inc.length > 0 || exc.length > 0) {
var isIdField = field === "platform" || field === "game" || field === "device" || field === "games";
filter[field] = {
value: included.map(function (item) { return {id: item.id, label: item.label}; }),
excludes: excluded.map(function (item) { return {id: item.id, label: item.label}; }),
modifier: modifier || "INCLUDES",
value: isIdField ? inc.map(Number) : inc,
excludes: isIdField ? exc.map(Number) : exc,
modifier: mod || "INCLUDES",
};
}
});
// 1. Text Fields
var textFields = [
{ name: "filter-price_currency", key: "price_currency" },
{ name: "filter-converted_currency", key: "converted_currency" },
{ name: "filter-name", key: "name" },
{ name: "filter-group", key: "group" },
{ name: "filter-playevent_note", key: "playevent_note" },
{ name: "filter-note", key: "note" }
];
textFields.forEach(function (tf) {
var modifierEl = form.querySelector('[name="' + tf.name + '-modifier"]:checked');
var modifier = modifierEl ? modifierEl.value : "EQUALS";
var isPresence = modifier === "IS_NULL" || modifier === "NOT_NULL";
if (isPresence) {
filter[tf.key] = { modifier: modifier };
// ── Session-specific fields ──
var pageIsSessions = !!form.querySelector('[data-selectable-filter="game"]');
// Game (sessions page)
var gameWidget = form.querySelector('[data-selectable-filter="game"]');
if (gameWidget) {
var gIncluded = parseJSONAttr(gameWidget, "data-included");
var gExcluded = parseJSONAttr(gameWidget, "data-excluded");
var gMod = gameWidget.getAttribute("data-modifier");
if (gMod === "NOT_NULL" || gMod === "IS_NULL") {
filter.game = { modifier: gMod };
} else if (gIncluded.length > 0 || gExcluded.length > 0) {
filter.game = {
value: gIncluded.map(Number),
excludes: gExcluded.map(Number),
modifier: gMod || "INCLUDES",
};
}
}
// Device (sessions page)
var deviceWidget = form.querySelector('[data-selectable-filter="device"]');
if (deviceWidget) {
var dIncluded = parseJSONAttr(deviceWidget, "data-included");
var dExcluded = parseJSONAttr(deviceWidget, "data-excluded");
var dMod = deviceWidget.getAttribute("data-modifier");
if (dMod === "NOT_NULL" || dMod === "IS_NULL") {
filter.device = { modifier: dMod };
} else if (dIncluded.length > 0 || dExcluded.length > 0) {
filter.device = {
value: dIncluded.map(Number),
excludes: dExcluded.map(Number),
modifier: dMod || "INCLUDES",
};
}
}
// Emulated checkbox (sessions page)
var emulated = form.querySelector('[name="filter-emulated"]');
if (emulated && emulated.checked) {
filter.emulated = criterion(true, null, "EQUALS");
}
// Active checkbox (sessions page)
var active = form.querySelector('[name="filter-active"]');
if (active && active.checked) {
filter.is_active = criterion(true, null, "EQUALS");
}
if (yearMin !== "" && yearMax !== "") {
filter.year_released = criterion(yearMin, yearMax, "BETWEEN");
} else if (yearMin !== "") {
filter.year_released = criterion(yearMin, null, "GREATER_THAN");
} else if (yearMax !== "") {
filter.year_released = criterion(yearMax, null, "LESS_THAN");
}
if (playMin !== "" || playMax !== "") {
var pMin = playMin !== "" ? Math.round(playMin * 60) : 0;
var pMax = playMax !== "" ? Math.round(playMax * 60) : 0;
// Skip if both are 0 — means slider is at default (no real filter)
if (pMin === 0 && pMax === 0) {
// don't add filter
} else {
var el = form.querySelector('[name="' + tf.name + '"]');
if (el && el.value.trim()) {
filter[tf.key] = { value: el.value.trim(), modifier: modifier };
var durKey = pageIsSessions ? "duration_minutes" : "playtime_minutes";
if (playMin !== "" && playMax !== "") {
filter[durKey] = criterion(pMin, pMax, "BETWEEN");
} else if (playMin !== "") {
filter[durKey] = criterion(pMin, null, "GREATER_THAN");
} else if (playMax !== "") {
filter[durKey] = criterion(pMax, null, "LESS_THAN");
}
}
});
}
// 2. Boolean Fields (Radio Button Groups)
var booleanFields = [
{ name: "filter-mastered", key: "mastered" },
{ name: "filter-emulated", key: "emulated" },
{ name: "filter-active", key: "is_active" },
{ name: "filter-refunded", key: "is_refunded" },
{ name: "filter-infinite", key: "infinite" },
{ name: "filter-needs-price-update", key: "needs_price_update" },
{ name: "filter-purchase-refunded", key: "purchase_refunded" },
{ name: "filter-purchase-infinite", key: "purchase_infinite" },
{ name: "filter-session-emulated", key: "session_emulated" }
];
booleanFields.forEach(function (bf) {
var el = form.querySelector('[name="' + bf.name + '"]:checked');
if (el) {
var val = el.value === "true";
filter[bf.key] = criterion(val, null, "EQUALS");
}
});
// 3. Range Fields
var rangeFields = [
{ prefix: "filter-year", key: "year_released" },
{ prefix: "filter-original-year", key: "original_year_released" },
{ prefix: "filter-session-count", key: "session_count" },
{ prefix: "filter-session-average", key: "session_average" },
{ prefix: "filter-purchase-count", key: "purchase_count" },
{ prefix: "filter-playevent-count", key: "playevent_count" },
{ prefix: "filter-duration-total-hours", key: "duration_total_hours" },
{ prefix: "filter-duration-manual-hours", key: "duration_manual_hours" },
{ prefix: "filter-duration-calculated-hours", key: "duration_calculated_hours" },
{ prefix: "filter-manual-playtime-hours", key: "manual_playtime_hours" },
{ prefix: "filter-calculated-playtime-hours", key: "calculated_playtime_hours" },
{ prefix: "filter-num-purchases", key: "num_purchases" },
{ prefix: "filter-price", key: "price" },
{ prefix: "filter-purchase-price-total", key: "purchase_price_total" },
{ prefix: "filter-purchase-price-any", key: "purchase_price_any" },
{ prefix: "filter-days-to-finish", key: "days_to_finish" },
{ prefix: "filter-playtime-hours", key: "playtime_hours", ignoreZeroZero: true }
];
rangeFields.forEach(function (rf) {
var vMin = numberValue(form, rf.prefix + "-min");
var vMax = numberValue(form, rf.prefix + "-max");
if (rf.convert) {
if (vMin !== "") vMin = rf.convert(vMin);
if (vMax !== "") vMax = rf.convert(vMax);
}
if (rf.ignoreZeroZero && vMin === 0 && vMax === 0) {
return; // both 0 means slider at default
}
var c = buildRangeCriterion(vMin, vMax);
if (c !== null) filter[rf.key] = c;
});
// 4. Date Range Fields — ISO date strings from <input type="date">; no
// numeric coercion. Same modifier derivation as numeric ranges.
var dateRangeFields = [
{ prefix: "filter-date-purchased", key: "date_purchased" },
{ prefix: "filter-date-refunded", key: "date_refunded" },
];
dateRangeFields.forEach(function (df) {
var vMin = stringValue(form, df.prefix + "-min");
var vMax = stringValue(form, df.prefix + "-max");
var c = buildRangeCriterion(vMin, vMax);
if (c !== null) filter[df.key] = c;
});
if (mastered && mastered.checked) {
filter.mastered = criterion(true, null, "EQUALS");
}
return filter;
}
@@ -249,19 +210,10 @@ import { onSwap } from "./utils.js";
if (!url) return;
var mode = "games";
var path = window.location.pathname;
if (path.indexOf("session") !== -1) mode = "sessions";
else if (path.indexOf("purchase") !== -1) mode = "purchases";
else if (path.indexOf("device") !== -1) mode = "devices";
else if (path.indexOf("platform") !== -1) mode = "platforms";
else if (path.indexOf("playevent") !== -1) mode = "playevents";
if (window.location.pathname.indexOf("session") !== -1) mode = "sessions";
else if (window.location.pathname.indexOf("purchase") !== -1) mode = "purchases";
var query = "";
if (url.indexOf("mode=") === -1) {
query = (url.indexOf("?") !== -1 ? "&" : "?") + "mode=" + mode;
}
fetch(url + query, { credentials: "same-origin" })
fetch(url + "?mode=" + mode, { credentials: "same-origin" })
.then(function (r) {
if (!r.ok) throw new Error("Failed to load presets");
return r.text();
@@ -312,27 +264,6 @@ import { onSwap } from "./utils.js";
});
}
/** Enable/disable the input text box depending on selected string modifier. */
window.toggleStringFilterInput = function (radio) {
var container = radio.closest(".flex-col");
if (!container) return;
var textInput = container.querySelector('input[type="text"]');
if (!textInput) return;
// Find the currently checked radio in the container
var checkedRadio = container.querySelector('input[type="radio"]:checked');
var val = checkedRadio ? checkedRadio.value : "";
if (val === "IS_NULL" || val === "NOT_NULL") {
textInput.disabled = true;
textInput.value = "";
textInput.classList.add("opacity-50", "cursor-not-allowed");
} else {
textInput.disabled = false;
textInput.classList.remove("opacity-50", "cursor-not-allowed");
}
};
/** Show the preset name input field and the confirm button. */
window.showPresetNameInput = function () {
var input = document.getElementById("preset-name-input");
@@ -360,12 +291,8 @@ import { onSwap } from "./utils.js";
var body = new URLSearchParams();
body.append("name", name);
var mode = "games";
var path = window.location.pathname;
if (path.indexOf("session") !== -1) mode = "sessions";
else if (path.indexOf("purchase") !== -1) mode = "purchases";
else if (path.indexOf("device") !== -1) mode = "devices";
else if (path.indexOf("platform") !== -1) mode = "platforms";
else if (path.indexOf("playevent") !== -1) mode = "playevents";
if (window.location.pathname.indexOf("session") !== -1) mode = "sessions";
else if (window.location.pathname.indexOf("purchase") !== -1) mode = "purchases";
body.append("mode", mode);
body.append("filter", JSON.stringify(filterObj));
@@ -412,68 +339,30 @@ import { onSwap } from "./utils.js";
// ── Init on page load ───────────────────────────────────────────────────
// ── Inject the search input into a filter form ──
function injectSearchInput(form) {
if (form.querySelector('[name="filter-search"]')) return; // already added
var input = document.createElement("input");
input.type = "text";
input.name = "filter-search";
input.placeholder = "Search\u2026";
input.className = "block w-full rounded-base border border-default-medium bg-neutral-secondary-medium text-sm text-heading p-2 mb-4 focus:ring-brand focus:border-brand";
// Pre-fill from existing filter JSON
var hidden = form.querySelector('[name="filter"]');
if (hidden && hidden.parentNode) {
try {
var existing = JSON.parse(hidden.value || "{}");
if (existing.search && existing.search.value) {
input.value = existing.search.value;
}
} catch (e) {}
hidden.parentNode.insertBefore(input, hidden.nextSibling);
}
}
/**
* Enable deselect-on-click behavior for filter radio buttons.
*/
function setupDeselectableRadios() {
document.querySelectorAll('input[type="radio"]').forEach(function (radio) {
radio.addEventListener('click', function (e) {
if (this.wasChecked) {
this.checked = false;
this.wasChecked = false;
this.dispatchEvent(new Event('change', { bubbles: true }));
} else {
var name = this.getAttribute('name');
if (name) {
document.querySelectorAll('input[type="radio"][name="' + name + '"]').forEach(function (r) {
r.wasChecked = false;
});
}
this.wasChecked = true;
// ── Inject search inputs into filter forms ──
function injectSearchInputs() {
document.querySelectorAll('[id^="filter-bar-form"]').forEach(function (form) {
if (form.querySelector('[name="filter-search"]')) return; // already added
var input = document.createElement("input");
input.type = "text";
input.name = "filter-search";
input.placeholder = "Search\u2026";
input.className = "block w-full rounded-base border border-default-medium bg-neutral-secondary-medium text-sm text-heading p-2 mb-4 focus:ring-brand focus:border-brand";
// Pre-fill from existing filter JSON
var hidden = form.querySelector('[name="filter"]');
if (hidden && hidden.parentNode) {
try {
var existing = JSON.parse(hidden.value || "{}");
if (existing.search && existing.search.value) {
input.value = existing.search.value;
}
} catch (e) {}
hidden.parentNode.insertBefore(input, hidden.nextSibling);
}
});
if (radio.checked) {
radio.wasChecked = true;
}
});
}
/**
* Set up event listeners for string modifier radio buttons.
*/
function setupStringFilters() {
document.querySelectorAll('input[data-string-modifier-radio]').forEach(function (radio) {
radio.addEventListener('change', function () {
window.toggleStringFilterInput(this);
});
});
}
onSwap('[id^="filter-bar-form"]', function (form) {
injectSearchInput(form);
setupDeselectableRadios();
setupStringFilters();
document.addEventListener("DOMContentLoaded", function () {
injectSearchInputs();
loadPresets();
});
})();
File diff suppressed because one or more lines are too long
+167 -201
View File
@@ -8,223 +8,189 @@
* Handles track-fill positioning and sync between handles and the connected
* number inputs (linked via data-target attributes).
*/
import { onSwap } from "./utils.js";
(function () {
"use strict";
function initializeSlider(slider) {
var mode = slider.getAttribute("data-mode") || "range";
var trackFill = slider.querySelector(".range-track-fill");
var minHandle = slider.querySelector(".range-handle-min");
var maxHandle = slider.querySelector(".range-handle-max");
if (!minHandle || !maxHandle) return;
function initAll(force) {
document.querySelectorAll(".range-slider").forEach(function (slider) {
if (force) slider._rsInit = false;
if (slider._rsInit) return;
slider._rsInit = true;
var minTarget = document.getElementById(
minHandle.getAttribute("data-target")
);
var maxTarget = document.getElementById(
maxHandle.getAttribute("data-target")
);
var dataMin = parseInt(slider.getAttribute("data-min"), 10);
var dataMax = parseInt(slider.getAttribute("data-max"), 10);
var step = parseInt(slider.getAttribute("data-step"), 10) || 1;
var mode = slider.getAttribute("data-mode") || "range";
var trackFill = slider.querySelector(".range-track-fill");
var minHandle = slider.querySelector(".range-handle-min");
var maxHandle = slider.querySelector(".range-handle-max");
if (!minHandle || !maxHandle) return;
// ── Helpers ──
var minTarget = document.getElementById(
minHandle.getAttribute("data-target")
);
var maxTarget = document.getElementById(
maxHandle.getAttribute("data-target")
);
var dataMin = parseInt(slider.getAttribute("data-min"), 10);
var dataMax = parseInt(slider.getAttribute("data-max"), 10);
var step = parseInt(slider.getAttribute("data-step"), 10) || 1;
function valueToPercent(value) {
return ((value - dataMin) / (dataMax - dataMin)) * 100;
}
function percentToValue(percent) {
var raw = dataMin + (percent / 100) * (dataMax - dataMin);
return Math.round(raw / step) * step;
}
function clamp(value, lo, hi) {
return Math.max(lo, Math.min(hi, value));
}
// ── Helpers ──
function getTargetValue(target, defaultVal) {
if (!target || target.value === "") return defaultVal;
var parsed = parseInt(target.value, 10);
return isNaN(parsed) ? defaultVal : parsed;
}
function setTargetValue(target, value) {
if (target) target.value = value;
}
// ── Track fill positioning ──
function updateTrackFill() {
if (!trackFill) return;
var minVal = clamp(getTargetValue(minTarget, dataMin), dataMin, dataMax);
var maxVal = clamp(getTargetValue(maxTarget, dataMax), dataMin, dataMax);
if (mode === "point") {
trackFill.style.left = "0%";
trackFill.style.width = valueToPercent(maxVal) + "%";
} else {
var leftPct = valueToPercent(minVal);
var rightPct = valueToPercent(maxVal);
if (leftPct > rightPct) {
var tmp = leftPct;
leftPct = rightPct;
rightPct = tmp;
}
var widthPct = rightPct - leftPct;
trackFill.style.left = leftPct + "%";
trackFill.style.width = widthPct + "%";
function valueToPercent(value) {
return ((value - dataMin) / (dataMax - dataMin)) * 100;
}
}
function updateHandles() {
var minVal = clamp(getTargetValue(minTarget, dataMin), dataMin, dataMax);
var maxVal = clamp(getTargetValue(maxTarget, dataMax), dataMin, dataMax);
minHandle.style.left = valueToPercent(minVal) + "%";
maxHandle.style.left = valueToPercent(maxVal) + "%";
updateTrackFill();
}
// ── Dragging ──
function makeDraggable(handle, isMin) {
handle.addEventListener("mousedown", function (e) {
e.preventDefault();
var rect = slider.getBoundingClientRect();
function onMove(ev) {
var pct = ((ev.clientX - rect.left) / rect.width) * 100;
var value = percentToValue(clamp(pct, 0, 100));
if (mode === "point") {
setTargetValue(minTarget, value);
setTargetValue(maxTarget, value);
if (minTarget)
minTarget.dispatchEvent(
new Event("input", { bubbles: true })
);
if (maxTarget)
maxTarget.dispatchEvent(
new Event("input", { bubbles: true })
);
} else if (isMin) {
setTargetValue(
minTarget,
clamp(value, dataMin, getTargetValue(maxTarget, dataMax))
);
if (minTarget)
minTarget.dispatchEvent(
new Event("input", { bubbles: true })
);
} else {
setTargetValue(
maxTarget,
clamp(value, getTargetValue(minTarget, dataMin), dataMax)
);
if (maxTarget)
maxTarget.dispatchEvent(
new Event("input", { bubbles: true })
);
}
updateHandles();
}
function onUp() {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
}
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
onMove(e);
});
}
makeDraggable(minHandle, true);
makeDraggable(maxHandle, false);
// ── Sync from number inputs back to handles ──
function syncFromInputs(e) {
if (mode === "point") {
var src = (e && e.target) || minTarget || maxTarget;
var val = src ? src.value : "";
setTargetValue(minTarget, val);
setTargetValue(maxTarget, val);
} else if (e && e.target) {
var minVal = getTargetValue(minTarget, dataMin);
var maxVal = getTargetValue(maxTarget, dataMax);
if (e.target === minTarget) {
if (minVal > maxVal) {
setTargetValue(maxTarget, minVal);
}
} else if (e.target === maxTarget) {
if (maxVal < minVal) {
setTargetValue(minTarget, maxVal);
}
}
function percentToValue(percent) {
var raw = dataMin + (percent / 100) * (dataMax - dataMin);
return Math.round(raw / step) * step;
}
updateHandles();
}
function enforceStrictBounds(e) {
if (e && e.target) {
var val = parseInt(e.target.value, 10);
if (!isNaN(val)) {
var clamped = clamp(val, dataMin, dataMax);
if (clamped !== val) {
setTargetValue(e.target, clamped);
e.target.dispatchEvent(new Event("input", { bubbles: true }));
}
}
function clamp(value, lo, hi) {
return Math.max(lo, Math.min(hi, value));
}
}
if (minTarget) {
minTarget.addEventListener("input", syncFromInputs);
minTarget.addEventListener("change", enforceStrictBounds);
}
if (maxTarget) {
maxTarget.addEventListener("input", syncFromInputs);
maxTarget.addEventListener("change", enforceStrictBounds);
}
function getTargetValue(target) {
return parseInt(target ? target.value : 0, 10) || dataMin;
}
function setTargetValue(target, value) {
if (target) target.value = value;
}
// ── Mode toggle ──
// ── Track fill positioning ──
var block = slider.closest(".range-slider-block");
var toggleButton =
block && block.querySelector(".range-mode-toggle");
if (toggleButton) {
toggleButton.addEventListener("click", function () {
var newMode = mode === "range" ? "point" : "range";
slider.setAttribute("data-mode", newMode);
// Swap toggle icons
var iconRange = toggleButton.querySelector(
".range-mode-icon-range"
);
var iconPoint = toggleButton.querySelector(
".range-mode-icon-point"
);
if (iconRange) iconRange.classList.toggle("hidden");
if (iconPoint) iconPoint.classList.toggle("hidden");
var dashSpan = block && block.querySelector(".range-dash");
if (newMode === "point") {
minHandle.style.display = "none";
setTargetValue(minTarget, maxTarget ? maxTarget.value : "");
if (minTarget) minTarget.classList.add("hidden");
if (dashSpan) dashSpan.classList.add("hidden");
function updateTrackFill() {
if (!trackFill) return;
var minValue = getTargetValue(minTarget);
var maxValue = getTargetValue(maxTarget);
if (mode === "point") {
trackFill.style.left = "0%";
trackFill.style.width = valueToPercent(maxValue) + "%";
} else {
minHandle.style.display = "";
if (minTarget) minTarget.classList.remove("hidden");
if (dashSpan) dashSpan.classList.remove("hidden");
var leftPct = valueToPercent(minValue);
var widthPct = valueToPercent(maxValue) - leftPct;
trackFill.style.left = leftPct + "%";
trackFill.style.width = widthPct + "%";
}
mode = newMode;
updateHandles();
});
}
}
// ── Initial position ──
updateHandles();
function updateHandles() {
minHandle.style.left = valueToPercent(getTargetValue(minTarget)) + "%";
maxHandle.style.left = valueToPercent(getTargetValue(maxTarget)) + "%";
updateTrackFill();
}
// ── Dragging ──
function makeDraggable(handle, isMin) {
handle.addEventListener("mousedown", function (e) {
e.preventDefault();
var rect = slider.getBoundingClientRect();
function onMove(ev) {
var pct = ((ev.clientX - rect.left) / rect.width) * 100;
var value = percentToValue(clamp(pct, 0, 100));
if (mode === "point") {
setTargetValue(minTarget, value);
setTargetValue(maxTarget, value);
if (minTarget)
minTarget.dispatchEvent(
new Event("input", { bubbles: true })
);
if (maxTarget)
maxTarget.dispatchEvent(
new Event("input", { bubbles: true })
);
} else if (isMin) {
setTargetValue(
minTarget,
clamp(value, dataMin, getTargetValue(maxTarget))
);
if (minTarget)
minTarget.dispatchEvent(
new Event("input", { bubbles: true })
);
} else {
setTargetValue(
maxTarget,
clamp(value, getTargetValue(minTarget), dataMax)
);
if (maxTarget)
maxTarget.dispatchEvent(
new Event("input", { bubbles: true })
);
}
updateHandles();
}
function onUp() {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
}
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
onMove(e);
});
}
makeDraggable(minHandle, true);
makeDraggable(maxHandle, false);
// ── Sync from number inputs back to handles ──
function syncFromInputs() {
if (mode === "point") {
var value =
getTargetValue(minTarget) || getTargetValue(maxTarget);
setTargetValue(minTarget, value);
setTargetValue(maxTarget, value);
}
updateHandles();
}
if (minTarget)
minTarget.addEventListener("input", syncFromInputs);
if (maxTarget)
maxTarget.addEventListener("input", syncFromInputs);
// ── Mode toggle ──
var block = slider.closest(".range-slider-block");
var toggleButton =
block && block.querySelector(".range-mode-toggle");
if (toggleButton) {
toggleButton.addEventListener("click", function () {
var newMode = mode === "range" ? "point" : "range";
slider.setAttribute("data-mode", newMode);
// Swap toggle icons
var iconRange = toggleButton.querySelector(
".range-mode-icon-range"
);
var iconPoint = toggleButton.querySelector(
".range-mode-icon-point"
);
if (iconRange) iconRange.classList.toggle("hidden");
if (iconPoint) iconPoint.classList.toggle("hidden");
var dashSpan = block && block.querySelector(".range-dash");
if (newMode === "point") {
minHandle.style.display = "none";
setTargetValue(minTarget, getTargetValue(maxTarget));
if (minTarget) minTarget.classList.add("hidden");
if (dashSpan) dashSpan.classList.add("hidden");
} else {
minHandle.style.display = "";
if (minTarget) minTarget.classList.remove("hidden");
if (dashSpan) dashSpan.classList.remove("hidden");
}
mode = newMode;
updateHandles();
});
}
// ── Initial position ──
updateHandles();
});
}
onSwap(".range-slider", initializeSlider);
document.addEventListener("DOMContentLoaded", initAll);
document.addEventListener("htmx:afterSwap", initAll);
window.initRangeSliders = initAll;
})();
+188 -532
View File
@@ -6,502 +6,174 @@
* focus clears it to search, picking an option fills it), with a lone hidden
* <input> carrying the value. Both keep hidden inputs so Django validation works.
*
* Filter mode (data-search-select-mode="filter", rendered by FilterSelect): value rows
* carry +/ buttons that add include (✓) / exclude () pills, plus pinned
* modifier pseudo-options ((Any)/(None)) that are mutually exclusive with value
* pills. Filter widgets have no hidden inputs; readSearchSelect serialises their
* state into data-included / data-excluded / data-modifier for the filter bar.
* Mirrors selectable_filter.js: initAll() on DOMContentLoaded + htmx:afterSwap,
* each widget guarded with el._ssInit.
*
* Widgets are initialized via onSwap() (utils.js), which covers the initial
* page load and every htmx-swapped fragment, once per widget.
*
* Dynamically-added rows and pills are cloned from hidden <template> elements
* the server renders with the same Python components (Pill / SearchSelect /
* FilterSelect). The JS only fills in the label slot ([data-search-select-label]), value,
* and data-* attributes so all markup and Tailwind class strings live in one
* place (the Python components), never duplicated here.
* The pill / option class strings below are kept byte-identical to the Python
* Pill / SearchSelect components so Tailwind generates the classes and
* server-rendered and JS-created pills are indistinguishable.
*/
import { onSwap } from "./utils.js";
(() => {
(function () {
"use strict";
const DEBOUNCE_MS = 100;
var PILL_CLASS =
"inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded " +
"bg-brand/15 text-heading";
var PILL_REMOVE_CLASS =
"ml-1 text-body hover:text-heading font-bold cursor-pointer";
var OPTION_ROW_CLASS =
"px-3 py-2 text-sm text-heading cursor-pointer hover:bg-brand/15";
// Must match Python common/components/filters.py:_PRESENCE_MODIFIERS.
// These modifiers are mutually exclusive with value pills — selecting
// one clears all value pills. Non-presence modifiers (INCLUDES_ALL,
// INCLUDES_ONLY) coexist with value pills.
const PRESENCE_MODIFIERS = ["NOT_NULL", "IS_NULL"];
var DEBOUNCE_MS = 500;
const initWidget = (container) => {
const search = container.querySelector("[data-search-select-search]");
const options = container.querySelector("[data-search-select-options]");
const pills = container.querySelector("[data-search-select-pills]");
function initAll() {
document.querySelectorAll("[data-search-select]").forEach(function (el) {
if (el._ssInit) return;
el._ssInit = true;
initWidget(el);
});
}
function initWidget(container) {
var search = container.querySelector("[data-ss-search]");
var options = container.querySelector("[data-ss-options]");
var pills = container.querySelector("[data-ss-pills]");
if (!search || !options || !pills) return;
const name = container.getAttribute("data-name");
const searchUrl = container.getAttribute("data-search-url");
const isFilter = container.getAttribute("data-search-select-mode") === "filter";
const freeText = container.getAttribute("data-search-select-free-text") === "true";
const multi = container.getAttribute("data-multi") === "true";
const alwaysVisible = container.getAttribute("data-always-visible") === "true";
const prefetch = parseInt(container.getAttribute("data-prefetch"), 10) || 0;
const syncUrl = container.getAttribute("data-sync-url") === "true";
var name = container.getAttribute("data-name");
var searchUrl = container.getAttribute("data-search-url");
var multi = container.getAttribute("data-multi") === "true";
var alwaysVisible = container.getAttribute("data-always-visible") === "true";
var itemsScroll = parseInt(container.getAttribute("data-items-scroll"), 10) || 10;
var syncUrl = container.getAttribute("data-sync-url") === "true";
const noResults = options.querySelector("[data-search-select-no-results]");
let debounceTimer = null;
let pendingRequest = null; // in-flight AbortController, so newer queries win
let hasPrefetched = false;
var noResults = options.querySelector("[data-ss-no-results]");
var debounceTimer = null;
const hasVisibleContent = () => {
const optionRows = options.querySelectorAll("[data-search-select-option]");
for (let i = 0; i < optionRows.length; i++) {
if (optionRows[i].style.display !== "none") return true;
}
if (noResults && !noResults.classList.contains("hidden")) return true;
if (options.querySelector("[data-search-select-modifier-option]")) return true;
return false;
};
const showPanel = () => {
if (alwaysVisible || hasVisibleContent()) {
options.classList.remove("hidden");
}
};
const hidePanel = () => {
function showPanel() {
options.classList.remove("hidden");
}
function hidePanel() {
if (!alwaysVisible) options.classList.add("hidden");
};
}
const setNoResults = (visible) => {
if (!noResults) return;
noResults.classList.toggle("hidden", !visible);
if (visible) showPanel();
};
// ── Highlight tracking (filter mode) ──
let highlightedRow = null;
const highlightOption = (row) => {
clearHighlight();
if (!row) return;
row.setAttribute("data-search-select-highlighted", "");
highlightedRow = row;
row.scrollIntoView({ block: "nearest" });
};
const clearHighlight = () => {
if (highlightedRow) {
highlightedRow.removeAttribute("data-search-select-highlighted");
highlightedRow = null;
}
};
const getVisibleOptions = () => {
const all = options.querySelectorAll("[data-search-select-option]");
return Array.from(all).filter(row => row.style.display !== "none");
};
const autoHighlight = (query) => {
const visible = getVisibleOptions();
if (visible.length === 0) {
clearHighlight();
return;
}
const lower = query.toLowerCase();
// 1. Starts-with match
for (let i = 0; i < visible.length; i++) {
const label = (visible[i].getAttribute("data-label") || "").toLowerCase();
if (lower && label.startsWith(lower)) {
highlightOption(visible[i]);
return;
}
}
// 2. Substring match (fuzzy-lite)
for (let j = 0; j < visible.length; j++) {
const subLabel = (visible[j].getAttribute("data-label") || "").toLowerCase();
if (lower && subLabel.includes(lower)) {
highlightOption(visible[j]);
return;
}
}
// 3. Fallback: first visible option
highlightOption(visible[0]);
};
// Get active values in both form and filter modes
const getSelectedValues = () => {
const vals = new Set();
pills.querySelectorAll('input[type="hidden"]').forEach(input => {
vals.add(input.value);
});
pills.querySelectorAll("[data-pill]").forEach(pill => {
const val = pill.getAttribute("data-value");
if (val) vals.add(val);
});
return vals;
};
function setNoResults(visible) {
if (noResults) noResults.classList.toggle("hidden", !visible);
}
// ── Render server-fetched rows into the panel ──
const renderRows = (items) => {
const selectedVals = getSelectedValues();
const preservedOptions = [];
// Extract existing option data for currently selected values before removing
options.querySelectorAll("[data-search-select-option]").forEach(row => {
const val = row.getAttribute("data-value");
if (selectedVals.has(val)) {
preservedOptions.push(optionFromRow(row));
}
row.remove();
function renderRows(items) {
options.querySelectorAll("[data-ss-option]").forEach(function (r) {
r.remove();
});
const renderedValues = new Set();
// Render preserved options first (to keep them at the top)
preservedOptions.forEach(opt => {
options.insertBefore(buildRow(opt), noResults || null);
renderedValues.add(String(opt.value));
items.slice(0, itemsScroll).forEach(function (item) {
options.insertBefore(buildRow(item), noResults || null);
});
// Render newly fetched items (excluding already rendered preserved ones)
// Fix DOM-limit vs fetch mismatch: Do not slice the items, render all returned items.
items.forEach(item => {
if (!renderedValues.has(String(item.value))) {
options.insertBefore(buildRow(item), noResults || null);
renderedValues.add(String(item.value));
}
});
setNoResults(items.length === 0);
showPanel();
};
}
// ── Clone a server-rendered <template> prototype by name. The server emits
// the mode-appropriate prototypes, so the JS never names a class. ──
const cloneTemplate = (name) => {
const template = container.querySelector(`template[data-search-select-template="${name}"]`);
return template
? template.content.firstElementChild.cloneNode(true)
: null;
};
const setLabel = (node, label) => {
const slot = node.querySelector("[data-search-select-label]");
if (slot) slot.textContent = label;
};
const applyData = (node, data = {}) => {
Object.keys(data).forEach(key => {
node.setAttribute(`data-${key}`, data[key]);
});
};
// Build an option row by cloning the "row" template (the same prototype the
// server renders, so fetched and pre-rendered rows are identical).
const buildRow = (option) => {
const row = cloneTemplate("row");
if (!row) return document.createComment("ss-row");
function buildRow(option) {
var row = document.createElement("div");
row.setAttribute("data-ss-option", "");
row.setAttribute("data-value", option.value);
row.setAttribute("data-label", option.label);
applyData(row, option.data);
setLabel(row, option.label);
row._searchSelectOption = option;
return row;
};
// ── Client-side filter of the currently loaded rows. Returns the number of
// visible rows so the caller decides whether to show the no-results node. ──
const filterRows = (query) => {
const lower = query.toLowerCase();
let visibleCount = 0;
options.querySelectorAll("[data-search-select-option]").forEach(item => {
const label = (item.getAttribute("data-label") || "").toLowerCase();
const match = label.includes(lower);
item.style.display = match ? "" : "none";
if (match) visibleCount += 1;
row.className = OPTION_ROW_CLASS;
var data = option.data || {};
Object.keys(data).forEach(function (key) {
row.setAttribute("data-" + key, data[key]);
});
return visibleCount;
};
row.textContent = option.label;
row._ssOption = option;
return row;
}
// ── Fetch matching rows from the server. The previous in-flight request is
// aborted so a slower earlier response can never overwrite a newer one. ──
const fetchFromServer = (query) => {
if (pendingRequest) pendingRequest.abort();
pendingRequest = new AbortController();
let url = `${searchUrl}?q=${encodeURIComponent(query)}`;
if (prefetch && !query) url += `&limit=${prefetch}`;
fetch(url, { credentials: "same-origin", signal: pendingRequest.signal })
.then(response => response.json())
.then(items => {
pendingRequest = null;
renderRows(items);
// Re-apply the live query: the box may hold more text than was sent.
setNoResults(filterRows(search.value.trim()) === 0);
autoHighlight(search.value.trim());
})
.catch(error => {
if (error?.name === "AbortError") return; // superseded
pendingRequest = null;
setNoResults(true);
});
};
// ── Client-side filter of pre-rendered rows ──
function filterRows(q) {
var lower = q.toLowerCase();
var anyVisible = false;
options.querySelectorAll("[data-ss-option]").forEach(function (item) {
var label = (item.getAttribute("data-label") || "").toLowerCase();
var match = label.indexOf(lower) !== -1;
item.style.display = match ? "" : "none";
if (match) anyVisible = true;
});
setNoResults(!anyVisible);
showPanel();
}
// In free-text mode the typed text is the value itself: there is no
// backing list, so we rebuild a single ephemeral option row reflecting the
// current query so the +/ buttons (or Enter) can commit it as a pill.
const rebuildFreeTextRow = (query) => {
options.querySelectorAll("[data-search-select-option]").forEach(row => row.remove());
if (!query) {
setNoResults(false);
clearHighlight();
return;
}
const row = buildRow({ value: query, label: query, data: {} });
options.insertBefore(row, noResults || null);
setNoResults(false);
highlightOption(row);
};
// Called on every keystroke. With a search_url, filter the loaded window
// instantly (zero latency) and debounce a server request for the rest;
// no-results stays hidden until the response decides it, to avoid a flash
// over an incomplete window. Without a search_url the loaded set is complete,
// so the client-side filter is authoritative.
const runSearch = () => {
const query = search.value.trim();
if (freeText) {
rebuildFreeTextRow(query);
showPanel();
return;
}
if (searchUrl) {
filterRows(query);
setNoResults(false);
function runSearch() {
var q = search.value.trim();
if (searchUrl && q) {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
fetchFromServer(query);
debounceTimer = setTimeout(function () {
fetch(searchUrl + "?q=" + encodeURIComponent(q), {
credentials: "same-origin",
})
.then(function (r) {
return r.json();
})
.then(renderRows)
.catch(function () {
setNoResults(true);
});
}, DEBOUNCE_MS);
} else {
setNoResults(filterRows(query) === 0);
filterRows(q);
}
autoHighlight(query);
showPanel();
};
}
// ── Single-select combobox: the search box shows the committed label;
// focusing clears it to search, blurring restores it (or deselects). ──
if (!multi) container._searchSelectLabel = search.value;
if (!multi) container._ssLabel = search.value;
search.addEventListener("focus", () => {
search.addEventListener("focus", function () {
if (!multi) {
// Hide the committed label so the box becomes a fresh search field.
search.value = "";
container._searchSelectDirty = false;
}
if (freeText) {
rebuildFreeTextRow(search.value.trim());
} else if (searchUrl) {
if (prefetch && !hasPrefetched) {
// Seed the window immediately on first open (not debounced).
hasPrefetched = true;
fetchFromServer("");
} else {
// Show whatever is already loaded; the server decides no-results.
filterRows(search.value.trim());
setNoResults(false);
autoHighlight(search.value.trim());
}
} else {
setNoResults(filterRows(search.value.trim()) === 0);
autoHighlight(search.value.trim());
}
showPanel();
});
search.addEventListener("input", () => {
clearHighlight();
if (!multi) {
if (!container._searchSelectDirty) {
const label = container._searchSelectLabel || "";
if (search.value.startsWith(label)) {
search.value = search.value.slice(label.length);
}
container._searchSelectDirty = true;
}
container._ssDirty = false;
}
runSearch();
});
search.addEventListener("input", function () {
if (!multi) container._ssDirty = true;
runSearch();
});
if (!multi) {
search.addEventListener("blur", () => {
search.addEventListener("blur", function () {
// Defer so an option click (which fires before blur settles) wins.
setTimeout(() => {
if (container._searchSelectDirty && search.value.trim() === "") {
setTimeout(function () {
if (container._ssDirty && search.value.trim() === "") {
// User intentionally cleared the box → deselect.
pills.innerHTML = "";
container._searchSelectLabel = "";
container._ssLabel = "";
emitChange(null);
} else {
// Focused-and-left, or typed a partial query without picking →
// restore the committed label (no-op right after a selection).
search.value = container._searchSelectLabel || "";
search.value = container._ssLabel || "";
}
}, 120);
});
}
// ── Keyboard navigation (both form and filter modes) ──
search.addEventListener("keydown", (event) => {
const { key } = event;
if (!multi && key === "Backspace" && !container._searchSelectDirty) {
event.preventDefault();
search.value = "";
search.dispatchEvent(new Event("input", { bubbles: true }));
return;
}
if (!["ArrowDown", "ArrowUp", "Enter", "Escape"].includes(key)) return;
const visible = getVisibleOptions();
if (visible.length === 0) {
if (key === "Escape") hidePanel();
return;
}
if (key === "ArrowDown") {
event.preventDefault();
showPanel();
const downIdx = highlightedRow ? visible.indexOf(highlightedRow) : -1;
highlightOption(visible[(downIdx + 1) % visible.length]);
} else if (key === "ArrowUp") {
event.preventDefault();
showPanel();
const upIdx = highlightedRow ? visible.indexOf(highlightedRow) : -1;
highlightOption(visible[(upIdx - 1 + visible.length) % visible.length]);
} else if (key === "Enter") {
if (highlightedRow) {
event.preventDefault();
const option = optionFromRow(highlightedRow);
if (isFilter) {
addFilterPill(option, "include");
search.value = "";
} else {
selectOption(option);
}
clearHighlight();
hidePanel();
}
} else if (key === "Escape") {
clearHighlight();
hidePanel();
}
});
// Clicking an option must not blur the input before the click selects.
options.addEventListener("mousedown", (event) => {
event.preventDefault();
options.addEventListener("mousedown", function (e) {
e.preventDefault();
});
// ── Option click → select (form mode) or include/exclude (filter mode) ──
options.addEventListener("click", (event) => {
if (isFilter) {
handleFilterOptionClick(event);
return;
}
const row = event.target.closest("[data-search-select-option]");
// ── Option click → select ──
options.addEventListener("click", function (e) {
var row = e.target.closest("[data-ss-option]");
if (!row) return;
selectOption(optionFromRow(row));
var option = optionFromRow(row);
selectOption(option);
});
const handleFilterOptionClick = (event) => {
// Pinned modifier pseudo-option → set the (mutually exclusive) modifier.
const modifierRow = event.target.closest("[data-search-select-modifier-option]");
if (modifierRow) {
setModifier(
modifierRow.getAttribute("data-search-select-modifier-option"),
modifierRow.getAttribute("data-label")
);
return;
}
// Include / exclude button on a value row.
const button = event.target.closest("[data-search-select-action]");
if (button) {
const row = button.closest("[data-search-select-option]");
if (!row) return;
addFilterPill(optionFromRow(row), button.getAttribute("data-search-select-action"));
return;
}
// Click on the option row itself → include.
const optionRow = event.target.closest("[data-search-select-option]");
if (optionRow) {
addFilterPill(optionFromRow(optionRow), "include");
}
};
// Add (or re-type) an include/exclude pill for a value. Selecting any value
// clears a presence modifier — NOT_NULL / IS_NULL are mutually exclusive
// with value pills. Non-presence modifiers (INCLUDES_ALL / INCLUDES_ONLY)
// persist alongside value pills.
const addFilterPill = (option, kind) => {
const modPill = pills.querySelector("[data-search-select-modifier]");
if (modPill) {
const modVal = modPill.getAttribute("data-search-select-modifier");
if (PRESENCE_MODIFIERS.includes(modVal)) {
clearModifier();
}
}
const existing = pills.querySelector(
`[data-pill][data-value="${cssEscape(option.value)}"]`
);
if (existing) existing.remove();
pills.appendChild(buildFilterValuePill(option, kind));
search.value = "";
emitChange(null);
};
const buildFilterValuePill = (option, kind) => {
const pill = cloneTemplate(kind === "include" ? "pill-include" : "pill-exclude");
pill.setAttribute("data-value", option.value);
pill.setAttribute("data-label", option.label);
applyData(pill, option.data);
setLabel(pill, option.label);
return pill;
};
// Set the modifier pill. Presence modifiers (NOT_NULL / IS_NULL) clear all
// value pills — they are mutually exclusive. Non-presence modifiers
// (INCLUDES_ALL / INCLUDES_ONLY) are prepended before existing value pills.
const setModifier = (modifierValue, label) => {
// Remove any existing modifier pill to avoid duplicates.
clearModifierPill();
if (PRESENCE_MODIFIERS.includes(modifierValue)) {
pills.innerHTML = "";
}
const pill = cloneTemplate("pill-modifier");
pill.setAttribute("data-search-select-modifier", modifierValue);
setLabel(pill, label);
pills.insertBefore(pill, pills.firstChild);
container.setAttribute("data-modifier", modifierValue);
hidePanel();
emitChange(null);
};
// Remove the modifier pill and its container attribute. Safe to call when
// there is no modifier pill (no-op). Does not touch value pills.
const clearModifierPill = () => {
const modifierPill = pills.querySelector("[data-search-select-modifier]");
if (modifierPill) modifierPill.remove();
container.removeAttribute("data-modifier");
};
const clearModifier = () => {
clearModifierPill();
};
const optionFromRow = (row) => {
if (row._searchSelectOption) return row._searchSelectOption;
const data = {};
Object.keys(row.dataset).forEach(key => {
function optionFromRow(row) {
if (row._ssOption) return row._ssOption;
var data = {};
Object.keys(row.dataset).forEach(function (key) {
if (key !== "value" && key !== "label" && key !== "ssOption") {
data[key] = row.dataset[key];
}
@@ -509,156 +181,140 @@ import { onSwap } from "./utils.js";
return {
value: row.getAttribute("data-value"),
label: row.getAttribute("data-label"),
data,
data: data,
};
};
}
const selectOption = (option) => {
function selectOption(option) {
if (multi) {
if (!pills.querySelector(`input[value="${cssEscape(option.value)}"]`)) {
if (!pills.querySelector('input[value="' + cssEscape(option.value) + '"]')) {
addPill(option);
}
search.value = "";
} else {
// Single-select: no pill — show the label in the search box and keep a
// lone hidden input under [data-search-select-pills] for submission.
// lone hidden input under [data-ss-pills] for submission.
pills.innerHTML = "";
pills.appendChild(buildHidden(option.value));
search.value = option.label;
container._searchSelectLabel = option.label;
container._searchSelectDirty = false;
container._ssLabel = option.label;
container._ssDirty = false;
hidePanel();
}
emitChange(option);
};
}
const addPill = (option) => {
const pill = buildPill(option);
if (pill) pills.appendChild(pill);
function addPill(option) {
pills.appendChild(buildPill(option));
pills.appendChild(buildHidden(option.value));
};
}
const buildPill = (option) => {
const pill = cloneTemplate("pill");
if (!pill) return null;
function buildPill(option) {
var pill = document.createElement("span");
pill.className = PILL_CLASS;
pill.setAttribute("data-pill", "");
pill.setAttribute("data-value", option.value);
applyData(pill, option.data);
setLabel(pill, option.label);
var data = option.data || {};
Object.keys(data).forEach(function (key) {
pill.setAttribute("data-" + key, data[key]);
});
pill.appendChild(document.createTextNode(option.label));
var remove = document.createElement("button");
remove.type = "button";
remove.setAttribute("data-pill-remove", "");
remove.className = PILL_REMOVE_CLASS;
remove.setAttribute("aria-label", "Remove");
remove.textContent = "×";
pill.appendChild(remove);
return pill;
};
}
const buildHidden = (value) => {
const input = document.createElement("input");
function buildHidden(value) {
var input = document.createElement("input");
input.type = "hidden";
input.name = name;
input.value = value;
return input;
};
}
// ── Pill × → remove ──
pills.addEventListener("click", (event) => {
const removeButton = event.target.closest("[data-pill-remove]");
if (!removeButton) return;
const pill = removeButton.closest("[data-pill]");
pills.addEventListener("click", function (e) {
var removeBtn = e.target.closest("[data-pill-remove]");
if (!removeBtn) return;
var pill = removeBtn.closest("[data-pill]");
if (!pill) return;
if (isFilter) {
// Filter pills have no hidden input.
if (pill.hasAttribute("data-search-select-modifier")) {
clearModifierPill();
} else {
pill.remove();
}
emitChange(null);
return;
}
const value = pill.getAttribute("data-value");
var value = pill.getAttribute("data-value");
pill.remove();
const hidden = pills.querySelector(`input[value="${cssEscape(value)}"]`);
var hidden = pills.querySelector('input[value="' + cssEscape(value) + '"]');
if (hidden) hidden.remove();
emitChange(null);
});
const currentValues = () => {
return Array.from(pills.querySelectorAll('input[type="hidden"]')).map(input => input.value);
};
function currentValues() {
return Array.prototype.map.call(
pills.querySelectorAll('input[type="hidden"]'),
function (input) {
return input.value;
}
);
}
const emitChange = (last) => {
const values = currentValues();
function emitChange(last) {
var values = currentValues();
if (syncUrl) syncToUrl(values);
container.dispatchEvent(
new CustomEvent("search-select:change", {
bubbles: true,
detail: { name, values, last },
detail: { name: name, values: values, last: last },
})
);
};
}
const syncToUrl = (values) => {
const params = new URLSearchParams(window.location.search);
function syncToUrl(values) {
var params = new URLSearchParams(window.location.search);
params.delete(name);
values.forEach(v => {
values.forEach(function (v) {
params.append(name, v);
});
const qs = params.toString();
history.replaceState(null, "", qs ? `?${qs}` : window.location.pathname);
};
var qs = params.toString();
history.replaceState(null, "", qs ? "?" + qs : window.location.pathname);
}
// On init, restore from URL params if the server supplied no selected pills.
if (syncUrl && !pills.querySelector("[data-pill]")) {
const initial = new URLSearchParams(window.location.search).getAll(name);
initial.forEach(v => {
var initial = new URLSearchParams(window.location.search).getAll(name);
initial.forEach(function (v) {
addPill({ value: v, label: v, data: {} });
});
}
// ── Close panel on outside click ──
document.addEventListener("click", (event) => {
if (!container.contains(event.target)) hidePanel();
document.addEventListener("click", function (e) {
if (!container.contains(e.target)) hidePanel();
});
};
}
/** Minimal escape for use inside an attribute-value selector. */
const cssEscape = (value) => String(value).replace(/["\\]/g, "\\$&");
function cssEscape(value) {
return String(value).replace(/["\\]/g, "\\$&");
}
// Serialise each widget's current state onto data-* attributes for the caller.
// Form widgets expose data-values (the submitted hidden-input values); filter
// widgets expose data-included / data-excluded / data-modifier for the filter
// bar to read.
window.readSearchSelect = (form) => {
form.querySelectorAll("[data-search-select]").forEach(container => {
const pills = container.querySelector("[data-search-select-pills]");
if (container.getAttribute("data-search-select-mode") === "filter") {
const included = [];
const excluded = [];
let modifier = "";
if (pills) {
pills.querySelectorAll("[data-pill]").forEach(pill => {
const pillModifier = pill.getAttribute("data-search-select-modifier");
if (pillModifier) {
modifier = pillModifier; // last modifier pill wins
return; // skip value extraction for this pill
// Forward-looking hook (parallels readSelectableFilters): write each widget's
// current values to a data-values JSON attribute.
window.readSearchSelect = function (form) {
form.querySelectorAll("[data-search-select]").forEach(function (container) {
var pills = container.querySelector("[data-ss-pills]");
var values = pills
? Array.prototype.map.call(
pills.querySelectorAll('input[type="hidden"]'),
function (input) {
return input.value;
}
const value = pill.getAttribute("data-value");
const label = pill.getAttribute("data-label") || "";
if (pill.getAttribute("data-search-select-type") === "exclude") {
excluded.push({ id: value, label });
} else {
included.push({ id: value, label });
}
});
}
container.setAttribute("data-included", JSON.stringify(included));
container.setAttribute("data-excluded", JSON.stringify(excluded));
if (modifier) container.setAttribute("data-modifier", modifier);
else container.removeAttribute("data-modifier");
return;
}
const values = pills
? Array.from(pills.querySelectorAll('input[type="hidden"]')).map(input => input.value)
)
: [];
container.setAttribute("data-values", JSON.stringify(values));
});
};
onSwap("[data-search-select]", initWidget);
document.addEventListener("DOMContentLoaded", initAll);
document.addEventListener("htmx:afterSwap", initAll);
})();
+149
View File
@@ -0,0 +1,149 @@
/**
* SelectableFilter widget Stash-style choice filter with search,
* include/exclude buttons, and modifier tags (Any / None).
*/
(function () {
"use strict";
function initAll() {
document.querySelectorAll("[data-selectable-filter]").forEach(function (el) {
if (el._sfInit) return;
el._sfInit = true;
initWidget(el);
});
}
function initWidget(container) {
var search = container.querySelector(".sf-search");
var options = container.querySelector(".sf-options");
var selectedArea = container.querySelector(".sf-selected");
if (!search || !options || !selectedArea) return;
// ── Search ──
search.addEventListener("input", function () {
var q = search.value.toLowerCase();
options.querySelectorAll(".sf-option").forEach(function (item) {
var label = (item.getAttribute("data-label") || "").toLowerCase();
item.style.display = label.indexOf(q) !== -1 ? "" : "none";
});
});
// ── Include / Exclude clicks ──
options.addEventListener("click", function (e) {
var btn = e.target.closest("button");
if (btn) {
var action = btn.getAttribute("data-action");
var itemEl = btn.closest(".sf-option");
if (!itemEl) return;
var value = itemEl.getAttribute("data-value");
var label = itemEl.getAttribute("data-label");
if (!value) return;
if (action === "include") addTag(container, value, label, "include");
else if (action === "exclude") addTag(container, value, label, "exclude");
return;
}
// Click on modifier option (not a button)
var modOption = e.target.closest(".sf-modifier-option");
if (modOption) {
var modVal = modOption.getAttribute("data-modifier");
setModifier(container, modVal);
}
});
// ── Remove selected tag ──
selectedArea.addEventListener("click", function (e) {
var removeBtn = e.target.closest(".sf-remove");
if (removeBtn) {
removeBtn.closest(".sf-tag").remove();
return;
}
// Click on active modifier tag → deselect it
var modTag = e.target.closest(".sf-modifier-tag");
if (modTag) {
clearModifier(container);
}
});
}
/** Add a tag to the selected area and clear modifier. */
function addTag(container, value, label, type) {
clearModifier(container);
var selectedArea = container.querySelector(".sf-selected");
// Check if already present
var existing = selectedArea.querySelector('.sf-tag[data-value="' + value + '"]');
if (existing) {
if (existing.getAttribute("data-type") !== type) {
existing.setAttribute("data-type", type);
existing.classList.toggle("sf-excluded", type === "exclude");
var text = existing.querySelector(".sf-tag-text");
if (text) text.textContent = (type === "exclude" ? "✗ " : "✓ ") + label;
}
return;
}
var tag = document.createElement("span");
tag.className = "sf-tag" + (type === "exclude" ? " sf-excluded" : "");
tag.setAttribute("data-value", value);
tag.setAttribute("data-type", type);
tag.innerHTML =
'<span class="sf-tag-text">' + (type === "exclude" ? "✗ " : "✓ ") + label + "</span>" +
'<button type="button" class="sf-remove" aria-label="Remove">×</button>';
selectedArea.appendChild(tag);
}
/** Set a modifier (Any / None) — clears all tags. */
function setModifier(container, modVal) {
var selectedArea = container.querySelector(".sf-selected");
// Clear all tags
selectedArea.querySelectorAll(".sf-tag").forEach(function (t) { t.remove(); });
// Clear existing modifier tag
selectedArea.querySelectorAll(".sf-modifier-tag").forEach(function (t) { t.remove(); });
// Add new modifier tag
var label = modVal === "NOT_NULL" ? "(Any)" : "(None)";
var tag = document.createElement("span");
tag.className = "sf-modifier-tag active";
tag.setAttribute("data-modifier", modVal);
tag.textContent = label;
selectedArea.appendChild(tag);
container.setAttribute("data-modifier", modVal);
}
/** Clear any active modifier, removing the tag. */
function clearModifier(container) {
var selectedArea = container.querySelector(".sf-selected");
selectedArea.querySelectorAll(".sf-modifier-tag").forEach(function (t) { t.remove(); });
container.removeAttribute("data-modifier");
}
// Read selections for form submission
window.readSelectableFilters = function (form) {
form.querySelectorAll("[data-selectable-filter]").forEach(function (container) {
var modifier = container.getAttribute("data-modifier");
var modTag = container.querySelector(".sf-modifier-tag.active");
if (modTag) modifier = modTag.getAttribute("data-modifier");
var included = [];
var excluded = [];
container.querySelectorAll(".sf-tag").forEach(function (tag) {
var val = tag.getAttribute("data-value");
if (tag.getAttribute("data-type") === "exclude") excluded.push(val);
else included.push(val);
});
container.setAttribute("data-included", JSON.stringify(included));
container.setAttribute("data-excluded", JSON.stringify(excluded));
if (modifier) container.setAttribute("data-modifier", modifier);
});
};
document.addEventListener("DOMContentLoaded", initAll);
document.addEventListener("htmx:afterSwap", initAll);
})();
-26
View File
@@ -1,28 +1,3 @@
/**
* @description Runs initializeElement once for each element matching selector,
* on initial page load and inside every htmx-swapped fragment (a port of
* FastHTML's proc_htmx). htmx fires htmx:load for the initial document and for
* each swapped-in element, so a single registration covers both; the WeakSet
* guarantees once-per-element initialization, replacing the old
* DOMContentLoaded + htmx:afterSwap + per-element guard-flag pattern.
* @param {string} selector
* @param {function(Element): void} initializeElement
*/
function onSwap(selector, initializeElement) {
const initialized = new WeakSet();
htmx.onLoad((swappedElement) => {
const elements = Array.from(htmx.findAll(swappedElement, selector));
if (swappedElement.matches && swappedElement.matches(selector)) {
elements.unshift(swappedElement);
}
for (const element of elements) {
if (initialized.has(element)) continue;
initialized.add(element);
initializeElement(element);
}
});
}
/**
* @description Formats Date to a UTC string accepted by the datetime-local input field.
* @param {Date} date
@@ -227,7 +202,6 @@ function disableElementsWhenTrue(targetSelect, targetValue, elementList) {
}
export {
onSwap,
toISOUTCString,
syncSelectInputUntilChanged,
getEl,
-38
View File
@@ -1,38 +0,0 @@
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();
}
});
+15 -13
View File
@@ -3,22 +3,24 @@ registration/login.html)."""
from django.contrib.auth import views as auth_views
from django.http import HttpResponse
from django.utils.safestring import SafeText, mark_safe
from common.components import CsrfInput, Div, Element, Input, Node, Safe
from common.components.primitives import Td, Tr
from common.components import Component, CsrfInput, Div, Input
from common.layout import render_page
def _login_content(form, request) -> Node:
table = Element(
"table",
def _login_content(form, request) -> SafeText:
table = Component(
tag_name="table",
children=[
CsrfInput(request),
Safe(str(form.as_table())),
Tr(
mark_safe(str(form.as_table())),
Component(
tag_name="tr",
children=[
Td(),
Td(
Component(tag_name="td"),
Component(
tag_name="td",
children=[
Input(type="submit", attributes=[("value", "Login")])
],
@@ -30,13 +32,13 @@ def _login_content(form, request) -> Node:
return Div(
[("class", "flex items-center flex-col")],
[
Element(
"h2",
Component(
tag_name="h2",
attributes=[("class", "text-3xl text-white mb-8")],
children=["Please log in to continue"],
),
Element(
"form",
Component(
tag_name="form",
attributes=[("method", "post")],
children=[table],
),
+6 -27
View File
@@ -6,37 +6,26 @@ from django.urls import reverse
from common.components import (
A,
AddForm,
Button,
ButtonGroup,
DeviceFilterBar,
Fragment,
Icon,
StyledButton,
paginated_table_content,
)
from common.layout import render_page
from common.time import dateformat, local_strftime
from common.utils import paginate
from games.filters import parse_device_filter
from games.forms import DeviceForm
from games.models import Device
@login_required
def list_devices(request: HttpRequest) -> HttpResponse:
devices = Device.objects.order_by("-created_at")
filter_json = request.GET.get("filter", "")
if filter_json:
device_filter = parse_device_filter(filter_json)
if device_filter is not None:
devices = devices.filter(device_filter.to_q())
devices, page_obj, elided_page_range = paginate(request, devices)
devices, page_obj, elided_page_range = paginate(
request, Device.objects.order_by("-created_at")
)
data = {
"header_action": A(href=reverse("games:add_device"))[
StyledButton()["Add device"]
],
"header_action": A([], Button([], "Add device"), url_name="games:add_device"),
"columns": [
"Name",
"Type",
@@ -72,17 +61,7 @@ def list_devices(request: HttpRequest) -> HttpResponse:
elided_page_range=elided_page_range,
request=request,
)
filter_bar = DeviceFilterBar(
filter_json=filter_json,
preset_list_url=reverse("games:list_presets") + "?mode=devices",
preset_save_url=reverse("games:save_preset") + "?mode=devices",
)
content = Fragment(filter_bar, content)
return render_page(
request,
content,
title="Manage devices",
)
return render_page(request, content, title="Manage devices")
@login_required
+2 -1
View File
@@ -8,6 +8,7 @@ from django.contrib.auth.decorators import login_required
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.safestring import mark_safe
from games.models import FilterPreset
@@ -39,7 +40,7 @@ def list_presets(request: HttpRequest) -> HttpResponse:
if not items:
items = ['<li class="px-4 py-2 text-sm text-body italic">No saved presets</li>']
return HttpResponse(f'<ul class="py-1">{"".join(items)}</ul>')
return HttpResponse(mark_safe(f'<ul class="py-1">{"".join(items)}</ul>'))
@login_required
+149 -121
View File
@@ -2,43 +2,39 @@ from typing import Any
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.middleware.csrf import get_token
from django.db.models import Q
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.middleware.csrf import get_token
from django.shortcuts import get_object_or_404, redirect
from django.template.defaultfilters import date as date_filter
from django.urls import reverse
from django.utils.safestring import SafeText
from django.utils.safestring import SafeText, mark_safe
from common.components import (
H1,
A,
AddForm,
Button,
ButtonGroup,
Component,
CsrfInput,
Div,
Element,
FilterBar,
Fragment,
GameStatus,
GameStatusSelector,
H1,
Icon,
SearchField,
LinkedPurchase,
Modal,
ModuleScript,
NameWithIcon,
Node,
Popover,
PopoverTruncated,
PurchasePrice,
Safe,
SearchField,
SimpleTable,
StyledButton,
Ul,
paginated_table_content,
)
from common.components.primitives import Li, P, Span, Strong
from common.icons import get_icon
from common.layout import render_page
from common.time import (
dateformat,
@@ -90,11 +86,12 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
data = {
"header_action": Div(
class_="flex justify-between",
)[
SearchField(search_string=search_string),
A(href=reverse("games:add_game"))[StyledButton()["Add game"]],
],
children=[
SearchField(search_string=search_string),
A([], Button([], "Add game"), url_name="games:add_game"),
],
attributes=[("class", "flex justify-between")],
),
"columns": [
"Name",
"Sort Name",
@@ -146,11 +143,14 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
preset_list_url=reverse("games:list_presets"),
preset_save_url=reverse("games:save_preset"),
)
content = Fragment(filter_bar, content)
content = mark_safe(str(filter_bar) + str(content))
return render_page(
request,
content,
title="Manage games",
scripts=ModuleScript("range_slider.js")
+ ModuleScript("selectable_filter.js")
+ ModuleScript("filter_bar.js"),
)
@@ -171,7 +171,7 @@ def add_game(request: HttpRequest) -> HttpResponse:
AddForm(
form,
request=request,
additional_row=StyledButton(
additional_row=Button(
[],
"Submit & Create Purchase",
color="gray",
@@ -193,16 +193,22 @@ def _delete_game_confirmation_modal(
) -> SafeText:
data_items = []
if session_count:
data_items.append(Li(children=[f"{session_count} session(s)"]))
data_items.append(
Component(tag_name="li", children=[f"{session_count} session(s)"])
)
if purchase_count:
data_items.append(Li(children=[f"{purchase_count} purchase(s)"]))
data_items.append(
Component(tag_name="li", children=[f"{purchase_count} purchase(s)"])
)
if playevent_count:
data_items.append(Li(children=[f"{playevent_count} play event(s)"]))
data_items.append(
Component(tag_name="li", children=[f"{playevent_count} play event(s)"])
)
if not (session_count or purchase_count or playevent_count):
data_items.append(Li(children=["No associated data"]))
data_items.append(Component(tag_name="li", children=["No associated data"]))
form = Element(
"form",
form = Component(
tag_name="form",
attributes=[
("hx-post", reverse("games:delete_game", args=[game.id])),
("hx-replace-url", "true"),
@@ -212,7 +218,8 @@ def _delete_game_confirmation_modal(
],
children=[
CsrfInput(request),
P(
Component(
tag_name="p",
attributes=[
(
"class",
@@ -224,7 +231,8 @@ def _delete_game_confirmation_modal(
"This will permanently delete this game and all associated data:"
],
),
Ul(
Component(
tag_name="ul",
attributes=[
(
"class",
@@ -234,7 +242,8 @@ def _delete_game_confirmation_modal(
],
children=data_items,
),
P(
Component(
tag_name="p",
attributes=[
(
"class",
@@ -247,14 +256,14 @@ def _delete_game_confirmation_modal(
Div(
[("class", "items-center mt-5")],
[
StyledButton(
Button(
[("class", "w-full")],
"Delete",
color="red",
size="lg",
type="submit",
),
StyledButton(
Button(
[("class", "mt-0 w-full")],
"Cancel",
color="gray",
@@ -270,7 +279,8 @@ def _delete_game_confirmation_modal(
return Modal(
"delete-game-confirmation-modal",
children=[
P(
Component(
tag_name="h1",
attributes=[
(
"class",
@@ -279,11 +289,12 @@ def _delete_game_confirmation_modal(
],
children=["Delete Game"],
),
P(
Component(
tag_name="p",
attributes=[("class", "dark:text-white text-center mt-5")],
children=[
"Are you sure you want to delete ",
Strong(children=[game.name]),
Component(tag_name="strong", children=[game.name]),
"?",
],
),
@@ -338,69 +349,69 @@ _STAT_SVGS = {
"playrange": '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5m-9-6h.008v.008H12v-.008ZM12 15h.008v.008H12V15Zm0 2.25h.008v.008H12v-.008ZM9.75 15h.008v.008H9.75V15Zm0 2.25h.008v.008H9.75v-.008ZM7.5 15h.008v.008H7.5V15Zm0 2.25h.008v.008H7.5v-.008Zm6.75-4.5h.008v.008h-.008v-.008Zm0 2.25h.008v.008h-.008V15Zm0 2.25h.008v.008h-.008v-.008Zm2.25-4.5h.008v.008H16.5v-.008Zm0 2.25h.008v.008H16.5V15Z" /></svg>',
}
_PLAYED_BTN = (
"px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 "
"hover:bg-gray-100 hover:text-blue-700 dark:bg-gray-800 dark:border-gray-700 "
"dark:text-white dark:hover:bg-gray-700 hover:cursor-pointer"
)
_PLAYED_MENU = (
"absolute top-full -left-px w-auto whitespace-nowrap z-10 text-sm font-medium "
"bg-gray-800/20 backdrop-blur-lg rounded-md rounded-tl-none border "
"border-gray-200 dark:border-gray-700"
)
_PLAYED_ROW_TEMPLATE = """<div class="flex gap-2 items-center" x-data="{ open: false }">
<span class="uppercase">Played</span>
<div class="inline-flex rounded-md shadow-2xs" role="group" x-data="{ played: @@PLAYED_COUNT@@ }">
<a href="@@ADD_PE@@">
<button type="button" class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
<span x-text="played"></span> times
</button>
</a>
<button type="button" x-on:click="open = !open" @click.outside="open = false" class="relative px-4 py-2 text-sm font-medium text-gray-900 bg-white border-e border-b border-t border-gray-200 rounded-e-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white align-middle hover:cursor-pointer">
@@ARROWDOWN@@
<div
class="absolute top-full -left-px w-auto whitespace-nowrap z-10 text-sm font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-tl-none border border-gray-200 dark:border-gray-700"
x-show="open"
>
<ul
class=""
>
<li class="px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-tr-md">
<a href="@@ADD_PE_FOR_GAME@@">Add playthrough...</a>
</li>
<li
x-on:click="createPlayEvent"
class="relative px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-b-md"
>
Played times +1
</li>
<script>
function createPlayEvent() {
this.played++;
// TODO: migrate to hx-post + hx-on::after-request for HTMX-native toast handling
fetchWithHtmxTriggers('@@API_CREATE@@', {
method: 'POST',
headers: { 'X-CSRFToken': '@@CSRF@@', 'Content-Type': 'application/json' },
body: '{"game_id": @@GAME_ID@@}'
})
.catch(() => {
this.played--;
console.error('Failed to record play');
});
}
</script>
</ul>
</div>
</button>
</div>
</div>"""
def _played_row(game: Game, request: HttpRequest) -> Node:
"""'Played N times' control as a custom element (ts/elements/play-event-row.ts)."""
from common.components import Element
from common.components.custom_elements import _PlayEventRow
from common.components.primitives import Button
played: int = 0
played = game.playevents.count()
count_button = A(href=reverse("games:add_playevent"))[
Button(class_=_PLAYED_BTN + " rounded-s-lg")[
Span(data_count="")[str(played)], " times"
]
]
menu = Div(data_menu="", hidden=True, class_=_PLAYED_MENU)[
Ul()[
Li(class_="px-4 py-2")[
A(href=reverse("games:add_playevent_for_game", args=[game.id]))[
"Add playthrough..."
]
],
Li(class_="px-4 py-2 cursor-pointer")[
Element(
"button",
[("type", "button"), ("data-add-play", "")],
children=["Played times +1"],
)
],
]
]
toggle = Element(
"button",
[
("type", "button"),
("data-toggle", ""),
("class", _PLAYED_BTN + " rounded-e-lg"),
],
[Icon("arrowdown")],
)
# Menu is a SIBLING of the toggle (not nested inside it): a <button> may not
# contain another <button>, and that invalid nesting makes the HTML parser
# close ancestors early, ejecting later page sections from their container.
toggle_group = Div(class_="relative inline-flex")[toggle, menu]
group = Div(class_="inline-flex items-stretch rounded-md shadow-2xs")[
count_button, toggle_group
]
return _PlayEventRow(
game_id=game.id,
csrf=get_token(request),
api_create_url=reverse("api-1.0.0:create_playevent"),
)[Div(class_="flex gap-2 items-center")[Span(class_="uppercase")["Played"], group]]
def _played_row(game: Game, request: HttpRequest) -> SafeText:
"""The 'Played N times' control with its Alpine.js dropdown."""
replacements = {
"@@PLAYED_COUNT@@": str(game.playevents.count()),
"@@ADD_PE@@": reverse("games:add_playevent"),
"@@ARROWDOWN@@": get_icon("arrowdown"),
"@@ADD_PE_FOR_GAME@@": reverse("games:add_playevent_for_game", args=[game.id]),
"@@API_CREATE@@": reverse("api-1.0.0:create_playevent"),
"@@CSRF@@": get_token(request),
"@@GAME_ID@@": str(game.id),
}
html = _PLAYED_ROW_TEMPLATE
for token, value in replacements.items():
html = html.replace(token, value)
return mark_safe(html)
def _stat_popover(popover_id: str, tooltip: str, svg_key: str, value: str) -> SafeText:
@@ -408,13 +419,17 @@ def _stat_popover(popover_id: str, tooltip: str, svg_key: str, value: str) -> Sa
popover_content=tooltip,
wrapped_classes="flex gap-2 items-center",
id=popover_id,
children=[Safe(_STAT_SVGS[svg_key]), str(value)],
children=[mark_safe(_STAT_SVGS[svg_key]), str(value)],
)
def _meta_row(label: str, value: Node | str, extra: Node | str = "") -> Node:
children: list[Node | str] = [
Span(attributes=[("class", "uppercase")], children=[label]),
def _meta_row(
label: str, value: SafeText | str, extra: SafeText | str = ""
) -> SafeText:
children: list[SafeText | str] = [
Component(
tag_name="span", attributes=[("class", "uppercase")], children=[label]
),
value,
]
if extra:
@@ -437,25 +452,27 @@ def _game_action_buttons(game: Game) -> SafeText:
"dark:text-white dark:hover:text-white dark:hover:bg-red-700 "
"dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer"
)
edit_link = A(
href=reverse("games:edit_game", args=[game.id]),
edit_link = Component(
tag_name="a",
attributes=[("href", reverse("games:edit_game", args=[game.id]))],
children=[
Element(
"button",
Component(
tag_name="button",
attributes=[("type", "button"), ("class", edit_class)],
children=["Edit"],
)
],
)
delete_link = A(
href="#",
delete_link = Component(
tag_name="a",
attributes=[
("href", "#"),
("hx-get", reverse("games:delete_game_confirmation", args=[game.id])),
("hx-target", "#global-modal-container"),
],
children=[
Element(
"button",
Component(
tag_name="button",
attributes=[("type", "button"), ("class", delete_class)],
children=["Delete"],
)
@@ -482,16 +499,21 @@ def _game_history(statuschanges) -> SafeText:
status=change.new_status,
children=[change.get_new_status_display()],
)
edit = A(
href=reverse("games:edit_statuschange", args=[change.id]),
edit = Component(
tag_name="a",
attributes=[("href", reverse("games:edit_statuschange", args=[change.id]))],
children=["Edit"],
)
delete = A(
href=reverse("games:delete_statuschange", args=[change.id]),
delete = Component(
tag_name="a",
attributes=[
("href", reverse("games:delete_statuschange", args=[change.id]))
],
children=["Delete"],
)
items.append(
Li(
Component(
tag_name="li",
attributes=[("class", "text-slate-500")],
children=[
f"{prefix} status from ",
@@ -506,7 +528,8 @@ def _game_history(statuschanges) -> SafeText:
],
)
)
return Ul(
return Component(
tag_name="ul",
attributes=[("class", "list-disc list-inside")],
children=items,
)
@@ -553,17 +576,19 @@ def _game_overview_metrics(game: Game) -> dict[str, Any]:
def _game_header(game: Game, request: HttpRequest, metrics: dict[str, Any]) -> SafeText:
grey_value_class = "text-black dark:text-slate-300"
title_span = Span(
title_span = Component(
tag_name="span",
attributes=[("class", "text-balance max-w-120 text-4xl")],
children=[
Span(
Component(
tag_name="span",
attributes=[("class", "font-bold font-serif")],
children=[game.name],
),
]
+ (
[
Safe("&nbsp;"),
mark_safe("&nbsp;"),
Popover(
popover_content="Original release year",
wrapped_classes="text-slate-500 text-2xl",
@@ -609,7 +634,8 @@ def _game_header(game: Game, request: HttpRequest, metrics: dict[str, Any]) -> S
[
_meta_row(
"Original year",
Span(
Component(
tag_name="span",
attributes=[("class", grey_value_class)],
children=[str(game.original_year_released)],
),
@@ -622,7 +648,8 @@ def _game_header(game: Game, request: HttpRequest, metrics: dict[str, Any]) -> S
_played_row(game, request),
_meta_row(
"Platform",
Span(
Component(
tag_name="span",
attributes=[("class", grey_value_class)],
children=[str(game.platform)],
),
@@ -684,9 +711,10 @@ def _sessions_section(game: Game, request: HttpRequest) -> SafeText:
header_action = Div(
children=[
A(href=reverse("games:add_session"))[
StyledButton(icon=True, color="blue", size="xs")[Icon("plus")]
],
A(
url_name="games:add_session",
children=Button(icon=True, size="xs", children=[Icon("play"), "LOG"]),
),
A(
href=reverse(
"games:list_sessions_start_session_from_session",
@@ -695,7 +723,7 @@ def _sessions_section(game: Game, request: HttpRequest) -> SafeText:
children=Popover(
popover_content=last_session.game.name,
children=[
StyledButton(
Button(
icon=True,
color="gray",
size="xs",
@@ -781,7 +809,7 @@ def _history_section(game: Game) -> SafeText:
)
_GET_SESSION_COUNT_SCRIPT = Safe(
_GET_SESSION_COUNT_SCRIPT = mark_safe(
"<script>\n"
" function getSessionCount() {\n"
" return document.getElementById('session-count')"
+11 -4
View File
@@ -13,14 +13,17 @@ from django.urls import reverse
from django.utils.timezone import localtime
from django.utils.timezone import now as timezone_now
from common.components import ExternalScript
from common.layout import render_page
from common.time import format_duration
from games.models import Game, Platform, Purchase, Session
from games.views.stats_content import stats_content
from games.views.stats_data import compute_stats
# The Flowbite-datepicker UMD bundle is declared as media on the YearPicker
# component, so Page() loads it automatically on the stats pages.
# Flowbite-datepicker UMD bundle, hoisted into the stats pages for YearPicker.
_STATS_SCRIPTS = ExternalScript(
"https://cdn.jsdelivr.net/npm/flowbite-datepicker@2.0.0/dist/Datepicker.umd.min.js"
)
def model_counts(request: HttpRequest) -> dict[str, bool]:
@@ -74,7 +77,9 @@ def use_custom_redirect(
def stats_alltime(request: HttpRequest) -> HttpResponse:
request.session["return_path"] = request.path
data = compute_stats(None)
return render_page(request, stats_content(data), title=data["title"])
return render_page(
request, stats_content(data), title=data["title"], scripts=_STATS_SCRIPTS
)
@login_required
@@ -88,7 +93,9 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
return HttpResponseRedirect(reverse("games:stats_alltime"))
request.session["return_path"] = request.path
data = compute_stats(year)
return render_page(request, stats_content(data), title=data["title"])
return render_page(
request, stats_content(data), title=data["title"], scripts=_STATS_SCRIPTS
)
@login_required
+8 -27
View File
@@ -6,17 +6,14 @@ from django.urls import reverse
from common.components import (
A,
AddForm,
Button,
ButtonGroup,
Fragment,
Icon,
PlatformFilterBar,
StyledButton,
paginated_table_content,
)
from common.layout import render_page
from common.time import dateformat, local_strftime
from common.utils import paginate
from games.filters import parse_platform_filter
from games.forms import PlatformForm
from games.models import Platform
from games.views.general import use_custom_redirect
@@ -24,20 +21,14 @@ from games.views.general import use_custom_redirect
@login_required
def list_platforms(request: HttpRequest) -> HttpResponse:
platforms = Platform.objects.order_by("name")
filter_json = request.GET.get("filter", "")
if filter_json:
platform_filter = parse_platform_filter(filter_json)
if platform_filter is not None:
platforms = platforms.filter(platform_filter.to_q())
platforms, page_obj, elided_page_range = paginate(request, platforms)
platforms, page_obj, elided_page_range = paginate(
request, Platform.objects.order_by("name")
)
data = {
"header_action": A(href=reverse("games:add_platform"))[
StyledButton()["Add platform"]
],
"header_action": A(
[], Button([], "Add platform"), url_name="games:add_platform"
),
"columns": [
"Name",
"Icon",
@@ -77,17 +68,7 @@ def list_platforms(request: HttpRequest) -> HttpResponse:
elided_page_range=elided_page_range,
request=request,
)
filter_bar = PlatformFilterBar(
filter_json=filter_json,
preset_list_url=reverse("games:list_presets") + "?mode=platforms",
preset_save_url=reverse("games:save_preset") + "?mode=platforms",
)
content = Fragment(filter_bar, content)
return render_page(
request,
content,
title="Manage platforms",
)
return render_page(request, content, title="Manage platforms")
@login_required
+8 -27
View File
@@ -12,18 +12,15 @@ from django.urls import reverse
from common.components import (
A,
AddForm,
Button,
ButtonGroup,
Fragment,
Icon,
ModuleScript,
PlayEventFilterBar,
StyledButton,
paginated_table_content,
)
from common.layout import render_page
from common.time import dateformat, format_duration, local_strftime
from common.utils import paginate
from games.filters import parse_playevent_filter
from games.forms import PlayEventForm
from games.models import Game, PlayEvent, Session
@@ -86,9 +83,9 @@ def create_playevent_tabledata(
for row in row_list
]
return {
"header_action": A(href=reverse("games:add_playevent"))[
StyledButton()["Add play event"]
],
"header_action": A(
[], Button([], "Add play event"), url_name="games:add_playevent"
),
"columns": list(filtered_column_list),
"rows": filtered_row_list,
}
@@ -129,15 +126,9 @@ def _get_formatted_playtime_for_game_sessions_in_range(
@login_required
def list_playevents(request: HttpRequest) -> HttpResponse:
playevents = PlayEvent.objects.order_by("-created_at")
filter_json = request.GET.get("filter", "")
if filter_json:
playevent_filter = parse_playevent_filter(filter_json)
if playevent_filter is not None:
playevents = playevents.filter(playevent_filter.to_q())
playevents, page_obj, elided_page_range = paginate(request, playevents)
playevents, page_obj, elided_page_range = paginate(
request, PlayEvent.objects.order_by("-created_at")
)
data = create_playevent_tabledata(playevents, request=request)
content = paginated_table_content(
data,
@@ -145,17 +136,7 @@ def list_playevents(request: HttpRequest) -> HttpResponse:
elided_page_range=elided_page_range,
request=request,
)
filter_bar = PlayEventFilterBar(
filter_json=filter_json,
preset_list_url=reverse("games:list_presets") + "?mode=playevents",
preset_save_url=reverse("games:save_preset") + "?mode=playevents",
)
content = Fragment(filter_bar, content)
return render_page(
request,
content,
title="Manage play events",
)
return render_page(request, content, title="Manage play events")
@login_required
+38 -33
View File
@@ -6,34 +6,32 @@ from django.http import (
HttpResponseRedirect,
)
from django.shortcuts import get_object_or_404, redirect
from django.template.defaultfilters import date as date_filter
from django.template.defaultfilters import floatformat
from django.urls import reverse
from django.utils import timezone
from django.utils.safestring import SafeText, mark_safe
from django.views.decorators.http import require_POST
from django.template.defaultfilters import date as date_filter
from django.template.defaultfilters import floatformat
from django.utils.safestring import SafeText, mark_safe
from common.components import (
A,
AddForm,
Button,
ButtonGroup,
Component,
CsrfInput,
Div,
Element,
Fragment,
GameLink,
Icon,
LinkedPurchase,
Modal,
ModuleScript,
Node,
PriceConverted,
PurchasePrice,
StyledButton,
TableRow,
paginated_table_content,
)
from common.components.primitives import Li, P, Td, Tr, Ul
from common.layout import render_page
from common.time import dateformat
from common.utils import paginate
@@ -110,9 +108,9 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
purchases, page_obj, elided_page_range = paginate(request, purchases)
data = {
"header_action": A(href=reverse("games:add_purchase"))[
StyledButton()["Add purchase"]
],
"header_action": A(
[], Button([], "Add purchase"), url_name="games:add_purchase"
),
"columns": [
"Name",
"Type",
@@ -131,29 +129,34 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
elided_page_range=elided_page_range,
request=request,
)
from common.components import PurchaseFilterBar
from common.components import PurchaseFilterBar, ModuleScript
filter_bar = PurchaseFilterBar(
filter_json=filter_json,
preset_list_url=reverse("games:list_presets"),
preset_save_url=reverse("games:save_preset"),
)
content = Fragment(filter_bar, content)
content = mark_safe(str(filter_bar) + str(content))
return render_page(
request,
content,
title="Manage purchases",
scripts=ModuleScript("range_slider.js")
+ ModuleScript("selectable_filter.js")
+ ModuleScript("filter_bar.js"),
)
def _purchase_additional_row() -> SafeText:
"""The 'Submit & Create Session' row shown below the main Submit button."""
return Tr(
return Component(
tag_name="tr",
children=[
Td(),
Td(
Component(tag_name="td"),
Component(
tag_name="td",
children=[
StyledButton(
Button(
[],
"Submit & Create Session",
color="gray",
@@ -259,7 +262,8 @@ def _view_purchase_content(purchase: Purchase) -> SafeText:
Div(
[("class", row_class)],
[
P(
Component(
tag_name="p",
children=[
"Price per game: ",
PriceConverted([floatformat(purchase.price_per_game, 0)]),
@@ -269,9 +273,10 @@ def _view_purchase_content(purchase: Purchase) -> SafeText:
],
),
Div([("class", row_class)], ["Games included in this purchase:"]),
Ul(
Component(
tag_name="ul",
children=[
Li(children=[GameLink(game.id, game.name)])
Component(tag_name="li", children=[GameLink(game.id, game.name)])
for game in purchase.games.all()
],
),
@@ -302,9 +307,9 @@ def drop_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
return redirect("games:list_purchases")
def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> Node:
form = Element(
"form",
def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> SafeText:
form = Component(
tag_name="form",
attributes=[
("hx-post", reverse("games:refund_purchase", args=[purchase_id])),
("hx-target", f"#purchase-row-{purchase_id}"),
@@ -312,21 +317,22 @@ def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> Node:
],
children=[
CsrfInput(request),
P(
Component(
tag_name="p",
attributes=[("class", "dark:text-white text-center mt-3 text-sm")],
children=["Games will be marked as abandoned."],
),
Div(
[("class", "items-center mt-5")],
[
StyledButton(
Button(
[("class", "w-full")],
"Refund",
color="blue",
size="lg",
type="submit",
),
StyledButton(
Button(
[("class", "mt-0 w-full")],
"Cancel",
color="gray",
@@ -340,8 +346,8 @@ def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> Node:
return Modal(
"refund-confirmation-modal",
children=[
Element(
"h1",
Component(
tag_name="h1",
attributes=[
(
"class",
@@ -350,7 +356,8 @@ def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> Node:
],
children=["Confirm Refund"],
),
P(
Component(
tag_name="p",
attributes=[("class", "dark:text-white text-center mt-5")],
children=["Are you sure you want to mark this purchase as refunded?"],
),
@@ -401,10 +408,8 @@ def related_purchase_by_game(request: HttpRequest) -> HttpResponse:
from games.forms import related_purchase_queryset
form = PurchaseForm()
qs = (
related_purchase_queryset()
.filter(games__in=games)
.order_by("games__sort_name")
qs = related_purchase_queryset().filter(games__in=games).order_by(
"games__sort_name"
)
form.fields["related_purchase"].queryset = qs
+68 -44
View File
@@ -13,22 +13,18 @@ from django.utils.safestring import SafeText, mark_safe
from common.components import (
A,
AddForm,
Button,
ButtonGroup,
Component,
Div,
Fragment,
Icon,
ModuleScript,
NameWithIcon,
Node,
Popover,
Safe,
SearchField,
SessionDeviceSelector,
SessionTimestampButtons,
StyledButton,
paginated_table_content,
)
from common.components.primitives import Span, Td, Tr
from common.layout import render_page
from common.time import (
dateformat,
@@ -77,13 +73,13 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
Div(
children=[
A(
href=reverse("games:add_session"),
)[
StyledButton(
url_name="games:add_session",
children=Button(
icon=True,
size="xs",
)[Icon("play"), "LOG"]
],
children=[Icon("play"), "LOG"],
),
),
A(
href=reverse(
"games:list_sessions_start_session_from_session",
@@ -92,7 +88,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
children=Popover(
popover_content=last_session.game.name,
children=[
StyledButton(
Button(
icon=True,
color="gray",
size="xs",
@@ -180,11 +176,14 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
preset_list_url=reverse("games:list_presets"),
preset_save_url=reverse("games:save_preset"),
)
content = Fragment(filter_bar, content)
content = mark_safe(str(filter_bar) + str(content))
return render_page(
request,
content,
title="Manage sessions",
scripts=ModuleScript("range_slider.js")
+ ModuleScript("selectable_filter.js")
+ ModuleScript("filter_bar.js"),
)
@@ -193,39 +192,52 @@ def search_sessions(request: HttpRequest) -> HttpResponse:
return list_sessions(request, search_string=request.GET.get("search_string", ""))
def _session_fields(form) -> Fragment:
def _session_fields(form) -> SafeText:
"""Manual per-field layout for the session form.
Mirrors the old add_session.html: each field gets its label and widget,
and the timestamp fields gain a row of now/toggle/copy helper buttons.
"""
rows: list[Node] = []
rows: list[SafeText] = []
for field in form:
children: list[Node | str] = [
Safe(str(field.label_tag())),
Safe(str(field)),
children: list[SafeText | str] = [
mark_safe(str(field.label_tag())),
mark_safe(str(field)),
]
if field.name in ("timestamp_start", "timestamp_end"):
this_side = "start" if field.name == "timestamp_start" else "end"
other_side = "end" if field.name == "timestamp_start" else "start"
children.append(
SessionTimestampButtons(
class_="form-row-button-group flex-row gap-3 justify-start mt-3",
hx_boost="false",
)[
StyledButton(data_target=field.name, data_type="now", size="xs")[
"Set to now"
Component(
tag_name="span",
attributes=[
(
"class",
"form-row-button-group flex-row gap-3 justify-start mt-3",
),
("hx-boost", "false"),
],
StyledButton(data_target=field.name, data_type="toggle", size="xs")[
"Toggle text"
children=[
Button(
[("data-target", field.name), ("data-type", "now")],
"Set to now",
size="xs",
),
Button(
[("data-target", field.name), ("data-type", "toggle")],
"Toggle text",
size="xs",
),
Button(
[("data-target", field.name), ("data-type", "copy")],
f"Copy {this_side} value to {other_side}",
size="xs",
),
],
StyledButton(data_target=field.name, data_type="copy", size="xs")[
f"Copy {this_side} value to {other_side}"
],
]
)
)
rows.append(Div(children=children))
return Fragment(*rows, separator="\n")
return mark_safe("\n".join(rows))
@login_required
@@ -254,7 +266,9 @@ def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
request,
AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
title="Add New Session",
scripts=mark_safe(ModuleScript("search_select.js")),
scripts=mark_safe(
ModuleScript("search_select.js") + ModuleScript("add_session.js")
),
)
@@ -269,15 +283,17 @@ def edit_session(request: HttpRequest, session_id: int) -> HttpResponse:
request,
AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
title="Edit Session",
scripts=mark_safe(ModuleScript("search_select.js")),
scripts=mark_safe(
ModuleScript("search_select.js") + ModuleScript("add_session.js")
),
)
def _session_row_fragment(session: Session) -> SafeText:
"""A single session <tr> (the old list_sessions.html#session-row partial),
returned by the inline end/clone-session HTMX endpoints."""
name_link = A(
href=reverse("games:view_game", args=[session.game.id]),
name_link = Component(
tag_name="a",
attributes=[
(
"class",
@@ -289,10 +305,12 @@ def _session_row_fragment(session: Session) -> SafeText:
"group-hover:outline-purple-400 group-hover:outline-4 "
"group-hover:decoration-purple-900 group-hover:text-purple-100",
),
("href", reverse("games:view_game", args=[session.game.id])),
],
children=[session.game.name],
)
name_td = Td(
name_td = Component(
tag_name="td",
attributes=[
(
"class",
@@ -301,13 +319,15 @@ def _session_row_fragment(session: Session) -> SafeText:
)
],
children=[
Span(
Component(
tag_name="span",
attributes=[("class", "inline-block relative")],
children=[name_link],
)
],
)
start_td = Td(
start_td = Component(
tag_name="td",
attributes=[
("class", "px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden sm:table-cell")
],
@@ -316,9 +336,10 @@ def _session_row_fragment(session: Session) -> SafeText:
if not session.timestamp_end:
end_url = reverse("games:list_sessions_end_session", args=[session.id])
end_inner: SafeText | str = A(
href=end_url,
end_inner: SafeText | str = Component(
tag_name="a",
attributes=[
("href", end_url),
("hx-get", end_url),
("hx-target", "closest tr"),
("hx-swap", "outerHTML"),
@@ -330,7 +351,8 @@ def _session_row_fragment(session: Session) -> SafeText:
),
],
children=[
Span(
Component(
tag_name="span",
attributes=[("class", "text-yellow-300")],
children=["Finish now?"],
)
@@ -340,17 +362,19 @@ def _session_row_fragment(session: Session) -> SafeText:
end_inner = "--"
else:
end_inner = date_filter(session.timestamp_end, "d/m/Y H:i")
end_td = Td(
end_td = Component(
tag_name="td",
attributes=[
("class", "px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell")
],
children=[end_inner],
)
duration_td = Td(
duration_td = Component(
tag_name="td",
attributes=[("class", "px-2 sm:px-4 md:px-6 md:py-2 font-mono")],
children=[session.duration_formatted()],
)
return Tr(children=[name_td, start_td, end_td, duration_td])
return Component(tag_name="tr", children=[name_td, start_td, end_td, duration_td])
def clone_session_by_id(session_id: int) -> Session:
+36 -44
View File
@@ -9,19 +9,9 @@ from django.template.defaultfilters import date as date_filter
from django.template.defaultfilters import floatformat
from django.urls import reverse
from django.utils.html import conditional_escape
from django.utils.safestring import SafeText, mark_safe
from common.components import (
A,
Div,
Element,
GameLink,
Node,
Safe,
Td,
Th,
Tr,
YearPicker,
)
from common.components import A, Component, Div, GameLink, YearPicker
from common.time import durationformat, format_duration
_CELL = "px-2 sm:px-4 md:px-6 md:py-2"
@@ -29,40 +19,41 @@ _CELL_MONO = f"{_CELL} font-mono"
_NAME_TH = f"{_CELL} purchase-name truncate max-w-20char"
def _td(children, cls: str = _CELL_MONO) -> Node:
def _td(children, cls: str = _CELL_MONO) -> SafeText:
if not isinstance(children, list):
children = [children]
return Td(attributes=[("class", cls)], children=children)
children = [c if isinstance(c, (str, SafeText)) else str(c) for c in children]
return Component(tag_name="td", attributes=[("class", cls)], children=children)
def _th(text: str, cls: str = _CELL) -> Node:
return Th(attributes=[("class", cls)], children=[text])
def _th(text: str, cls: str = _CELL) -> SafeText:
return Component(tag_name="th", attributes=[("class", cls)], children=[text])
def _tr(cells: list) -> Node:
return Tr(children=cells)
def _tr(cells: list) -> SafeText:
return Component(tag_name="tr", children=cells)
def _kv(label, value) -> Node:
def _kv(label, value) -> SafeText:
"""A label/value row: plain label cell + mono value cell."""
return _tr([_td(label, _CELL), _td(value)])
def _h1(title: str) -> Node:
return Element(
"h1",
def _h1(title: str) -> SafeText:
return Component(
tag_name="h1",
attributes=[("class", "text-3xl text-heading text-center my-6")],
children=[title],
)
def _table(rows: list, thead: Node | None = None) -> Node:
def _table(rows: list, thead: SafeText | None = None) -> SafeText:
children = []
if thead is not None:
children.append(thead)
children.append(Element("tbody", children=rows))
return Element(
"table",
children.append(Component(tag_name="tbody", children=rows))
return Component(
tag_name="table",
attributes=[("class", "responsive-table")],
children=children,
)
@@ -72,7 +63,7 @@ def _dur(value) -> str:
return format_duration(value, durationformat)
def _purchase_name(purchase) -> Node:
def _purchase_name(purchase) -> SafeText:
"""Mirror of the `purchase-name` partial in the old template."""
game_name = getattr(purchase, "game_name", None)
first_game = purchase.first_game
@@ -80,12 +71,12 @@ def _purchase_name(purchase) -> Node:
name = game_name or purchase.name
link = GameLink(first_game.id, name)
suffix = f" ({first_game.name} {purchase.get_type_display()})"
return Safe(str(link) + conditional_escape(suffix))
return mark_safe(str(link) + conditional_escape(suffix))
name = game_name or first_game.name
return GameLink(first_game.id, name)
def _year_nav(year, year_range, url_template) -> Node:
def _year_nav(year, year_range, url_template) -> SafeText:
# `year` is an int for a specific year, or "Alltime" (from compute_stats)
# for the all-time view. Normalize to int-or-None so nothing downstream has
# to know about the "Alltime" sentinel.
@@ -101,9 +92,10 @@ def _year_nav(year, year_range, url_template) -> Node:
else "text-body hover:text-heading underline decoration-dotted"
)
alltime_btn = A(
href=reverse("games:stats_alltime"),
class_=alltime_classes,
)["All-time stats"]
url_name="games:stats_alltime",
attributes=[("class", alltime_classes)],
children=["All-time stats"],
)
picker = YearPicker(
year=year_int,
available_years=tuple(year_range or []),
@@ -115,7 +107,7 @@ def _year_nav(year, year_range, url_template) -> Node:
)
def _playtime_table(ctx) -> Node:
def _playtime_table(ctx) -> SafeText:
year = ctx.get("year")
rows = [
_kv("Hours", ctx.get("total_hours")),
@@ -194,7 +186,7 @@ def _playtime_table(ctx) -> Node:
return _table(rows)
def _purchases_table(ctx) -> Node:
def _purchases_table(ctx) -> SafeText:
rows = [
_kv("Total", ctx.get("all_purchased_this_year_count")),
_kv(
@@ -221,18 +213,18 @@ def _purchases_table(ctx) -> Node:
return _table(rows)
def _two_col_table(header: str, items, name_key, value_fn) -> Node:
thead = Element(
"thead",
def _two_col_table(header: str, items, name_key, value_fn) -> SafeText:
thead = Component(
tag_name="thead",
children=[_tr([_th(header), _th("Playtime")])],
)
rows = [_tr([_td(name_key(item)), _td(value_fn(item))]) for item in items]
return _table(rows, thead)
def _finished_table(purchases) -> Node:
thead = Element(
"thead",
def _finished_table(purchases) -> SafeText:
thead = Component(
tag_name="thead",
children=[_tr([_th("Name", _NAME_TH), _th("Date")])],
)
rows = [
@@ -242,9 +234,9 @@ def _finished_table(purchases) -> Node:
return _table(rows, thead)
def _priced_table(purchases, currency) -> Node:
thead = Element(
"thead",
def _priced_table(purchases, currency) -> SafeText:
thead = Component(
tag_name="thead",
children=[
_tr([_th("Name", _NAME_TH), _th(f"Price ({currency})"), _th("Date")])
],
@@ -262,7 +254,7 @@ def _priced_table(purchases, currency) -> Node:
return _table(rows, thead)
def stats_content(ctx: dict) -> Node:
def stats_content(ctx: dict) -> SafeText:
year = ctx.get("year")
currency = ctx.get("total_spent_currency")
# Build a navigation URL with an `__year__` placeholder the picker's JS
+8 -8
View File
@@ -7,13 +7,12 @@ from django.utils.safestring import SafeText
from common.components import (
A,
AddForm,
Button,
Component,
CsrfInput,
Div,
Element,
StyledButton,
paginated_table_content,
)
from common.components.primitives import P
from common.layout import render_page
from common.time import dateformat, local_strftime
from common.utils import paginate
@@ -76,21 +75,22 @@ def _delete_statuschange_content(statuschange, request: HttpRequest) -> SafeText
inner = Div(
[],
[
P(
Component(
tag_name="p",
children=["Are you sure you want to delete this status change?"],
),
StyledButton(
Button(
[("class", "w-full")], "Delete", color="red", type="submit", size="lg"
),
A(
[("class", "")],
StyledButton([("class", "w-full")], "Cancel", color="gray"),
Button([("class", "w-full")], "Cancel", color="gray"),
href=reverse("games:view_game", args=[statuschange.game.id]),
),
],
)
form = Element(
"form",
form = Component(
tag_name="form",
attributes=[("method", "post"), ("class", "dark:text-white")],
children=[CsrfInput(request), inner],
)
+1 -3
View File
@@ -1,12 +1,10 @@
{
"packageManager": "pnpm@10.33.0",
"devDependencies": {
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.13",
"concurrently": "^8.2.2",
"npm-check-updates": "^16.14.20",
"tailwindcss": "^4.1.18",
"typescript": "^5.6.0"
"tailwindcss": "^4.1.18"
},
"dependencies": {
"@tailwindcss/cli": "^4.1.18",
-3358
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,2 +1,2 @@
overrides:
tar: ^7.5.11
allowBuilds:
'@parcel/watcher': false
-1
View File
@@ -43,7 +43,6 @@ dev = [
"django-debug-toolbar>=4.4.2,<5",
"ruff",
"pytest-django>=4.12.0",
"pytest-playwright>=0.8.0",
]
[tool.uv]
-15
View File
@@ -1,15 +0,0 @@
# Alternative to a .env file for non-Docker / bare-metal deployments.
# Copy to settings.ini (next to manage.py) or point INI_FILE at it.
# Real environment variables and a .env file both take precedence over this.
# See docs/configuration.md for the full reference.
[timetracker]
DEBUG = false
SECRET_KEY = change-me-to-a-long-random-string
APP_URL = https://tracker.kucharczyk.xyz
TZ = Europe/Prague
DATA_DIR = /var/lib/timetracker
# Optional explicit overrides (comma-separated); win over APP_URL when set.
# ALLOWED_HOSTS = *
# CSRF_TRUSTED_ORIGINS = https://tracker.kucharczyk.xyz
-7
View File
@@ -8,15 +8,8 @@ pkgs.mkShell {
python3
uv
ruff
pnpm
];
# manylinux wheels with native extensions (greenlet, pulled in by
# pytest-playwright) link against libstdc++.so.6, which the nixpkgs
# Python cannot find on its default search path. Scoped to this dev
# shell only — a global LD_LIBRARY_PATH would leak into other programs.
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc.lib ];
shellHook = ''
uv venv --clear
. .venv/bin/activate
+148 -260
View File
@@ -2,29 +2,21 @@ import unittest
from unittest.mock import MagicMock, patch
import django
from django.test import SimpleTestCase
from django.utils.safestring import SafeText, mark_safe
from common import components
from games.models import Game, Platform, Purchase, Session
# Component builders return lazy ``Node`` objects; these tests assert on rendered
# HTML, so node-returning calls are wrapped in ``str(...)`` at the call site
# (``Node.__str__`` returns a ``SafeText``). Non-node helpers (``randomid``,
# ``_resolve_name_with_icon``, ``_render_element``) are called
# directly.
from games.models import Platform, Game, Purchase, Session
class ComponentIntegrationTest(unittest.TestCase):
"""Test Element() renders correctly with caching transparent."""
"""Test Component() works correctly with caching transparent."""
def test_tag_name_component(self):
result = str(
components.Element(
tag_name="div",
attributes=[("class", "test")],
children="hello",
)
result = components.Component(
tag_name="div",
attributes=[("class", "test")],
children="hello",
)
self.assertEqual(result, '<div class="test">hello</div>')
@@ -36,17 +28,9 @@ class ComponentCacheTest(unittest.TestCase):
components._render_element.cache_clear()
def test_identical_components_hit_cache(self):
str(
components.Element(
tag_name="div", attributes=[("class", "x")], children="hi"
)
)
components.Component(tag_name="div", attributes=[("class", "x")], children="hi")
misses = components._render_element.cache_info().misses
str(
components.Element(
tag_name="div", attributes=[("class", "x")], children="hi"
)
)
components.Component(tag_name="div", attributes=[("class", "x")], children="hi")
info = components._render_element.cache_info()
self.assertEqual(info.misses, misses) # no new miss
self.assertGreaterEqual(info.hits, 1) # served from cache
@@ -55,12 +39,10 @@ class ComponentCacheTest(unittest.TestCase):
self.assertEqual(components._render_element.cache_parameters()["maxsize"], 4096)
def test_safe_and_unsafe_children_do_not_collide(self):
"""A Safe-node ``<b>`` and a plain-string ``<b>`` render differently —
the cache key must keep them distinct."""
safe = str(
components.Element(tag_name="span", children=[components.Safe("<b>x</b>")])
)
unsafe = str(components.Element(tag_name="span", children=["<b>x</b>"]))
"""A SafeText "<b>" and a plain "<b>" are equal as strings but must
render differently the cache key must keep them distinct."""
safe = components.Component(tag_name="span", children=[mark_safe("<b>x</b>")])
unsafe = components.Component(tag_name="span", children=["<b>x</b>"])
self.assertIn("<b>x</b>", safe)
self.assertIn("&lt;b&gt;x&lt;/b&gt;", unsafe)
self.assertNotEqual(safe, unsafe)
@@ -132,37 +114,33 @@ class PopoverDeterministicTest(unittest.TestCase):
"""Test that Popover() produces deterministic HTML output."""
def test_same_popover_same_id(self):
r1 = str(components.Popover("hello", wrapped_content="hello"))
r2 = str(components.Popover("hello", wrapped_content="hello"))
r1 = components.Popover("hello", wrapped_content="hello")
r2 = components.Popover("hello", wrapped_content="hello")
self.assertEqual(r1, r2)
def test_different_content_different_id(self):
r1 = str(components.Popover("content_a", wrapped_content="content_a"))
r2 = str(components.Popover("content_b", wrapped_content="content_b"))
r1 = components.Popover("content_a", wrapped_content="content_a")
r2 = components.Popover("content_b", wrapped_content="content_b")
self.assertNotEqual(r1, r2)
def test_wrapped_classes_affect_id(self):
r1 = str(
components.Popover("c", wrapped_content="c", wrapped_classes="class_x")
)
r2 = str(
components.Popover("c", wrapped_content="c", wrapped_classes="class_y")
)
r1 = components.Popover("c", wrapped_content="c", wrapped_classes="class_x")
r2 = components.Popover("c", wrapped_content="c", wrapped_classes="class_y")
self.assertNotEqual(r1, r2)
def test_wrapped_content_affects_id(self):
r1 = str(components.Popover("popover", wrapped_content="wrapped_a"))
r2 = str(components.Popover("popover", wrapped_content="wrapped_b"))
r1 = components.Popover("popover", wrapped_content="wrapped_a")
r2 = components.Popover("popover", wrapped_content="wrapped_b")
self.assertNotEqual(r1, r2)
def test_popover_content_affects_id(self):
r1 = str(components.Popover("popover_a", wrapped_content="wrapped"))
r2 = str(components.Popover("popover_b", wrapped_content="wrapped"))
r1 = components.Popover("popover_a", wrapped_content="wrapped")
r2 = components.Popover("popover_b", wrapped_content="wrapped")
self.assertNotEqual(r1, r2)
def test_full_html_deterministic(self):
r1 = str(components.Popover("hello world", wrapped_content="hello world"))
r2 = str(components.Popover("hello world", wrapped_content="hello world"))
r1 = components.Popover("hello world", wrapped_content="hello world")
r2 = components.Popover("hello world", wrapped_content="hello world")
self.assertEqual(r1.encode(), r2.encode())
@@ -202,50 +180,63 @@ class ComponentReturnTypeTest(unittest.TestCase):
"""Test that component functions return SafeText and render correctly."""
def test_div_returns_safe_text(self):
result = str(components.Div([("class", "x")], "hello"))
result = components.Div([("class", "x")], "hello")
self.assertIsInstance(result, SafeText)
def test_div_deterministic(self):
r1 = str(components.Div([("class", "x")], "hello"))
r2 = str(components.Div([("class", "x")], "hello"))
r1 = components.Div([("class", "x")], "hello")
r2 = components.Div([("class", "x")], "hello")
self.assertEqual(r1, r2)
self.assertIn('<div class="x">hello</div>', r1)
def test_div_no_args(self):
result = str(components.Div(children="test"))
result = components.Div(children="test")
self.assertIsInstance(result, SafeText)
self.assertIn("<div>test</div>", result)
def test_a_returns_safe_text(self):
result = str(components.A([], "link"))
result = components.A([], "link")
self.assertIsInstance(result, SafeText)
def test_a_literal_href(self):
result = str(components.A([], "x", href="/literal/path"))
result = components.A([], "x", href="/literal/path")
self.assertIn('href="/literal/path"', result)
def test_a_url_name_reversed(self):
from unittest.mock import patch
with patch(
"common.components.primitives.reverse", return_value="/resolved/url"
):
result = components.A([], "link", url_name="some_name")
self.assertIn('href="/resolved/url"', result)
def test_a_no_url_or_href(self):
result = str(components.A([], "link"))
result = components.A([], "link")
self.assertIn("<a>link</a>", result)
self.assertNotIn("href=", result)
def test_a_both_url_name_and_href_raises(self):
with self.assertRaises(ValueError):
components.A(href="/path", url_name="some_name")
def test_button_returns_safe_text(self):
result = str(components.StyledButton([], "click"))
result = components.Button([], "click")
self.assertIsInstance(result, SafeText)
self.assertIn("<button", result)
def test_button_default_colors(self):
result = str(components.StyledButton([], "click"))
result = components.Button([], "click")
self.assertIn("text-white bg-brand", result)
def test_name_with_icon_no_link(self):
result = str(components.NameWithIcon(name="Game", linkify=False))
result = components.NameWithIcon(name="Game", linkify=False)
self.assertIsInstance(result, SafeText)
self.assertIn("Game", result)
self.assertNotIn("<a ", result)
def test_name_with_icon_no_trailing_comma(self):
result = str(components.NameWithIcon(name="Test", linkify=False))
result = components.NameWithIcon(name="Test", linkify=False)
self.assertIsInstance(result, SafeText)
self.assertNotIsInstance(result, tuple)
@@ -255,23 +246,21 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
def test_component_output_starts_with_tag(self):
for label, html in [
("A", str(components.A(href="/foo", children=["link"]))),
("Button", str(components.StyledButton([], "click"))),
("Div", str(components.Div([], ["hello"]))),
("Input", str(components.Input())),
("ButtonGroup", str(components.ButtonGroup([]))),
("A", components.A(href="/foo", children=["link"])),
("Button", components.Button([], "click")),
("Div", components.Div([], ["hello"])),
("Input", components.Input()),
("ButtonGroup", components.ButtonGroup([])),
(
"ButtonGroup with buttons",
str(
components.ButtonGroup(
[{"href": "/", "slot": components.Icon("edit")}]
)
components.ButtonGroup(
[{"href": "/", "slot": components.Icon("edit")}]
),
),
("SearchField", str(components.SearchField())),
("PriceConverted", str(components.PriceConverted(["27 CZK"]))),
("H1", str(components.H1(["Title"]))),
("H1 with badge", str(components.H1(["Title"], badge="3"))),
("SearchField", components.SearchField()),
("PriceConverted", components.PriceConverted(["27 CZK"])),
("H1", components.H1(["Title"])),
("H1 with badge", components.H1(["Title"], badge="3")),
]:
with self.subTest(component=label):
self.assertTrue(
@@ -280,112 +269,90 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
)
def test_button_with_icon_children_not_escaped(self):
result = str(
components.StyledButton(
icon=True,
size="xs",
children=[components.Icon("play"), "LOG"],
)
result = components.Button(
icon=True,
size="xs",
children=[components.Icon("play"), "LOG"],
)
self.assertTrue(str(result).startswith("<button"))
def test_popover_with_button_children_not_escaped(self):
result = str(
components.Popover(
popover_content="test tooltip",
children=[
components.StyledButton(
icon=True,
color="gray",
size="xs",
children=[components.Icon("play"), "test"],
),
],
)
result = components.Popover(
popover_content="test tooltip",
children=[
components.Button(
icon=True,
color="gray",
size="xs",
children=[components.Icon("play"), "test"],
),
],
)
self.assertTrue(str(result).startswith("<span data-popover-target"))
def test_name_with_icon_output_not_escaped(self):
result = str(components.NameWithIcon(name="Test", linkify=False))
result = components.NameWithIcon(name="Test", linkify=False)
self.assertTrue(str(result).startswith("<div"))
class ComponentEdgeCasesTest(unittest.TestCase):
"""Test Element() edge cases and error handling."""
"""Test Component() edge cases and error handling."""
def test_no_tag_name_raises(self):
with self.assertRaises(ValueError) as ctx:
str(components.Element("", children="hello"))
components.Component(children="hello")
self.assertIn("tag_name", str(ctx.exception))
def test_single_string_children_wrapped(self):
result = str(components.Element(tag_name="span", children="hello"))
result = components.Component(tag_name="span", children="hello")
self.assertIn("hello", result)
def test_multiple_children_joined_with_newlines(self):
result = str(components.Element(tag_name="div", children=["hello", "world"]))
result = components.Component(tag_name="div", children=["hello", "world"])
self.assertIn("hello\nworld", result)
self.assertIn("<div>", result)
self.assertIn("</div>", result)
def test_raw_html_children_are_escaped(self):
result = str(
components.Element(
tag_name="div", children=["<script>alert('xss')</script>"]
)
result = components.Component(
tag_name="div", children=["<script>alert('xss')</script>"]
)
self.assertNotIn("<script>", result)
self.assertIn("&lt;script&gt;", result)
def test_safe_node_children_pass_through(self):
result = str(
components.Element(
tag_name="div", children=[components.Safe("<span>safe</span>")]
)
def test_mark_safe_children_pass_through(self):
result = components.Component(
tag_name="div", children=[mark_safe("<span>safe</span>")]
)
self.assertIn("<span>safe</span>", result)
def test_mark_safe_string_children_are_escaped(self):
# Trusted markup must be a Safe node; a mark_safe string is still a
# string, so it is escaped like any other text child.
result = str(
components.Element(
tag_name="div", children=[mark_safe("<span>safe</span>")]
)
)
self.assertIn("&lt;span&gt;safe&lt;/span&gt;", result)
def test_attribute_values_are_escaped(self):
result = str(
components.Element(
tag_name="div",
attributes=[("data-x", 'foo"bar')],
)
result = components.Component(
tag_name="div",
attributes=[("data-x", 'foo"bar')],
)
self.assertIn("&quot;", result)
self.assertNotIn('"foo"bar"', result)
def test_attributes_serialized_correctly(self):
result = str(
components.Element(
tag_name="div", attributes=[("class", "foo"), ("id", "bar")]
)
result = components.Component(
tag_name="div", attributes=[("class", "foo"), ("id", "bar")]
)
self.assertIn('class="foo"', result)
self.assertIn('id="bar"', result)
def test_empty_attributes_no_extra_space(self):
result = str(components.Element(tag_name="span", children="x"))
result = components.Component(tag_name="span", children="x")
self.assertEqual(result, "<span>x</span>")
self.assertNotIn(" <span", result)
def test_non_string_children_not_supported(self):
"""Component only accepts str for children, not integers."""
result = str(components.Element(tag_name="span", children=str(42)))
result = components.Component(tag_name="span", children=str(42))
self.assertIn("42", result)
def test_returns_safetext(self):
result = str(components.Element(tag_name="div", children="test"))
result = components.Component(tag_name="div", children="test")
self.assertIsInstance(result, SafeText)
@@ -393,22 +360,22 @@ class IconTest(unittest.TestCase):
"""Test Icon() component function."""
def test_valid_icon_renders_svg(self):
result = str(components.Icon("play"))
result = components.Icon("play")
self.assertIsInstance(result, SafeText)
self.assertIn("<svg", result)
self.assertIn("</svg>", result)
def test_unavailable_icon_falls_back(self):
result = str(components.Icon("zzz_nonexistent_platform"))
result = components.Icon("zzz_nonexistent_platform")
self.assertIsInstance(result, SafeText)
self.assertIn("<svg", result)
def test_icon_passes_attributes_to_template(self):
result = str(components.Icon("play", attributes=[("title", "Play")]))
result = components.Icon("play", attributes=[("title", "Play")])
self.assertIsInstance(result, SafeText)
def test_returns_safetext(self):
result = str(components.Icon("delete"))
result = components.Icon("delete")
self.assertIsInstance(result, SafeText)
@@ -416,19 +383,17 @@ class InputTest(unittest.TestCase):
"""Test the Input() component."""
def test_input_default_type_text(self):
result = str(components.Input())
result = components.Input()
self.assertIn("<input", result)
self.assertIn('type="text"', result)
def test_input_custom_type(self):
result = str(components.Input(type="submit"))
result = components.Input(type="submit")
self.assertIn('type="submit"', result)
def test_input_attributes_merged_with_type(self):
result = str(
components.Input(
type="email", attributes=[("id", "email"), ("class", "form-input")]
)
result = components.Input(
type="email", attributes=[("id", "email"), ("class", "form-input")]
)
self.assertIn('type="email"', result)
self.assertIn('id="email"', result)
@@ -439,12 +404,12 @@ class PopoverTruncatedTest(unittest.TestCase):
"""Test PopoverTruncated() component function."""
def test_short_string_no_popover(self):
result = str(components.PopoverTruncated("hi"))
result = components.PopoverTruncated("hi")
self.assertEqual(result, "hi")
def test_long_string_wrapped_in_popover(self):
long_text = "a" * 100
result = str(components.PopoverTruncated(long_text))
result = components.PopoverTruncated(long_text)
# Should NOT equal the truncated form directly
truncated = components.truncate(long_text, 30)
self.assertNotEqual(result, truncated)
@@ -453,55 +418,47 @@ class PopoverTruncatedTest(unittest.TestCase):
def test_custom_ellipsis_used(self):
long_text = "a" * 50
result = str(components.PopoverTruncated(long_text, ellipsis=">>"))
result = components.PopoverTruncated(long_text, ellipsis=">>")
# Django template escapes >> to &gt;&gt; in the wrapped_content
self.assertIn("&gt;&gt;", result)
def test_popover_if_not_truncated_flag(self):
short_text = "hi"
result = str(
components.PopoverTruncated(
short_text,
popover_content="full content",
popover_if_not_truncated=True,
)
result = components.PopoverTruncated(
short_text, popover_content="full content", popover_if_not_truncated=True
)
# Should be wrapped in popover even though short
self.assertNotEqual(result, "hi")
self.assertIn("data-popover-target", result)
def test_popover_content_override(self):
result = str(
components.PopoverTruncated("short", popover_content="custom popover")
)
result = components.PopoverTruncated("short", popover_content="custom popover")
# With popover_if_not_truncated=False (default), short text returns as-is
self.assertEqual(result, "short")
def test_popover_content_override_with_flag(self):
result = str(
components.PopoverTruncated(
"short", popover_content="custom popover", popover_if_not_truncated=True
)
result = components.PopoverTruncated(
"short", popover_content="custom popover", popover_if_not_truncated=True
)
self.assertIn("custom popover", result)
def test_endpart_visible_in_output(self):
long_text = "a" * 50
result = str(components.PopoverTruncated(long_text, endpart="..."))
result = components.PopoverTruncated(long_text, endpart="...")
self.assertIn("...", result)
def test_returns_safetext(self):
result = str(components.PopoverTruncated("a" * 100))
result = components.PopoverTruncated("a" * 100)
self.assertIsInstance(result, SafeText)
def test_default_length(self):
text = "a" * 31
result = str(components.PopoverTruncated(text))
result = components.PopoverTruncated(text)
# 31 chars exceeds default length of 30, so should be truncated
self.assertIn("data-popover-target", result)
def test_length_zero(self):
result = str(components.PopoverTruncated("hello", length=0))
result = components.PopoverTruncated("hello", length=0)
# Even empty length triggers popover for any content
self.assertIn("data-popover-target", result)
@@ -533,7 +490,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
def test_name_with_icon_linkify_with_game(self):
platform = self._create_platform(name="Steam", icon="steam")
game = self._create_game(platform)
result = str(components.NameWithIcon(game=game, linkify=True))
result = components.NameWithIcon(game=game, linkify=True)
self.assertIsInstance(result, SafeText)
self.assertIn("<a ", result)
self.assertIn("Test Game", result)
@@ -542,9 +499,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
def test_name_with_icon_no_linkify(self):
platform = self._create_platform(name="GOG", icon="gog")
game = self._create_game(platform)
result = str(
components.NameWithIcon(name="Test Game", game=game, linkify=False)
)
result = components.NameWithIcon(name="Test Game", game=game, linkify=False)
self.assertIsInstance(result, SafeText)
self.assertNotIn("<a ", result)
self.assertIn("Test Game", result)
@@ -557,13 +512,13 @@ class ModelDependentComponentsTest(django.test.TestCase):
timestamp_start="2025-01-01 00:00:00+00:00",
emulated=True,
)
result = str(components.NameWithIcon(session=session, linkify=True))
result = components.NameWithIcon(session=session, linkify=True)
self.assertIsInstance(result, SafeText)
self.assertIn("<a ", result)
self.assertIn("Emulated", result)
def test_name_with_icon_no_platform(self):
result = str(components.NameWithIcon(name="Standalone", linkify=False))
result = components.NameWithIcon(name="Standalone", linkify=False)
self.assertIsInstance(result, SafeText)
self.assertIn("Standalone", result)
@@ -574,7 +529,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
game=game,
timestamp_start="2025-01-01 00:00:00+00:00",
)
result = str(components.NameWithIcon(session=session, linkify=True))
result = components.NameWithIcon(session=session, linkify=True)
self.assertIsInstance(result, SafeText)
self.assertIn("Epic Game", result)
@@ -582,7 +537,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
platform = self._create_platform()
game = self._create_game(platform)
purchase = self._create_purchase([game], price=29.99)
result = str(components.PurchasePrice(purchase))
result = components.PurchasePrice(purchase)
self.assertIsInstance(result, SafeText)
# floatformat rounds to 1 decimal: 29.99 -> 30.0
self.assertIn("30.0", result)
@@ -593,7 +548,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
platform = self._create_platform(icon="steam")
game = self._create_game(platform, name="Single Game")
purchase = self._create_purchase([game], price=14.99)
result = str(components.LinkedPurchase(purchase))
result = components.LinkedPurchase(purchase)
self.assertIsInstance(result, SafeText)
self.assertIn("Single Game", result)
self.assertIn("<a ", result)
@@ -604,7 +559,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
game1 = self._create_game(platform, name="Game One")
game2 = self._create_game(platform, name="Game Two")
purchase = self._create_purchase([game1, game2], price=24.99)
result = str(components.LinkedPurchase(purchase))
result = components.LinkedPurchase(purchase)
self.assertIsInstance(result, SafeText)
self.assertIn("2 games", result)
self.assertIn("<a ", result)
@@ -620,7 +575,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
)
purchase.name = "Bundle"
purchase.save()
result = str(components.LinkedPurchase(purchase))
result = components.LinkedPurchase(purchase)
self.assertIsInstance(result, SafeText)
self.assertIn("Bundle", result)
@@ -629,7 +584,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
game1 = self._create_game(platform, name="Alpha")
game2 = self._create_game(platform, name="Beta")
purchase = self._create_purchase([game1, game2], price=19.99)
result = str(components.LinkedPurchase(purchase))
result = components.LinkedPurchase(purchase)
self.assertIsInstance(result, SafeText)
self.assertIn("Alpha", result)
self.assertIn("Beta", result)
@@ -640,18 +595,18 @@ class PurchaseTruncatedTest(unittest.TestCase):
def test_endpart_shorter_than_length(self):
text = "a" * 50
result = str(components.PopoverTruncated(text, length=10, endpart="x"))
result = components.PopoverTruncated(text, length=10, endpart="x")
# endpart=x takes 1 char, so content gets truncated at 9 chars
self.assertIn("data-popover-target", result)
self.assertIn("x", result)
def test_no_truncation_no_ellipsis(self):
result = str(components.PopoverTruncated("short text"))
result = components.PopoverTruncated("short text")
self.assertEqual(result, "short text")
def test_custom_length(self):
text = "hello world"
result = str(components.PopoverTruncated(text, length=6))
result = components.PopoverTruncated(text, length=6)
self.assertIn("data-popover-target", result)
@@ -665,14 +620,12 @@ class NameWithIconPlatformTest(django.test.TestCase):
cls.game = Game.objects.create(name="Zelda", platform=cls.platform)
def test_name_with_icon_shows_platform_icon(self):
result = str(
components.NameWithIcon(name="Zelda", game=self.game, linkify=True)
)
result = components.NameWithIcon(name="Zelda", game=self.game, linkify=True)
self.assertIsInstance(result, SafeText)
self.assertIn("Zelda", result)
def test_name_with_icon_no_game_id_no_platform(self):
result = str(components.NameWithIcon(name="Unknown Game", linkify=False))
result = components.NameWithIcon(name="Unknown Game", linkify=False)
self.assertIsInstance(result, SafeText)
self.assertIn("Unknown Game", result)
@@ -796,11 +749,9 @@ class SimpleTableRenderingTest(unittest.TestCase):
def test_simple_table_renders_list_rows(self):
"""Verify list-style rows render as <tr> with <th scope='row'> + <td>."""
result = str(
str(
components.SimpleTable(
columns=["Game", "Started", "Ended"],
rows=[["Game1", "2025-01-01", "2025-03-01"]],
)
components.SimpleTable(
columns=["Game", "Started", "Ended"],
rows=[["Game1", "2025-01-01", "2025-03-01"]],
)
)
tbody = self._tbody(result)
@@ -823,11 +774,9 @@ class SimpleTableRenderingTest(unittest.TestCase):
def test_simple_table_multiple_rows(self):
"""Verify multiple rows all render."""
result = str(
str(
components.SimpleTable(
columns=["Game", "Started"],
rows=[["GameA", "2025-01-01"], ["GameB", "2025-02-01"]],
)
components.SimpleTable(
columns=["Game", "Started"],
rows=[["GameA", "2025-01-01"], ["GameB", "2025-02-01"]],
)
)
tbody = self._tbody(result)
@@ -837,13 +786,13 @@ class SimpleTableRenderingTest(unittest.TestCase):
def test_simple_table_header_action_as_caption(self):
"""Verify header_action renders inside <caption>."""
from django.utils.safestring import mark_safe
result = str(
str(
components.SimpleTable(
columns=["Game", "Started"],
rows=[["Game1", "2025-01-01"]],
header_action=components.Safe('<a href="/add">Add</a>'),
)
components.SimpleTable(
columns=["Game", "Started"],
rows=[["Game1", "2025-01-01"]],
header_action=mark_safe('<a href="/add">Add</a>'),
)
)
self.assertIn("<caption", result)
@@ -853,17 +802,15 @@ class SimpleTableRenderingTest(unittest.TestCase):
def test_simple_table_dict_rows_with_cell_data(self):
"""Verify dict-style rows with row_id and cell_data render correctly."""
result = str(
str(
components.SimpleTable(
columns=["Name", "Date"],
rows=[
{
"row_id": "session-row-1",
"hx_trigger": "device-changed",
"cell_data": ["Game1", "2025-01-01"],
}
],
)
components.SimpleTable(
columns=["Name", "Date"],
rows=[
{
"row_id": "session-row-1",
"hx_trigger": "device-changed",
"cell_data": ["Game1", "2025-01-01"],
}
],
)
)
tbody = self._tbody(result)
@@ -874,64 +821,5 @@ class SimpleTableRenderingTest(unittest.TestCase):
self.assertIn("2025-01-01", tbody)
class ComponentPrimitivesTest(SimpleTestCase):
def test_checkbox_primitive(self):
html = str(
components.Checkbox(
name="test-check", label="Accept Terms", checked=True, value="yes"
)
)
self.assertIn('type="checkbox"', html)
self.assertIn('name="test-check"', html)
self.assertIn('value="yes"', html)
self.assertIn('checked="true"', html)
self.assertIn("Accept Terms", html)
def test_checkbox_headless(self):
html = str(components.Checkbox(name="test-headless", label=None, checked=True))
self.assertNotIn("<label", html)
self.assertIn("<input", html)
self.assertIn('type="checkbox"', html)
self.assertIn('name="test-headless"', html)
def test_radio_primitive(self):
html = str(
components.Radio(
name="test-radio", label="Option A", checked=False, value="A"
)
)
self.assertIn('type="radio"', html)
self.assertIn('name="test-radio"', html)
self.assertIn('value="A"', html)
self.assertNotIn('checked="true"', html)
self.assertIn("Option A", html)
class PrimitiveWidgetsTest(SimpleTestCase):
def test_mixin_applies_widget_to_boolean_fields_only(self):
from django import forms
from games.forms import PrimitiveCheckboxWidget, PrimitiveWidgetsMixin
class DummyForm(PrimitiveWidgetsMixin, forms.Form):
agree = forms.BooleanField(required=False)
name = forms.CharField(required=False)
form = DummyForm()
self.assertIsInstance(form.fields["agree"].widget, PrimitiveCheckboxWidget)
self.assertNotIsInstance(form.fields["name"].widget, PrimitiveCheckboxWidget)
def test_primitive_checkbox_widget_renders_headless(self):
from games.forms import PrimitiveCheckboxWidget
widget = PrimitiveCheckboxWidget()
html = widget.render(name="agree", value=True)
self.assertNotIn("<label", html)
self.assertIn("<input", html)
self.assertIn('type="checkbox"', html)
self.assertIn('name="agree"', html)
self.assertIn('checked="true"', html)
if __name__ == "__main__":
unittest.main()
-198
View File
@@ -1,198 +0,0 @@
"""Tests for the configuration reader in ``timetracker/config.py``."""
import pytest
from django.core.exceptions import ImproperlyConfigured
from timetracker import config as config_module
from timetracker.config import config
@pytest.fixture(autouse=True)
def _clear_caches():
"""Each test sees freshly parsed files."""
config_module.reset_caches()
yield
config_module.reset_caches()
@pytest.fixture
def env_file(tmp_path, monkeypatch):
def _write(contents: str):
path = tmp_path / ".env"
path.write_text(contents)
monkeypatch.setenv("ENV_FILE", str(path))
config_module.reset_caches()
return path
return _write
@pytest.fixture
def ini_file(tmp_path, monkeypatch):
def _write(contents: str):
path = tmp_path / "settings.ini"
path.write_text(contents)
monkeypatch.setenv("INI_FILE", str(path))
config_module.reset_caches()
return path
return _write
def test_default_returned_when_unset():
assert config("TOTALLY_UNSET_VALUE", default="fallback") == "fallback"
def test_missing_without_default_raises():
with pytest.raises(ImproperlyConfigured):
config("TOTALLY_UNSET_VALUE")
def test_env_var_overrides_default(monkeypatch):
monkeypatch.setenv("SOME_SETTING", "from-env")
assert config("SOME_SETTING", default="fallback") == "from-env"
def test_priority_env_beats_files(monkeypatch, env_file, ini_file):
ini_file("[timetracker]\nVALUE = from-ini\n")
env_file("VALUE=from-dotenv\n")
monkeypatch.setenv("VALUE", "from-env")
assert config("VALUE") == "from-env"
def test_priority_dotenv_beats_ini(env_file, ini_file):
ini_file("[timetracker]\nVALUE = from-ini\n")
env_file("VALUE=from-dotenv\n")
assert config("VALUE") == "from-dotenv"
def test_priority_ini_beats_default(ini_file):
ini_file("[timetracker]\nVALUE = from-ini\n")
assert config("VALUE", default="fallback") == "from-ini"
def test_ini_preserves_key_case(ini_file):
ini_file("[timetracker]\nSECRET_KEY = abc\n")
assert config("SECRET_KEY") == "abc"
# --- __FILE secret pointer -------------------------------------------------
def test_file_pointer_read_and_stripped(tmp_path, monkeypatch):
secret = tmp_path / "secret"
secret.write_text("super-secret-value\n") # trailing newline must be stripped
monkeypatch.setenv("SECRET_KEY__FILE", str(secret))
assert config("SECRET_KEY", allow_file=True) == "super-secret-value"
def test_file_pointer_ignored_without_allow_file(tmp_path, monkeypatch):
secret = tmp_path / "secret"
secret.write_text("ignored")
monkeypatch.setenv("SECRET_KEY__FILE", str(secret))
assert config("SECRET_KEY", default="fallback") == "fallback"
def test_file_pointer_beats_env(tmp_path, monkeypatch):
secret = tmp_path / "secret"
secret.write_text("from-file")
monkeypatch.setenv("SECRET_KEY__FILE", str(secret))
monkeypatch.setenv("SECRET_KEY", "from-env")
assert config("SECRET_KEY", allow_file=True) == "from-file"
# --- casting ---------------------------------------------------------------
@pytest.mark.parametrize(
"raw,expected",
[
("true", True),
("True", True),
("1", True),
("yes", True),
("on", True),
("false", False),
("0", False),
("no", False),
("", False),
],
)
def test_cast_bool(monkeypatch, raw, expected):
monkeypatch.setenv("FLAG", raw)
assert config("FLAG", cast=bool) is expected
def test_cast_list(monkeypatch):
monkeypatch.setenv("HOSTS", "a.example, b.example , ,c.example")
assert config("HOSTS", cast=list) == ["a.example", "b.example", "c.example"]
def test_cast_int(monkeypatch):
monkeypatch.setenv("COUNT", "42")
assert config("COUNT", cast=int) == 42
def test_cast_not_applied_to_default():
# A None default passes through untouched even with a cast set.
assert config("UNSET", default=None, cast=list) is None
# --- required_in_prod ------------------------------------------------------
def test_required_in_prod_raises_when_prod(monkeypatch):
monkeypatch.setenv("DEBUG", "false")
with pytest.raises(ImproperlyConfigured):
config("SECRET_KEY", default="dev-default", required_in_prod=True)
def test_required_in_prod_uses_default_in_debug(monkeypatch):
monkeypatch.setenv("DEBUG", "true")
assert config("SECRET_KEY", default="dev-default", required_in_prod=True) == (
"dev-default"
)
def test_deprecated_prod_var_implies_production(monkeypatch):
monkeypatch.delenv("DEBUG", raising=False)
monkeypatch.setenv("PROD", "1")
with pytest.raises(ImproperlyConfigured):
config("SECRET_KEY", default="dev-default", required_in_prod=True)
# --- .env parser edge cases ------------------------------------------------
def test_env_parser_quotes_comments_and_export(env_file):
env_file(
"\n".join(
[
"# a comment line",
"PLAIN=value",
"export EXPORTED=exported-value",
'DOUBLE="quoted value"',
"SINGLE='single quoted'",
"INLINE=value # trailing comment",
'HASH_IN_QUOTES="a # b"',
"EMPTY=",
'QUOTED_THEN_COMMENT="keep" # drop',
]
)
+ "\n"
)
assert config("PLAIN") == "value"
assert config("EXPORTED") == "exported-value"
assert config("DOUBLE") == "quoted value"
assert config("SINGLE") == "single quoted"
assert config("INLINE") == "value"
assert config("HASH_IN_QUOTES") == "a # b"
assert config("EMPTY", default="x") == ""
assert config("QUOTED_THEN_COMMENT") == "keep"
def test_missing_files_are_ignored(monkeypatch, tmp_path):
monkeypatch.setenv("ENV_FILE", str(tmp_path / "does-not-exist.env"))
monkeypatch.setenv("INI_FILE", str(tmp_path / "does-not-exist.ini"))
config_module.reset_caches()
assert config("ANYTHING", default="fallback") == "fallback"
-95
View File
@@ -1,95 +0,0 @@
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)
-196
View File
@@ -1,196 +0,0 @@
"""Unit tests for the DateRangePicker component family.
Pins the structural contract of DateRangeField / DateRangeCalendar /
DateRangePicker segment inputs ordered by ``dateformat_hyphenated``, the
hidden ISO ``{prefix}-min`` / ``{prefix}-max`` inputs that ``filter_bar.js``
serializes, the calendar's preset/footer hooks — and the PurchaseFilterBar
integration that replaced the native-date DateRangeFilter for the Purchased
field.
"""
import json
import re
from django.test import SimpleTestCase, TestCase
from common.components import (
DateRangeCalendar,
DateRangeField,
DateRangePicker,
PurchaseFilterBar,
)
from common.time import date_parts, dateformat_hyphenated
_ESCAPED_TAG_MARKERS = ["&lt;div", "&lt;span", "&lt;button", "&lt;input"]
class DatePartsTest(SimpleTestCase):
def test_default_format_yields_day_month_year(self):
parts = date_parts()
self.assertEqual([part.name for part in parts], ["day", "month", "year"])
self.assertEqual([part.placeholder for part in parts], ["DD", "MM", "YYYY"])
self.assertEqual([part.length for part in parts], [2, 2, 4])
def test_parts_follow_format_order(self):
parts = date_parts("%Y-%d-%m")
self.assertEqual([part.name for part in parts], ["year", "day", "month"])
def test_dateformat_hyphenated_is_parseable(self):
self.assertEqual(len(date_parts(dateformat_hyphenated)), 3)
class DateRangeFieldTest(SimpleTestCase):
def render(self, **kwargs):
defaults = {"label": "Purchased", "input_name_prefix": "filter-date-purchased"}
defaults.update(kwargs)
return str(DateRangeField(**defaults))
def test_renders_hidden_iso_inputs(self):
html = self.render(min_value="2024-03-15", max_value="2024-09-20")
self.assertIn('name="filter-date-purchased-min"', html)
self.assertIn('name="filter-date-purchased-max"', html)
self.assertIn('data-date-range-hidden="min"', html)
self.assertIn('data-date-range-hidden="max"', html)
self.assertIn('value="2024-03-15"', html)
self.assertIn('value="2024-09-20"', html)
def test_renders_segments_in_dateformat_order_for_both_sides(self):
html = self.render()
for side in ("min", "max"):
side_segments = re.findall(
rf'data-date-part="(\w+)" data-date-side="{side}"', html
)
self.assertEqual(side_segments, ["day", "month", "year"])
def test_segment_placeholders_and_lengths(self):
html = self.render()
self.assertEqual(html.count('placeholder="DD"'), 2)
self.assertEqual(html.count('placeholder="MM"'), 2)
self.assertEqual(html.count('placeholder="YYYY"'), 2)
self.assertEqual(html.count('maxlength="2"'), 4)
self.assertEqual(html.count('maxlength="4"'), 2)
self.assertEqual(html.count('inputmode="numeric"'), 6)
def test_prefills_segments_from_iso_values(self):
html = self.render(min_value="2024-03-15")
self.assertIn('value="15" data-date-part="day" data-date-side="min"', html)
self.assertIn('value="03" data-date-part="month" data-date-side="min"', html)
self.assertIn('value="2024" data-date-part="year" data-date-side="min"', html)
# The max side stays empty.
self.assertIn('value="" data-date-part="day" data-date-side="max"', html)
def test_malformed_iso_value_renders_empty_segments(self):
html = self.render(min_value="not-a-date")
self.assertIn('value="" data-date-part="day" data-date-side="min"', html)
def test_renders_calendar_toggle(self):
html = self.render()
self.assertIn("data-date-range-calendar-toggle", html)
self.assertIn('aria-label="Open Purchased calendar"', html)
def test_no_native_date_inputs(self):
self.assertNotIn('type="date"', self.render())
class DateRangeCalendarTest(SimpleTestCase):
def render(self):
return str(DateRangeCalendar(input_name_prefix="filter-date-purchased"))
def test_renders_all_presets(self):
html = self.render()
for preset in (
"today",
"yesterday",
"last_7_days",
"last_30_days",
"this_month",
"last_month",
"this_year",
):
self.assertIn(f'data-date-range-preset="{preset}"', html)
def test_renders_footer_buttons(self):
html = self.render()
self.assertIn("data-date-range-cancel", html)
self.assertIn("data-date-range-clear", html)
self.assertIn("data-date-range-select", html)
self.assertIn(">Cancel<", html)
self.assertIn(">Clear<", html)
self.assertIn(">Select<", html)
def test_renders_grid_and_navigation_hooks(self):
html = self.render()
self.assertIn("data-date-range-grid", html)
self.assertIn("data-date-range-month-label", html)
self.assertIn("data-date-range-prev", html)
self.assertIn("data-date-range-next", html)
def test_starts_hidden(self):
self.assertIn('class="hidden absolute', self.render())
def test_all_buttons_are_type_button(self):
"""No button inside the calendar may submit the surrounding filter form."""
html = self.render()
button_count = html.count("<button")
self.assertEqual(html.count('<button type="button"'), button_count)
class DateRangePickerTest(SimpleTestCase):
def test_composes_field_and_calendar(self):
html = str(
DateRangePicker(
label="Purchased",
input_name_prefix="filter-date-purchased",
min_value="2024-01-01",
max_value="2024-12-31",
)
)
self.assertIn("data-date-range-picker", html)
self.assertIn('data-input-name-prefix="filter-date-purchased"', html)
self.assertIn("data-date-range-field", html)
self.assertIn("data-date-range-calendar", html)
for marker in _ESCAPED_TAG_MARKERS:
self.assertNotIn(marker, html)
class PurchaseFilterBarDateRangePickerTest(TestCase):
"""The Purchased filter uses the DateRangePicker; Refunded keeps the
native-date DateRangeFilter (the picker is a tryout on one field)."""
def render(self, filter_json=""):
return str(
PurchaseFilterBar(
filter_json=filter_json, preset_list_url="/l", preset_save_url="/s"
)
)
def test_purchased_uses_date_range_picker(self):
html = self.render()
self.assertIn("data-date-range-picker", html)
self.assertIn('data-input-name-prefix="filter-date-purchased"', html)
# The hidden ISO inputs keep the names filter_bar.js serializes.
self.assertIn('name="filter-date-purchased-min"', html)
self.assertIn('name="filter-date-purchased-max"', html)
def test_refunded_keeps_native_date_inputs(self):
html = self.render()
refunded_min = html.find('name="filter-date-refunded-min"')
self.assertGreater(refunded_min, 0)
self.assertIn('type="date"', html)
self.assertNotIn('data-input-name-prefix="filter-date-refunded"', html)
def test_prefilled_between_filter_round_trips_into_picker(self):
filter_json = json.dumps(
{
"date_purchased": {
"value": "2024-03-15",
"value2": "2024-09-20",
"modifier": "BETWEEN",
}
}
)
html = self.render(filter_json)
self.assertIn('value="2024-03-15"', html)
self.assertIn('value="2024-09-20"', html)
self.assertIn('value="15" data-date-part="day" data-date-side="min"', html)
self.assertIn('value="20" data-date-part="day" data-date-side="max"', html)
+19 -252
View File
@@ -15,6 +15,7 @@ from django.test import TestCase
from common.components import (
FilterBar,
PurchaseFilterBar,
SelectableFilter,
SessionFilterBar,
)
from games.models import Device, Game, Platform
@@ -92,68 +93,15 @@ class FilterBarRenderingTest(TestCase):
self._assert_shell(html, "/presets/purchases/list", "/presets/purchases/save")
self._assert_range_slider(html)
def test_purchase_filter_bar_games_has_m2m_modifiers(self):
"""The many-to-many games field surfaces (All)/(Only) pseudo-options
in the dropdown alongside the presence (Any)/(None) rows. Single-valued
fields (platform) do not get M2M modifiers."""
html = str(
PurchaseFilterBar(
filter_json="", preset_list_url="/l", preset_save_url="/s"
)
)
# (All) and (Only) appear as modifier rows in the dropdown.
self.assertIn('data-search-select-modifier-option="INCLUDES_ALL"', html)
self.assertIn('data-search-select-modifier-option="INCLUDES_ONLY"', html)
# No legacy match-mode <select>.
self.assertNotIn("data-search-select-match", html)
# Platform is single-valued: no M2M modifier options in its section.
games_start = html.find('data-name="games"')
platform_start = html.find('data-name="platform"')
platform_section = html[platform_start:]
self.assertNotIn("INCLUDES_ALL", platform_section)
self.assertGreater(games_start, 0)
def test_purchase_filter_bar_roundtrips_includes_all(self):
"""A stored INCLUDES_ALL modifier renders as the modifier pill and the
included game still renders as a value pill."""
filter_json = json.dumps(
{
"games": {
"value": [{"id": "5", "label": "Hollow Knight"}],
"modifier": "INCLUDES_ALL",
}
}
)
html = str(
PurchaseFilterBar(
filter_json=filter_json, preset_list_url="/l", preset_save_url="/s"
)
)
self.assertIn('data-modifier="INCLUDES_ALL"', html)
self.assertIn("(All)", html) # modifier pill label
self.assertIn("Hollow Knight", html)
self.assertIn('data-search-select-type="include"', html)
self.assertNoEscapedTags(html)
def test_game_filter_bar_roundtrips_selected_status(self):
"""A status in filter_json renders as an include pill in the widget."""
filter_json = json.dumps(
{
"status": {
"value": [{"id": "f", "label": "Finished"}],
"modifier": "INCLUDES",
}
}
)
"""A status in filter_json renders as a selected tag in the widget."""
filter_json = json.dumps({"status": {"value": ["f"], "modifier": ""}})
html = str(
FilterBar(
filter_json=filter_json, preset_list_url="/l", preset_save_url="/s"
)
)
self.assertIn('data-search-select-mode="filter"', html)
self.assertIn(
'data-search-select-type="include"', html
) # rendered as an include pill
self.assertIn("sf-tag", html)
self.assertIn('data-value="f"', html) # selected status reflected in widget
self.assertIn("Finished", html) # ...with its label
self.assertNoEscapedTags(html)
@@ -163,202 +111,21 @@ class FilterBarRenderingTest(TestCase):
self.assertIn("&quot;status&quot;", html)
self.assertNotIn("&amp;quot;", html)
def test_game_filter_bar_preserves_excludes_modifier(self):
"""An enum field with an EXCLUDES modifier renders data-modifier correctly
so the JS roundtrip preserves the modifier (regression: _split_modifier
silently dropped non-presence modifiers when match_modes was None)."""
filter_json = json.dumps(
{
"status": {
"value": [{"id": "f", "label": "Finished"}],
"modifier": "EXCLUDES",
}
}
)
class SelectableFilterTest(TestCase):
"""The shared widget the deduped FilterBar will be built on."""
OPTIONS = [("f", "Finished"), ("a", "Abandoned"), ("u", "Unplayed")]
def test_plain_widget_has_no_tags(self):
html = str(SelectableFilter("status", self.OPTIONS))
self.assertNotIn("sf-tag", html)
def test_include_and_exclude_tags(self):
html = str(
FilterBar(
filter_json=filter_json, preset_list_url="/l", preset_save_url="/s"
)
SelectableFilter("status", self.OPTIONS, selected=["f"], excluded=["a"])
)
# The full modifier is stored on data-modifier when there's no match-mode
# select (enum/choice fields). No data-match attribute is present.
self.assertIn('data-modifier="EXCLUDES"', html)
self.assertNotIn("data-match=", html)
self.assertIn('data-type="include"', html)
self.assertIn('data-type="exclude"', html)
self.assertIn("Finished", html)
self.assertNoEscapedTags(html)
def test_device_filter_bar(self):
from common.components import DeviceFilterBar
html = str(
DeviceFilterBar(
filter_json="",
preset_list_url="/presets/devices/list",
preset_save_url="/presets/devices/save",
)
)
self._assert_shell(html, "/presets/devices/list", "/presets/devices/save")
def test_platform_filter_bar(self):
from common.components import PlatformFilterBar
html = str(
PlatformFilterBar(
filter_json="",
preset_list_url="/presets/platforms/list",
preset_save_url="/presets/platforms/save",
)
)
self._assert_shell(html, "/presets/platforms/list", "/presets/platforms/save")
def test_playevent_filter_bar(self):
from common.components import PlayEventFilterBar
html = str(
PlayEventFilterBar(
filter_json="",
preset_list_url="/presets/playevents/list",
preset_save_url="/presets/playevents/save",
)
)
self._assert_shell(html, "/presets/playevents/list", "/presets/playevents/save")
def test_game_filter_bar_has_new_widgets(self):
"""The expanded games FilterBar exposes platform_group, device, playevent_note,
purchase_type / purchase_ownership_type, plus count and aggregate-playtime
range sliders and the new boolean checkboxes."""
html = str(
FilterBar(
filter_json="",
preset_list_url="/l",
preset_save_url="/s",
)
)
# New search-backed selects
self.assertIn('data-search-url="/api/devices/search"', html)
self.assertIn('data-search-url="/api/platforms/groups"', html)
# New enum selects (purchase type / ownership)
self.assertIn('data-name="purchase_type"', html)
self.assertIn('data-name="purchase_ownership_type"', html)
# Free-text widget for playevent notes (now StringFilter)
self.assertIn('name="filter-playevent_note"', html)
self.assertIn('name="filter-playevent_note-modifier"', html)
# New range slider input prefixes
self.assertIn('name="filter-purchase-count-min"', html)
self.assertIn('name="filter-playevent-count-min"', html)
self.assertIn('name="filter-manual-playtime-hours-min"', html)
self.assertIn('name="filter-calculated-playtime-hours-min"', html)
self.assertIn('name="filter-original-year-min"', html)
self.assertIn('name="filter-purchase-price-total-min"', html)
self.assertIn('name="filter-purchase-price-any-min"', html)
# New boolean checkboxes
self.assertIn('name="filter-purchase-refunded"', html)
self.assertIn('name="filter-purchase-infinite"', html)
self.assertIn('name="filter-session-emulated"', html)
# Removed boolean checkboxes
self.assertNotIn('name="filter-has-purchases"', html)
self.assertNotIn('name="filter-has-playevents"', html)
# Playtime label renamed
self.assertIn("Total playtime", html)
def test_purchase_filter_bar_renders_date_inputs(self):
"""PurchaseFilterBar surfaces date_purchased and date_refunded as
type=date input pairs with -min/-max naming."""
html = str(
PurchaseFilterBar(
filter_json="", preset_list_url="/l", preset_save_url="/s"
)
)
for name in (
"filter-date-purchased-min",
"filter-date-purchased-max",
"filter-date-refunded-min",
"filter-date-refunded-max",
):
self.assertIn(f'name="{name}"', html)
self.assertIn(f'id="{name}"', html)
# Inputs are native date pickers, not text.
self.assertIn('type="date"', html)
self.assertNoEscapedTags(html)
def test_purchase_filter_bar_prepopulates_dates_between(self):
"""A BETWEEN filter populates both date bounds via _parse_range."""
filter_json = json.dumps(
{
"date_purchased": {
"value": "2024-01-01",
"value2": "2024-12-31",
"modifier": "BETWEEN",
}
}
)
html = str(
PurchaseFilterBar(
filter_json=filter_json,
preset_list_url="/l",
preset_save_url="/s",
)
)
self.assertIn(
'name="filter-date-purchased-min" id="filter-date-purchased-min" '
'value="2024-01-01"',
html,
)
self.assertIn(
'name="filter-date-purchased-max" id="filter-date-purchased-max" '
'value="2024-12-31"',
html,
)
def test_purchase_filter_bar_prepopulates_dates_single_bound(self):
"""A single-bound (GREATER_THAN) filter populates min only."""
filter_json = json.dumps(
{
"date_refunded": {
"value": "2024-06-01",
"modifier": "GREATER_THAN",
}
}
)
html = str(
PurchaseFilterBar(
filter_json=filter_json,
preset_list_url="/l",
preset_save_url="/s",
)
)
self.assertIn(
'name="filter-date-refunded-min" id="filter-date-refunded-min" '
'value="2024-06-01"',
html,
)
# Max input is still present but with empty value.
self.assertIn(
'name="filter-date-refunded-max" id="filter-date-refunded-max" value=""',
html,
)
def test_boolean_fields_render_as_radio_groups(self):
"""Boolean fields must render as radio groups with True/False choices."""
from common.components import FilterBar, SessionFilterBar, PurchaseFilterBar
# 1. Games Filter Bar
games_html = str(FilterBar(filter_json=""))
self.assertIn('type="radio"', games_html)
self.assertIn('name="filter-mastered"', games_html)
self.assertIn('value="true"', games_html)
self.assertIn('value="false"', games_html)
# 2. Session Filter Bar
session_html = str(SessionFilterBar(filter_json=""))
self.assertIn('type="radio"', session_html)
self.assertIn('name="filter-emulated"', session_html)
self.assertIn('value="true"', session_html)
self.assertIn('value="false"', session_html)
# 3. Purchase Filter Bar
purchase_html = str(PurchaseFilterBar(filter_json=""))
self.assertIn('type="radio"', purchase_html)
self.assertIn('name="filter-refunded"', purchase_html)
self.assertIn('value="true"', purchase_html)
self.assertIn('value="false"', purchase_html)
self.assertIn("Abandoned", html)
+1 -20
View File
@@ -2,7 +2,7 @@
from django.test import SimpleTestCase
from common.components.filters import _parse_bool, _parse_range, _parse_bool_nullable
from common.components.filters import _parse_bool, _parse_range
class ParseRangeTest(SimpleTestCase):
@@ -66,22 +66,3 @@ class ParseBoolTest(SimpleTestCase):
def test_missing_value_in_field(self):
self.assertFalse(_parse_bool({"field": {}}, "field"))
class ParseBoolNullableTest(SimpleTestCase):
def test_missing_key(self):
self.assertIsNone(_parse_bool_nullable({}, "field"))
def test_null_value(self):
self.assertIsNone(_parse_bool_nullable({"field": None}, "field"))
self.assertIsNone(_parse_bool_nullable({"field": {}}, "field"))
def test_boolean_values(self):
self.assertTrue(_parse_bool_nullable({"field": {"value": True}}, "field"))
self.assertFalse(_parse_bool_nullable({"field": {"value": False}}, "field"))
def test_string_values(self):
self.assertTrue(_parse_bool_nullable({"field": {"value": "true"}}, "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": "0"}}, "field"))
+19 -965
View File
File diff suppressed because it is too large Load Diff
-221
View File
@@ -1,221 +0,0 @@
"""Phase 1: the lazy node layer (Node/Element/Safe/Fragment/BaseComponent/Media).
These cover the new machinery directly: rendering, escaping, media bubbling.
"""
import unittest
from django.utils.safestring import mark_safe
from common.components import (
BaseComponent,
Element,
Fragment,
Media,
Node,
Safe,
collect_media,
render,
)
class ElementRenderTest(unittest.TestCase):
def test_renders_tag_attrs_children(self):
element = Element("div", [("class", "test")], "hello")
self.assertEqual(render(element), '<div class="test">hello</div>')
def test_plain_string_children_escaped(self):
self.assertEqual(
render(Element("span", children=["<b>"])), "<span>&lt;b&gt;</span>"
)
def test_safe_node_child_passes_through(self):
self.assertEqual(
render(Element("span", children=[Safe("<b>x</b>")])),
"<span><b>x</b></span>",
)
def test_safetext_child_is_escaped(self):
# A string child is always escaped — even a mark_safe/SafeText one.
# Trusted markup must be a Safe node, not a safe string.
self.assertEqual(
render(Element("span", children=[mark_safe("<b>x</b>")])),
"<span>&lt;b&gt;x&lt;/b&gt;</span>",
)
def test_node_children_render_safely(self):
inner = Element("b", children=["x"])
self.assertEqual(
render(Element("span", children=[inner])), "<span><b>x</b></span>"
)
class SafeAndFragmentTest(unittest.TestCase):
def test_safe_passes_html_through(self):
self.assertEqual(render(Safe("<i>raw</i>")), "<i>raw</i>")
def test_fragment_concatenates(self):
frag = Fragment(
Element("span", children=["a"]), Element("span", children=["b"])
)
self.assertEqual(render(frag), "<span>a</span><span>b</span>")
def test_fragment_skips_empty_children(self):
frag = Fragment("", None, Element("span", children=["a"]))
self.assertEqual(render(frag), "<span>a</span>")
def test_fragment_escapes_plain_strings(self):
self.assertEqual(render(Fragment("<x>", Safe("<y>"))), "&lt;x&gt;<y>")
class MediaTest(unittest.TestCase):
def test_merge_dedups_preserving_order(self):
merged = Media(js=["a.js", "b.js"]) + Media(js=["b.js", "c.js"])
self.assertEqual(merged.js, ("a.js", "b.js", "c.js"))
def test_external_kept_separate(self):
merged = Media(js=["a.js"]) + Media(js_external=["umd.js"])
self.assertEqual(merged.js, ("a.js",))
self.assertEqual(merged.js_external, ("umd.js",))
def test_sum_with_radd(self):
merged = sum([Media(js=["a.js"]), Media(js=["b.js"])], Media())
self.assertEqual(merged.js, ("a.js", "b.js"))
def test_falsy_when_empty(self):
self.assertFalse(Media())
self.assertTrue(Media(js=["a.js"]))
class MediaCollectionTest(unittest.TestCase):
def test_bubbles_through_element_children(self):
class Widget(BaseComponent):
media = Media(js=["widget.js"])
def render(self) -> Node:
return Element("div", children=["x"])
tree = Element("section", children=[Element("div", children=[Widget()])])
self.assertEqual(collect_media(tree).js, ("widget.js",))
def test_bubbles_through_fragment(self):
class Widget(BaseComponent):
media = Media(js=["w.js"])
def render(self) -> Node:
return Element("div")
self.assertEqual(collect_media(Fragment(Widget(), Element("p"))).js, ("w.js",))
def test_component_merges_own_and_subtree_media(self):
class Inner(BaseComponent):
media = Media(js=["inner.js"])
def render(self) -> Node:
return Element("span")
class Outer(BaseComponent):
media = Media(js=["outer.js"])
def render(self) -> Node:
return Element("div", children=[Inner()])
self.assertEqual(collect_media(Outer()).js, ("outer.js", "inner.js"))
def test_bare_string_has_no_media(self):
self.assertFalse(collect_media("just a string"))
class RealComponentMediaTest(unittest.TestCase):
"""Phase 3: JS-bearing components declare media that bubbles up the tree."""
def test_search_select_declares_its_script(self):
from common.components import SearchSelect
self.assertEqual(
collect_media(SearchSelect(name="games")).js, ("search_select.js",)
)
def test_filter_select_declares_its_script(self):
from common.components import FilterSelect
self.assertIn(
"search_select.js", collect_media(FilterSelect(field_name="type")).js
)
def test_date_range_picker_declares_its_script(self):
from common.components import DateRangePicker
media = collect_media(
DateRangePicker(label="Played", input_name_prefix="played")
)
self.assertEqual(media.js, ("date_range_picker.js",))
def test_range_slider_declares_its_script(self):
from common.components.filters import RangeSlider
media = collect_media(
RangeSlider(
label="Year", input_name_prefix="year", range_min=2000, range_max=2025
)
)
self.assertEqual(media.js, ("range_slider.js",))
def test_filter_bar_collects_chrome_and_widget_media(self):
"""A FilterBar's media merges its own chrome script with the scripts that
bubble up from the FilterSelect and RangeSlider widgets it contains
exactly the set the view used to thread by hand. (FilterBar wraps its DB
aggregates in try/except, so it builds without a database.)"""
from common.components import FilterBar
media = collect_media(FilterBar())
self.assertIn("filter_bar.js", media.js)
self.assertIn("search_select.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__":
unittest.main()
-13
View File
@@ -60,16 +60,3 @@ class PathWorksTest(TestCase):
def test_list_purchases_returns_200(self):
response = self.client.get(reverse("games:list_purchases"))
self.assertEqual(response.status_code, 200)
def test_platform_groups_api_returns_200(self):
# Distinct platform groups are returned as string-valued options.
Platform.objects.create(name="Switch", icon="switch", group="Nintendo")
response = self.client.get("/api/platforms/groups")
self.assertEqual(response.status_code, 200)
body = response.json()
groups = {item["value"] for item in body}
self.assertIn("Nintendo", groups)
filtered = self.client.get("/api/platforms/groups?q=nin")
self.assertEqual(filtered.status_code, 200)
self.assertEqual({item["value"] for item in filtered.json()}, {"Nintendo"})
+2 -172
View File
@@ -57,22 +57,6 @@ class RenderedPagesTest(TestCase):
marker, html, f"Found double-escaped markup ({marker!r}) in output"
)
# --- scripts auto-collected from component media (Phase 4) ---------------
def test_list_page_auto_loads_widget_scripts(self):
"""The games list view passes no scripts= argument; the filter bar's
components declare their JS and Page() collects it."""
html = self.get("games:list_games").content.decode()
self.assertIn("js/filter_bar.js", html)
self.assertIn("js/search_select.js", html)
self.assertIn("js/range_slider.js", html)
def test_stats_page_auto_loads_datepicker(self):
"""YearPicker declares the datepicker UMD bundle as media; the stats
view no longer hoists it by hand."""
html = self.get("games:stats_alltime").content.decode()
self.assertIn("js/datepicker.umd.js", html)
# --- layout wrapper ------------------------------------------------------
def test_page_layout_wrapper(self):
@@ -139,7 +123,7 @@ class RenderedPagesTest(TestCase):
def test_add_session_form_has_timestamp_helpers(self):
html = self.get("games:add_session").content.decode()
self.assertIn("session-timestamp-buttons", html)
self.assertIn("add_session.js", html)
for marker in [
"Set to now",
"Toggle text",
@@ -168,7 +152,7 @@ class RenderedPagesTest(TestCase):
"Platform",
'id="history-container"',
"status-changed from:body",
"<play-event-row", # the played-row custom element
"createPlayEvent", # the played-row Alpine dropdown script
'hx-target="#global-modal-container"', # delete trigger
"Purchases",
"Sessions",
@@ -179,14 +163,6 @@ class RenderedPagesTest(TestCase):
self.assertNoEscapedTags(html)
self.assertEqual(html.count("<div"), html.count("</div>"))
def test_view_game_uses_play_event_row_element(self):
game = Game.objects.create(name="Played Game", platform=self.platform)
html = self.get("games:view_game", game.id).content.decode()
self.assertIn("<play-event-row", html)
self.assertIn('game-id="', html)
self.assertNotIn("@@", html) # token-replace hack gone
self.assertNotIn("createPlayEvent", html) # the old Alpine fn is gone
def test_view_game_empty_sections(self):
"""A game with no sessions/purchases/etc shows the empty messages."""
lonely = Game.objects.create(name="Lonely Game", platform=self.platform)
@@ -314,149 +290,3 @@ class RenderedPagesTest(TestCase):
self.assertNoEscapedTags(html)
# The Python builder emits well-formed, balanced markup.
self.assertEqual(html.count("<div"), html.count("</div>"))
class PurchaseListDateFilterTest(TestCase):
"""End-to-end: GET /tracker/purchase/list?filter=… narrows the rendered
list and pre-fills the date inputs from the URL filter.
Replaces the manual curl smoke that earlier verified the same path.
"""
def setUp(self) -> None:
import datetime
self.user = User.objects.create_superuser(
username="datetester", email="dt@example.com", password="testpass"
)
self.client.force_login(self.user)
self.platform = Platform.objects.create(name="DateP", icon="datep")
# Markers are placed on the Game name because LinkedPurchase renders
# the linked game's name (purchase.name doesn't surface in the list row).
early_game = Game.objects.create(name="EARLY-MARKER", platform=self.platform)
mid_game = Game.objects.create(name="MID-MARKER", platform=self.platform)
late_game = Game.objects.create(name="LATE-MARKER", platform=self.platform)
self.early = Purchase.objects.create(
platform=self.platform, date_purchased=datetime.date(2024, 1, 15)
)
self.early.games.add(early_game)
self.mid = Purchase.objects.create(
platform=self.platform,
date_purchased=datetime.date(2024, 6, 15),
date_refunded=datetime.date(2024, 7, 1),
)
self.mid.games.add(mid_game)
self.late = Purchase.objects.create(
platform=self.platform, date_purchased=datetime.date(2025, 1, 15)
)
self.late.games.add(late_game)
def _get(self, filter_obj=None, raw_filter=None):
import json
from django.urls import reverse
url = reverse("games:list_purchases")
if raw_filter is not None:
return self.client.get(url, {"filter": raw_filter})
if filter_obj is not None:
return self.client.get(url, {"filter": json.dumps(filter_obj)})
return self.client.get(url)
def test_unfiltered_lists_all_three(self):
html = self._get().content.decode()
self.assertEqual(html.count("EARLY-MARKER"), 1)
self.assertEqual(html.count("MID-MARKER"), 1)
self.assertEqual(html.count("LATE-MARKER"), 1)
def test_date_purchased_between_narrows_and_prepopulates(self):
"""BETWEEN 2024-01-01..2024-12-31 → only early + mid; both date
inputs pre-filled with the filter bounds."""
response = self._get(
{
"date_purchased": {
"value": "2024-01-01",
"value2": "2024-12-31",
"modifier": "BETWEEN",
}
}
)
self.assertEqual(response.status_code, 200)
html = response.content.decode()
self.assertIn("EARLY-MARKER", html)
self.assertIn("MID-MARKER", html)
self.assertNotIn("LATE-MARKER", html)
# Pre-populated date inputs round-trip the filter bounds.
self.assertIn(
'name="filter-date-purchased-min" id="filter-date-purchased-min" '
'value="2024-01-01"',
html,
)
self.assertIn(
'name="filter-date-purchased-max" id="filter-date-purchased-max" '
'value="2024-12-31"',
html,
)
def test_date_purchased_greater_than_single_bound(self):
"""GREATER_THAN populates min only, leaves max blank."""
response = self._get(
{
"date_purchased": {
"value": "2024-06-15",
"modifier": "GREATER_THAN",
}
}
)
self.assertEqual(response.status_code, 200)
html = response.content.decode()
self.assertNotIn("EARLY-MARKER", html)
self.assertNotIn("MID-MARKER", html)
self.assertIn("LATE-MARKER", html)
self.assertIn(
'name="filter-date-purchased-min" id="filter-date-purchased-min" '
'value="2024-06-15"',
html,
)
self.assertIn(
'name="filter-date-purchased-max" id="filter-date-purchased-max" value=""',
html,
)
def test_date_refunded_not_null(self):
response = self._get({"date_refunded": {"value": "", "modifier": "NOT_NULL"}})
self.assertEqual(response.status_code, 200)
html = response.content.decode()
self.assertNotIn("EARLY-MARKER", html)
self.assertIn("MID-MARKER", html)
self.assertNotIn("LATE-MARKER", html)
def test_combined_dates_and_is_refunded(self):
"""date_purchased BETWEEN 2024 AND date_refunded NOT_NULL → only the
mid purchase. Confirms AND-composition through the view layer."""
response = self._get(
{
"date_purchased": {
"value": "2024-01-01",
"value2": "2024-12-31",
"modifier": "BETWEEN",
},
"date_refunded": {"value": "", "modifier": "NOT_NULL"},
}
)
self.assertEqual(response.status_code, 200)
html = response.content.decode()
self.assertNotIn("EARLY-MARKER", html)
self.assertIn("MID-MARKER", html)
self.assertNotIn("LATE-MARKER", html)
def test_malformed_json_filter_falls_back_to_unfiltered(self):
"""parse_purchase_filter returns None on bad JSON → view ignores
the filter and renders the full list (no 500)."""
response = self._get(raw_filter="this is not json")
self.assertEqual(response.status_code, 200)
html = response.content.decode()
# All three purchases are present, same as the unfiltered baseline.
self.assertIn("EARLY-MARKER", html)
self.assertIn("MID-MARKER", html)
self.assertIn("LATE-MARKER", html)
-41
View File
@@ -1,41 +0,0 @@
from datetime import timedelta
from django.contrib.sessions.models import Session as DjangoSession
from django.core.management import call_command
from django.test import TransactionTestCase
from django.utils.timezone import now
from django_q.models import Schedule
class ScrubStagingTest(TransactionTestCase):
# TransactionTestCase flushes the DB before each test instead of wrapping
# in a savepoint. Required here because scrub_staging deletes all sessions
# — a TestCase savepoint rollback would restore any sessions committed by
# earlier tests (e.g. force_login in test_paths_return_200) and leak state
# into the e2e live-server tests that follow.
def test_scrub_removes_sessions_and_schedules(self):
DjangoSession.objects.create(
session_key="copied-from-prod",
session_data="",
expire_date=now() + timedelta(days=1),
)
Schedule.objects.create(
func="games.tasks.convert_prices",
name="Update converted prices",
schedule_type=Schedule.MINUTES,
)
self.assertEqual(DjangoSession.objects.count(), 1)
self.assertEqual(Schedule.objects.count(), 1)
call_command("scrub_staging")
self.assertEqual(DjangoSession.objects.count(), 0)
self.assertEqual(Schedule.objects.count(), 0)
def test_scrub_is_safe_on_empty_database(self):
call_command("scrub_staging")
self.assertEqual(DjangoSession.objects.count(), 0)
self.assertEqual(Schedule.objects.count(), 0)
+30 -234
View File
@@ -7,62 +7,56 @@ import django.test
from django.utils.safestring import SafeText
from common.components import (
Pill,
SearchSelect,
searchselect_selected,
)
from common.components import FilterSelect, Pill, SearchSelect
from games.models import Game, Platform
# These components are lazy nodes; the tests below assert on rendered HTML, so
# each call is wrapped in ``str(...)`` (``Node.__str__`` returns a ``SafeText``,
# which keeps the ``assertIsInstance(..., SafeText)`` checks meaningful and the
# string assertions working).
class PillTest(unittest.TestCase):
def test_returns_safetext(self):
self.assertIsInstance(str(Pill("hi")), SafeText)
self.assertIsInstance(Pill("hi"), SafeText)
def test_plain_pill_has_data_pill_no_remove(self):
html = str(Pill("hi"))
html = Pill("hi")
self.assertIn("data-pill", html)
self.assertNotIn("data-pill-remove", html)
def test_removable_adds_remove_button(self):
html = str(Pill("hi", removable=True))
html = Pill("hi", removable=True)
self.assertIn("data-pill-remove", html)
self.assertIn('aria-label="Remove"', html)
def test_value_becomes_data_value(self):
html = str(Pill("hi", value="42"))
html = Pill("hi", value="42")
self.assertIn('data-value="42"', html)
def test_no_value_omits_data_value(self):
self.assertNotIn("data-value", str(Pill("hi")))
self.assertNotIn("data-value", Pill("hi"))
def test_label_is_escaped(self):
html = str(Pill("<b>x</b>"))
html = Pill("<b>x</b>")
self.assertIn("&lt;b&gt;", html)
self.assertNotIn("<b>x</b>", html)
def test_extra_data_attributes(self):
html = str(Pill("hi", attributes=[("data-platform", "3")]))
html = Pill("hi", attributes=[("data-platform", "3")])
self.assertIn('data-platform="3"', html)
class SearchSelectComponentTest(unittest.TestCase):
def test_returns_safetext(self):
self.assertIsInstance(str(SearchSelect(name="games")), SafeText)
self.assertIsInstance(SearchSelect(name="games"), SafeText)
def test_empty_options_renders_no_results_scaffold(self):
html = str(SearchSelect(name="games"))
self.assertIn("data-search-select-no-results", html)
html = SearchSelect(name="games")
self.assertIn("data-ss-no-results", html)
self.assertIn("No results", html)
def test_outer_container_carries_config(self):
html = str(
SearchSelect(
name="games", search_url="/api/games/search", multi_select=True
)
html = SearchSelect(
name="games", search_url="/api/games/search", multi_select=True
)
self.assertIn("data-search-select", html)
self.assertIn('data-name="games"', html)
@@ -70,245 +64,47 @@ class SearchSelectComponentTest(unittest.TestCase):
self.assertIn('data-multi="true"', html)
def test_multi_selected_renders_pills_and_hidden_inputs(self):
html = str(
SearchSelect(
name="games",
multi_select=True,
selected=[{"value": 7, "label": "Game A", "data": {"platform": "2"}}],
)
html = SearchSelect(
name="games",
multi_select=True,
selected=[{"value": 7, "label": "Game A", "data": {"platform": "2"}}],
)
self.assertIn("data-pill", html)
self.assertIn('<input name="games" value="7" type="hidden">', html)
self.assertIn('<input type="hidden" name="games" value="7">', html)
self.assertIn('data-platform="2"', html)
# exactly one submitted value (the hidden input) — the search box has no
# name. The leading space avoids matching the container's data-name.
self.assertEqual(html.count(' name="games"'), 1)
def test_single_selected_has_no_pill_and_value_in_search_box(self):
html = str(
SearchSelect(
name="games",
selected=[{"value": 7, "label": "Game A", "data": {"platform": "2"}}],
)
html = SearchSelect(
name="games",
selected=[{"value": 7, "label": "Game A", "data": {"platform": "2"}}],
)
# single-select renders no pill — the label lives in the search box
self.assertNotIn("data-pill", html)
self.assertIn('value="Game A"', html)
# the value is still submitted via a lone hidden input
self.assertIn('<input name="games" value="7" type="hidden">', html)
self.assertIn('<input type="hidden" name="games" value="7">', html)
self.assertEqual(html.count(' name="games"'), 1)
def test_search_box_has_no_name(self):
html = str(SearchSelect(name="games"))
self.assertIn("data-search-select-search", html)
html = SearchSelect(name="games")
self.assertIn("data-ss-search", html)
# container exposes data-name, never a submittable name on the search box
self.assertEqual(html.count(' name="games"'), 0)
def test_tuple_options_are_normalized(self):
html = str(SearchSelect(name="t", options=[("1", "One")]))
self.assertIn('data-search-select-option=""', html)
html = SearchSelect(name="t", options=[("1", "One")])
self.assertIn('data-ss-option=""', html)
self.assertIn('data-value="1"', html)
self.assertIn("One", html)
def test_options_omitted_when_search_url_set(self):
html = str(
SearchSelect(
name="t", options=[("1", "One")], search_url="/api/games/search"
)
html = SearchSelect(
name="t", options=[("1", "One")], search_url="/api/games/search"
)
# No pre-rendered rows in the live panel; the row prototype lives only in
# the cloneable <template>.
panel = html.split("data-search-select-template")[0]
self.assertNotIn('data-search-select-option=""', panel)
self.assertIn('data-search-select-template="row"', html)
def test_templates_carry_label_slot_for_js_cloning(self):
# The dynamic shapes the JS clones expose a [data-search-select-label] slot so the JS
# only fills text — classes/structure stay server-side.
html = str(
SearchSelect(name="t", search_url="/api/games/search", multi_select=True)
)
self.assertIn('data-search-select-template="row"', html)
self.assertIn('data-search-select-template="pill"', html)
self.assertIn("data-search-select-label", html)
def test_shell_region_order_pills_search_options(self):
# The shared shell assembles the three regions in a fixed order; option
# rows precede the trailing no-results node inside the options panel.
html = str(SearchSelect(name="t", options=[("1", "One")]))
pills = html.index("data-search-select-pills")
search = html.index("data-search-select-search")
options = html.index("data-search-select-options")
option_row = html.index('data-search-select-option=""')
no_results = html.index("data-search-select-no-results")
self.assertLess(pills, search)
self.assertLess(search, options)
self.assertLess(options, option_row)
self.assertLess(option_row, no_results)
def test_prefetch_attribute_and_defaults(self):
# Default prefetch is 0 in SearchSelect
html_default = str(SearchSelect(name="t"))
self.assertIn('data-prefetch="0"', html_default)
# Custom prefetch is rendered
html_custom = str(SearchSelect(name="t", prefetch=42))
self.assertIn('data-prefetch="42"', html_custom)
class FilterSelectComponentTest(unittest.TestCase):
MODIFIERS = [("NOT_NULL", "(Any)"), ("IS_NULL", "(None)")]
def test_returns_safetext(self):
self.assertIsInstance(str(FilterSelect(field_name="type")), SafeText)
def test_is_filter_mode_on_shared_shell(self):
html = str(FilterSelect(field_name="type"))
# Reuses the SearchSelect shell (data-search-select) but flags filter mode.
self.assertIn("data-search-select", html)
self.assertIn('data-search-select-mode="filter"', html)
self.assertIn('data-name="type"', html)
# No name is submitted — state is read from the DOM into the filter JSON.
self.assertEqual(html.count(' name="type"'), 0)
def test_value_rows_have_include_exclude_buttons(self):
html = str(FilterSelect(field_name="type", options=[("g", "Game")]))
self.assertIn('data-search-select-action="include"', html)
self.assertIn('data-search-select-action="exclude"', html)
self.assertIn('data-value="g"', html)
def test_included_renders_check_pill_excluded_renders_cross_pill(self):
html = str(
FilterSelect(
field_name="platform",
options=[("1", "Steam"), ("2", "GOG")],
included=[("1", "Steam")],
excluded=[("2", "GOG")],
)
)
# Labels live in a [data-search-select-label] slot (so JS can fill clones); the ✓/✗
# symbol is a sibling text node.
self.assertIn('data-search-select-type="include"', html)
self.assertIn("", html)
self.assertIn(">Steam</span>", html)
self.assertIn('data-search-select-type="exclude"', html)
self.assertIn("", html)
self.assertIn(">GOG</span>", html)
self.assertIn("line-through", html) # excluded pill styling
def test_modifier_options_render_pinned_rows(self):
html = str(FilterSelect(field_name="platform", modifier_options=self.MODIFIERS))
# Pinned pseudo-options carry data-search-select-modifier-option, never data-search-select-option,
# so the text filter leaves them visible.
self.assertIn('data-search-select-modifier-option="NOT_NULL"', html)
self.assertIn('data-search-select-modifier-option="IS_NULL"', html)
def test_modifier_pill_coexists_with_value_pills(self):
"""Modifier and value pills both render server-side; the JS handles
mutual exclusivity for presence modifiers (PRESENCE_MODIFIERS)."""
html = str(
FilterSelect(
field_name="platform",
options=[("1", "Steam")],
included=[("1", "Steam")],
modifier="IS_NULL",
modifier_options=self.MODIFIERS,
)
)
# Both the modifier pill and the value pill render.
self.assertIn('data-search-select-modifier="IS_NULL"', html)
self.assertIn("(None)", html)
self.assertIn('data-search-select-type="include"', html) # value pill present
self.assertIn('data-modifier="IS_NULL"', html) # container carries it too
def test_search_url_omits_value_rows_but_keeps_modifiers(self):
html = str(
FilterSelect(
field_name="game",
search_url="/api/games/search",
prefetch=20,
modifier_options=self.MODIFIERS,
)
)
# No value rows in the live panel (they're fetched); the row prototype
# lives only in a <template>.
panel = html.split("data-search-select-template")[0]
self.assertNotIn('data-search-select-option=""', panel)
self.assertIn('data-search-select-template="row"', html)
self.assertIn(
'data-search-select-modifier-option="NOT_NULL"', html
) # still pinned
self.assertIn('data-prefetch="20"', html)
def test_search_url_pills_use_resolved_labels(self):
# A selected value outside the fetched window still shows its label.
html = str(
FilterSelect(
field_name="game",
search_url="/api/games/search",
excluded=[{"value": 4172, "label": "Obscure Game", "data": {}}],
)
)
self.assertIn(">Obscure Game</span>", html)
self.assertIn('data-value="4172"', html)
M2M_MODIFIERS = [
("INCLUDES_ALL", "(All)"),
("INCLUDES_ONLY", "(Only)"),
]
def test_m2m_modifiers_render_as_option_rows(self):
"""M2M modifiers (All)/(Only) render as modifier-option rows in the
dropdown, not as a separate <select>."""
html = str(
FilterSelect(
field_name="games",
modifier_options=[
("NOT_NULL", "(Any)"),
("IS_NULL", "(None)"),
("INCLUDES_ALL", "(All)"),
("INCLUDES_ONLY", "(Only)"),
],
)
)
self.assertIn('data-search-select-modifier-option="INCLUDES_ALL"', html)
self.assertIn('data-search-select-modifier-option="INCLUDES_ONLY"', html)
self.assertIn('data-search-select-modifier-option="NOT_NULL"', html)
# No legacy match-mode <select>.
self.assertNotIn("data-search-select-match", html)
def test_active_modifier_renders_pill(self):
"""When modifier is INCLUDES_ALL, the modifier pill renders with the
(All) label alongside any value pills."""
html = str(
FilterSelect(
field_name="games",
modifier="INCLUDES_ALL",
modifier_options=[
("NOT_NULL", "(Any)"),
("IS_NULL", "(None)"),
("INCLUDES_ALL", "(All)"),
("INCLUDES_ONLY", "(Only)"),
],
included=[{"value": 5, "label": "Hollow Knight", "data": {}}],
)
)
self.assertIn('data-modifier="INCLUDES_ALL"', html)
self.assertIn("(All)", html)
self.assertIn("Hollow Knight", html)
self.assertIn('data-search-select-type="include"', html)
def test_presence_only_modifiers_no_m2m_rows(self):
"""When modifier_options only has presence entries, no M2M rows appear."""
html = str(
FilterSelect(
field_name="status",
modifier_options=[("NOT_NULL", "(Any)"), ("IS_NULL", "(None)")],
options=[("f", "Finished")],
)
)
self.assertNotIn("INCLUDES_ALL", html)
self.assertNotIn("INCLUDES_ONLY", html)
self.assertNotIn('data-ss-option=""', html)
class SearchLabelTest(django.test.TestCase):
-195
View File
@@ -1,195 +0,0 @@
"""
Centralized configuration reading for timetracker.
Every configurable Django setting is resolved through :func:`config`, which
consults several sources in a fixed priority order (highest first):
1. ``NAME__FILE`` path to a file whose *stripped* contents are the value.
Only consulted when the setting opts in with
``allow_file=True``. Intended for Docker/Kubernetes
secrets, which are mounted as files rather than env vars.
2. ``NAME`` a real process environment variable.
3. ``.env`` file ``KEY=value`` lines (see the supported syntax below).
4. ``settings.ini`` the ``[timetracker]`` section, parsed with
:mod:`configparser`.
5. ``default`` the in-code fallback passed to :func:`config`.
If no source supplies a value and no ``default`` is given, an
:class:`~django.core.exceptions.ImproperlyConfigured` error is raised.
``.env`` syntax supported:
- ``KEY=value`` and ``export KEY=value``
- blank lines and ``#`` full-line comments
- single- or double-quoted values (the surrounding quotes are stripped); a
``#`` inside quotes is treated literally
- an inline ``# comment`` after an *unquoted* value
Deliberately NOT supported (documented limits, not bugs):
- variable interpolation (``${OTHER}``)
- multiline values
File locations default to ``.env`` and ``settings.ini`` next to the project
root and can be overridden with the ``ENV_FILE`` / ``INI_FILE`` environment
variables. Missing files are silently ignored so env-only deployments are
unaffected.
"""
import os
from configparser import ConfigParser
from pathlib import Path
from typing import Any, Callable
from django.core.exceptions import ImproperlyConfigured
BASE_DIR = Path(__file__).resolve().parent.parent
# Sentinel distinguishing "no default supplied" from an explicit ``None``.
NOT_SET: Any = object()
INI_SECTION = "timetracker"
_env_file_cache: dict[str, str] | None = None
_ini_file_cache: dict[str, str] | None = None
def _unquote(value: str) -> str:
"""Strip surrounding quotes, or an inline comment from an unquoted value."""
if not value:
return value
quote = value[0]
if quote in "\"'":
closing = value.find(quote, 1)
if closing != -1:
return value[1:closing]
# Opening quote with no match: drop it and keep the rest verbatim.
return value[1:]
comment_index = value.find("#")
if comment_index != -1:
value = value[:comment_index]
return value.strip()
def _parse_env_file(path: Path) -> dict[str, str]:
values: dict[str, str] = {}
for raw_line in path.read_text().splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if line.startswith("export "):
line = line[len("export ") :].lstrip()
if "=" not in line:
continue
name, _, value = line.partition("=")
name = name.strip()
if not name:
continue
values[name] = _unquote(value.strip())
return values
def _load_env_file() -> dict[str, str]:
global _env_file_cache
if _env_file_cache is None:
path = Path(os.environ.get("ENV_FILE", BASE_DIR / ".env"))
_env_file_cache = _parse_env_file(path) if path.is_file() else {}
return _env_file_cache
def _load_ini_file() -> dict[str, str]:
global _ini_file_cache
if _ini_file_cache is None:
path = Path(os.environ.get("INI_FILE", BASE_DIR / "settings.ini"))
if path.is_file():
parser = ConfigParser()
# Preserve key case; ConfigParser lowercases option names by default.
parser.optionxform = str # type: ignore[assignment, method-assign]
parser.read(path)
_ini_file_cache = (
dict(parser[INI_SECTION]) if parser.has_section(INI_SECTION) else {}
)
else:
_ini_file_cache = {}
return _ini_file_cache
def reset_caches() -> None:
"""Clear parsed-file caches. Intended for use in tests."""
global _env_file_cache, _ini_file_cache
_env_file_cache = None
_ini_file_cache = None
def _cast_value(value: str, cast: Callable[[str], Any] | None) -> Any:
if cast is None:
return value
if cast is bool:
return value.strip().lower() in {"true", "1", "yes", "on"}
if cast is list:
return [item.strip() for item in value.split(",") if item.strip()]
return cast(value)
def _resolve_raw(name: str, allow_file: bool) -> str | None:
"""Return the first raw string from the source chain, or ``None``."""
if allow_file:
file_pointer = os.environ.get(f"{name}__FILE")
if file_pointer:
return Path(file_pointer).read_text().strip()
if name in os.environ:
return os.environ[name]
env_file = _load_env_file()
if name in env_file:
return env_file[name]
ini_file = _load_ini_file()
if name in ini_file:
return ini_file[name]
return None
def _debug_enabled() -> bool:
"""Whether the app runs in DEBUG mode, mirroring ``settings.DEBUG``.
Defaults to on for local development; turned off by ``DEBUG=false`` or the
deprecated ``PROD`` env var. Used to decide whether ``required_in_prod``
settings may fall back to a development default.
"""
raw = _resolve_raw("DEBUG", allow_file=False)
if raw is not None:
return _cast_value(raw, bool)
return not bool(os.environ.get("PROD"))
def config(
name: str,
*,
default: Any = NOT_SET,
cast: Callable[[str], Any] | None = None,
allow_file: bool = False,
required_in_prod: bool = False,
) -> Any:
"""Resolve a configuration value from the source chain.
Args:
name: The setting / environment variable name.
default: Fallback when no source provides a value. If omitted, a
missing value raises ``ImproperlyConfigured``.
cast: Coercion applied to string values ``bool``, ``list``, ``int``,
``Path``, or any callable taking a string. Defaults are returned
untouched.
allow_file: Whether to honor a ``NAME__FILE`` secret pointer.
required_in_prod: When ``True``, a missing value raises in production
(DEBUG off) even if a ``default`` is given, so insecure development
defaults never leak into a deployment.
"""
raw = _resolve_raw(name, allow_file=allow_file)
if raw is None:
if required_in_prod and not _debug_enabled():
raise ImproperlyConfigured(
f"{name} must be set in production (DEBUG is off)."
)
if default is NOT_SET:
raise ImproperlyConfigured(f"Required setting {name} is not configured.")
return default
return _cast_value(raw, cast)
+12 -41
View File
@@ -11,11 +11,7 @@ https://docs.djangoproject.com/en/4.1/ref/settings/
"""
import os
import warnings
from pathlib import Path
from urllib.parse import urlparse
from timetracker.config import config
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
@@ -24,44 +20,13 @@ BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/
# SECURITY WARNING: don't run with debug turned on in production!
# DEBUG defaults on for local development. Production turns it off via
# DEBUG=false (preferred) or the deprecated PROD env var.
_debug = config("DEBUG", default=None, cast=bool)
if _debug is None:
if os.environ.get("PROD"):
warnings.warn(
"The PROD environment variable is deprecated; set DEBUG=false instead.",
DeprecationWarning,
stacklevel=2,
)
_debug = False
else:
_debug = True
DEBUG = _debug
# SECURITY WARNING: keep the secret key used in production secret!
# Each deployment supplies its own key (env, .env/.ini, or a SECRET_KEY__FILE
# secret); falls back to an insecure default only in DEBUG. Missing in
# production is a hard error rather than a silent insecure fallback.
SECRET_KEY = config(
"SECRET_KEY",
default="django-insecure-x0_t$gei=_o_p(%%!-db$jezka@y+d67$a8tvw13nl^8$l*t@=",
allow_file=True,
required_in_prod=True,
)
SECRET_KEY = "django-insecure-x0_t$gei=_o_p(%%!-db$jezka@y+d67$a8tvw13nl^8$l*t@="
# ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS are configured independently (they
# guard different things), but both default off a single user-facing APP_URL
# when not set explicitly. Power users override either one directly — e.g.
# ALLOWED_HOSTS=* behind a reverse proxy while CSRF stays locked to the domain.
APP_URL = config("APP_URL", default="http://localhost:8000")
_app_url = urlparse(APP_URL)
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = False if os.environ.get("PROD") else True
ALLOWED_HOSTS = config("ALLOWED_HOSTS", default=None, cast=list) or [_app_url.hostname]
CSRF_TRUSTED_ORIGINS = config("CSRF_TRUSTED_ORIGINS", default=None, cast=list) or [
f"{_app_url.scheme}://{_app_url.netloc}"
]
ALLOWED_HOSTS = ["*"]
# Application definition
@@ -144,7 +109,7 @@ WSGI_APPLICATION = "timetracker.wsgi.application"
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": config("DATA_DIR", default=BASE_DIR, cast=Path) / "db.sqlite3",
"NAME": Path(os.environ.get("DATA_DIR", str(BASE_DIR))) / "db.sqlite3",
"OPTIONS": {
"timeout": 20,
"init_command": "PRAGMA synchronous=FULL; PRAGMA journal_mode=WAL;",
@@ -177,7 +142,7 @@ AUTH_PASSWORD_VALIDATORS = [
LANGUAGE_CODE = "en-us"
TIME_ZONE = config("TZ", default="Europe/Prague" if DEBUG else "UTC")
TIME_ZONE = "Europe/Prague" if DEBUG else os.environ.get("TZ", "UTC")
USE_I18N = True
@@ -210,3 +175,9 @@ LOGGING = {
"games": {"handlers": ["console"], "level": "INFO", "propagate": False},
},
}
_csrf_trusted_origins = os.environ.get("CSRF_TRUSTED_ORIGINS")
if _csrf_trusted_origins:
CSRF_TRUSTED_ORIGINS = _csrf_trusted_origins.split(",")
else:
CSRF_TRUSTED_ORIGINS = []
-50
View File
@@ -1,50 +0,0 @@
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
@@ -1,16 +0,0 @@
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);

Some files were not shown because too many files have changed in this diff Show More