Compare commits
156 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
9d77fca009
|
|||
| 783fb324ef | |||
|
f1cafab525
|
|||
|
c2f9263f52
|
|||
| d8558eca89 | |||
| 2e9e6b4fcf | |||
| 6a3f66b1a9 | |||
| 1b0cccacf8 | |||
|
2b450c6d47
|
|||
|
9d02121c5b
|
|||
|
d2bf6efdb4
|
|||
|
227b1f674d
|
|||
| 017e3a61a8 | |||
| 2c699eb976 | |||
| f19d24ee98 | |||
| 263299ca52 | |||
| 0b7ddc260f | |||
| d9a8835696 | |||
| 029c65da79 | |||
| 008d92d433 | |||
|
9e17b94516
|
|||
|
507353bb48
|
|||
|
a9e148701d
|
|||
| c3de90e805 | |||
|
1d2dfd23af
|
|||
|
395f6e8dea
|
|||
|
abfdd03c6e
|
|||
|
e15b197623
|
|||
|
e12c667572
|
|||
| 874d3e236e | |||
|
f036a246a8
|
|||
|
7751c29529
|
|||
|
5f411b8ae9
|
|||
|
3fb9aa9f84
|
|||
|
138136e285
|
|||
|
2364d868fa
|
|||
|
ce976e8f2e
|
|||
|
c7af814364
|
|||
|
1258c529d2
|
|||
|
48644037f6
|
|||
|
04552aa8f6
|
|||
|
0f0dfc48fb
|
|||
|
763c00c50e
|
|||
|
5fd82c78d4
|
|||
| 58008d6f2c | |||
|
3ff3eed164
|
|||
|
7d46cc24b9
|
|||
|
3f95692746
|
|||
|
0527412265
|
|||
|
0c6c536d07
|
|||
|
544da26a9d
|
|||
|
7104605c06
|
|||
|
9c42d85f52
|
|||
|
bec7a1074c
|
|||
|
022d43a5a5
|
|||
|
1c5bff8651
|
|||
|
925cf007f4
|
|||
| 2d3ae4e04f | |||
| 0819ddb87d | |||
| 4031657bb5 | |||
| f673f3ac80 | |||
| e7db7eb0e8 | |||
| b68a131bae | |||
| 88cf374f33 | |||
| be919c992d | |||
| 0fa860c237 | |||
| 15a97dee9a | |||
| 1822ea8b51 | |||
| f32a88b47d | |||
| 6dfd6c83c9 | |||
| 19f1cdd197 | |||
| b5546ed828 | |||
| 9cb911401a | |||
| 2190b9d590 | |||
| 0c109cf2a1 | |||
| e8a49df2cf | |||
| 3c7ccbdd2b | |||
| 1322e6e71c | |||
| 58b274a452 | |||
| e309ff1b30 | |||
| 35d314768f | |||
| f9032eef9e | |||
| 99af73781b | |||
| 79d1be2852 | |||
| ebfc9aebfc | |||
| 03adcf99a7 | |||
| b1a4da2704 | |||
| 3ce6da708f | |||
| ab079cb447 | |||
| c2996fd91b | |||
| 22c688bd9a | |||
| 4e77934d06 | |||
| b8d807d302 | |||
| 67b40255ed | |||
| eda9d39cdc | |||
| 3a5b6e2d51 | |||
|
e45be806fc
|
|||
|
83aefcb849
|
|||
|
c7c196a054
|
|||
|
c639196266
|
|||
|
ed086c9702
|
|||
|
6f4841eaaa
|
|||
|
5c9bf45c61
|
|||
|
bd228365ed
|
|||
| 1c9fb474df | |||
| 737dd9275b | |||
| 9f436b245d | |||
| 7ebaa51eb0 | |||
| a7ff2962a6 | |||
| 103219a5e7 | |||
| 14efff8078 | |||
| ba9b92d419 | |||
| 05534875d6 | |||
| 428edbcfe8 | |||
| 11cd62a3b9 | |||
| d9902146dc | |||
| 83cbac9505 | |||
| 0285243172 | |||
| 112d3107ef | |||
| 22d7834ae9 | |||
| 60773e7755 | |||
| 79fa4bef44 | |||
| 15bb3ce1b9 | |||
| a06e772e42 | |||
| 29b42e0f3d | |||
| f210f818a9 | |||
| 6bc7da9f2f | |||
| c9189b9f8e | |||
| a37257f9c8 | |||
| db047dfaf2 | |||
| 6aff12b7b2 | |||
| 12b0b0af61 | |||
| 1a206d719b | |||
| a6532807cb | |||
| d7e6efa68a | |||
| 003e6ebe15 | |||
| e2cbd4a9f4 | |||
| 547894d8d0 | |||
| 061b5e6d8a | |||
| 05e30610e9 | |||
| 0aa87a17fe | |||
| 7c2c08501e | |||
| d3b29ff1d4 | |||
| 1c17fbcb6d | |||
| 3b9c05d674 | |||
| 2c2827df47 | |||
| a6384fc003 | |||
| 7f5384de48 | |||
| ffcc4ba0f3 | |||
|
7493f6fc28
|
|||
|
f9b91c5900
|
|||
|
36098374c2
|
|||
| afc16aabbb | |||
| 3ce3356064 | |||
| ed8589a972 | |||
| f4161bf3f4 |
+44
-14
@@ -1,21 +1,51 @@
|
||||
# Docker registry URL (used in docker-compose.yml)
|
||||
REGISTRY_URL=registry.kucharczyk.xyz
|
||||
# =============================================================================
|
||||
# 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.
|
||||
# =============================================================================
|
||||
|
||||
# Container timezone
|
||||
# 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.
|
||||
TZ=Europe/Prague
|
||||
|
||||
# User/group IDs for container (used in entrypoint.sh)
|
||||
# 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.
|
||||
PUID=1000
|
||||
PGID=100
|
||||
|
||||
# External port mapping
|
||||
# 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.
|
||||
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
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
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 }}
|
||||
@@ -0,0 +1,86 @@
|
||||
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
|
||||
@@ -19,6 +19,20 @@ 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
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
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
|
||||
+10
-1
@@ -4,11 +4,20 @@ __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/
|
||||
|
||||
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| Install dependencies | `make init` (installs Python via uv + npm packages) |
|
||||
| Install dependencies | `make init` (installs Python via uv + npm packages, loads platform fixtures) |
|
||||
| Development server | `make dev` (runs Django runserver + Tailwind CSS watcher) |
|
||||
| Production-like dev | `make dev-prod` (Caddy + Gunicorn/Uvicorn + Django-Q cluster) |
|
||||
| Run tests | `make test` (or `uv run --with pytest-django pytest`) |
|
||||
@@ -20,67 +20,174 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
| Auto-fix lint | `make lint-fix` (`ruff check --fix`) |
|
||||
| Lint + format check + tests | `make check` (CI-style aggregate) |
|
||||
| Sync uv.lock | `uv sync` (after editing pyproject.toml) |
|
||||
| Load platform fixtures | `make loadplatforms` |
|
||||
| Load sample data | `make loadsample` |
|
||||
| Dump games data | `make dumpgames` |
|
||||
|
||||
## Architecture
|
||||
|
||||
A Django 6+ monolith with a single app (`games/`) for tracking video game purchases, play sessions, and statistics. Uses HTMX for interactivity with a custom server-side component system, plus a Django Ninja REST API.
|
||||
A Django 6+ monolith (v1.7.0) with a single app (`games/`) for tracking video game purchases, play sessions, and statistics. Uses HTMX for interactivity with a pure-Python server-side component system, plus a Django Ninja REST API.
|
||||
|
||||
### Directory layout
|
||||
|
||||
```
|
||||
games/ — Django app: models, views, templates, forms, signals, tasks, API
|
||||
common/ — Shared utilities: time formatting, component system, HTML helpers
|
||||
games/ — Django app: models, views, templates, forms, signals, tasks, API, filters
|
||||
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
|
||||
```
|
||||
|
||||
### Models (in `games/models.py`)
|
||||
|
||||
- **Game** — name, platform, status (Unplayed/Played/Finished/Retired/Abandoned), mastered, playtime
|
||||
- **Platform** — name, group, icon slug
|
||||
- **Purchase** — ownership type, prices, currency conversion (`converted_price`, `price_per_game` is a GeneratedField), links to Game via M2M
|
||||
- **Session** — start/end timestamps, manual duration, device. `duration_calculated` and `duration_total` are GeneratedFields (cannot be written directly)
|
||||
- **Device** — name, type (PC/Console/Handheld/Mobile/SBC/Unknown)
|
||||
- **PlayEvent** — marks when a game was started/finished (separate from Sessions), `days_to_finish` is a GeneratedField
|
||||
- **Game** — `name`, `platform` (FK), `status` (u/p/f/r/a), `mastered`, `playtime` (DurationField updated via signal), `year_released`, `sort_name`, `wikidata`
|
||||
- **Platform** — `name`, `group`, `icon` (slug, auto-generated from name)
|
||||
- **Purchase** — ownership type, prices, currency conversion (`converted_price`, `price_per_game` is a `GeneratedField`), links to Game via M2M. `num_purchases` counts linked games. DLC/SeasonPass/BattlePass must have a `related_purchase`
|
||||
- **Session** — `timestamp_start`/`timestamp_end`, `duration_manual`, `device` (FK), `note`, `emulated`. `duration_calculated` and `duration_total` are `GeneratedField`s (cannot be written directly)
|
||||
- **Device** — `name`, `type` (PC/Console/Handheld/Mobile/SBC/Unknown)
|
||||
- **PlayEvent** — marks when a game was started/finished (separate from Sessions), `days_to_finish` is a `GeneratedField`
|
||||
- **ExchangeRate** — cached FX rates per currency pair per year
|
||||
- **GameStatusChange** — audit log of status transitions
|
||||
- **GameStatusChange** — audit log of status transitions, ordered by `-timestamp`
|
||||
- **FilterPreset** — saved filter configuration; `mode` (games/sessions/purchases/playevents), `find_filter`, `object_filter`, `ui_options` (all JSON). Follows Stash's SavedFilter pattern
|
||||
|
||||
**Sentinel objects**: `get_sentinel_platform()` returns an "Unspecified" platform used when a Game has no platform. A similar sentinel Device ("Unknown") is created when a Session has no device.
|
||||
|
||||
**GeneratedField constraint**: `duration_calculated`, `duration_total`, `price_per_game`, `days_to_finish` are computed by the database and cannot be written from application code.
|
||||
|
||||
### Key patterns
|
||||
|
||||
**Component system** (`common/components.py`): Python functions return HTML via django-cotton templates. Every component wraps `Component()` which calls `render_to_string` (LRU-cached in production). Key helpers: `A()`, `Button()`, `Icon()`, `Popover()`, `PopoverTruncated()`, `NameWithIcon()`, `LinkedPurchase()`, `Div()`, `Form()`.
|
||||
**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.
|
||||
|
||||
**Views** (`games/views/`): Function-based views decorated with `@login_required`. Organized by domain entity: `session.py`, `game.py`, `purchase.py`, `playevent.py`, `platform.py`, `device.py`, `statuschange.py`, `general.py`. The `general.py` has two context processors: `model_counts` and `global_current_year`.
|
||||
**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`:
|
||||
|
||||
- **`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()`).
|
||||
- **`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`
|
||||
|
||||
**Filter system** (`games/filters.py` + `common/criteria.py`): Stash-inspired structured filtering.
|
||||
|
||||
- `common/criteria.py` defines typed criterion classes: `StringCriterion`, `IntCriterion`, `FloatCriterion`, `DateCriterion`, `BoolCriterion`, `MultiCriterion`, `ChoiceCriterion`. Each has a `modifier` (`Modifier` enum: EQUALS, NOT_EQUALS, INCLUDES, EXCLUDES, GREATER_THAN, LESS_THAN, BETWEEN, IS_NULL, etc.) and a `to_q(field_name)` method.
|
||||
- `OperatorFilter` base class provides AND/OR/NOT sub-filter composition and JSON serialization/deserialization.
|
||||
- `games/filters.py` defines `GameFilter`, `SessionFilter`, `PurchaseFilter` (all `@dataclass` subclasses of `OperatorFilter`) and `FindFilter` (sort/pagination). Filters serialize to/from JSON and are passed in the `?filter=` query parameter.
|
||||
- `parse_game_filter()`, `parse_session_filter()`, `parse_purchase_filter()` helpers deserialize from a JSON string.
|
||||
- `FilterPreset` model stores named filter configurations that users can save and reload.
|
||||
|
||||
**Views** (`games/views/`): Function-based views decorated with `@login_required`. Organized by domain entity:
|
||||
|
||||
- `session.py`, `game.py`, `purchase.py`, `playevent.py`, `platform.py`, `device.py`, `statuschange.py` — CRUD for each entity
|
||||
- `general.py` — `stats()`, `stats_alltime()`, `index()`, `model_counts` context processor, `global_current_year` context processor, `use_custom_redirect` decorator (redirects to `request.session["return_path"]` if set)
|
||||
- `stats_data.py` — `compute_stats(year)` returns a `StatsData` TypedDict; pure computation, no HTTP
|
||||
- `stats_content.py` — renders stats page content from a `StatsData` dict
|
||||
- `filter_presets.py` — `list_presets`, `save_preset`, `delete_preset`, `load_preset`
|
||||
- `auth.py` — custom `LoginView` subclassing Django's auth view, renders login page via `render_page()`
|
||||
|
||||
**Signals** (`games/signals.py`):
|
||||
- `pre_save` on Purchase: snapshots old price/currency for change detection
|
||||
- `post_save` on Purchase: sets `needs_price_update` if price/currency changed
|
||||
- `m2m_changed` on Purchase.games: updates `num_purchases` count
|
||||
- `pre_delete` on Game: decrements `num_purchases` on related Purchases
|
||||
- `post_save/post_delete` on Session: recalculates Game.playtime
|
||||
- `pre_save` on Game: creates GameStatusChange audit records
|
||||
- `pre_delete` on Game: decrements `num_purchases` on related Purchases (deletes Purchase if count reaches 0)
|
||||
- `post_save/post_delete` on Session: recalculates `Game.playtime` from session aggregate
|
||||
- `pre_save` on Game: creates `GameStatusChange` audit records when `status` changes
|
||||
|
||||
**Background tasks**: django-q2 cluster runs `games.tasks.convert_prices()` on a schedule to fetch exchange rates and convert purchase prices to CZK.
|
||||
**Background tasks**: django-q2 cluster runs `games.tasks.convert_prices()` on a schedule to fetch exchange rates from `cdn.jsdelivr.net/npm/@fawazahmed0/currency-api` and convert purchase prices to CZK.
|
||||
|
||||
**HTMX toast middleware** (`games/htmx_middleware.py`): Converts Django messages into `HX-Trigger` headers with `show-toast` event. Skips if `HX-Redirect` is present.
|
||||
**HTMX toast middleware** (`games/htmx_middleware.py`): Converts Django messages into `HX-Trigger` headers with `show-toast` event. Skips if `HX-Redirect` is present. Toast rendering is handled client-side by Alpine.js (`games/static/js/toast.js`).
|
||||
|
||||
**REST API** (`games/api.py`): Django Ninja with routers for playevents, games, and sessions. Game status and session device can be PATCHed via the API.
|
||||
**REST API** (`games/api.py`): Django Ninja with routers mounted at `/api/`:
|
||||
- `GET /api/games/search` — search games for autocomplete
|
||||
- `PATCH /api/games/{id}/status` — update game status
|
||||
- `GET/POST /api/playevent/` — list/create play events
|
||||
- `GET/PATCH/DELETE /api/playevent/{id}` — get/update/delete play event
|
||||
- `PATCH /api/session/{id}/device` — update session device
|
||||
|
||||
### Templates
|
||||
|
||||
Templates live in `games/templates/`. The layout uses django-cotton components in `templates/cotton/` — a reusable component library with `button.html`, `table.html`, `popover.html`, etc. Platform icons are stored as individual HTML snippet files under `cotton/icon/<slug>.html`. Partials for HTMX responses are in `templates/partials/`.
|
||||
Only a small number of HTML templates remain (platform icon snippets and partials). The bulk of the UI is built via Python components. Template files:
|
||||
|
||||
- `games/templates/icons/<slug>.html` — SVG icon snippets (loaded by `common/icons.py` via `get_icon()`)
|
||||
- `games/templates/` — minimal partials for HTMX responses where needed
|
||||
|
||||
### 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
|
||||
- **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()`.
|
||||
|
||||
### Deployment
|
||||
|
||||
Docker-based: multi-stage Dockerfile (uv builder → slim runtime), Caddy as reverse proxy on port 8000, Gunicorn with UvicornWorker (ASGI), Supervisor to manage Caddy + Gunicorn + django-q2. `make dev-prod` mimics production locally. CI/CD via Drone (`.drone.yml`): runs tests, builds Docker image, deploys via Portainer webhook.
|
||||
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.
|
||||
|
||||
### Database
|
||||
|
||||
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 GeneratedFields 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` 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.
|
||||
|
||||
### Configuration
|
||||
|
||||
- `DEBUG` is `True` unless `PROD` env var is set
|
||||
- `TIME_ZONE` defaults to `Europe/Prague` in debug, otherwise reads `TZ` env var
|
||||
- Django Admin and Debug Toolbar are only available in DEBUG mode
|
||||
- `CSRF_TRUSTED_ORIGINS` is parsed from a comma-separated env var
|
||||
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.
|
||||
- django-q2 cluster: 1 worker, 60s timeout, 120s retry, ORM broker
|
||||
|
||||
### Testing
|
||||
|
||||
Tests live in `tests/`. Run with `make test` or `uv run --with pytest-django pytest`. Key test files:
|
||||
|
||||
- `test_components.py` — component rendering
|
||||
- `test_filter_bars.py`, `test_filter_helpers.py`, `test_filters.py` — filter system
|
||||
- `test_paths_return_200.py` — smoke test all list/view URLs
|
||||
- `test_rendered_pages.py` — HTML output of pages
|
||||
- `test_signals.py` — signal side-effects (playtime recalc, status change audit, etc.)
|
||||
- `test_stats.py` — stats computation
|
||||
- `test_streak.py`, `test_time.py`, `test_session_formatting.py` — utilities
|
||||
- `test_middleware_integration.py`, `test_toast_middleware.py` — HTMX middleware
|
||||
- `test_price_update.py` — currency conversion signals
|
||||
- `test_search_select.py` — SearchSelect component
|
||||
|
||||
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
@@ -15,6 +15,25 @@ 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
|
||||
|
||||
@@ -44,6 +63,10 @@ WORKDIR /home/timetracker/app
|
||||
|
||||
COPY --from=builder --chown=timetracker:timetracker /home/timetracker/app /home/timetracker/app
|
||||
|
||||
# Built front-end assets from the Node stage (Tailwind CSS + compiled TS).
|
||||
COPY --from=assets --chown=timetracker:timetracker /app/games/static/base.css /home/timetracker/app/games/static/base.css
|
||||
COPY --from=assets --chown=timetracker:timetracker /app/games/static/js/dist /home/timetracker/app/games/static/js/dist
|
||||
|
||||
COPY --chown=timetracker:timetracker Caddyfile /etc/caddy/Caddyfile
|
||||
COPY --chown=timetracker:timetracker supervisor.conf /etc/supervisor/conf.d/supervisor.conf
|
||||
COPY --chown=timetracker:timetracker entrypoint.sh /
|
||||
|
||||
@@ -22,12 +22,25 @@ 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" \
|
||||
--prefix-colors "blue,green" \
|
||||
--names "Django,Tailwind,TS" \
|
||||
--prefix-colors "blue,green,magenta" \
|
||||
"uv run python -Wa manage.py runserver" \
|
||||
"pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css --watch"
|
||||
"pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css --watch" \
|
||||
"pnpm exec tsc --watch"
|
||||
|
||||
|
||||
caddy:
|
||||
@@ -67,6 +80,9 @@ 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
|
||||
|
||||
@@ -79,7 +95,7 @@ format:
|
||||
format-check:
|
||||
uv run ruff format --check
|
||||
|
||||
check: lint format-check test
|
||||
check: lint format-check ts-check test
|
||||
|
||||
date:
|
||||
uv run python -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))'
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
with open("common/components.py", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
# Count FilterBar functions to know which replacement targets which
|
||||
n = content.count('("name", "filter"), ("value", escape(filter_json))')
|
||||
print(f"Found {n} hidden filter inputs")
|
||||
|
||||
# Simple: after each hidden filter input, insert a search input
|
||||
search_html = ''' Component(tag_name="input", attributes=[
|
||||
("type", "text"), ("name", "filter-search"),
|
||||
("value", escape(search_val)),
|
||||
("placeholder", "Search\u2026"),
|
||||
("class", "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"),
|
||||
]),
|
||||
'''
|
||||
|
||||
old = ''' Component(tag_name="input", attributes=[
|
||||
("type", "hidden"), ("id", filter_input_id),
|
||||
("name", "filter"), ("value", escape(filter_json)),
|
||||
]),
|
||||
Component(tag_name="div", attributes=['''
|
||||
|
||||
# Only replace occurrences in FilterBar functions (after 'def FilterBar' or 'def SessionFilterBar' or 'def PurchaseFilterBar')
|
||||
# Find each occurrence and replace
|
||||
import re
|
||||
# Strategy: split by the old pattern, insert search_html between first two parts of each split
|
||||
parts = content.split(old)
|
||||
print(f"Split into {len(parts)} parts")
|
||||
|
||||
new_content = parts[0]
|
||||
for i in range(1, len(parts)):
|
||||
# Check if this occurrence is inside a FilterBar function (not inside SelectableFilter)
|
||||
# Simple heuristic: the context before should contain 'FilterBar'
|
||||
chunk_before = parts[i-1][-500:] if len(parts[i-1]) > 500 else parts[i-1]
|
||||
is_filterbar = 'FilterBar' in chunk_before or 'filter_bar' in chunk_before.lower()
|
||||
if is_filterbar:
|
||||
new_content += old + search_html + parts[i]
|
||||
else:
|
||||
new_content += old + parts[i]
|
||||
|
||||
with open("common/components.py", "w") as f:
|
||||
f.write(new_content)
|
||||
|
||||
import ast
|
||||
ast.parse(new_content)
|
||||
print("OK")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,168 @@
|
||||
"""Server-side HTML component library.
|
||||
|
||||
Split into core / primitives / domain / filters submodules; this package
|
||||
re-exports the public API so ``from common.components import X`` keeps working.
|
||||
"""
|
||||
|
||||
from common.components.core import (
|
||||
BaseComponent,
|
||||
Element,
|
||||
Fragment,
|
||||
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.domain import (
|
||||
GameLink,
|
||||
GameStatus,
|
||||
GameStatusSelector,
|
||||
LinkedPurchase,
|
||||
NameWithIcon,
|
||||
PriceConverted,
|
||||
PurchasePrice,
|
||||
SessionDeviceSelector,
|
||||
_resolve_name_with_icon,
|
||||
)
|
||||
from common.components.filters import (
|
||||
DeviceFilterBar,
|
||||
FilterBar,
|
||||
PlatformFilterBar,
|
||||
PlayEventFilterBar,
|
||||
PurchaseFilterBar,
|
||||
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",
|
||||
"HTMLAttribute",
|
||||
"HTMLTag",
|
||||
"_render_element",
|
||||
"randomid",
|
||||
"A",
|
||||
"AddForm",
|
||||
"StyledButton",
|
||||
"ButtonGroup",
|
||||
"Checkbox",
|
||||
"CsrfInput",
|
||||
"Div",
|
||||
"ExternalScript",
|
||||
"H1",
|
||||
"Icon",
|
||||
"Input",
|
||||
"Modal",
|
||||
"ModuleScript",
|
||||
"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",
|
||||
"GameStatus",
|
||||
"GameStatusSelector",
|
||||
"LinkedPurchase",
|
||||
"NameWithIcon",
|
||||
"PriceConverted",
|
||||
"PurchasePrice",
|
||||
"SessionDeviceSelector",
|
||||
"_resolve_name_with_icon",
|
||||
"DateRangeCalendar",
|
||||
"DateRangeField",
|
||||
"DateRangePicker",
|
||||
"FilterBar",
|
||||
"PurchaseFilterBar",
|
||||
"SessionFilterBar",
|
||||
"DeviceFilterBar",
|
||||
"PlatformFilterBar",
|
||||
"PlayEventFilterBar",
|
||||
"StringFilter",
|
||||
]
|
||||
@@ -0,0 +1,353 @@
|
||||
"""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.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from collections.abc import Sequence
|
||||
from functools import lru_cache
|
||||
|
||||
from django.utils.html import escape
|
||||
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.
|
||||
|
||||
``attrs_key`` is (name, stringified value) pairs (values always escaped);
|
||||
``children_key`` is (text, is_safe) pairs (safe passes through, else escaped).
|
||||
"""
|
||||
children_blob = "\n".join(
|
||||
child if is_safe else escape(child) for child, is_safe in children_key
|
||||
)
|
||||
if attrs_key:
|
||||
attributes_blob = " " + " ".join(
|
||||
f'{name}="{escape(value)}"' for name, value in attrs_key
|
||||
)
|
||||
else:
|
||||
attributes_blob = ""
|
||||
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 randomid(seed: str = "", content: str = "", length: int = 10) -> str:
|
||||
if not seed and not content:
|
||||
return seed
|
||||
hash_input = f"{seed}:{content}" if seed else content
|
||||
content_hash = hashlib.sha1(hash_input.encode()).hexdigest()
|
||||
base = (
|
||||
content_hash[:length]
|
||||
if not seed
|
||||
else content_hash[: max(0, length - len(seed))]
|
||||
)
|
||||
return seed + base
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Custom-element builder, registry, and TypeScript codegen.
|
||||
|
||||
A custom element is a light-DOM Web Component: the Python builder emits a
|
||||
semantic tag whose typed props become kebab-case attributes and whose behavior
|
||||
lives in a compiled TS module (loaded via Media). One ``TypedDict`` per element
|
||||
is the single source of truth for the server<->client contract;
|
||||
``gen_element_types`` turns each registered spec into a TS interface + attribute
|
||||
reader so drift fails ``tsc``.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TypedDict, get_type_hints
|
||||
|
||||
from common.components.core import Media
|
||||
from common.components.primitives import custom_element_builder
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ElementSpec:
|
||||
tag: str # e.g. "game-status-selector"
|
||||
ts_name: str # e.g. "GameStatusSelector"
|
||||
props: type # a TypedDict subclass
|
||||
|
||||
|
||||
ELEMENT_REGISTRY: list[ElementSpec] = []
|
||||
|
||||
|
||||
def register_element(tag: str, ts_name: str, props: type) -> None:
|
||||
"""Register an element so codegen can emit its TS contract."""
|
||||
ELEMENT_REGISTRY.append(ElementSpec(tag, ts_name, props))
|
||||
|
||||
|
||||
def _kebab(name: str) -> str:
|
||||
return name.replace("_", "-")
|
||||
|
||||
|
||||
# ── Codegen ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_TYPE_MAP = {int: "number", float: "number", str: "string", bool: "boolean"}
|
||||
|
||||
|
||||
def _camel(name: str) -> str:
|
||||
head, *tail = name.split("_")
|
||||
return head + "".join(part.title() for part in tail)
|
||||
|
||||
|
||||
def _reader_expr(name: str, python_type: type) -> str:
|
||||
attr = _kebab(name)
|
||||
if python_type in (int, float):
|
||||
return f'Number(el.getAttribute("{attr}"))'
|
||||
if python_type is bool:
|
||||
return f'el.getAttribute("{attr}") === "true"'
|
||||
return f'el.getAttribute("{attr}") ?? ""'
|
||||
|
||||
|
||||
def _ts_for_spec(spec: ElementSpec) -> str:
|
||||
hints = get_type_hints(spec.props)
|
||||
interface_lines = "\n".join(
|
||||
f" {_camel(name)}: {_TYPE_MAP[python_type]};"
|
||||
for name, python_type in hints.items()
|
||||
)
|
||||
reader_lines = "\n".join(
|
||||
f" {_camel(name)}: {_reader_expr(name, python_type)},"
|
||||
for name, python_type in hints.items()
|
||||
)
|
||||
return (
|
||||
f"export interface {spec.ts_name}Props {{\n{interface_lines}\n}}\n\n"
|
||||
f"export function read{spec.ts_name}Props(el: HTMLElement): "
|
||||
f"{spec.ts_name}Props {{\n return {{\n{reader_lines}\n }};\n}}"
|
||||
)
|
||||
|
||||
|
||||
def render_props_module() -> str:
|
||||
"""The full ``ts/generated/props.ts`` content for every registered element."""
|
||||
header = "// GENERATED by `manage.py gen_element_types` — do not edit.\n"
|
||||
blocks = [_ts_for_spec(spec) for spec in ELEMENT_REGISTRY]
|
||||
return header + "\n" + "\n\n".join(blocks) + "\n"
|
||||
|
||||
|
||||
# ── Element prop schemas (registered at import time) ─────────────────────────
|
||||
|
||||
|
||||
class GameStatusSelectorProps(TypedDict):
|
||||
game_id: int
|
||||
status: str
|
||||
csrf: str
|
||||
|
||||
|
||||
register_element("game-status-selector", "GameStatusSelector", GameStatusSelectorProps)
|
||||
|
||||
|
||||
class SessionDeviceSelectorProps(TypedDict):
|
||||
session_id: int
|
||||
csrf: str
|
||||
|
||||
|
||||
register_element(
|
||||
"session-device-selector", "SessionDeviceSelector", SessionDeviceSelectorProps
|
||||
)
|
||||
|
||||
|
||||
class PlayEventRowProps(TypedDict):
|
||||
game_id: int
|
||||
csrf: str
|
||||
api_create_url: str
|
||||
|
||||
|
||||
register_element("play-event-row", "PlayEventRow", PlayEventRowProps)
|
||||
|
||||
|
||||
class SessionTimestampButtonsProps(TypedDict):
|
||||
pass
|
||||
|
||||
|
||||
register_element(
|
||||
"session-timestamp-buttons", "SessionTimestampButtons", SessionTimestampButtonsProps
|
||||
)
|
||||
|
||||
|
||||
# ── Named tag builders (consistent htpy-style with Div/Span) ─────────────────
|
||||
# Underscore-prefixed: used internally by domain wrappers.
|
||||
# Public ones (no domain wrapper): exported directly.
|
||||
|
||||
_GameStatusSelector = custom_element_builder("game-status-selector")
|
||||
_SessionDeviceSelector = custom_element_builder("session-device-selector")
|
||||
_PlayEventRow = custom_element_builder("play-event-row")
|
||||
SessionTimestampButtons = custom_element_builder("session-timestamp-buttons")
|
||||
@@ -0,0 +1,354 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,308 @@
|
||||
"""Domain components for games / purchases / sessions."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from django.template.defaultfilters import floatformat
|
||||
from django.urls import reverse
|
||||
|
||||
from common.components.core import Children, Node, Safe, as_children
|
||||
from common.components.primitives import (
|
||||
A,
|
||||
Div,
|
||||
Icon,
|
||||
Popover,
|
||||
PopoverTruncated,
|
||||
Span,
|
||||
)
|
||||
from games.models import Game, Purchase, Session
|
||||
|
||||
|
||||
def GameLink(
|
||||
game_id: int,
|
||||
name: str = "",
|
||||
children: Children = None,
|
||||
) -> Node:
|
||||
"""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]
|
||||
link = reverse("games:view_game", args=[game_id])
|
||||
|
||||
return Span(
|
||||
attributes=[("class", "truncate-container")],
|
||||
children=[
|
||||
A(
|
||||
href=link,
|
||||
attributes=[
|
||||
("class", "underline decoration-slate-500 sm:decoration-2"),
|
||||
],
|
||||
children=display,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
_STATUS_COLORS = {
|
||||
"u": "bg-gray-500",
|
||||
"p": "bg-orange-400",
|
||||
"f": "bg-green-500",
|
||||
"a": "bg-red-500",
|
||||
"r": "bg-purple-500",
|
||||
}
|
||||
|
||||
|
||||
def GameStatus(
|
||||
children: Children = None,
|
||||
status: str = "u",
|
||||
display: str = "",
|
||||
class_: str = "",
|
||||
) -> Node:
|
||||
"""Colored status dot with label. Status codes: u/p/f/a/r."""
|
||||
children = children or []
|
||||
outer_class = (
|
||||
f"{'flex' if display == 'flex' else 'inline-flex'} "
|
||||
"gap-2 items-center align-middle"
|
||||
)
|
||||
if class_:
|
||||
outer_class += f" {class_}"
|
||||
dot_color = _STATUS_COLORS.get(status, _STATUS_COLORS["u"])
|
||||
|
||||
dot = Span(
|
||||
attributes=[("class", f"rounded-xl w-3 h-3 {dot_color}")],
|
||||
children=["\xa0"],
|
||||
)
|
||||
|
||||
return Span(
|
||||
attributes=[("class", outer_class)],
|
||||
children=[dot] + as_children(children),
|
||||
)
|
||||
|
||||
|
||||
def PriceConverted(
|
||||
children: Children = None,
|
||||
) -> Node:
|
||||
"""Wrap content in a span that indicates the price was converted."""
|
||||
children = children or []
|
||||
return Span(
|
||||
attributes=[
|
||||
("title", "Price is a result of conversion and rounding."),
|
||||
("class", "decoration-dotted underline"),
|
||||
],
|
||||
children=as_children(children),
|
||||
)
|
||||
|
||||
|
||||
def LinkedPurchase(purchase: Purchase) -> Node:
|
||||
link = reverse("games:view_purchase", args=[int(purchase.id)])
|
||||
link_content = ""
|
||||
popover_content = ""
|
||||
game_count = purchase.games.count()
|
||||
popover_if_not_truncated = False
|
||||
if game_count == 1:
|
||||
link_content += purchase.games.first().name
|
||||
popover_content = link_content
|
||||
if game_count > 1:
|
||||
if purchase.name:
|
||||
link_content += f"{purchase.name}"
|
||||
popover_content += f"<h1>{purchase.name}</h1><br>"
|
||||
else:
|
||||
link_content += f"{game_count} games"
|
||||
popover_if_not_truncated = True
|
||||
popover_content += f"""
|
||||
<ul class="list-disc list-inside">
|
||||
{"".join(f"<li>{game.name}</li>" for game in purchase.games.all())}
|
||||
</ul>
|
||||
"""
|
||||
icon = (
|
||||
(purchase.platform.icon if purchase.platform else "unspecified")
|
||||
if game_count == 1
|
||||
else "unspecified"
|
||||
)
|
||||
if link_content == "":
|
||||
raise ValueError("link_content is empty!!")
|
||||
a_content = Div(
|
||||
[("class", "inline-flex gap-2 items-center")],
|
||||
[
|
||||
Icon(
|
||||
icon,
|
||||
[("title", "Multiple")],
|
||||
),
|
||||
PopoverTruncated(
|
||||
input_string=link_content,
|
||||
popover_content=Safe(popover_content),
|
||||
popover_if_not_truncated=popover_if_not_truncated,
|
||||
),
|
||||
],
|
||||
)
|
||||
return A(href=link, children=[a_content])
|
||||
|
||||
|
||||
def NameWithIcon(
|
||||
name: str = "",
|
||||
game: Game | None = None,
|
||||
session: Session | None = None,
|
||||
linkify: bool = True,
|
||||
emulated: bool = False,
|
||||
) -> Node:
|
||||
_name, platform, final_emulated, create_link, link = _resolve_name_with_icon(
|
||||
name, game, session, linkify
|
||||
)
|
||||
|
||||
content = Div(
|
||||
[("class", "inline-flex gap-2 items-center")],
|
||||
[
|
||||
Icon(
|
||||
platform.icon,
|
||||
[("title", platform.name)],
|
||||
)
|
||||
if platform
|
||||
else "",
|
||||
Icon("emulated", [("title", "Emulated")]) if final_emulated else "",
|
||||
PopoverTruncated(_name),
|
||||
],
|
||||
)
|
||||
|
||||
return (
|
||||
A(
|
||||
href=link,
|
||||
children=[content],
|
||||
)
|
||||
if create_link
|
||||
else content
|
||||
)
|
||||
|
||||
|
||||
def _resolve_name_with_icon(
|
||||
name: str,
|
||||
game: Game | None,
|
||||
session: Session | None,
|
||||
linkify: bool,
|
||||
) -> tuple[str, Any, bool, bool, str]:
|
||||
create_link = False
|
||||
link = ""
|
||||
platform = None
|
||||
final_emulated = False
|
||||
|
||||
if session is not None:
|
||||
game = session.game
|
||||
platform = game.platform
|
||||
final_emulated = session.emulated
|
||||
if linkify:
|
||||
create_link = True
|
||||
link = reverse("games:view_game", args=[int(game.pk)])
|
||||
elif game is not None:
|
||||
platform = game.platform
|
||||
if linkify:
|
||||
create_link = True
|
||||
link = reverse("games:view_game", args=[int(game.pk)])
|
||||
|
||||
_name = name or (game.name if game else "")
|
||||
|
||||
return _name, platform, final_emulated, create_link, link
|
||||
|
||||
|
||||
def PurchasePrice(purchase) -> Node:
|
||||
return Popover(
|
||||
popover_content=f"{floatformat(purchase.price)} {purchase.price_currency}",
|
||||
wrapped_content=f"{floatformat(purchase.converted_price)} {purchase.converted_currency}",
|
||||
wrapped_classes="underline decoration-dotted",
|
||||
)
|
||||
|
||||
|
||||
_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"),
|
||||
)
|
||||
]
|
||||
for value, label in game_statuses
|
||||
]
|
||||
current_label = Span(data_label="")[
|
||||
GameStatus(
|
||||
status=game.status,
|
||||
children=[game.get_status_display()],
|
||||
display="flex",
|
||||
)
|
||||
]
|
||||
toggle = Element(
|
||||
"button",
|
||||
[("type", "button"), ("data-toggle", ""), ("class", _SELECTOR_TOGGLE_CLASS)],
|
||||
Span(class_="flex flex-row gap-4 justify-between items-center")[
|
||||
current_label, Icon("arrowdown")
|
||||
],
|
||||
)
|
||||
menu = Div(data_menu="", hidden=True, class_=_SELECTOR_MENU_CLASS)[Ul()[*options]]
|
||||
dropdown = Div(
|
||||
data_dropdown="", class_="inline-flex rounded-md shadow-2xs relative"
|
||||
)[toggle, menu]
|
||||
return _GameStatusSelector(game_id=game.id, status=game.status, csrf=csrf_token)[
|
||||
Div(class_="flex gap-2 items-center")[dropdown]
|
||||
]
|
||||
|
||||
|
||||
def SessionDeviceSelector(session, session_devices, csrf_token: str) -> Node:
|
||||
"""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]
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,576 @@
|
||||
"""Search field + dropdown select component (pure Python, domain-agnostic).
|
||||
|
||||
Pairs a search box with a dropdown of options. Supports single/multi select;
|
||||
in multi-select, chosen items render as removable ``Pill``s, each backed by a
|
||||
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 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",))
|
||||
|
||||
|
||||
class SearchSelectOption(TypedDict):
|
||||
value: str | int
|
||||
label: str
|
||||
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"
|
||||
)
|
||||
_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"
|
||||
)
|
||||
_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."""
|
||||
if isinstance(option, dict):
|
||||
return {
|
||||
"value": option["value"],
|
||||
"label": option["label"],
|
||||
"data": option.get("data") or {},
|
||||
}
|
||||
value, label = option
|
||||
return {"value": value, "label": label, "data": {}}
|
||||
|
||||
|
||||
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 _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(
|
||||
attributes=[
|
||||
("data-search-select-option", ""),
|
||||
("data-value", str(option["value"])),
|
||||
("data-label", option["label"]),
|
||||
("class", _OPTION_ROW_CLASS),
|
||||
*_data_attributes(option["data"]),
|
||||
],
|
||||
children=[_label_slot(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,
|
||||
selected: list[SearchSelectOption] | None = None,
|
||||
options: list[SearchSelectOption] | None = None,
|
||||
search_url: str = "",
|
||||
multi_select: bool = False,
|
||||
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:
|
||||
"""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 [])]
|
||||
|
||||
# ── 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] = []
|
||||
search_value = ""
|
||||
if multi_select:
|
||||
for option in selected:
|
||||
pills_children.append(
|
||||
Pill(
|
||||
option["label"],
|
||||
value=str(option["value"]),
|
||||
removable=True,
|
||||
label_slot=True,
|
||||
attributes=_data_attributes(option["data"]),
|
||||
)
|
||||
)
|
||||
pills_children.append(_hidden_input(name, option["value"]))
|
||||
elif selected:
|
||||
option = selected[0]
|
||||
pills_children.append(_hidden_input(name, option["value"]))
|
||||
search_value = option["label"]
|
||||
|
||||
pills = Div(
|
||||
attributes=[("data-search-select-pills", ""), ("class", _PILLS_CLASS)],
|
||||
children=pills_children,
|
||||
)
|
||||
|
||||
# ── Search box (NO name — the query is never submitted) ──
|
||||
search_attrs: list[HTMLAttribute] = [
|
||||
("data-search-select-search", ""),
|
||||
("placeholder", placeholder),
|
||||
("autocomplete", "off"),
|
||||
("class", _SEARCH_CLASS),
|
||||
]
|
||||
if autofocus:
|
||||
search_attrs.append(("autofocus", ""))
|
||||
if search_value:
|
||||
search_attrs.append(("value", search_value))
|
||||
|
||||
# ── 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 []
|
||||
|
||||
# ── 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] = [
|
||||
("data-search-select", ""),
|
||||
("data-name", name),
|
||||
("data-search-url", search_url),
|
||||
("data-multi", "true" if multi_select else "false"),
|
||||
("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))
|
||||
|
||||
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=["×"],
|
||||
)
|
||||
|
||||
|
||||
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]],
|
||||
) -> list[SearchSelectOption]:
|
||||
"""Resolve ``values`` into ``SearchSelectOption``s via ``resolver``.
|
||||
|
||||
``resolver(values)`` should resolve ONLY the given ids (a ``pk__in`` query)
|
||||
— never iterating all choices, so it stays cheap.
|
||||
"""
|
||||
if not values:
|
||||
return []
|
||||
return [_normalize_option(option) for option in resolver(values)]
|
||||
+135
-74
@@ -30,6 +30,7 @@ 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"
|
||||
@@ -38,19 +39,27 @@ class Modifier(str, Enum):
|
||||
@classmethod
|
||||
def for_strings(cls) -> list[Self]:
|
||||
return [
|
||||
cls.EQUALS, cls.NOT_EQUALS,
|
||||
cls.INCLUDES, cls.EXCLUDES,
|
||||
cls.MATCHES_REGEX, cls.NOT_MATCHES_REGEX,
|
||||
cls.IS_NULL, cls.NOT_NULL,
|
||||
cls.EQUALS,
|
||||
cls.NOT_EQUALS,
|
||||
cls.INCLUDES,
|
||||
cls.EXCLUDES,
|
||||
cls.MATCHES_REGEX,
|
||||
cls.NOT_MATCHES_REGEX,
|
||||
cls.IS_NULL,
|
||||
cls.NOT_NULL,
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def for_numbers(cls) -> list[Self]:
|
||||
return [
|
||||
cls.EQUALS, cls.NOT_EQUALS,
|
||||
cls.GREATER_THAN, cls.LESS_THAN,
|
||||
cls.BETWEEN, cls.NOT_BETWEEN,
|
||||
cls.IS_NULL, cls.NOT_NULL,
|
||||
cls.EQUALS,
|
||||
cls.NOT_EQUALS,
|
||||
cls.GREATER_THAN,
|
||||
cls.LESS_THAN,
|
||||
cls.BETWEEN,
|
||||
cls.NOT_BETWEEN,
|
||||
cls.IS_NULL,
|
||||
cls.NOT_NULL,
|
||||
]
|
||||
|
||||
@classmethod
|
||||
@@ -60,9 +69,12 @@ class Modifier(str, Enum):
|
||||
@classmethod
|
||||
def for_multi(cls) -> list[Self]:
|
||||
return [
|
||||
cls.INCLUDES, cls.EXCLUDES,
|
||||
cls.INCLUDES,
|
||||
cls.EXCLUDES,
|
||||
cls.INCLUDES_ALL,
|
||||
cls.IS_NULL, cls.NOT_NULL,
|
||||
cls.INCLUDES_ONLY,
|
||||
cls.IS_NULL,
|
||||
cls.NOT_NULL,
|
||||
]
|
||||
|
||||
|
||||
@@ -152,8 +164,12 @@ class IntCriterion(_Criterion):
|
||||
if m == Modifier.BETWEEN:
|
||||
if self.value2 is None:
|
||||
raise ValueError("BETWEEN requires value2")
|
||||
return Q(**{f"{field_name}__gte": min(self.value, self.value2),
|
||||
f"{field_name}__lte": max(self.value, self.value2)})
|
||||
return Q(
|
||||
**{
|
||||
f"{field_name}__gte": min(self.value, self.value2),
|
||||
f"{field_name}__lte": max(self.value, self.value2),
|
||||
}
|
||||
)
|
||||
if m == Modifier.NOT_BETWEEN:
|
||||
if self.value2 is None:
|
||||
raise ValueError("NOT_BETWEEN requires value2")
|
||||
@@ -185,8 +201,12 @@ class FloatCriterion(_Criterion):
|
||||
if m == Modifier.BETWEEN:
|
||||
if self.value2 is None:
|
||||
raise ValueError("BETWEEN requires value2")
|
||||
return Q(**{f"{field_name}__gte": min(self.value, self.value2),
|
||||
f"{field_name}__lte": max(self.value, self.value2)})
|
||||
return Q(
|
||||
**{
|
||||
f"{field_name}__gte": min(self.value, self.value2),
|
||||
f"{field_name}__lte": max(self.value, self.value2),
|
||||
}
|
||||
)
|
||||
if m == Modifier.NOT_BETWEEN:
|
||||
if self.value2 is None:
|
||||
raise ValueError("NOT_BETWEEN requires value2")
|
||||
@@ -218,12 +238,15 @@ class DateCriterion(_Criterion):
|
||||
if m == Modifier.BETWEEN:
|
||||
if self.value2 is None:
|
||||
raise ValueError("BETWEEN requires value2")
|
||||
return Q(**{f"{field_name}__gte": self.value,
|
||||
f"{field_name}__lte": self.value2})
|
||||
return Q(
|
||||
**{f"{field_name}__gte": self.value, f"{field_name}__lte": self.value2}
|
||||
)
|
||||
if m == Modifier.NOT_BETWEEN:
|
||||
if self.value2 is None:
|
||||
raise ValueError("NOT_BETWEEN requires value2")
|
||||
return Q(**{f"{field_name}__lt": self.value}) | Q(**{f"{field_name}__gt": self.value2})
|
||||
return Q(**{f"{field_name}__lt": self.value}) | Q(
|
||||
**{f"{field_name}__gt": self.value2}
|
||||
)
|
||||
if m == Modifier.IS_NULL:
|
||||
return Q(**{f"{field_name}__isnull": True})
|
||||
if m == Modifier.NOT_NULL:
|
||||
@@ -246,75 +269,109 @@ class BoolCriterion(_Criterion):
|
||||
|
||||
|
||||
@dataclass
|
||||
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)
|
||||
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:
|
||||
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:
|
||||
modifier = self.modifier
|
||||
if modifier == Modifier.IS_NULL:
|
||||
return Q(**{f"{field_name}__isnull": True})
|
||||
if m == Modifier.NOT_NULL:
|
||||
if modifier == Modifier.NOT_NULL:
|
||||
return Q(**{f"{field_name}__isnull": False})
|
||||
raise ValueError(f"Unsupported modifier {m} for multi field")
|
||||
# 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 ChoiceCriterion(_Criterion):
|
||||
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.
|
||||
"""
|
||||
|
||||
value: list[int] = field(default_factory=list)
|
||||
excludes: list[int] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChoiceCriterion(_SetCriterion):
|
||||
"""Filter on a choice/enum field with multi-select include/exclude.
|
||||
|
||||
Used by SelectableFilter widgets for status, ownership_type, etc.
|
||||
Supports INCLUDES, EXCLUDES, EQUALS, IS_NULL, NOT_NULL modifiers.
|
||||
Used by FilterSelect widgets for status, ownership_type, etc. Shares all
|
||||
modifier logic with ``MultiCriterion`` via ``_SetCriterion``.
|
||||
"""
|
||||
|
||||
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 ────────────────────────────────────────────────────
|
||||
@@ -407,9 +464,13 @@ class OperatorFilter:
|
||||
f_type = f_type.split("|")[0].strip()
|
||||
if isinstance(f_type, str) and f_type in criterion_types:
|
||||
criterion_cls = criterion_types[f_type]
|
||||
kwargs[f.name] = criterion_cls.from_json(raw) if isinstance(raw, dict) else None
|
||||
kwargs[f.name] = (
|
||||
criterion_cls.from_json(raw) if isinstance(raw, dict) else None
|
||||
)
|
||||
elif isinstance(f_type, type) and issubclass(f_type, _Criterion):
|
||||
kwargs[f.name] = f_type.from_json(raw) if isinstance(raw, dict) else None
|
||||
kwargs[f.name] = (
|
||||
f_type.from_json(raw) if isinstance(raw, dict) else None
|
||||
)
|
||||
return cls(**kwargs)
|
||||
|
||||
def to_json(self) -> dict[str, Any]:
|
||||
|
||||
+1
-3
@@ -1,9 +1,7 @@
|
||||
import functools
|
||||
from pathlib import Path
|
||||
|
||||
_ICON_DIR = (
|
||||
Path(__file__).resolve().parent.parent / "games" / "templates" / "icons"
|
||||
)
|
||||
_ICON_DIR = Path(__file__).resolve().parent.parent / "games" / "templates" / "icons"
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
|
||||
+3
-5
@@ -206,11 +206,8 @@ textarea:disabled {
|
||||
label {
|
||||
@apply mb-2.5 text-sm font-medium text-heading;
|
||||
}
|
||||
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;
|
||||
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;
|
||||
}
|
||||
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;
|
||||
@@ -231,3 +228,4 @@ textarea:disabled {
|
||||
@apply fixed z-50 flex flex-col items-end bottom-0 right-0 p-4;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+59
-27
@@ -8,9 +8,11 @@ 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
|
||||
@@ -19,6 +21,9 @@ 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)) {
|
||||
@@ -182,12 +187,18 @@ 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) -> SafeText:
|
||||
"""Top navigation bar."""
|
||||
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
|
||||
|
||||
logo = static("icons/schedule.png")
|
||||
return mark_safe(f"""<nav class="bg-neutral-primary-soft border-b border-default">
|
||||
return 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')}"
|
||||
<a href="{reverse("games:index")}"
|
||||
class="flex items-center space-x-3 rtl:space-x-reverse">
|
||||
<img src="{logo}" height="48" width="48" alt="Timetracker Logo" class="mr-4" />
|
||||
<span class="self-center text-2xl font-semibold whitespace-nowrap dark:text-white">Timetracker</span>
|
||||
@@ -229,11 +240,11 @@ def Navbar(*, today_played: str, last_7_played: str, current_year: int) -> SafeT
|
||||
</button>
|
||||
<div id="dropdownNavbarNew" class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700 dark:divide-gray-600">
|
||||
<ul class="py-2 text-sm text-gray-700 dark:text-gray-400" aria-labelledby="dropdownLargeButton">
|
||||
<li><a href="{reverse('games:add_device')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Device</a></li>
|
||||
<li><a href="{reverse('games:add_game')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Game</a></li>
|
||||
<li><a href="{reverse('games:add_platform')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platform</a></li>
|
||||
<li><a href="{reverse('games:add_purchase')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchase</a></li>
|
||||
<li><a href="{reverse('games:add_session')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Session</a></li>
|
||||
<li><a href="{reverse("games:add_device")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Device</a></li>
|
||||
<li><a href="{reverse("games:add_game")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Game</a></li>
|
||||
<li><a href="{reverse("games:add_platform")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platform</a></li>
|
||||
<li><a href="{reverse("games:add_purchase")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchase</a></li>
|
||||
<li><a href="{reverse("games:add_session")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Session</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
@@ -247,20 +258,23 @@ def Navbar(*, today_played: str, last_7_played: str, current_year: int) -> SafeT
|
||||
</button>
|
||||
<div id="dropdownNavbarManage" class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700 dark:divide-gray-600">
|
||||
<ul class="py-2 text-sm text-gray-700 dark:text-gray-400" aria-labelledby="dropdownLargeButton">
|
||||
<li><a href="{reverse('games:list_devices')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Devices</a></li>
|
||||
<li><a href="{reverse('games:list_games')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Games</a></li>
|
||||
<li><a href="{reverse('games:list_platforms')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platforms</a></li>
|
||||
<li><a href="{reverse('games:list_playevents')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Play events</a></li>
|
||||
<li><a href="{reverse('games:list_purchases')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchases</a></li>
|
||||
<li><a href="{reverse('games:list_sessions')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Sessions</a></li>
|
||||
<li><a href="{reverse("games:list_devices")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Devices</a></li>
|
||||
<li><a href="{reverse("games:list_games")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Games</a></li>
|
||||
<li><a href="{reverse("games:list_platforms")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platforms</a></li>
|
||||
<li><a href="{reverse("games:list_playevents")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Play events</a></li>
|
||||
<li><a href="{reverse("games:list_purchases")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchases</a></li>
|
||||
<li><a href="{reverse("games:list_sessions")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Sessions</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{reverse('games:stats_by_year', args=[current_year])}" class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Stats</a>
|
||||
<a href="{reverse("games:stats_by_year", args=[current_year])}" class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Stats</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{reverse('logout')}" class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Log out</a>
|
||||
<form method="post" action="{reverse("logout")}">
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value="{csrf_token}">
|
||||
<button type="submit" class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Log out</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -269,22 +283,37 @@ def Navbar(*, today_played: str, last_7_played: str, current_year: int) -> SafeT
|
||||
|
||||
|
||||
def Page(
|
||||
content: SafeText | str,
|
||||
content: "Node | SafeText | str",
|
||||
*,
|
||||
request: HttpRequest,
|
||||
title: str = "",
|
||||
scripts: SafeText | str = "",
|
||||
scripts: "Node | SafeText | str" = "",
|
||||
mastered: bool = False,
|
||||
) -> SafeText:
|
||||
"""Assemble a full HTML document around `content` (the fast_app equivalent)."""
|
||||
"""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
|
||||
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 = [
|
||||
@@ -309,9 +338,12 @@ 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'
|
||||
' <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'
|
||||
# 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'
|
||||
f" {_THEME_FOUC_SCRIPT}\n"
|
||||
" </head>\n"
|
||||
)
|
||||
@@ -325,9 +357,9 @@ 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" {scripts}\n"
|
||||
f" {all_scripts}\n"
|
||||
f" {_main_script(mastered)}\n"
|
||||
' <!-- hx-swap-oob makes sure the modal gets removed upon any HTMX response -->\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'
|
||||
f" {_TOAST_CONTAINER}\n"
|
||||
f' <script src="{static("js/toast.js")}"></script>\n'
|
||||
@@ -339,10 +371,10 @@ def Page(
|
||||
|
||||
def render_page(
|
||||
request: HttpRequest,
|
||||
content: SafeText | str,
|
||||
content: "Node | SafeText | str",
|
||||
*,
|
||||
title: str = "",
|
||||
scripts: SafeText | str = "",
|
||||
scripts: "Node | SafeText | str" = "",
|
||||
mastered: bool = False,
|
||||
status: int = 200,
|
||||
) -> HttpResponse:
|
||||
|
||||
@@ -1,17 +1,43 @@
|
||||
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)
|
||||
|
||||
@@ -7,8 +7,11 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
container_name: timetracker
|
||||
environment:
|
||||
- DEBUG=false
|
||||
- TZ=Europe/Prague
|
||||
- CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz"
|
||||
# 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
|
||||
user: "1000"
|
||||
# volumes:
|
||||
# - "db:/home/timetracker/app/src/timetracker/db.sqlite3"
|
||||
|
||||
+6
-2
@@ -1,17 +1,21 @@
|
||||
---
|
||||
services:
|
||||
timetracker:
|
||||
image: ${REGISTRY_URL:-registry.kucharczyk.xyz}/timetracker:1.7.0
|
||||
image: ${REGISTRY_URL:-registry.kucharczyk.xyz}/timetracker:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: timetracker
|
||||
environment:
|
||||
- DEBUG=${DEBUG:-false}
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- TZ=${TZ:-Europe/Prague}
|
||||
- CSRF_TRUSTED_ORIGINS=https://tracker.kucharczyk.xyz
|
||||
# APP_URL drives ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS unless overridden.
|
||||
- APP_URL=${APP_URL:-http://localhost:8000}
|
||||
- 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:
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
# 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=*`.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Custom Element API: Two patterns, one goal
|
||||
|
||||
## Pattern 1: Named builder (current, preferred)
|
||||
|
||||
A tag builder with auto-attached `Media`, created via `custom_element_builder()`:
|
||||
|
||||
```python
|
||||
# definition (custom_elements.py)
|
||||
SessionTimestampButtons = custom_element_builder("session-timestamp-buttons")
|
||||
|
||||
# usage (session.py)
|
||||
SessionTimestampButtons(class_="form-row-button-group", hx_boost="false")[
|
||||
Button(data_target="timestamp_start", data_type="now", size="xs")["Set to now"],
|
||||
Button(data_target="timestamp_start", data_type="toggle", size="xs")["Toggle text"],
|
||||
]
|
||||
```
|
||||
|
||||
**Pros:** explicit dependency, visible import, fails loudly if builder deleted
|
||||
**Cons:** one line of ceremony per element
|
||||
|
||||
## Pattern 2: Element + registry (proposed, not implemented)
|
||||
|
||||
A global `CUSTOM_ELEMENT_MEDIA` dict in `core.py` that maps tag names to their `Media`. `register_element()` populates it automatically at import time, so `Element("session-timestamp-buttons")` silently picks up its JS dependency:
|
||||
|
||||
```python
|
||||
# definition (custom_elements.py)
|
||||
register_element("session-timestamp-buttons", "SessionTimestampButtons", EmptyProps)
|
||||
# CUSTOM_ELEMENT_MEDIA["session-timestamp-buttons"] = Media(js=("dist/elements/...",))
|
||||
|
||||
# usage (session.py) — no builder import needed
|
||||
Element("session-timestamp-buttons",
|
||||
[("class", "form-row-button-group"), ("hx-boost", "false")],
|
||||
children=[...],
|
||||
)
|
||||
```
|
||||
|
||||
**Pros:** one universal API — `Div(...)`, `Button(...)`, `Element("custom-tag")` all same pattern
|
||||
**Cons:** implicit dependency — deleting a `register_element()` call produces no error, just broken JS at runtime
|
||||
|
||||
## Recommendation
|
||||
|
||||
Start with Pattern 1 (named builders) — safe by default. Add Pattern 2 later if the ceremony becomes annoying. The two are **not mutually exclusive**: a named builder is just a thin wrapper around an `Element`; the registry can be added without changing any call sites.
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Want | Write |
|
||||
|------|-------|
|
||||
| Plain HTML tag | `Div(class_="flex")["text"]` |
|
||||
| Custom element (builder) | `SessionTimestampButtons(class_="...")[child]` |
|
||||
| Raw element | `Element("custom-tag", attributes_list, children=[...])` |
|
||||
| Builder from scratch | `custom_element_builder("tag-name")` |
|
||||
@@ -0,0 +1,398 @@
|
||||
# Form Overhaul Plan
|
||||
|
||||
> Last updated: 2026-05-12
|
||||
> Status: Decided — awaiting implementation
|
||||
>
|
||||
> **Decisions made:**
|
||||
> - All forms (simple and complex) get section headers for consistency
|
||||
> - Two-column layout uses **flexbox** (auto-reflow on different screen sizes)
|
||||
> - `cotton/layouts/add.html` enhanced with **Option A**: `c-section` component slots
|
||||
> - `add_purchase.html` dual-submit **simplified** — remove `<tr><td>`, use same `c-button` pattern as `add_game.html`
|
||||
> - GameStatusChange delete confirmation **converted to modal** (via HTMX trigger)
|
||||
|
||||
## Goal
|
||||
|
||||
Modernize all forms and form-like elements to align with Flowbite design, improve visual consistency, and adopt responsive multi-column layouts for complex forms.
|
||||
|
||||
---
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### Form Pages (add/edit)
|
||||
|
||||
All use `cotton/layouts/add.html` — single column, `max-w-xl`, `form.as_div`:
|
||||
|
||||
| Page | Form | Fields | Complexity |
|
||||
|---|---|---|---|
|
||||
| Game | `GameForm` | 7 fields: name, sort_name, platform, year, year_orig, status, mastered, wikidata | Medium |
|
||||
| Purchase | `PurchaseForm` | 11 fields: games, platform, dates, price, currency, type, ownership, related, infinite, name | High |
|
||||
| Session | `SessionForm` | 8 fields: game, timestamps, duration, emulated, device, note, checkbox (custom rendering) | High |
|
||||
| Platform | `PlatformForm` | 3 fields: name, icon, group | Low |
|
||||
| Device | `DeviceForm` | 2 fields: name, type | Low |
|
||||
| PlayEvent | `PlayEventForm` | 5 fields: game, dates, note, checkbox | Low |
|
||||
| GameStatusChange | `GameStatusChangeForm` | 4 fields | Low |
|
||||
|
||||
### Other Form-Like Elements
|
||||
|
||||
| Element | Template | Notes |
|
||||
|---|---|---|
|
||||
| Login | `registration/login.html` | Flowbite card, already good |
|
||||
| Search | `cotton/search_field.html` | Reusable, already good |
|
||||
| Delete Game | `partials/delete_game_confirmation.html` | Inline modal, inconsistent button layout |
|
||||
| Delete PlayEvent | `gamestatuschange_confirm_delete.html` | Full-page form, no modal |
|
||||
| Refund Purchase | `partials/refund_purchase_confirmation.html` | Inline modal, inconsistent button layout |
|
||||
| Stats Year Select | `stats.html` | Manual `<select>`, no Flowbite styling |
|
||||
| Status Selector | `partials/gamestatus_selector.html` | Alpine.js dropdown, old Tailwind classes |
|
||||
| Device Selector | `partials/sessiondevice_selector.html` | Alpine.js dropdown, old Tailwind classes |
|
||||
|
||||
---
|
||||
|
||||
## Issues to Fix
|
||||
|
||||
### P0: Broken/Inconsistent
|
||||
|
||||
1. ~~**`modal.html` has a missing `<form>` tag** (line 13: `</form>` with no opening `<form>`)** — *Resolved: rewritten as proper component with form wrapping support, body + footer slots, reusable `close_button` component. Ready for standardizing all inline modals later.*
|
||||
2. **Delete confirmations are inconsistent** — three different patterns (inline modal, full-page form, inline modal)
|
||||
3. **`.errorlist` CSS** has fixed `width: 300px` — too narrow, breaks on mobile. *No scoping needed: Django auto-applies `.errorlist` to form error output only, never used explicitly in templates.*
|
||||
4. **`add_purchase.html` has `<tr><td>`** in a `c-slot` that renders inside a `<div>` — semantic mismatch. **Decision: simplify dual-submit** to match `add_game.html` pattern (use `<c-button>` only).
|
||||
5. **`#button-container` and `.basic-button` in `input.css`** — legacy patterns, unused or dead code
|
||||
|
||||
### P1: Layout & UX
|
||||
|
||||
6. **All add/edit forms are single-column** — PurchaseForm (11 fields) and GameForm (7 fields) would benefit from multi-column
|
||||
7. **No field grouping** — related fields listed flat without visual hierarchy
|
||||
8. **Stats year `<select>`** has no Flowbite styling
|
||||
9. **Search field** is not wrapped in `<form method="get">` — no native clear-on-Enter behavior
|
||||
|
||||
### P2: Styling Consistency
|
||||
|
||||
10. **Status/device selectors** use old Tailwind v3 patterns (`rounded-sm`, `shadow-2xs`, `border-gray-200` without explicit color)
|
||||
11. **`navbar.html` buttons** use `rounded-sm` instead of `rounded-base`
|
||||
12. **`simple_table.html` pagination buttons** use `rounded-s-lg`/`rounded-e-lg` — could be simplified
|
||||
|
||||
---
|
||||
|
||||
## Proposed Improvements
|
||||
|
||||
### 1. Two-Column Layout for Complex Forms (Flexbox)
|
||||
|
||||
**Scope**: `GameForm`, `PurchaseForm`, `PlayEventForm`, `SessionForm`
|
||||
|
||||
Use **flexbox** with wrap behavior so fields auto-reflow on different screen sizes. No fixed column count — fields sit side-by-side on `md:`+ and wrap naturally on smaller screens.
|
||||
|
||||
#### GameForm Layout
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ Game Details │
|
||||
│ ┌──────────────────┬───────────┐ │
|
||||
│ │ Name │ Platform │ │
|
||||
│ │ Sort Name │ Year │ │
|
||||
│ │ Original Year │ Wikidata │ │
|
||||
│ └──────────────────┴───────────┘ │
|
||||
│ Status │
|
||||
│ ┌──────────────────┬───────────┐ │
|
||||
│ │ Status │ Mastered │ │
|
||||
│ └──────────────────┴───────────┘ │
|
||||
│ [Submit] │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### PurchaseForm Layout (simplified)
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Purchase Details │
|
||||
│ ┌──────────────────────┬───────────────┐ │
|
||||
│ │ Games (multi-select) │ Platform │ │
|
||||
│ │ Type │ Ownership │ │
|
||||
│ │ Name │ Related Purch │ │
|
||||
│ └──────────────────────┴───────────────┘ │
|
||||
│ Dates │ Price │
|
||||
│ ┌───────────────┬──────┴───────────────┐ │
|
||||
│ │ Date Purch │ Price Curr │ │
|
||||
│ │ Date Refund │ Infinite [ ] │ │
|
||||
│ └───────────────┴──────────────────────┘ │
|
||||
│ [Submit] [Submit + Session] │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Implementation**: `c-section` component accepts `columns="2"` (or `"3"`) which applies `flex flex-wrap gap-4 [&>div]:w-[calc(50%-0.5rem)]` on md+ screens. Each field wraps in a `<div>` inside the section slot.
|
||||
|
||||
**Decision**: Dual-submit in `add_purchase.html` simplified — remove `<tr><td>`, use same `<c-button>` pattern as `add_game.html`.
|
||||
|
||||
### 2. Field Grouping with Card Sections
|
||||
|
||||
**Decision**: ALL forms get section headers for consistency (not just complex forms).
|
||||
|
||||
Group related fields with section headings and subtle borders/backgrounds:
|
||||
|
||||
```html
|
||||
<c-section title="Game Details" columns="2">
|
||||
{{ form.name }}
|
||||
{{ form.platform }}
|
||||
{{ form.sort_name }}
|
||||
{{ form.year_released }}
|
||||
</c-section>
|
||||
```
|
||||
|
||||
Each section renders as:
|
||||
```html
|
||||
<fieldset class="form-section p-5 border-t border-default-medium bg-neutral-primary-soft/30 first-of-type:border-t-0 first-of-type:pt-0">
|
||||
<h3 class="text-sm font-medium text-heading uppercase mb-4">Section Title</h3>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<!-- fields in <div> wrappers, each taking calc(50% - 0.5rem) on md+ -->
|
||||
</div>
|
||||
</fieldset>
|
||||
```
|
||||
|
||||
Each section gets:
|
||||
- Subtle background (`bg-neutral-primary-soft/30`)
|
||||
- Top border with spacing (`border-t border-default-medium`)
|
||||
- Section heading (`text-sm font-medium text-heading uppercase mb-4`)
|
||||
- Flexbox gap for responsive field reflow
|
||||
|
||||
### 1b. `c-section` Component Specification
|
||||
|
||||
New cotton component for the `cotton/` directory:
|
||||
|
||||
```python
|
||||
# games/templates/cotton/section.py (or inline in components.py)
|
||||
from common.components import Div
|
||||
|
||||
def Section(title: str = "", columns: str = "1", children: str = "") -> SafeText:
|
||||
"""Renders a form field section with optional multi-column flexbox layout.
|
||||
|
||||
Args:
|
||||
title: Section heading (renders as uppercase label)
|
||||
columns: "1" (default), "2", or "3" — target column count on md+ screens
|
||||
children: Field markup (each field wrapped in <div> for flex wrapping)
|
||||
"""
|
||||
col_class = {
|
||||
"1": "flex flex-col",
|
||||
"2": "flex flex-wrap gap-4 [&>div]:w-[calc(50%-0.5rem)]",
|
||||
"3": "flex flex-wrap gap-4 [&>div]:w-[calc(33.333%-0.67rem)]",
|
||||
}.get(columns, "flex flex-col")
|
||||
|
||||
return Div(
|
||||
cls=f"form-section p-5 border-t border-default-medium bg-neutral-primary-soft/30 first-of-type:border-t-0 first-of-type:pt-0",
|
||||
children=f"""
|
||||
<h3 class="text-sm font-medium text-heading uppercase mb-4">{title}</h3>
|
||||
<div class="{col_class}">{children}</div>
|
||||
"""
|
||||
)
|
||||
```
|
||||
|
||||
**Template usage:**
|
||||
```django
|
||||
{# add_game.html #}
|
||||
<c-layouts.add title="New Game">
|
||||
<c-section title="Game Details" columns="2">
|
||||
<div>{{ form.name }}</div>
|
||||
<div>{{ form.platform }}</div>
|
||||
<div>{{ form.sort_name }}</div>
|
||||
<div>{{ form.year_released }}</div>
|
||||
<div>{{ form.original_year_released }}</div>
|
||||
<div>{{ form.wikidata }}</div>
|
||||
</c-section>
|
||||
<c-section title="Status" columns="2">
|
||||
<div>{{ form.status }}</div>
|
||||
<div>{{ form.mastered }}</div>
|
||||
</c-section>
|
||||
</c-layouts.add>
|
||||
```
|
||||
|
||||
**`cotton/layouts/add.html` changes:**
|
||||
- Remove hardcoded `{{ form.as_div }}` rendering
|
||||
- Accept optional `sections` variable (list of rendered `c-section` output)
|
||||
- If `sections` provided, render them; otherwise fall back to `{{ form.as_div }}` for simple forms
|
||||
- Keep `additional_row` slot for dual-submit buttons
|
||||
|
||||
### 3. CSS/Style Fixes
|
||||
|
||||
#### `input.css` changes:
|
||||
```css
|
||||
/* Update errorlist */
|
||||
.errorlist {
|
||||
@apply mt-4 mb-1 pl-3 py-2 bg-red-600 text-slate-200 w-full max-w-xl; /* was w-[300px] */
|
||||
}
|
||||
|
||||
/* Remove: #button-container, .basic-button — unused legacy */
|
||||
|
||||
/* Remove: .flowbite-input — custom class is code smell with Tailwind */
|
||||
/* Remove: flowbite-input @apply block (line 229-234) */
|
||||
|
||||
/* Add Flowbite styling for select in stats */
|
||||
#yearSelect {
|
||||
@apply bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand;
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: The styling previously provided by `.flowbite-input` must be preserved. The element-level `@apply` rules for `input`, `select`, and `textarea` in `input.css` (lines 209-219) already provide equivalent styling. These rules automatically apply to all form inputs without needing custom classes:
|
||||
- `input:not([type="checkbox"])` — background, border, text, radius, focus ring, padding
|
||||
- `select` — same base styling as inputs
|
||||
- `textarea` — same base styling with adjusted padding
|
||||
|
||||
**Files to clean up:**
|
||||
- `common/input.css`: Remove `.flowbite-input` class entirely (lines 229-234)
|
||||
- `games/forms.py`: Remove `flowbite_input_widget` and `flowbite_password_widget` (lines 22-23)
|
||||
- `games/forms.py`: Remove `widget=` from `LoginForm` fields (lines 28, 32) — login template uses explicit Tailwind classes already
|
||||
|
||||
#### Rewrite `modal.html`:
|
||||
- Remove stray `</form>` tag and restructure as a proper cotton component
|
||||
- New `c-modal` component with: `modal_id`, `title`, `size="xl"`, `backdrop_close` variables
|
||||
- `{{ slot }}` (cotton default slot) for body content — passed as children of `<c-modal>`, no block tags needed
|
||||
- `{{ footer }}` (optional named slot via `<c-slot name="footer">`) for non-form buttons
|
||||
- Reusable `cotton/close_button.html` via `<c-close-button />`
|
||||
- Size mapping via inline `{% if %}`: `{% if size == 'sm' %}max-w-sm{% elif size == 'lg' %}max-w-lg{% else %}max-w-xl{% endif %}`
|
||||
- Horizontal centering: `mx-auto` on inner container (matching old modal pattern)
|
||||
- Click-to-dismiss backdrop with `event.stopPropagation()` on inner container
|
||||
- Flowbite-style styling: `rounded-lg shadow`, `bg-white dark:bg-gray-800`, `sm:p-5`
|
||||
|
||||
### 4. Unify Delete Confirmations (All Modal)
|
||||
|
||||
**Decision**: GameStatusChange delete confirmation converted from full-page to modal. All three use the same modal pattern.
|
||||
|
||||
**Target**: All confirmation modals use the same pattern:
|
||||
|
||||
```html
|
||||
<div class="fixed inset-0 bg-black/70 dark:bg-gray-600/50 ...">
|
||||
<div class="relative mx-auto p-6 bg-white dark:bg-gray-900 rounded-lg shadow-lg max-w-md w-full">
|
||||
<h2 class="text-xl font-medium text-center">Confirm Action</h2>
|
||||
<p class="text-center mt-4 text-sm text-body">Are you sure...?</p>
|
||||
{% if details %}
|
||||
<ul class="text-center mt-2 text-sm text-body list-disc list-inside">
|
||||
<li>{{ detail }}</li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
<p class="text-center mt-3 text-sm font-medium text-red-600">This action cannot be undone.</p>
|
||||
<div class="flex gap-3 mt-6">
|
||||
<c-button color="red" class="w-full" type="submit">Delete</c-button>
|
||||
<c-button color="gray" class="w-full">Cancel</c-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- **Delete Game** (`partials/delete_game_confirmation.html`): Update template to match standard pattern
|
||||
- **Delete StatusChange** (`gamestatuschange_confirm_delete.html` → `partials/statuschange_delete_confirmation.html`): Adopt the same 2-view pattern as delete-game.
|
||||
- Add `delete_statuschange_confirmation` view (GET → renders modal partial) + URL before the delete URL
|
||||
- Update `partials/history.html` — add `hx-get="{% url 'games:delete_statuschange_confirmation' change.id %}" hx-target="#global-modal-container"` to the Delete link
|
||||
- Create new `partials/statuschange_delete_confirmation.html` using `<c-modal>`, same structure as `delete_game_confirmation.html` (detail list, red warning text, same button layout, `<c-gamestatus>` badge for old status)
|
||||
- Modify `GameStatusChangeDeleteView` to only handle POST (remove its GET-rendered template)
|
||||
- Delete old `gamestatuschange_confirm_delete.html` after migration
|
||||
- **Refund Purchase** (`partials/refund_purchase_confirmation.html`): Update template to match standard pattern
|
||||
|
||||
### 5. Search Form Enhancement
|
||||
|
||||
Wrap `search_field.html` in proper `<form method="get">`:
|
||||
|
||||
```html
|
||||
<form class="max-w-md mx-auto" method="get" x-data x-on:keydown.escape="this.querySelector('input').value=''; this.submit()">
|
||||
<!-- input + button -->
|
||||
</form>
|
||||
```
|
||||
|
||||
This enables:
|
||||
- Native form submission on Enter
|
||||
- Potential for "clear all" functionality
|
||||
- Proper browser form autofill behavior
|
||||
|
||||
### 6. Status/Device Selector Styling
|
||||
|
||||
Update Alpine.js dropdowns to use consistent button classes:
|
||||
- Replace `rounded-lg` with `rounded-base`
|
||||
- Replace `shadow-2xs` with `shadow-xs`
|
||||
- Standardize border colors with `border-default`
|
||||
- Use `text-heading` / `text-body` for dark mode compatibility
|
||||
|
||||
---
|
||||
|
||||
## Templates That Need Changes
|
||||
|
||||
| Template | Change | Effort |
|
||||
|---|---|---|
|
||||
| `cotton/layouts/add.html` | Add `c-section` component support (title, columns, fields slots) | Medium |
|
||||
| `add_game.html` | Multi-column flexbox layout, section headers | Medium |
|
||||
| `add_purchase.html` | Multi-column flexbox layout, simplify dual-submit, section headers | High |
|
||||
| `add_session.html` | Flexbox layout for timestamps+duration, section headers | Low |
|
||||
| `add_playevent.html` | Flexbox layout, section headers | Low |
|
||||
| `add_platform.html` | Section headers (was flat single-column) | Low |
|
||||
| `add_device.html` | Section headers (was flat single-column) | Low |
|
||||
| `partials/delete_game_confirmation.html` | Standardize to shared modal pattern | Low |
|
||||
| `partials/refund_purchase_confirmation.html` | Standardize to shared modal pattern | Low |
|
||||
| `partials/statuschange_delete_confirmation.html` | New — adopt same 2-view pattern as delete-game (modal, `<c-modal>`, HTMX triggers) | Medium |
|
||||
| `gamestatuschange_confirm_delete.html` | Delete (replaced by new partial) | Trivial |
|
||||
| `cotton/modal.html` | Fix missing `<form>` tag | Low |
|
||||
| `stats.html` | Add Flowbite select styling | Low |
|
||||
| `partials/gamestatus_selector.html` | Update button classes | Low |
|
||||
| `partials/sessiondevice_selector.html` | Update button classes | Low |
|
||||
| `cotton/search_field.html` | Wrap in `<form method="get">` | Low |
|
||||
| `common/input.css` | Remove legacy, fix errorlist, add select styles | Low |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
### Phase 1: Quick Wins (low risk, no breaking changes)
|
||||
|
||||
1. **CSS fixes** (`input.css`) — fix errorlist width, remove legacy `.basic-button` / `#button-container`, add select styles
|
||||
2. ~~**`modal.html` rewrite**~~ — add missing `<form>` tag, conditional form wrapper ✓ Implemented (uses `{{ slot }}` cotton default slot, no `{% partial %}` tags; `size` defaults to `"xl"` with inline `{% if %}` mapping)
|
||||
3. **Delete confirmation standardization** — 3 templates → all modal, same pattern (including GameStatusChange: full-page → modal)
|
||||
4. **Search field enhancement** — wrap in `<form method="get">`
|
||||
5. **Stats select styling** — add Flowbite select classes
|
||||
6. **Selector styling updates** — gamestatus + sessiondevice selectors, consistent classes
|
||||
|
||||
### Phase 2: `c-section` Component
|
||||
|
||||
7. **Create `c-section` component** — title, columns, fields slots
|
||||
8. **Update `cotton/layouts/add.html`** — support `sections` variable, fallback to `form.as_div`
|
||||
|
||||
### Phase 3: Form Layout Overhaul (largest change)
|
||||
|
||||
9. **`GameForm`** — section headers + 2-col flexbox (`add_game.html`)
|
||||
10. **`PlayEventForm`** — section headers + 2-col flexbox
|
||||
11. **`PurchaseForm`** — section headers + 2/3-col flexbox + simplify dual-submit (`add_purchase.html`)
|
||||
12. **`SessionForm`** — section headers + flexbox for timestamps+duration (custom rendering already exists)
|
||||
13. **Simple forms** — `add_platform.html`, `add_device.html` get section headers (single column)
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- Run `make test` after Phase 1 changes to verify nothing broke
|
||||
- `tests/test_paths_return_200.py` — URL-level smoke tests (186 tests). All views must have a `test_*_returns_200` test. Adding new views requires a corresponding test to prevent `TemplateDoesNotExist` regressions.
|
||||
- CSS changes do not require test changes (no test coverage for rendering), but visual verification is recommended
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
- [x] Simple forms section headers? → **All forms get section headers** for consistency
|
||||
- [x] CSS Grid or Flexbox? → **Flexbox** — auto-reflow on different screen sizes
|
||||
- [x] add.html layout variable? → **Option A** — `c-section` cotton component with `title` and `columns` slots
|
||||
- [x] add_purchase.html dual-submit? → **Simplify** — remove `<tr><td>`, use same `<c-button>` pattern as `add_game.html`
|
||||
- [x] GameStatusChange modal or full-page? → **Modal** — trigger via HTMX, same pattern as delete-game
|
||||
- [x] .flowbite-input class? → **Remove entirely** — rely on element-level `@apply` in `input.css`
|
||||
|
||||
## Decision Summary
|
||||
|
||||
| Question | Decision |
|
||||
|---|---|
|
||||
| Section headers on simple forms | Yes, all forms get them |
|
||||
| Layout approach for multi-column | Flexbox with wrap |
|
||||
| Layout mechanism in add.html | Option A: `c-section` cotton component |
|
||||
| Purchase dual-submit | Simplify — single submit button, same as Game |
|
||||
| GameStatusChange delete | Convert to modal (HTMX-triggered) |
|
||||
| .flowbite-input class | Remove — preserve styling via element-level `@apply` in `input.css` |
|
||||
| `modal.html` component | Rewrite with form wrapping, body + footer slots, reusable close button ✓ Implemented
|
||||
|
||||
## Build Step
|
||||
|
||||
After any CSS changes to `common/input.css`, the compiled output must be rebuilt:
|
||||
|
||||
- **`make css`** — one-shot build: `npx @tailwindcss/cli -i ./common/input.css -o ./games/static/base.css`
|
||||
- **`make dev`** — watch mode: Tailwind rebuilds automatically on every `input.css` save
|
||||
|
||||
Running `make dev` is sufficient for development since it concurrently runs Django and the CSS watcher.
|
||||
Only use `make css` if you only want to rebuild CSS without starting the dev server.
|
||||
|
||||
**Important**: Legacy CSS removals (`.basic-button`, `#button-container`, `.flowbite-input`) will only take effect in the browser after a rebuild. The old compiled `base.css` will still contain them until rebuilt.
|
||||
@@ -0,0 +1,485 @@
|
||||
# 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"
|
||||
```
|
||||
@@ -0,0 +1,662 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,577 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,177 @@
|
||||
# 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
@@ -0,0 +1,197 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,157 @@
|
||||
# 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.
|
||||
@@ -0,0 +1 @@
|
||||
# e2e tests package
|
||||
@@ -0,0 +1,22 @@
|
||||
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
|
||||
@@ -0,0 +1,112 @@
|
||||
"""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
|
||||
@@ -0,0 +1,84 @@
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authenticated_page(live_server, page: Page, django_user_model) -> Page:
|
||||
django_user_model.objects.create_user(username="tester", password="secret123")
|
||||
page.goto(f"{live_server.url}{reverse('login')}")
|
||||
page.fill('input[name="username"]', "tester")
|
||||
page.fill('input[name="password"]', "secret123")
|
||||
page.click('input[type="submit"]')
|
||||
page.wait_for_url(f"{live_server.url}/tracker**")
|
||||
return page
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_game_status_selector_opens_and_patches(authenticated_page: Page, live_server):
|
||||
from games.models import Game, Platform
|
||||
|
||||
platform = Platform.objects.create(name="PC", icon="pc")
|
||||
game = Game.objects.create(name="Test Game", platform=platform, status="u")
|
||||
|
||||
page = authenticated_page
|
||||
page.goto(f"{live_server.url}{reverse('games:list_games')}")
|
||||
|
||||
host = page.locator("game-status-selector").first
|
||||
expect(host).to_be_attached()
|
||||
host.locator("[data-toggle]").click()
|
||||
expect(host.locator("[data-menu]")).to_be_visible()
|
||||
with page.expect_response(
|
||||
lambda r: "/status" in r.url and r.request.method == "PATCH"
|
||||
):
|
||||
host.locator('[data-option][data-value="f"]').click()
|
||||
expect(host.locator("[data-menu]")).to_be_hidden()
|
||||
game.refresh_from_db()
|
||||
assert game.status == "f"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_session_device_selector_patches(authenticated_page: Page, live_server):
|
||||
from games.models import Device, Game, Platform, Session
|
||||
|
||||
platform = Platform.objects.create(name="PC", icon="pc")
|
||||
game = Game.objects.create(name="Test Game", platform=platform)
|
||||
desktop = Device.objects.create(name="Desktop")
|
||||
deck = Device.objects.create(name="Deck")
|
||||
session = Session.objects.create(
|
||||
game=game, device=desktop, timestamp_start="2025-01-01 00:00:00+00:00"
|
||||
)
|
||||
|
||||
page = authenticated_page
|
||||
page.goto(f"{live_server.url}{reverse('games:list_sessions')}")
|
||||
|
||||
host = page.locator("session-device-selector").first
|
||||
expect(host).to_be_attached()
|
||||
host.locator("[data-toggle]").click()
|
||||
with page.expect_response(
|
||||
lambda r: "/device" in r.url and r.request.method == "PATCH"
|
||||
):
|
||||
host.locator(f'[data-option][data-value="{deck.id}"]').click()
|
||||
session.refresh_from_db()
|
||||
assert session.device_id == deck.id
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_play_event_row_increments(authenticated_page: Page, live_server):
|
||||
from games.models import Game, Platform
|
||||
|
||||
platform = Platform.objects.create(name="PC", icon="pc")
|
||||
game = Game.objects.create(name="Test Game", platform=platform)
|
||||
|
||||
page = authenticated_page
|
||||
page.goto(f"{live_server.url}{reverse('games:view_game', args=[game.id])}")
|
||||
|
||||
host = page.locator("play-event-row").first
|
||||
expect(host).to_be_attached()
|
||||
host.locator("[data-toggle]").click()
|
||||
with page.expect_response(
|
||||
lambda r: "playevent" in r.url.lower() and r.request.method == "POST"
|
||||
):
|
||||
host.locator("[data-add-play]").click()
|
||||
expect(host.locator("[data-count]")).to_have_text("1")
|
||||
assert game.playevents.count() == 1
|
||||
@@ -0,0 +1,167 @@
|
||||
"""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",
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
"""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",
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"""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
|
||||
@@ -0,0 +1,109 @@
|
||||
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"
|
||||
@@ -0,0 +1,150 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,135 @@
|
||||
"""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()
|
||||
+41
-2
@@ -1,8 +1,16 @@
|
||||
#!/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
|
||||
@@ -10,14 +18,45 @@ groupmod -o -g "$PGID" timetracker
|
||||
usermod -o -u "$PUID" timetracker
|
||||
usermod -d "${USERHOME}" timetracker
|
||||
|
||||
mkdir -p /home/timetracker/app/data /var/log/supervisor
|
||||
mkdir -p "$DATA_DIR" /var/log/supervisor
|
||||
chmod 755 /home/timetracker/app
|
||||
chmod 755 /home/timetracker/app/.venv
|
||||
|
||||
chown "$PUID:$PGID" /home/timetracker/app/data
|
||||
chown "$PUID:$PGID" "$DATA_DIR"
|
||||
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
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# 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"
|
||||
+61
-3
@@ -2,15 +2,18 @@ from datetime import date, datetime
|
||||
from typing import List
|
||||
|
||||
from django.contrib import messages
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.timezone import now as django_timezone_now
|
||||
from ninja import Field, ModelSchema, NinjaAPI, Router, Schema, Status
|
||||
|
||||
from games.models import Game, PlayEvent, Session
|
||||
from games.models import Device, Game, Platform, PlayEvent, Session
|
||||
|
||||
api = NinjaAPI()
|
||||
playevent_router = Router()
|
||||
game_router = Router()
|
||||
device_router = Router()
|
||||
platform_router = Router()
|
||||
|
||||
NOW_FACTORY = django_timezone_now
|
||||
|
||||
@@ -50,6 +53,33 @@ class PlayEventOut(Schema):
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class GameOption(Schema): # mirrors SearchSelectOption
|
||||
value: int
|
||||
label: str
|
||||
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")
|
||||
if q:
|
||||
qs = qs.filter(Q(name__icontains=q) | Q(sort_name__icontains=q))
|
||||
return [
|
||||
{
|
||||
"value": g.id,
|
||||
"label": g.search_label,
|
||||
"data": {"platform": g.platform_id or ""},
|
||||
}
|
||||
for g in qs[:limit]
|
||||
]
|
||||
|
||||
|
||||
@game_router.patch("/{game_id}/status", response={204: None})
|
||||
def partial_update_game(request, game_id: int, payload: GameStatusUpdate):
|
||||
game = get_object_or_404(Game, id=game_id)
|
||||
@@ -93,8 +123,35 @@ def delete_playevent(request, playevent_id: int):
|
||||
return Status(204, None)
|
||||
|
||||
|
||||
@device_router.get("/search", response=list[GameOption])
|
||||
def search_devices(request, q: str = "", limit: int = 10):
|
||||
qs = Device.objects.order_by("name")
|
||||
if q:
|
||||
qs = qs.filter(name__icontains=q)
|
||||
return [{"value": d.id, "label": d.name, "data": {}} for d in qs[:limit]]
|
||||
|
||||
|
||||
@platform_router.get("/search", response=list[GameOption])
|
||||
def search_platforms(request, q: str = "", limit: int = 10):
|
||||
qs = Platform.objects.order_by("name")
|
||||
if q:
|
||||
qs = qs.filter(name__icontains=q)
|
||||
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)
|
||||
api.add_router("/platforms", platform_router)
|
||||
|
||||
session_router = Router()
|
||||
|
||||
@@ -104,7 +161,9 @@ class SessionDeviceUpdate(Schema):
|
||||
|
||||
|
||||
@session_router.patch("/{session_id}/device", response={204: None})
|
||||
def partial_update_session_device(request, session_id: int, payload: SessionDeviceUpdate):
|
||||
def partial_update_session_device(
|
||||
request, session_id: int, payload: SessionDeviceUpdate
|
||||
):
|
||||
session = get_object_or_404(Session, id=session_id)
|
||||
session.device_id = payload.device_id
|
||||
session.save()
|
||||
@@ -113,4 +172,3 @@ def partial_update_session_device(request, session_id: int, payload: SessionDevi
|
||||
|
||||
|
||||
api.add_router("/session", session_router)
|
||||
|
||||
|
||||
+664
-69
@@ -13,9 +13,12 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
from common.criteria import (
|
||||
BoolCriterion,
|
||||
ChoiceCriterion,
|
||||
DateCriterion,
|
||||
FloatCriterion,
|
||||
IntCriterion,
|
||||
Modifier,
|
||||
@@ -32,11 +35,11 @@ from common.criteria import (
|
||||
class FindFilter:
|
||||
"""Sorting and pagination, separate from filtering criteria (Stash-style)."""
|
||||
|
||||
q: str | None = None # free-text search
|
||||
q: str | None = None # free-text search
|
||||
page: int = 1
|
||||
per_page: int = 25
|
||||
sort: str | None = None # e.g. "-created_at"
|
||||
direction: str = "desc" # asc / desc
|
||||
sort: str | None = None # e.g. "-created_at"
|
||||
direction: str = "desc" # asc / desc
|
||||
|
||||
|
||||
# ── GameFilter ─────────────────────────────────────────────────────────────
|
||||
@@ -55,19 +58,48 @@ class GameFilter(OperatorFilter):
|
||||
year_released: IntCriterion | None = None
|
||||
original_year_released: IntCriterion | None = None
|
||||
wikidata: StringCriterion | None = None
|
||||
platform: ChoiceCriterion | None = None # selectable filter widget
|
||||
status: ChoiceCriterion | None = None # selectable filter widget
|
||||
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_minutes: IntCriterion | None = None # converted to timedelta on to_q()
|
||||
created_at: StringCriterion | None = None # date string
|
||||
updated_at: StringCriterion | None = None # date string
|
||||
playtime_hours: 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
|
||||
|
||||
def to_q(self) -> "Q": # type: ignore[no-any-unimported]
|
||||
from django.db.models import Q
|
||||
# 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()
|
||||
|
||||
# ── individual criteria ──
|
||||
@@ -87,13 +119,183 @@ class GameFilter(OperatorFilter):
|
||||
q &= self.status.to_q("status")
|
||||
if self.mastered is not None:
|
||||
q &= self.mastered.to_q("mastered")
|
||||
if self.playtime_minutes is not None:
|
||||
q &= self._playtime_to_q(self.playtime_minutes)
|
||||
if self.playtime_hours is not None:
|
||||
q &= self._playtime_to_q(self.playtime_hours)
|
||||
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 = (
|
||||
@@ -105,6 +307,43 @@ 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:
|
||||
@@ -118,36 +357,48 @@ class GameFilter(OperatorFilter):
|
||||
return q
|
||||
|
||||
@staticmethod
|
||||
def _playtime_to_q(c: IntCriterion) -> "Q": # type: ignore[no-any-unimported]
|
||||
"""Convert minutes-based criterion to a DurationField Q object.
|
||||
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.
|
||||
|
||||
Django stores DurationField as microseconds in SQLite, so we convert
|
||||
minutes → timedelta(microseconds=X) and use the appropriate lookups.
|
||||
hours → timedelta(microseconds=X) and use the appropriate lookups.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
from common.criteria import Modifier
|
||||
from django.db.models import Q
|
||||
|
||||
m = c.modifier
|
||||
field = "playtime"
|
||||
td_val = timedelta(minutes=c.value)
|
||||
td_val = timedelta(hours=c.value)
|
||||
|
||||
if m == Modifier.EQUALS:
|
||||
return Q(**{f"{field}__gte": td_val, f"{field}__lt": timedelta(minutes=c.value + 1)})
|
||||
return Q(
|
||||
**{
|
||||
f"{field}__gte": td_val,
|
||||
f"{field}__lt": timedelta(hours=c.value + 1),
|
||||
}
|
||||
)
|
||||
if m == Modifier.NOT_EQUALS:
|
||||
return ~Q(**{f"{field}__gte": td_val, f"{field}__lt": timedelta(minutes=c.value + 1)})
|
||||
return ~Q(
|
||||
**{
|
||||
f"{field}__gte": td_val,
|
||||
f"{field}__lt": timedelta(hours=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))
|
||||
lo = timedelta(hours=min(c.value, c.value2))
|
||||
hi = timedelta(hours=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))
|
||||
lo = timedelta(hours=min(c.value, c.value2))
|
||||
hi = timedelta(hours=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)})
|
||||
@@ -155,6 +406,17 @@ 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 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -167,15 +429,18 @@ class SessionFilter(OperatorFilter):
|
||||
OR: SessionFilter | None = None
|
||||
NOT: SessionFilter | None = None
|
||||
|
||||
game: MultiCriterion | None = None # filters on game_id
|
||||
device: MultiCriterion | None = None # filters on device_id
|
||||
game: MultiCriterion | None = None # filters on game_id
|
||||
device: MultiCriterion | None = None # filters on device_id
|
||||
emulated: BoolCriterion | None = None
|
||||
note: StringCriterion | 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
|
||||
is_manual: BoolCriterion | None = None # duration_manual > 0
|
||||
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
|
||||
is_active: BoolCriterion | None = None # timestamp_end IS NULL
|
||||
timestamp_start: StringCriterion | None = None # date string
|
||||
timestamp_end: StringCriterion | None = None # date string
|
||||
is_manual: BoolCriterion | None = None # duration_manual > 0
|
||||
created_at: StringCriterion | None = None
|
||||
|
||||
# Free-text search
|
||||
@@ -184,10 +449,49 @@ class SessionFilter(OperatorFilter):
|
||||
# Cross-entity: sessions for games matching these criteria
|
||||
game_filter: GameFilter | None = None
|
||||
|
||||
def to_q(self) -> "Q": # type: ignore[no-any-unimported]
|
||||
# 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
|
||||
|
||||
from django.db.models import Q
|
||||
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
|
||||
|
||||
q = Q()
|
||||
|
||||
@@ -199,31 +503,16 @@ class SessionFilter(OperatorFilter):
|
||||
q &= self.emulated.to_q("emulated")
|
||||
if self.note is not None:
|
||||
q &= self.note.to_q("note")
|
||||
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.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.is_active is not None:
|
||||
if self.is_active.value:
|
||||
q &= Q(timestamp_end__isnull=True)
|
||||
@@ -256,10 +545,19 @@ class SessionFilter(OperatorFilter):
|
||||
# Cross-entity filter: sessions for games matching GameFilter
|
||||
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)
|
||||
|
||||
# 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:
|
||||
@@ -285,29 +583,34 @@ class PurchaseFilter(OperatorFilter):
|
||||
NOT: PurchaseFilter | None = None
|
||||
|
||||
name: StringCriterion | None = None
|
||||
platform: ChoiceCriterion | None = None # platform_id
|
||||
games: ChoiceCriterion | None = None # games (M2M IDs)
|
||||
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
|
||||
platform: ChoiceCriterion | None = None # platform_id
|
||||
games: ChoiceCriterion | None = None # games (M2M IDs)
|
||||
date_purchased: DateCriterion | None = None
|
||||
date_refunded: DateCriterion | None = None
|
||||
is_refunded: BoolCriterion | None = None # date_refunded IS NOT NULL
|
||||
price: FloatCriterion | None = None # on price field
|
||||
converted_price: FloatCriterion | None = None
|
||||
price_currency: StringCriterion | None = None
|
||||
num_purchases: IntCriterion | None = None
|
||||
ownership_type: ChoiceCriterion | None = None # ph/di/du/re/bo/tr/de/pi
|
||||
type: ChoiceCriterion | None = None # game/dlc/season_pass/battle_pass
|
||||
ownership_type: ChoiceCriterion | None = None # ph/di/du/re/bo/tr/de/pi
|
||||
type: ChoiceCriterion | None = None # game/dlc/season_pass/battle_pass
|
||||
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
|
||||
|
||||
def to_q(self) -> "Q": # type: ignore[no-any-unimported]
|
||||
from django.db.models import Q
|
||||
# Cross-entity: purchases for platforms matching these criteria
|
||||
platform_filter: PlatformFilter | None = None
|
||||
|
||||
def to_q(self) -> Q:
|
||||
q = Q()
|
||||
|
||||
if self.name is not None:
|
||||
@@ -315,7 +618,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("games")
|
||||
q &= self._games_to_q(self.games)
|
||||
if self.date_purchased is not None:
|
||||
q &= self.date_purchased.to_q("date_purchased")
|
||||
if self.date_refunded is not None:
|
||||
@@ -338,6 +641,12 @@ 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:
|
||||
@@ -353,10 +662,284 @@ class PurchaseFilter(OperatorFilter):
|
||||
# Cross-entity 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(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:
|
||||
@@ -382,3 +965,15 @@ 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)
|
||||
|
||||
+55
-36
@@ -1,71 +1,90 @@
|
||||
- model: games.game
|
||||
pk: 1
|
||||
fields:
|
||||
name: Nioh 2
|
||||
wikidata: Q67482292
|
||||
- model: games.game
|
||||
pk: 2
|
||||
fields:
|
||||
name: Elden Ring
|
||||
wikidata: Q64826862
|
||||
- model: games.game
|
||||
pk: 3
|
||||
fields:
|
||||
name: Cyberpunk 2077
|
||||
wikidata: Q3182559
|
||||
- model: games.purchase
|
||||
pk: 1
|
||||
fields:
|
||||
game: 1
|
||||
platform: 1
|
||||
date_purchased: 2021-02-13
|
||||
date_refunded: null
|
||||
- model: games.purchase
|
||||
pk: 2
|
||||
fields:
|
||||
game: 2
|
||||
platform: 1
|
||||
date_purchased: 2022-02-24
|
||||
date_refunded: null
|
||||
- model: games.purchase
|
||||
pk: 3
|
||||
fields:
|
||||
game: 3
|
||||
platform: 1
|
||||
date_purchased: 2020-12-07
|
||||
date_refunded: null
|
||||
- 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]
|
||||
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]
|
||||
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]
|
||||
platform: 1
|
||||
date_purchased: 2020-12-07
|
||||
date_refunded: null
|
||||
created_at: "2020-12-07T00:00:00Z"
|
||||
updated_at: "2020-12-07T00:00:00Z"
|
||||
|
||||
+207
-38
@@ -1,8 +1,15 @@
|
||||
from django import forms
|
||||
from django.db import transaction
|
||||
from django.urls import reverse
|
||||
from django.db.models import OuterRef, Subquery
|
||||
|
||||
from common.utils import safe_getattr
|
||||
from common.components import (
|
||||
DEFAULT_PREFETCH,
|
||||
SearchSelect,
|
||||
SearchSelectOption,
|
||||
render,
|
||||
searchselect_selected,
|
||||
)
|
||||
from common.components.primitives import Checkbox
|
||||
from games.models import (
|
||||
Device,
|
||||
Game,
|
||||
@@ -20,20 +27,154 @@ 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 f"{obj.sort_name} ({obj.platform}, {obj.year_released})"
|
||||
return obj.search_label
|
||||
|
||||
|
||||
class SingleGameChoiceField(forms.ModelChoiceField):
|
||||
def label_from_instance(self, obj) -> str:
|
||||
return f"{obj.sort_name} ({obj.platform}, {obj.year_released})"
|
||||
return obj.search_label
|
||||
|
||||
|
||||
class SessionForm(forms.ModelForm):
|
||||
def _game_options(values) -> list[SearchSelectOption]:
|
||||
"""Resolve game ids (or instances) to SearchSelectOptions via one pk__in query."""
|
||||
return [
|
||||
{
|
||||
"value": g.id,
|
||||
"label": g.search_label,
|
||||
"data": {"platform": g.platform_id or ""},
|
||||
}
|
||||
for g in Game.objects.filter(pk__in=values).select_related("platform")
|
||||
]
|
||||
|
||||
|
||||
def _device_options(values) -> list[SearchSelectOption]:
|
||||
return [
|
||||
{"value": d.id, "label": d.name, "data": {}}
|
||||
for d in Device.objects.filter(pk__in=values)
|
||||
]
|
||||
|
||||
|
||||
def _platform_options(values) -> list[SearchSelectOption]:
|
||||
return [
|
||||
{"value": p.id, "label": p.name, "data": {}}
|
||||
for p in Platform.objects.filter(pk__in=values)
|
||||
]
|
||||
|
||||
|
||||
class SearchSelectWidget(forms.Widget):
|
||||
"""Thin Django adapter that renders a `SearchSelect()` component.
|
||||
|
||||
The only place that knows about Django/forms — the component itself stays
|
||||
reusable outside forms.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
search_url,
|
||||
options_resolver,
|
||||
multi_select=False,
|
||||
items_visible=5,
|
||||
items_scroll=10,
|
||||
prefetch=DEFAULT_PREFETCH,
|
||||
always_visible=False,
|
||||
placeholder="Search…",
|
||||
attrs=None,
|
||||
):
|
||||
super().__init__(attrs)
|
||||
self.search_url = search_url
|
||||
self.options_resolver = options_resolver
|
||||
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
|
||||
|
||||
@staticmethod
|
||||
def _values(value) -> list:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [v for v in value if v not in (None, "")]
|
||||
return [value] if value not in (None, "") else []
|
||||
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
def value_from_datadict(self, data, files, name):
|
||||
return data.get(name)
|
||||
|
||||
|
||||
class SearchSelectMultiple(SearchSelectWidget):
|
||||
def value_from_datadict(self, data, files, name):
|
||||
if hasattr(data, "getlist"):
|
||||
return data.getlist(name)
|
||||
return data.get(name)
|
||||
|
||||
|
||||
class SessionForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
game = SingleGameChoiceField(
|
||||
queryset=Game.objects.order_by("sort_name"),
|
||||
widget=forms.Select(attrs={"autofocus": "autofocus"}),
|
||||
widget=SearchSelectWidget(
|
||||
search_url="/api/games/search", options_resolver=_game_options
|
||||
),
|
||||
)
|
||||
|
||||
duration_manual = forms.DurationField(
|
||||
@@ -43,7 +184,13 @@ class SessionForm(forms.ModelForm):
|
||||
),
|
||||
label="Manual duration",
|
||||
)
|
||||
device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"), required=False)
|
||||
device = forms.ModelChoiceField(
|
||||
queryset=Device.objects.order_by("name"),
|
||||
required=False,
|
||||
widget=SearchSelectWidget(
|
||||
search_url="/api/devices/search", options_resolver=_device_options
|
||||
),
|
||||
)
|
||||
|
||||
mark_as_played = forms.BooleanField(
|
||||
required=False,
|
||||
@@ -81,38 +228,52 @@ class SessionForm(forms.ModelForm):
|
||||
return session
|
||||
|
||||
|
||||
class IncludePlatformSelect(forms.SelectMultiple):
|
||||
def create_option(self, name, value, *args, **kwargs):
|
||||
option = super().create_option(name, value, *args, **kwargs)
|
||||
if platform_id := safe_getattr(value, "instance.platform.id"):
|
||||
option["attrs"]["data-platform"] = platform_id
|
||||
return option
|
||||
def related_purchase_queryset():
|
||||
"""GAME purchases annotated with their first game's name.
|
||||
|
||||
Rendering the ``related_purchase`` ``<select>`` calls ``str()`` on every
|
||||
option, and ``Purchase.__str__`` falls back to ``first_game`` — one extra
|
||||
query per option (700+ on a large library). Annotating the first game's
|
||||
name via a subquery lets the choice field build labels without those
|
||||
per-row queries.
|
||||
"""
|
||||
first_game_name = Subquery(
|
||||
Game.objects.filter(purchases=OuterRef("pk")).order_by("id").values("name")[:1]
|
||||
)
|
||||
return Purchase.objects.filter(type=Purchase.GAME).annotate(
|
||||
_first_game_name=first_game_name
|
||||
)
|
||||
|
||||
|
||||
class PurchaseForm(forms.ModelForm):
|
||||
class RelatedPurchaseChoiceField(forms.ModelChoiceField):
|
||||
def label_from_instance(self, obj) -> str:
|
||||
# Mirrors Purchase.standardized_name but reads the annotated first-game
|
||||
# name instead of querying first_game per option.
|
||||
name = obj.name or getattr(obj, "_first_game_name", None)
|
||||
return name or obj.standardized_name
|
||||
|
||||
|
||||
class PurchaseForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Automatically update related_purchase <select/>
|
||||
# to only include purchases of the selected game.
|
||||
related_purchase_by_game_url = reverse("games:related_purchase_by_game")
|
||||
self.fields["games"].widget.attrs.update(
|
||||
{
|
||||
"hx-trigger": "load, click",
|
||||
"hx-get": related_purchase_by_game_url,
|
||||
"hx-target": "#id_related_purchase",
|
||||
"hx-swap": "outerHTML",
|
||||
}
|
||||
)
|
||||
self.fields["platform"].queryset = Platform.objects.order_by("name")
|
||||
|
||||
games = MultipleGameChoiceField(
|
||||
queryset=Game.objects.order_by("sort_name"),
|
||||
widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}),
|
||||
widget=SearchSelectMultiple(
|
||||
search_url="/api/games/search",
|
||||
options_resolver=_game_options,
|
||||
multi_select=True,
|
||||
),
|
||||
)
|
||||
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
|
||||
related_purchase = forms.ModelChoiceField(
|
||||
queryset=Purchase.objects.filter(type=Purchase.GAME),
|
||||
platform = forms.ModelChoiceField(
|
||||
queryset=Platform.objects.order_by("name"),
|
||||
widget=SearchSelectWidget(
|
||||
search_url="/api/platforms/search", options_resolver=_platform_options
|
||||
),
|
||||
)
|
||||
related_purchase = RelatedPurchaseChoiceField(
|
||||
queryset=related_purchase_queryset(),
|
||||
required=False,
|
||||
)
|
||||
|
||||
@@ -185,9 +346,13 @@ class GameModelChoiceField(forms.ModelChoiceField):
|
||||
return obj.sort_name
|
||||
|
||||
|
||||
class GameForm(forms.ModelForm):
|
||||
class GameForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
platform = forms.ModelChoiceField(
|
||||
queryset=Platform.objects.order_by("name"), required=False
|
||||
queryset=Platform.objects.order_by("name"),
|
||||
required=False,
|
||||
widget=SearchSelectWidget(
|
||||
search_url="/api/platforms/search", options_resolver=_platform_options
|
||||
),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -205,7 +370,7 @@ class GameForm(forms.ModelForm):
|
||||
widgets = {"name": autofocus_input_widget}
|
||||
|
||||
|
||||
class PlatformForm(forms.ModelForm):
|
||||
class PlatformForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = Platform
|
||||
fields = [
|
||||
@@ -216,17 +381,21 @@ class PlatformForm(forms.ModelForm):
|
||||
widgets = {"name": autofocus_input_widget}
|
||||
|
||||
|
||||
class DeviceForm(forms.ModelForm):
|
||||
class DeviceForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = Device
|
||||
fields = ["name", "type"]
|
||||
widgets = {"name": autofocus_input_widget}
|
||||
|
||||
|
||||
class PlayEventForm(forms.ModelForm):
|
||||
game = GameModelChoiceField(
|
||||
class PlayEventForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
game = SingleGameChoiceField(
|
||||
queryset=Game.objects.order_by("sort_name"),
|
||||
widget=forms.Select(attrs={"autofocus": "autofocus"}),
|
||||
widget=SearchSelectWidget(
|
||||
search_url="/api/games/search",
|
||||
options_resolver=_game_options,
|
||||
attrs={"autofocus": "autofocus"},
|
||||
),
|
||||
)
|
||||
|
||||
mark_as_finished = forms.BooleanField(
|
||||
@@ -254,7 +423,7 @@ class PlayEventForm(forms.ModelForm):
|
||||
return session
|
||||
|
||||
|
||||
class GameStatusChangeForm(forms.ModelForm):
|
||||
class GameStatusChangeForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = GameStatusChange
|
||||
fields = [
|
||||
|
||||
@@ -34,9 +34,11 @@ class HTMXMessagesMiddleware:
|
||||
if "HX-Redirect" in response:
|
||||
return response
|
||||
|
||||
min_level = message_constants.DEBUG if settings.DEBUG else message_constants.INFO
|
||||
min_level = (
|
||||
message_constants.DEBUG if settings.DEBUG else message_constants.INFO
|
||||
)
|
||||
backend = django_messages.get_messages(request)
|
||||
if hasattr(backend, '_set_level') and backend._get_level() > min_level:
|
||||
if hasattr(backend, "_set_level") and backend._get_level() > min_level:
|
||||
backend._set_level(min_level)
|
||||
messages = list(backend)
|
||||
if not messages:
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Write ts/generated/props.ts from the registered custom-element specs."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
# Importing the components package triggers element registration at import time.
|
||||
import common.components # noqa: F401
|
||||
from common.components.custom_elements import render_props_module
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Generate ts/generated/props.ts from registered custom elements."
|
||||
|
||||
def handle(self, *args, **options) -> None:
|
||||
output_dir = Path(settings.BASE_DIR) / "ts" / "generated"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = output_dir / "props.ts"
|
||||
target.write_text(render_props_module(), encoding="utf-8")
|
||||
self.stdout.write(self.style.SUCCESS(f"Wrote {target}"))
|
||||
@@ -0,0 +1,28 @@
|
||||
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."
|
||||
)
|
||||
)
|
||||
@@ -6,99 +6,265 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Device',
|
||||
name="Device",
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('type', models.CharField(choices=[('PC', 'PC'), ('Console', 'Console'), ('Handheld', 'Handheld'), ('Mobile', 'Mobile'), ('Single-board computer', 'Single-board computer'), ('Unknown', 'Unknown')], default='Unknown', max_length=255)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
(
|
||||
"type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("PC", "PC"),
|
||||
("Console", "Console"),
|
||||
("Handheld", "Handheld"),
|
||||
("Mobile", "Mobile"),
|
||||
("Single-board computer", "Single-board computer"),
|
||||
("Unknown", "Unknown"),
|
||||
],
|
||||
default="Unknown",
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Platform',
|
||||
name="Platform",
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('group', models.CharField(blank=True, default=None, max_length=255, null=True)),
|
||||
('icon', models.SlugField(blank=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
(
|
||||
"group",
|
||||
models.CharField(
|
||||
blank=True, default=None, max_length=255, null=True
|
||||
),
|
||||
),
|
||||
("icon", models.SlugField(blank=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ExchangeRate',
|
||||
name="ExchangeRate",
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('currency_from', models.CharField(max_length=255)),
|
||||
('currency_to', models.CharField(max_length=255)),
|
||||
('year', models.PositiveIntegerField()),
|
||||
('rate', models.FloatField()),
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("currency_from", models.CharField(max_length=255)),
|
||||
("currency_to", models.CharField(max_length=255)),
|
||||
("year", models.PositiveIntegerField()),
|
||||
("rate", models.FloatField()),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('currency_from', 'currency_to', 'year')},
|
||||
"unique_together": {("currency_from", "currency_to", "year")},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Game',
|
||||
name="Game",
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('sort_name', models.CharField(blank=True, default=None, max_length=255, null=True)),
|
||||
('year_released', models.IntegerField(blank=True, default=None, null=True)),
|
||||
('wikidata', models.CharField(blank=True, default=None, max_length=50, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('platform', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='games.platform')),
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
(
|
||||
"sort_name",
|
||||
models.CharField(
|
||||
blank=True, default=None, max_length=255, null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"year_released",
|
||||
models.IntegerField(blank=True, default=None, null=True),
|
||||
),
|
||||
(
|
||||
"wikidata",
|
||||
models.CharField(
|
||||
blank=True, default=None, max_length=50, null=True
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"platform",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_DEFAULT,
|
||||
to="games.platform",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('name', 'platform', 'year_released')},
|
||||
"unique_together": {("name", "platform", "year_released")},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Purchase',
|
||||
name="Purchase",
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('date_purchased', models.DateField()),
|
||||
('date_refunded', models.DateField(blank=True, null=True)),
|
||||
('date_finished', models.DateField(blank=True, null=True)),
|
||||
('date_dropped', models.DateField(blank=True, null=True)),
|
||||
('infinite', models.BooleanField(default=False)),
|
||||
('price', models.FloatField(default=0)),
|
||||
('price_currency', models.CharField(default='USD', max_length=3)),
|
||||
('converted_price', models.FloatField(null=True)),
|
||||
('converted_currency', models.CharField(max_length=3, null=True)),
|
||||
('ownership_type', models.CharField(choices=[('ph', 'Physical'), ('di', 'Digital'), ('du', 'Digital Upgrade'), ('re', 'Rented'), ('bo', 'Borrowed'), ('tr', 'Trial'), ('de', 'Demo'), ('pi', 'Pirated')], default='di', max_length=2)),
|
||||
('type', models.CharField(choices=[('game', 'Game'), ('dlc', 'DLC'), ('season_pass', 'Season Pass'), ('battle_pass', 'Battle Pass')], default='game', max_length=255)),
|
||||
('name', models.CharField(blank=True, default='', max_length=255, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('games', models.ManyToManyField(blank=True, related_name='purchases', to='games.game')),
|
||||
('platform', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='games.platform')),
|
||||
('related_purchase', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_purchases', to='games.purchase')),
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("date_purchased", models.DateField()),
|
||||
("date_refunded", models.DateField(blank=True, null=True)),
|
||||
("date_finished", models.DateField(blank=True, null=True)),
|
||||
("date_dropped", models.DateField(blank=True, null=True)),
|
||||
("infinite", models.BooleanField(default=False)),
|
||||
("price", models.FloatField(default=0)),
|
||||
("price_currency", models.CharField(default="USD", max_length=3)),
|
||||
("converted_price", models.FloatField(null=True)),
|
||||
("converted_currency", models.CharField(max_length=3, null=True)),
|
||||
(
|
||||
"ownership_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("ph", "Physical"),
|
||||
("di", "Digital"),
|
||||
("du", "Digital Upgrade"),
|
||||
("re", "Rented"),
|
||||
("bo", "Borrowed"),
|
||||
("tr", "Trial"),
|
||||
("de", "Demo"),
|
||||
("pi", "Pirated"),
|
||||
],
|
||||
default="di",
|
||||
max_length=2,
|
||||
),
|
||||
),
|
||||
(
|
||||
"type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("game", "Game"),
|
||||
("dlc", "DLC"),
|
||||
("season_pass", "Season Pass"),
|
||||
("battle_pass", "Battle Pass"),
|
||||
],
|
||||
default="game",
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
(
|
||||
"name",
|
||||
models.CharField(blank=True, default="", max_length=255, null=True),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"games",
|
||||
models.ManyToManyField(
|
||||
blank=True, related_name="purchases", to="games.game"
|
||||
),
|
||||
),
|
||||
(
|
||||
"platform",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="games.platform",
|
||||
),
|
||||
),
|
||||
(
|
||||
"related_purchase",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="related_purchases",
|
||||
to="games.purchase",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Session',
|
||||
name="Session",
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('timestamp_start', models.DateTimeField()),
|
||||
('timestamp_end', models.DateTimeField(blank=True, null=True)),
|
||||
('duration_manual', models.DurationField(blank=True, default=datetime.timedelta(0), null=True)),
|
||||
('duration_calculated', models.DurationField(blank=True, null=True)),
|
||||
('note', models.TextField(blank=True, null=True)),
|
||||
('emulated', models.BooleanField(default=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('modified_at', models.DateTimeField(auto_now=True)),
|
||||
('device', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='games.device')),
|
||||
('game', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='games.game')),
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("timestamp_start", models.DateTimeField()),
|
||||
("timestamp_end", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"duration_manual",
|
||||
models.DurationField(
|
||||
blank=True, default=datetime.timedelta(0), null=True
|
||||
),
|
||||
),
|
||||
("duration_calculated", models.DurationField(blank=True, null=True)),
|
||||
("note", models.TextField(blank=True, null=True)),
|
||||
("emulated", models.BooleanField(default=False)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("modified_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"device",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_DEFAULT,
|
||||
to="games.device",
|
||||
),
|
||||
),
|
||||
(
|
||||
"game",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="sessions",
|
||||
to="games.game",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'get_latest_by': 'timestamp_start',
|
||||
"get_latest_by": "timestamp_start",
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -4,15 +4,14 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0001_initial'),
|
||||
("games", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='purchase',
|
||||
name='price_per_game',
|
||||
model_name="purchase",
|
||||
name="price_per_game",
|
||||
field=models.FloatField(null=True),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -4,15 +4,14 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0002_purchase_price_per_game'),
|
||||
("games", "0002_purchase_price_per_game"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='purchase',
|
||||
name='updated_at',
|
||||
model_name="purchase",
|
||||
name="updated_at",
|
||||
field=models.DateTimeField(auto_now=True),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -5,55 +5,66 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0005_game_mastered_game_status'),
|
||||
("games", "0005_game_mastered_game_status"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='game',
|
||||
name='sort_name',
|
||||
field=models.CharField(blank=True, default='', max_length=255),
|
||||
model_name="game",
|
||||
name="sort_name",
|
||||
field=models.CharField(blank=True, default="", max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='game',
|
||||
name='wikidata',
|
||||
field=models.CharField(blank=True, default='', max_length=50),
|
||||
model_name="game",
|
||||
name="wikidata",
|
||||
field=models.CharField(blank=True, default="", max_length=50),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='platform',
|
||||
name='group',
|
||||
field=models.CharField(blank=True, default='', max_length=255),
|
||||
model_name="platform",
|
||||
name="group",
|
||||
field=models.CharField(blank=True, default="", max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchase',
|
||||
name='converted_currency',
|
||||
field=models.CharField(blank=True, default='', max_length=3),
|
||||
model_name="purchase",
|
||||
name="converted_currency",
|
||||
field=models.CharField(blank=True, default="", max_length=3),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchase',
|
||||
name='games',
|
||||
field=models.ManyToManyField(related_name='purchases', to='games.game'),
|
||||
model_name="purchase",
|
||||
name="games",
|
||||
field=models.ManyToManyField(related_name="purchases", to="games.game"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchase',
|
||||
name='name',
|
||||
field=models.CharField(blank=True, default='', max_length=255),
|
||||
model_name="purchase",
|
||||
name="name",
|
||||
field=models.CharField(blank=True, default="", max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchase',
|
||||
name='related_purchase',
|
||||
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_purchases', to='games.purchase'),
|
||||
model_name="purchase",
|
||||
name="related_purchase",
|
||||
field=models.ForeignKey(
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="related_purchases",
|
||||
to="games.purchase",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='session',
|
||||
name='game',
|
||||
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='games.game'),
|
||||
model_name="session",
|
||||
name="game",
|
||||
field=models.ForeignKey(
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="sessions",
|
||||
to="games.game",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='session',
|
||||
name='note',
|
||||
field=models.TextField(blank=True, default=''),
|
||||
model_name="session",
|
||||
name="note",
|
||||
field=models.TextField(blank=True, default=""),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -4,15 +4,14 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0006_alter_game_sort_name_alter_game_wikidata_and_more'),
|
||||
("games", "0006_alter_game_sort_name_alter_game_wikidata_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='game',
|
||||
name='updated_at',
|
||||
model_name="game",
|
||||
name="updated_at",
|
||||
field=models.DateTimeField(auto_now=True),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -4,18 +4,17 @@ from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0008_game_original_year_released_gamestatuschange_and_more'),
|
||||
("games", "0008_game_original_year_released_gamestatuschange_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='purchase',
|
||||
name='date_dropped',
|
||||
model_name="purchase",
|
||||
name="date_dropped",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='purchase',
|
||||
name='date_finished',
|
||||
model_name="purchase",
|
||||
name="date_finished",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -4,14 +4,13 @@ from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0009_remove_purchase_date_dropped_and_more'),
|
||||
("games", "0009_remove_purchase_date_dropped_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='purchase',
|
||||
name='price_per_game',
|
||||
model_name="purchase",
|
||||
name="price_per_game",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -6,15 +6,24 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0010_remove_purchase_price_per_game'),
|
||||
("games", "0010_remove_purchase_price_per_game"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='purchase',
|
||||
name='price_per_game',
|
||||
field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(django.db.models.functions.comparison.Coalesce(models.F('converted_price'), models.F('price'), 0), '/', models.F('num_purchases')), output_field=models.FloatField()),
|
||||
model_name="purchase",
|
||||
name="price_per_game",
|
||||
field=models.GeneratedField(
|
||||
db_persist=True,
|
||||
expression=django.db.models.expressions.CombinedExpression(
|
||||
django.db.models.functions.comparison.Coalesce(
|
||||
models.F("converted_price"), models.F("price"), 0
|
||||
),
|
||||
"/",
|
||||
models.F("num_purchases"),
|
||||
),
|
||||
output_field=models.FloatField(),
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -5,15 +5,20 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0013_game_playtime'),
|
||||
("games", "0013_game_playtime"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='session',
|
||||
name='duration_total',
|
||||
field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(models.F('duration_calculated'), '+', models.F('duration_manual')), output_field=models.DurationField()),
|
||||
model_name="session",
|
||||
name="duration_total",
|
||||
field=models.GeneratedField(
|
||||
db_persist=True,
|
||||
expression=django.db.models.expressions.CombinedExpression(
|
||||
models.F("duration_calculated"), "+", models.F("duration_manual")
|
||||
),
|
||||
output_field=models.DurationField(),
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -5,35 +5,39 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0014_session_duration_total'),
|
||||
("games", "0014_session_duration_total"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='purchase',
|
||||
name='date_purchased',
|
||||
field=models.DateField(verbose_name='Purchased'),
|
||||
model_name="purchase",
|
||||
name="date_purchased",
|
||||
field=models.DateField(verbose_name="Purchased"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchase',
|
||||
name='date_refunded',
|
||||
field=models.DateField(blank=True, null=True, verbose_name='Refunded'),
|
||||
model_name="purchase",
|
||||
name="date_refunded",
|
||||
field=models.DateField(blank=True, null=True, verbose_name="Refunded"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='session',
|
||||
name='duration_manual',
|
||||
field=models.DurationField(blank=True, default=datetime.timedelta(0), null=True, verbose_name='Manual duration'),
|
||||
model_name="session",
|
||||
name="duration_manual",
|
||||
field=models.DurationField(
|
||||
blank=True,
|
||||
default=datetime.timedelta(0),
|
||||
null=True,
|
||||
verbose_name="Manual duration",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='session',
|
||||
name='timestamp_end',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='End'),
|
||||
model_name="session",
|
||||
name="timestamp_end",
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name="End"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='session',
|
||||
name='timestamp_start',
|
||||
field=models.DateTimeField(verbose_name='Start'),
|
||||
model_name="session",
|
||||
name="timestamp_start",
|
||||
field=models.DateTimeField(verbose_name="Start"),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -4,15 +4,14 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0015_alter_purchase_date_purchased_and_more'),
|
||||
("games", "0015_alter_purchase_date_purchased_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='purchase',
|
||||
name='needs_price_update',
|
||||
model_name="purchase",
|
||||
name="needs_price_update",
|
||||
field=models.BooleanField(db_index=True, default=True),
|
||||
),
|
||||
migrations.RunSQL(
|
||||
|
||||
@@ -4,26 +4,45 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0016_add_needs_price_update'),
|
||||
("games", "0016_add_needs_price_update"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='FilterPreset',
|
||||
name="FilterPreset",
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('mode', models.CharField(choices=[('games', 'Games'), ('sessions', 'Sessions'), ('purchases', 'Purchases'), ('playevents', 'Play Events')], default='games', max_length=50)),
|
||||
('find_filter', models.JSONField(blank=True, default=dict)),
|
||||
('object_filter', models.JSONField(blank=True, default=dict)),
|
||||
('ui_options', models.JSONField(blank=True, default=dict)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
(
|
||||
"mode",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("games", "Games"),
|
||||
("sessions", "Sessions"),
|
||||
("purchases", "Purchases"),
|
||||
("playevents", "Play Events"),
|
||||
],
|
||||
default="games",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
("find_filter", models.JSONField(blank=True, default=dict)),
|
||||
("object_filter", models.JSONField(blank=True, default=dict)),
|
||||
("ui_options", models.JSONField(blank=True, default=dict)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['name'],
|
||||
"ordering": ["name"],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 6.0.1 on 2026-06-06 20:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0017_add_filter_preset"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="session",
|
||||
name="timestamp_start",
|
||||
field=models.DateTimeField(db_index=True, verbose_name="Start"),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 6.0.5 on 2026-06-13 18:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0018_alter_session_timestamp_start"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="filterpreset",
|
||||
name="mode",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("games", "Games"),
|
||||
("sessions", "Sessions"),
|
||||
("purchases", "Purchases"),
|
||||
("playevents", "Play Events"),
|
||||
("devices", "Devices"),
|
||||
("platforms", "Platforms"),
|
||||
],
|
||||
default="games",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
]
|
||||
+11
-3
@@ -65,9 +65,15 @@ class Game(models.Model):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def search_label(self) -> str:
|
||||
return f"{self.sort_name} ({self.platform}, {self.year_released})"
|
||||
|
||||
def finished(self):
|
||||
return (self.status == self.Status.FINISHED or
|
||||
self.playevents.filter(ended__isnull=False).exists())
|
||||
return (
|
||||
self.status == self.Status.FINISHED
|
||||
or self.playevents.filter(ended__isnull=False).exists()
|
||||
)
|
||||
|
||||
def abandoned(self):
|
||||
return self.status == self.Status.ABANDONED
|
||||
@@ -288,7 +294,7 @@ class Session(models.Model):
|
||||
default=None,
|
||||
related_name="sessions",
|
||||
)
|
||||
timestamp_start = models.DateTimeField(verbose_name="Start")
|
||||
timestamp_start = models.DateTimeField(verbose_name="Start", db_index=True)
|
||||
timestamp_end = models.DateTimeField(blank=True, null=True, verbose_name="End")
|
||||
duration_manual = models.DurationField(
|
||||
blank=True, null=True, default=timedelta(0), verbose_name="Manual duration"
|
||||
@@ -495,6 +501,8 @@ class FilterPreset(models.Model):
|
||||
("sessions", "Sessions"),
|
||||
("purchases", "Purchases"),
|
||||
("playevents", "Play Events"),
|
||||
("devices", "Devices"),
|
||||
("platforms", "Platforms"),
|
||||
]
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
|
||||
+374
-105
@@ -306,7 +306,6 @@
|
||||
--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);
|
||||
@@ -467,6 +466,9 @@
|
||||
}
|
||||
}
|
||||
@layer utilities {
|
||||
.\@container {
|
||||
container-type: inline-size;
|
||||
}
|
||||
.pointer-events-auto {
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -811,6 +813,9 @@
|
||||
.static {
|
||||
position: static;
|
||||
}
|
||||
.sticky {
|
||||
position: sticky;
|
||||
}
|
||||
.inset-0 {
|
||||
inset: calc(var(--spacing) * 0);
|
||||
}
|
||||
@@ -898,9 +903,6 @@
|
||||
max-width: 96rem;
|
||||
}
|
||||
}
|
||||
.mx-2 {
|
||||
margin-inline: calc(var(--spacing) * 2);
|
||||
}
|
||||
.mx-auto {
|
||||
margin-inline: auto;
|
||||
}
|
||||
@@ -916,6 +918,9 @@
|
||||
.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);
|
||||
}
|
||||
@@ -940,6 +945,9 @@
|
||||
.mt-5 {
|
||||
margin-top: calc(var(--spacing) * 5);
|
||||
}
|
||||
.mt-6 {
|
||||
margin-top: calc(var(--spacing) * 6);
|
||||
}
|
||||
.apexcharts-canvas {
|
||||
& .apexcharts-tooltip {
|
||||
background-color: primary !important;
|
||||
@@ -1199,6 +1207,9 @@
|
||||
min-width: 4rem;
|
||||
}
|
||||
}
|
||||
.mr-3 {
|
||||
margin-right: calc(var(--spacing) * 3);
|
||||
}
|
||||
.mr-4 {
|
||||
margin-right: calc(var(--spacing) * 4);
|
||||
}
|
||||
@@ -1245,6 +1256,9 @@
|
||||
.mb-10 {
|
||||
margin-bottom: calc(var(--spacing) * 10);
|
||||
}
|
||||
.mb-12 {
|
||||
margin-bottom: calc(var(--spacing) * 12);
|
||||
}
|
||||
.apexcharts-xaxistooltip {
|
||||
.apexcharts-canvas & {
|
||||
color: var(--color-body) !important;
|
||||
@@ -1276,6 +1290,12 @@
|
||||
margin-left: -10px !important;
|
||||
}
|
||||
}
|
||||
.ml-1 {
|
||||
margin-left: calc(var(--spacing) * 1);
|
||||
}
|
||||
.ml-2 {
|
||||
margin-left: calc(var(--spacing) * 2);
|
||||
}
|
||||
.ml-4 {
|
||||
margin-left: calc(var(--spacing) * 4);
|
||||
}
|
||||
@@ -1412,6 +1432,9 @@
|
||||
.block {
|
||||
display: block;
|
||||
}
|
||||
.contents {
|
||||
display: contents;
|
||||
}
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
@@ -1458,6 +1481,9 @@
|
||||
.h-8 {
|
||||
height: calc(var(--spacing) * 8);
|
||||
}
|
||||
.h-9 {
|
||||
height: calc(var(--spacing) * 9);
|
||||
}
|
||||
.h-10 {
|
||||
height: calc(var(--spacing) * 10);
|
||||
}
|
||||
@@ -1470,15 +1496,9 @@
|
||||
.h-full {
|
||||
height: 100%;
|
||||
}
|
||||
.max-h-40 {
|
||||
max-height: calc(var(--spacing) * 40);
|
||||
}
|
||||
.max-h-full {
|
||||
max-height: 100%;
|
||||
}
|
||||
.min-h-\[28px\] {
|
||||
min-height: 28px;
|
||||
}
|
||||
.min-h-screen {
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -1562,6 +1582,12 @@
|
||||
.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);
|
||||
}
|
||||
@@ -1577,6 +1603,15 @@
|
||||
.w-72 {
|
||||
width: calc(var(--spacing) * 72);
|
||||
}
|
||||
.w-\[2\.5ch\] {
|
||||
width: 2.5ch;
|
||||
}
|
||||
.w-\[4\.5ch\] {
|
||||
width: 4.5ch;
|
||||
}
|
||||
.w-\[300px\] {
|
||||
width: 300px;
|
||||
}
|
||||
.w-auto {
|
||||
width: auto;
|
||||
}
|
||||
@@ -1595,6 +1630,9 @@
|
||||
.max-w-120 {
|
||||
max-width: calc(var(--spacing) * 120);
|
||||
}
|
||||
.max-w-lg {
|
||||
max-width: var(--container-lg);
|
||||
}
|
||||
.max-w-md {
|
||||
max-width: var(--container-md);
|
||||
}
|
||||
@@ -1646,6 +1684,9 @@
|
||||
border-color: var(--color-brand);
|
||||
}
|
||||
}
|
||||
.min-w-\[8rem\] {
|
||||
min-width: 8rem;
|
||||
}
|
||||
.flex-1 {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -1655,6 +1696,9 @@
|
||||
.flex-shrink-0 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.shrink-0 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.-translate-x-full {
|
||||
--tw-translate-x: -100%;
|
||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
||||
@@ -1704,6 +1748,9 @@
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
.cursor-text {
|
||||
cursor: text;
|
||||
}
|
||||
.resize {
|
||||
resize: both;
|
||||
}
|
||||
@@ -1713,12 +1760,12 @@
|
||||
.list-disc {
|
||||
list-style-type: disc;
|
||||
}
|
||||
.appearance-none {
|
||||
appearance: none;
|
||||
}
|
||||
.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));
|
||||
}
|
||||
@@ -1743,6 +1790,9 @@
|
||||
.items-start {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.items-stretch {
|
||||
align-items: stretch;
|
||||
}
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
@@ -1755,9 +1805,15 @@
|
||||
.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);
|
||||
}
|
||||
@@ -1770,6 +1826,9 @@
|
||||
.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;
|
||||
@@ -1798,6 +1857,9 @@
|
||||
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);
|
||||
}
|
||||
@@ -1866,6 +1928,9 @@
|
||||
.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);
|
||||
@@ -1890,20 +1955,21 @@
|
||||
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-tr-md {
|
||||
border-top-right-radius: var(--radius-md);
|
||||
.rounded-r-lg {
|
||||
border-top-right-radius: var(--radius-lg);
|
||||
border-bottom-right-radius: var(--radius-lg);
|
||||
}
|
||||
.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;
|
||||
@@ -1912,14 +1978,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;
|
||||
@@ -1989,9 +2055,21 @@
|
||||
.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);
|
||||
}
|
||||
@@ -2059,6 +2137,12 @@
|
||||
.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)) {
|
||||
@@ -2077,6 +2161,24 @@
|
||||
.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)) {
|
||||
@@ -2089,6 +2191,9 @@
|
||||
.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);
|
||||
}
|
||||
@@ -2125,6 +2230,15 @@
|
||||
.bg-neutral-primary-soft {
|
||||
background-color: var(--color-neutral-primary-soft);
|
||||
}
|
||||
.bg-neutral-primary-soft\/30 {
|
||||
background-color: color-mix(in srgb, #fff 30%, transparent);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
background-color: color-mix(in oklab, var(--color-neutral-primary-soft) 30%, transparent);
|
||||
}
|
||||
}
|
||||
.bg-neutral-quaternary {
|
||||
background-color: var(--color-neutral-quaternary);
|
||||
}
|
||||
.bg-neutral-secondary-medium {
|
||||
background-color: var(--color-neutral-secondary-medium);
|
||||
}
|
||||
@@ -2149,6 +2263,15 @@
|
||||
.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);
|
||||
}
|
||||
.bg-red-700 {
|
||||
background-color: var(--color-red-700);
|
||||
}
|
||||
@@ -2228,6 +2351,9 @@
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
.p-0 {
|
||||
padding: calc(var(--spacing) * 0);
|
||||
}
|
||||
.p-1 {
|
||||
padding: calc(var(--spacing) * 1);
|
||||
}
|
||||
@@ -2252,6 +2378,9 @@
|
||||
.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);
|
||||
}
|
||||
@@ -2331,12 +2460,12 @@
|
||||
color: heading !important;
|
||||
}
|
||||
}
|
||||
.pb-1 {
|
||||
padding-bottom: calc(var(--spacing) * 1);
|
||||
}
|
||||
.pb-16 {
|
||||
padding-bottom: calc(var(--spacing) * 16);
|
||||
}
|
||||
.pl-3 {
|
||||
padding-left: calc(var(--spacing) * 3);
|
||||
}
|
||||
.datatable-empty {
|
||||
.datatable-wrapper .datatable-table & {
|
||||
text-align: center;
|
||||
@@ -2351,6 +2480,9 @@
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
.text-start {
|
||||
text-align: start;
|
||||
}
|
||||
.align-middle {
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -2375,10 +2507,6 @@
|
||||
font-size: var(--text-4xl);
|
||||
line-height: var(--tw-leading, var(--text-4xl--line-height));
|
||||
}
|
||||
.text-5xl {
|
||||
font-size: var(--text-5xl);
|
||||
line-height: var(--tw-leading, var(--text-5xl--line-height));
|
||||
}
|
||||
.text-base {
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--tw-leading, var(--text-base--line-height));
|
||||
@@ -2533,6 +2661,9 @@
|
||||
.text-amber-500 {
|
||||
color: var(--color-amber-500);
|
||||
}
|
||||
.text-amber-600 {
|
||||
color: var(--color-amber-600);
|
||||
}
|
||||
.text-amber-800 {
|
||||
color: var(--color-amber-800);
|
||||
}
|
||||
@@ -2545,6 +2676,9 @@
|
||||
.text-blue-500 {
|
||||
color: var(--color-blue-500);
|
||||
}
|
||||
.text-blue-600 {
|
||||
color: var(--color-blue-600);
|
||||
}
|
||||
.text-blue-800 {
|
||||
color: var(--color-blue-800);
|
||||
}
|
||||
@@ -2605,6 +2739,9 @@
|
||||
.text-red-800 {
|
||||
color: var(--color-red-800);
|
||||
}
|
||||
.text-slate-200 {
|
||||
color: var(--color-slate-200);
|
||||
}
|
||||
.text-slate-300 {
|
||||
color: var(--color-slate-300);
|
||||
}
|
||||
@@ -2626,21 +2763,33 @@
|
||||
.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
.no-underline\! {
|
||||
text-decoration-line: none !important;
|
||||
.line-through {
|
||||
text-decoration-line: line-through;
|
||||
}
|
||||
.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%;
|
||||
}
|
||||
@@ -2673,6 +2822,13 @@
|
||||
--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;
|
||||
@@ -2694,6 +2850,11 @@
|
||||
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));
|
||||
@@ -2733,6 +2894,9 @@
|
||||
.\[program\:qcluster\] {
|
||||
program: qcluster;
|
||||
}
|
||||
.ring-inset {
|
||||
--tw-ring-inset: inset;
|
||||
}
|
||||
.group-hover\:absolute {
|
||||
&:is(:where(.group):hover *) {
|
||||
@media (hover: hover) {
|
||||
@@ -2833,6 +2997,16 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.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);
|
||||
@@ -2853,6 +3027,33 @@
|
||||
background-color: var(--color-gray-50);
|
||||
}
|
||||
}
|
||||
.first-of-type\:border-t-0 {
|
||||
&:first-of-type {
|
||||
border-top-style: var(--tw-border-style);
|
||||
border-top-width: 0px;
|
||||
}
|
||||
}
|
||||
.first-of-type\:pt-0 {
|
||||
&:first-of-type {
|
||||
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) {
|
||||
@@ -2870,6 +3071,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:border-brand-strong {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
border-color: var(--color-brand-strong);
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:border-default {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
@@ -2877,6 +3085,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:border-gray-300 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
border-color: var(--color-gray-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:border-green-600 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
@@ -2891,6 +3106,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:bg-brand {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
background-color: var(--color-brand);
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:bg-brand-strong {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
@@ -2898,6 +3120,16 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:bg-brand\/15 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:bg-gray-50 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
@@ -2919,6 +3151,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:bg-gray-700 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
background-color: var(--color-gray-700);
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:bg-green-500 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
@@ -2940,6 +3179,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.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) {
|
||||
@@ -3059,6 +3305,24 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.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;
|
||||
@@ -3069,6 +3333,14 @@
|
||||
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);
|
||||
@@ -3158,6 +3430,36 @@
|
||||
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;
|
||||
@@ -3190,6 +3492,11 @@
|
||||
border-bottom-left-radius: var(--radius-lg);
|
||||
}
|
||||
}
|
||||
.sm\:p-5 {
|
||||
@media (width >= 40rem) {
|
||||
padding: calc(var(--spacing) * 5);
|
||||
}
|
||||
}
|
||||
.sm\:px-4 {
|
||||
@media (width >= 40rem) {
|
||||
padding-inline: calc(var(--spacing) * 4);
|
||||
@@ -3338,6 +3645,11 @@
|
||||
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;
|
||||
@@ -3383,6 +3695,11 @@
|
||||
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);
|
||||
@@ -3418,6 +3735,11 @@
|
||||
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);
|
||||
@@ -3715,6 +4037,15 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.dark\:hover\:text-blue-500 {
|
||||
&:is(.dark *) {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
color: var(--color-blue-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.dark\:hover\:text-gray-300 {
|
||||
&:is(.dark *) {
|
||||
&:hover {
|
||||
@@ -3865,17 +4196,6 @@
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
}
|
||||
.\[\&_li\:first-of-type_a\]\:rounded-none {
|
||||
& li:first-of-type a {
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
.\[\&_li\:last-of-type_a\]\:rounded-t-none {
|
||||
& li:last-of-type a {
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
}
|
||||
.\[\&_td\:last-child\]\:text-right {
|
||||
& td:last-child {
|
||||
text-align: right;
|
||||
@@ -3895,51 +4215,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.\[\&\:\:-webkit-slider-thumb\]\:relative {
|
||||
&::-webkit-slider-thumb {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
.\[\&\:\:-webkit-slider-thumb\]\:z-10 {
|
||||
&::-webkit-slider-thumb {
|
||||
z-index: 10;
|
||||
}
|
||||
}
|
||||
.\[\&\:\:-webkit-slider-thumb\]\:z-20 {
|
||||
&::-webkit-slider-thumb {
|
||||
z-index: 20;
|
||||
}
|
||||
}
|
||||
.\[\&\:\:-webkit-slider-thumb\]\:h-4 {
|
||||
&::-webkit-slider-thumb {
|
||||
height: calc(var(--spacing) * 4);
|
||||
}
|
||||
}
|
||||
.\[\&\:\:-webkit-slider-thumb\]\:w-4 {
|
||||
&::-webkit-slider-thumb {
|
||||
width: calc(var(--spacing) * 4);
|
||||
}
|
||||
}
|
||||
.\[\&\:\:-webkit-slider-thumb\]\:cursor-pointer {
|
||||
&::-webkit-slider-thumb {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
.\[\&\:\:-webkit-slider-thumb\]\:appearance-none {
|
||||
&::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
}
|
||||
}
|
||||
.\[\&\:\:-webkit-slider-thumb\]\:rounded-full {
|
||||
&::-webkit-slider-thumb {
|
||||
border-radius: calc(infinity * 1px);
|
||||
}
|
||||
}
|
||||
.\[\&\:\:-webkit-slider-thumb\]\:bg-brand {
|
||||
&::-webkit-slider-thumb {
|
||||
background-color: var(--color-brand);
|
||||
}
|
||||
}
|
||||
.\[\&\:first-of-type_button\]\:rounded-s-lg {
|
||||
&:first-of-type button {
|
||||
border-start-start-radius: var(--radius-lg);
|
||||
@@ -3952,6 +4227,16 @@
|
||||
border-end-end-radius: var(--radius-lg);
|
||||
}
|
||||
}
|
||||
.\[\&\>div\]\:w-\[calc\(33\.333\%-0\.67rem\)\] {
|
||||
&>div {
|
||||
width: calc(33.333% - 0.67rem);
|
||||
}
|
||||
}
|
||||
.\[\&\>div\]\:w-\[calc\(50\%-0\.5rem\)\] {
|
||||
&>div {
|
||||
width: calc(50% - 0.5rem);
|
||||
}
|
||||
}
|
||||
}
|
||||
.dark {
|
||||
--color-body: var(--color-gray-400);
|
||||
@@ -4249,7 +4534,7 @@ form input:disabled, select:disabled, textarea:disabled {
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-heading);
|
||||
}
|
||||
input:not([type="checkbox"]) {
|
||||
input:not([type="checkbox"]):not([data-search-select-search]) {
|
||||
margin-bottom: calc(var(--spacing) * 3);
|
||||
display: block;
|
||||
width: 100%;
|
||||
@@ -4275,22 +4560,6 @@ 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);
|
||||
|
||||
@@ -1,20 +1,35 @@
|
||||
import {
|
||||
syncSelectInputUntilChanged,
|
||||
getEl,
|
||||
disableElementsWhenTrue,
|
||||
disableElementsWhenValueNotEqual,
|
||||
} from "./utils.js";
|
||||
import { disableElementsWhenTrue, onSwap } from "./utils.js";
|
||||
|
||||
let syncData = [
|
||||
{
|
||||
source: "#id_games",
|
||||
source_value: "dataset.platform",
|
||||
target: "#id_platform",
|
||||
target_value: "value",
|
||||
},
|
||||
];
|
||||
const RELATED_PURCHASE_URL = "/tracker/purchase/related-purchase-by-game";
|
||||
|
||||
syncSelectInputUntilChanged(syncData, "form");
|
||||
// The games field is now a SearchSelect widget (a <div>, not a <select>), so we
|
||||
// react to its custom "search-select:change" event instead of syncing a select.
|
||||
document.addEventListener("search-select:change", (event) => {
|
||||
if (event.detail.name !== "games") return;
|
||||
|
||||
// (a) Auto-fill platform from the clicked option's data-platform.
|
||||
const last = event.detail.last;
|
||||
const platformId = last && last.data ? last.data.platform : "";
|
||||
if (platformId) {
|
||||
const platformEl = document.querySelector("#id_platform");
|
||||
if (platformEl) platformEl.value = platformId;
|
||||
}
|
||||
|
||||
// (b) Refresh #id_related_purchase for the currently selected games.
|
||||
const query = event.detail.values
|
||||
.map((value) => "games=" + encodeURIComponent(value))
|
||||
.join("&");
|
||||
fetch(RELATED_PURCHASE_URL + "?" + query, { credentials: "same-origin" })
|
||||
.then((response) => {
|
||||
if (response.status === 204) return null;
|
||||
return response.text();
|
||||
})
|
||||
.then((html) => {
|
||||
if (html === null) return;
|
||||
const target = document.querySelector("#id_related_purchase");
|
||||
if (target) target.outerHTML = html;
|
||||
});
|
||||
});
|
||||
|
||||
function setupElementHandlers() {
|
||||
disableElementsWhenTrue("#id_type", "game", [
|
||||
@@ -23,9 +38,9 @@ function setupElementHandlers() {
|
||||
]);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", setupElementHandlers);
|
||||
document.addEventListener("htmx:afterSwap", setupElementHandlers);
|
||||
getEl("#id_type").addEventListener("change", () => {
|
||||
onSwap("#id_type", (typeSelect) => {
|
||||
setupElementHandlers();
|
||||
}
|
||||
);
|
||||
typeSelect.addEventListener("change", () => {
|
||||
setupElementHandlers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { toISOUTCString } from "./utils.js";
|
||||
|
||||
for (let button of document.querySelectorAll("[data-target]")) {
|
||||
let target = button.getAttribute("data-target");
|
||||
let type = button.getAttribute("data-type");
|
||||
let targetElement = document.querySelector(`#id_${target}`);
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
if (type == "now") {
|
||||
targetElement.value = toISOUTCString(new Date());
|
||||
} else if (type == "copy") {
|
||||
const oppositeName =
|
||||
targetElement.name == "timestamp_start"
|
||||
? "timestamp_end"
|
||||
: "timestamp_start";
|
||||
document.querySelector(`[name='${oppositeName}']`).value =
|
||||
targetElement.value;
|
||||
} else if (type == "toggle") {
|
||||
if (targetElement.type == "datetime-local") targetElement.type = "text";
|
||||
else targetElement.type = "datetime-local";
|
||||
}
|
||||
});
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
(()=>{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)});})();
|
||||
Vendored
+5
File diff suppressed because one or more lines are too long
@@ -0,0 +1,530 @@
|
||||
/**
|
||||
* 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
+230
-131
@@ -4,6 +4,8 @@
|
||||
* 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";
|
||||
|
||||
@@ -30,6 +32,24 @@
|
||||
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');
|
||||
@@ -46,15 +66,7 @@
|
||||
* Returns a plain object ready for JSON.stringify.
|
||||
*/
|
||||
function buildFilterJSON(form) {
|
||||
// Read all SelectableFilter widgets first
|
||||
readSelectableFilters(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"]');
|
||||
@@ -62,111 +74,128 @@
|
||||
filter.search = { value: searchInput.value.trim(), modifier: "INCLUDES" };
|
||||
}
|
||||
|
||||
// ── 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";
|
||||
// ── 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).
|
||||
filter[field] = {
|
||||
value: isIdField ? inc.map(Number) : inc,
|
||||
excludes: isIdField ? exc.map(Number) : exc,
|
||||
modifier: mod || "INCLUDES",
|
||||
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",
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// ── 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 !== "") {
|
||||
// Skip if both equal the data range extremes (no real filter)
|
||||
var yrMinNum = parseInt(yearMin, 10);
|
||||
var yrMaxNum = parseInt(yearMax, 10);
|
||||
if (yrMinNum === yrMaxNum) {
|
||||
// don't add filter
|
||||
// 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 };
|
||||
} else {
|
||||
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 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");
|
||||
var el = form.querySelector('[name="' + tf.name + '"]');
|
||||
if (el && el.value.trim()) {
|
||||
filter[tf.key] = { value: el.value.trim(), modifier: modifier };
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (mastered && mastered.checked) {
|
||||
filter.mastered = criterion(true, null, "EQUALS");
|
||||
}
|
||||
// 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;
|
||||
});
|
||||
|
||||
return filter;
|
||||
}
|
||||
@@ -220,10 +249,19 @@
|
||||
if (!url) return;
|
||||
|
||||
var mode = "games";
|
||||
if (window.location.pathname.indexOf("session") !== -1) mode = "sessions";
|
||||
else if (window.location.pathname.indexOf("purchase") !== -1) mode = "purchases";
|
||||
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";
|
||||
|
||||
fetch(url + "?mode=" + mode, { credentials: "same-origin" })
|
||||
var query = "";
|
||||
if (url.indexOf("mode=") === -1) {
|
||||
query = (url.indexOf("?") !== -1 ? "&" : "?") + "mode=" + mode;
|
||||
}
|
||||
|
||||
fetch(url + query, { credentials: "same-origin" })
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error("Failed to load presets");
|
||||
return r.text();
|
||||
@@ -274,6 +312,27 @@
|
||||
});
|
||||
}
|
||||
|
||||
/** 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");
|
||||
@@ -301,8 +360,12 @@
|
||||
var body = new URLSearchParams();
|
||||
body.append("name", name);
|
||||
var mode = "games";
|
||||
if (window.location.pathname.indexOf("session") !== -1) mode = "sessions";
|
||||
else if (window.location.pathname.indexOf("purchase") !== -1) mode = "purchases";
|
||||
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";
|
||||
body.append("mode", mode);
|
||||
body.append("filter", JSON.stringify(filterObj));
|
||||
|
||||
@@ -349,32 +412,68 @@
|
||||
|
||||
// ── Init on page load ───────────────────────────────────────────────────
|
||||
|
||||
// ── 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);
|
||||
// ── 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;
|
||||
}
|
||||
});
|
||||
if (radio.checked) {
|
||||
radio.wasChecked = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
injectSearchInputs();
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
injectSearchInputs();
|
||||
/**
|
||||
* 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();
|
||||
loadPresets();
|
||||
});
|
||||
})();
|
||||
|
||||
Vendored
+2
File diff suppressed because one or more lines are too long
+208
-74
@@ -1,96 +1,230 @@
|
||||
/**
|
||||
* Dual-handle range slider — pure JS with draggable handles.
|
||||
* Range slider — custom draggable handles (no native <input type=range>).
|
||||
*
|
||||
* Supports two modes on each slider, toggled via the .range-mode-toggle button:
|
||||
* range (default) — two handles, min ≤ max constraint
|
||||
* point — single handle, sets both number inputs to the same value
|
||||
*
|
||||
* 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 initAll(force) {
|
||||
document.querySelectorAll(".range-slider").forEach(function (slider) {
|
||||
if (force) slider._rsInit = false;
|
||||
if (slider._rsInit) return;
|
||||
slider._rsInit = true;
|
||||
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;
|
||||
|
||||
var minHandle = slider.querySelector(".range-handle-min");
|
||||
var maxHandle = slider.querySelector(".range-handle-max");
|
||||
var track = slider.querySelector(".range-track-fill");
|
||||
if (!minHandle || !maxHandle) return;
|
||||
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 minTarget = document.getElementById(minHandle.getAttribute("data-target"));
|
||||
var maxTarget = document.getElementById(maxHandle.getAttribute("data-target"));
|
||||
var dMin = parseInt(slider.getAttribute("data-min"), 10);
|
||||
var dMax = parseInt(slider.getAttribute("data-max"), 10);
|
||||
var step = parseInt(slider.getAttribute("data-step"), 10) || 1;
|
||||
// ── Helpers ──
|
||||
|
||||
function valueToPercent(v) { return ((v - dMin) / (dMax - dMin)) * 100; }
|
||||
function percentToValue(p) {
|
||||
var raw = dMin + (p / 100) * (dMax - dMin);
|
||||
return Math.round(raw / step) * step;
|
||||
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));
|
||||
}
|
||||
|
||||
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 clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
|
||||
}
|
||||
|
||||
function getTargetVal(el) { return parseInt(el ? el.value : minTarget.value, 10) || dMin; }
|
||||
function setTargetVal(el, v) { if (el) el.value = v; }
|
||||
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();
|
||||
}
|
||||
|
||||
function update() {
|
||||
var minV = getTargetVal(minTarget);
|
||||
var maxV = getTargetVal(maxTarget);
|
||||
minV = clamp(minV, dMin, dMax);
|
||||
maxV = clamp(maxV, dMin, dMax);
|
||||
if (minV > maxV) minV = maxV;
|
||||
if (maxV < minV) maxV = minV;
|
||||
setTargetVal(minTarget, minV);
|
||||
setTargetVal(maxTarget, maxV);
|
||||
var minP = valueToPercent(minV);
|
||||
var maxP = valueToPercent(maxV);
|
||||
minHandle.style.left = minP + "%";
|
||||
maxHandle.style.left = maxP + "%";
|
||||
if (track) {
|
||||
track.style.left = minP + "%";
|
||||
track.style.width = (maxP - minP) + "%";
|
||||
// ── 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
updateHandles();
|
||||
}
|
||||
|
||||
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 v = percentToValue(clamp(pct, 0, 100));
|
||||
if (isMin) {
|
||||
minTarget.value = clamp(v, dMin, getTargetVal(maxTarget));
|
||||
} else {
|
||||
maxTarget.value = clamp(v, getTargetVal(minTarget), dMax);
|
||||
}
|
||||
update();
|
||||
// Trigger input event on the target so any listeners fire
|
||||
var tgt = isMin ? minTarget : maxTarget;
|
||||
if (tgt) tgt.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
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 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);
|
||||
if (minTarget) {
|
||||
minTarget.addEventListener("input", syncFromInputs);
|
||||
minTarget.addEventListener("change", enforceStrictBounds);
|
||||
}
|
||||
if (maxTarget) {
|
||||
maxTarget.addEventListener("input", syncFromInputs);
|
||||
maxTarget.addEventListener("change", enforceStrictBounds);
|
||||
}
|
||||
|
||||
// Sync from inputs to slider
|
||||
function fromInputs() { update(); }
|
||||
if (minTarget) minTarget.addEventListener("input", fromInputs);
|
||||
if (maxTarget) maxTarget.addEventListener("input", fromInputs);
|
||||
// ── Mode toggle ──
|
||||
|
||||
update();
|
||||
});
|
||||
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");
|
||||
} else {
|
||||
minHandle.style.display = "";
|
||||
if (minTarget) minTarget.classList.remove("hidden");
|
||||
if (dashSpan) dashSpan.classList.remove("hidden");
|
||||
}
|
||||
mode = newMode;
|
||||
updateHandles();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Initial position ──
|
||||
updateHandles();
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", initAll);
|
||||
document.addEventListener("htmx:afterSwap", initAll);
|
||||
// Expose for manual re-init (filter bar toggle)
|
||||
window.initRangeSliders = initAll;
|
||||
onSwap(".range-slider", initializeSlider);
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,664 @@
|
||||
/**
|
||||
* SearchSelect widget — a search box paired with a dropdown of options.
|
||||
* Multi-select renders chosen items as removable pills (inline with the search
|
||||
* box), each backed by a hidden <input>. Single-select renders no pill: the
|
||||
* committed label lives inside the search box (which doubles as a combobox —
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
import { onSwap } from "./utils.js";
|
||||
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const DEBOUNCE_MS = 100;
|
||||
|
||||
// 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"];
|
||||
|
||||
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]");
|
||||
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";
|
||||
|
||||
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;
|
||||
|
||||
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 = () => {
|
||||
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;
|
||||
};
|
||||
|
||||
// ── 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();
|
||||
});
|
||||
|
||||
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));
|
||||
});
|
||||
|
||||
// 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));
|
||||
}
|
||||
});
|
||||
|
||||
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");
|
||||
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;
|
||||
});
|
||||
return visibleCount;
|
||||
};
|
||||
|
||||
// ── 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);
|
||||
});
|
||||
};
|
||||
|
||||
// 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);
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
fetchFromServer(query);
|
||||
}, DEBOUNCE_MS);
|
||||
} else {
|
||||
setNoResults(filterRows(query) === 0);
|
||||
}
|
||||
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;
|
||||
|
||||
search.addEventListener("focus", () => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
runSearch();
|
||||
});
|
||||
|
||||
if (!multi) {
|
||||
search.addEventListener("blur", () => {
|
||||
// Defer so an option click (which fires before blur settles) wins.
|
||||
setTimeout(() => {
|
||||
if (container._searchSelectDirty && search.value.trim() === "") {
|
||||
// User intentionally cleared the box → deselect.
|
||||
pills.innerHTML = "";
|
||||
container._searchSelectLabel = "";
|
||||
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 || "";
|
||||
}
|
||||
}, 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();
|
||||
});
|
||||
|
||||
// ── 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]");
|
||||
if (!row) return;
|
||||
selectOption(optionFromRow(row));
|
||||
});
|
||||
|
||||
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 => {
|
||||
if (key !== "value" && key !== "label" && key !== "ssOption") {
|
||||
data[key] = row.dataset[key];
|
||||
}
|
||||
});
|
||||
return {
|
||||
value: row.getAttribute("data-value"),
|
||||
label: row.getAttribute("data-label"),
|
||||
data,
|
||||
};
|
||||
};
|
||||
|
||||
const selectOption = (option) => {
|
||||
if (multi) {
|
||||
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.
|
||||
pills.innerHTML = "";
|
||||
pills.appendChild(buildHidden(option.value));
|
||||
search.value = option.label;
|
||||
container._searchSelectLabel = option.label;
|
||||
container._searchSelectDirty = false;
|
||||
hidePanel();
|
||||
}
|
||||
emitChange(option);
|
||||
};
|
||||
|
||||
const addPill = (option) => {
|
||||
const pill = buildPill(option);
|
||||
if (pill) pills.appendChild(pill);
|
||||
pills.appendChild(buildHidden(option.value));
|
||||
};
|
||||
|
||||
const buildPill = (option) => {
|
||||
const pill = cloneTemplate("pill");
|
||||
if (!pill) return null;
|
||||
pill.setAttribute("data-value", option.value);
|
||||
applyData(pill, option.data);
|
||||
setLabel(pill, option.label);
|
||||
return pill;
|
||||
};
|
||||
|
||||
const buildHidden = (value) => {
|
||||
const 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]");
|
||||
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");
|
||||
pill.remove();
|
||||
const 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);
|
||||
};
|
||||
|
||||
const emitChange = (last) => {
|
||||
const values = currentValues();
|
||||
if (syncUrl) syncToUrl(values);
|
||||
container.dispatchEvent(
|
||||
new CustomEvent("search-select:change", {
|
||||
bubbles: true,
|
||||
detail: { name, values, last },
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const syncToUrl = (values) => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.delete(name);
|
||||
values.forEach(v => {
|
||||
params.append(name, v);
|
||||
});
|
||||
const 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 => {
|
||||
addPill({ value: v, label: v, data: {} });
|
||||
});
|
||||
}
|
||||
|
||||
// ── Close panel on outside click ──
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!container.contains(event.target)) hidePanel();
|
||||
});
|
||||
};
|
||||
|
||||
/** Minimal escape for use inside an attribute-value selector. */
|
||||
const cssEscape = (value) => 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
|
||||
}
|
||||
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);
|
||||
})();
|
||||
@@ -1,149 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
})();
|
||||
@@ -1,3 +1,28 @@
|
||||
/**
|
||||
* @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
|
||||
@@ -202,6 +227,7 @@ function disableElementsWhenTrue(targetSelect, targetValue, elementList) {
|
||||
}
|
||||
|
||||
export {
|
||||
onSwap,
|
||||
toISOUTCString,
|
||||
syncSelectInputUntilChanged,
|
||||
getEl,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { onSwap } from "./utils.js";
|
||||
|
||||
onSwap("#year-picker-input", function(pickerEl) {
|
||||
const selectedYear = pickerEl.dataset.selectedYear;
|
||||
const urlTemplate = pickerEl.dataset.urlTemplate;
|
||||
const currentYear = new Date().getFullYear();
|
||||
const availableYears = new Set(
|
||||
pickerEl.dataset.availableYears
|
||||
.split(",")
|
||||
.map(s => parseInt(s.trim()))
|
||||
.filter(n => !isNaN(n))
|
||||
);
|
||||
|
||||
const picker = new Datepicker(pickerEl, {
|
||||
pickLevel: 2,
|
||||
format: "yyyy",
|
||||
minDate: new Date(1999, 0, 1),
|
||||
maxDate: new Date(currentYear, 11, 31),
|
||||
autohide: false,
|
||||
orientation: "bottom end",
|
||||
showOnClick: false,
|
||||
showOnFocus: false,
|
||||
beforeShowYear: (date) => ({ enabled: availableYears.has(date.getFullYear()) }),
|
||||
});
|
||||
pickerEl._pickerInstance = picker;
|
||||
|
||||
picker.element.addEventListener("changeDate", (event) => {
|
||||
const year = event.detail.date?.getFullYear();
|
||||
if (year && urlTemplate) {
|
||||
window.location.href = urlTemplate.replace("__year__", year);
|
||||
}
|
||||
});
|
||||
|
||||
if (selectedYear) {
|
||||
picker.dates = [new Date(parseInt(selectedYear), 0, 1)];
|
||||
picker.update();
|
||||
}
|
||||
});
|
||||
+3
-1
@@ -60,7 +60,9 @@ def _save_converted_price(purchase, converted_price, needs_update):
|
||||
purchase.converted_currency = currency_to
|
||||
if needs_update:
|
||||
purchase.needs_price_update = False
|
||||
purchase.save(update_fields=["converted_price", "converted_currency", "needs_price_update"])
|
||||
purchase.save(
|
||||
update_fields=["converted_price", "converted_currency", "needs_price_update"]
|
||||
)
|
||||
|
||||
|
||||
def convert_prices():
|
||||
|
||||
@@ -9,5 +9,5 @@ register = template.Library()
|
||||
def randomid(seed: str = "") -> str:
|
||||
content_hash = hashlib.sha1(seed.encode()).hexdigest()
|
||||
if seed:
|
||||
return content_hash[:max(0, 10 - len(seed))] + seed
|
||||
return content_hash[: max(0, 10 - len(seed))] + seed
|
||||
return content_hash[:10]
|
||||
|
||||
+6
-2
@@ -23,7 +23,11 @@ urlpatterns = [
|
||||
path("game/add", game.add_game, name="add_game"),
|
||||
path("game/<int:game_id>/edit", game.edit_game, name="edit_game"),
|
||||
path("game/<int:game_id>/view", game.view_game, name="view_game"),
|
||||
path("game/<int:game_id>/delete/confirm", game.delete_game_confirmation, name="delete_game_confirmation"),
|
||||
path(
|
||||
"game/<int:game_id>/delete/confirm",
|
||||
game.delete_game_confirmation,
|
||||
name="delete_game_confirmation",
|
||||
),
|
||||
path("game/<int:game_id>/delete", game.delete_game, name="delete_game"),
|
||||
path("game/list", game.list_games, name="list_games"),
|
||||
path("platform/add", platform.add_platform, name="add_platform"),
|
||||
@@ -175,4 +179,4 @@ urlpatterns = [
|
||||
filter_presets.load_preset,
|
||||
name="load_preset",
|
||||
),
|
||||
]
|
||||
]
|
||||
|
||||
+13
-15
@@ -3,24 +3,22 @@ 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 Component, CsrfInput, Div, Input
|
||||
from common.components import CsrfInput, Div, Element, Input, Node, Safe
|
||||
from common.components.primitives import Td, Tr
|
||||
from common.layout import render_page
|
||||
|
||||
|
||||
def _login_content(form, request) -> SafeText:
|
||||
table = Component(
|
||||
tag_name="table",
|
||||
def _login_content(form, request) -> Node:
|
||||
table = Element(
|
||||
"table",
|
||||
children=[
|
||||
CsrfInput(request),
|
||||
mark_safe(str(form.as_table())),
|
||||
Component(
|
||||
tag_name="tr",
|
||||
Safe(str(form.as_table())),
|
||||
Tr(
|
||||
children=[
|
||||
Component(tag_name="td"),
|
||||
Component(
|
||||
tag_name="td",
|
||||
Td(),
|
||||
Td(
|
||||
children=[
|
||||
Input(type="submit", attributes=[("value", "Login")])
|
||||
],
|
||||
@@ -32,13 +30,13 @@ def _login_content(form, request) -> SafeText:
|
||||
return Div(
|
||||
[("class", "flex items-center flex-col")],
|
||||
[
|
||||
Component(
|
||||
tag_name="h2",
|
||||
Element(
|
||||
"h2",
|
||||
attributes=[("class", "text-3xl text-white mb-8")],
|
||||
children=["Please log in to continue"],
|
||||
),
|
||||
Component(
|
||||
tag_name="form",
|
||||
Element(
|
||||
"form",
|
||||
attributes=[("method", "post")],
|
||||
children=[table],
|
||||
),
|
||||
|
||||
+27
-6
@@ -6,26 +6,37 @@ 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, page_obj, elided_page_range = paginate(
|
||||
request, Device.objects.order_by("-created_at")
|
||||
)
|
||||
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)
|
||||
|
||||
data = {
|
||||
"header_action": A([], Button([], "Add device"), url_name="games:add_device"),
|
||||
"header_action": A(href=reverse("games:add_device"))[
|
||||
StyledButton()["Add device"]
|
||||
],
|
||||
"columns": [
|
||||
"Name",
|
||||
"Type",
|
||||
@@ -61,7 +72,17 @@ def list_devices(request: HttpRequest) -> HttpResponse:
|
||||
elided_page_range=elided_page_range,
|
||||
request=request,
|
||||
)
|
||||
return render_page(request, content, title="Manage devices")
|
||||
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",
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
|
||||
@@ -5,10 +5,9 @@ from urllib.parse import quote
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||
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 SafeText, mark_safe
|
||||
|
||||
from games.models import FilterPreset
|
||||
|
||||
@@ -21,9 +20,7 @@ def list_presets(request: HttpRequest) -> HttpResponse:
|
||||
|
||||
items: list[str] = []
|
||||
for preset in presets:
|
||||
filter_json = (
|
||||
json.dumps(preset.object_filter) if preset.object_filter else ""
|
||||
)
|
||||
filter_json = json.dumps(preset.object_filter) if preset.object_filter else ""
|
||||
list_url = reverse(f"games:list_{mode}")
|
||||
delete_url = reverse("games:delete_preset", args=[preset.id])
|
||||
|
||||
@@ -40,14 +37,9 @@ 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>"
|
||||
]
|
||||
items = ['<li class="px-4 py-2 text-sm text-body italic">No saved presets</li>']
|
||||
|
||||
return HttpResponse(
|
||||
mark_safe(f'<ul class="py-1">{"".join(items)}</ul>')
|
||||
)
|
||||
return HttpResponse(f'<ul class="py-1">{"".join(items)}</ul>')
|
||||
|
||||
|
||||
@login_required
|
||||
|
||||
+296
-330
@@ -2,39 +2,43 @@ 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, mark_safe
|
||||
from django.utils.safestring import SafeText
|
||||
|
||||
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.icons import get_icon
|
||||
from common.components.primitives import Li, P, Span, Strong
|
||||
from common.layout import render_page
|
||||
from common.time import (
|
||||
dateformat,
|
||||
@@ -86,12 +90,11 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
|
||||
|
||||
data = {
|
||||
"header_action": Div(
|
||||
children=[
|
||||
SearchField(search_string=search_string),
|
||||
A([], Button([], "Add game"), url_name="games:add_game"),
|
||||
],
|
||||
attributes=[("class", "flex justify-between")],
|
||||
),
|
||||
class_="flex justify-between",
|
||||
)[
|
||||
SearchField(search_string=search_string),
|
||||
A(href=reverse("games:add_game"))[StyledButton()["Add game"]],
|
||||
],
|
||||
"columns": [
|
||||
"Name",
|
||||
"Sort Name",
|
||||
@@ -143,12 +146,11 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
|
||||
preset_list_url=reverse("games:list_presets"),
|
||||
preset_save_url=reverse("games:save_preset"),
|
||||
)
|
||||
content = mark_safe(str(filter_bar) + str(content))
|
||||
content = Fragment(filter_bar, content)
|
||||
return render_page(
|
||||
request,
|
||||
content,
|
||||
title="Manage games",
|
||||
scripts=ModuleScript("range_slider.js") + ModuleScript("selectable_filter.js") + ModuleScript("filter_bar.js"),
|
||||
)
|
||||
|
||||
|
||||
@@ -169,7 +171,7 @@ def add_game(request: HttpRequest) -> HttpResponse:
|
||||
AddForm(
|
||||
form,
|
||||
request=request,
|
||||
additional_row=Button(
|
||||
additional_row=StyledButton(
|
||||
[],
|
||||
"Submit & Create Purchase",
|
||||
color="gray",
|
||||
@@ -178,7 +180,7 @@ def add_game(request: HttpRequest) -> HttpResponse:
|
||||
),
|
||||
),
|
||||
title="Add New Game",
|
||||
scripts=ModuleScript("add_game.js"),
|
||||
scripts=ModuleScript("search_select.js") + ModuleScript("add_game.js"),
|
||||
)
|
||||
|
||||
|
||||
@@ -191,22 +193,16 @@ def _delete_game_confirmation_modal(
|
||||
) -> SafeText:
|
||||
data_items = []
|
||||
if session_count:
|
||||
data_items.append(
|
||||
Component(tag_name="li", children=[f"{session_count} session(s)"])
|
||||
)
|
||||
data_items.append(Li(children=[f"{session_count} session(s)"]))
|
||||
if purchase_count:
|
||||
data_items.append(
|
||||
Component(tag_name="li", children=[f"{purchase_count} purchase(s)"])
|
||||
)
|
||||
data_items.append(Li(children=[f"{purchase_count} purchase(s)"]))
|
||||
if playevent_count:
|
||||
data_items.append(
|
||||
Component(tag_name="li", children=[f"{playevent_count} play event(s)"])
|
||||
)
|
||||
data_items.append(Li(children=[f"{playevent_count} play event(s)"]))
|
||||
if not (session_count or purchase_count or playevent_count):
|
||||
data_items.append(Component(tag_name="li", children=["No associated data"]))
|
||||
data_items.append(Li(children=["No associated data"]))
|
||||
|
||||
form = Component(
|
||||
tag_name="form",
|
||||
form = Element(
|
||||
"form",
|
||||
attributes=[
|
||||
("hx-post", reverse("games:delete_game", args=[game.id])),
|
||||
("hx-replace-url", "true"),
|
||||
@@ -216,8 +212,7 @@ def _delete_game_confirmation_modal(
|
||||
],
|
||||
children=[
|
||||
CsrfInput(request),
|
||||
Component(
|
||||
tag_name="p",
|
||||
P(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
@@ -229,8 +224,7 @@ def _delete_game_confirmation_modal(
|
||||
"This will permanently delete this game and all associated data:"
|
||||
],
|
||||
),
|
||||
Component(
|
||||
tag_name="ul",
|
||||
Ul(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
@@ -240,8 +234,7 @@ def _delete_game_confirmation_modal(
|
||||
],
|
||||
children=data_items,
|
||||
),
|
||||
Component(
|
||||
tag_name="p",
|
||||
P(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
@@ -254,14 +247,14 @@ def _delete_game_confirmation_modal(
|
||||
Div(
|
||||
[("class", "items-center mt-5")],
|
||||
[
|
||||
Button(
|
||||
StyledButton(
|
||||
[("class", "w-full")],
|
||||
"Delete",
|
||||
color="red",
|
||||
size="lg",
|
||||
type="submit",
|
||||
),
|
||||
Button(
|
||||
StyledButton(
|
||||
[("class", "mt-0 w-full")],
|
||||
"Cancel",
|
||||
color="gray",
|
||||
@@ -277,8 +270,7 @@ def _delete_game_confirmation_modal(
|
||||
return Modal(
|
||||
"delete-game-confirmation-modal",
|
||||
children=[
|
||||
Component(
|
||||
tag_name="h1",
|
||||
P(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
@@ -287,12 +279,11 @@ def _delete_game_confirmation_modal(
|
||||
],
|
||||
children=["Delete Game"],
|
||||
),
|
||||
Component(
|
||||
tag_name="p",
|
||||
P(
|
||||
attributes=[("class", "dark:text-white text-center mt-5")],
|
||||
children=[
|
||||
"Are you sure you want to delete ",
|
||||
Component(tag_name="strong", children=[game.name]),
|
||||
Strong(children=[game.name]),
|
||||
"?",
|
||||
],
|
||||
),
|
||||
@@ -330,7 +321,12 @@ def edit_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect("games:list_sessions")
|
||||
return render_page(request, AddForm(form, request=request), title="Edit Game")
|
||||
return render_page(
|
||||
request,
|
||||
AddForm(form, request=request),
|
||||
title="Edit Game",
|
||||
scripts=ModuleScript("search_select.js"),
|
||||
)
|
||||
|
||||
|
||||
# --- view_game content builders -------------------------------------------
|
||||
@@ -342,69 +338,69 @@ _STAT_SVGS = {
|
||||
"playrange": '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5m-9-6h.008v.008H12v-.008ZM12 15h.008v.008H12V15Zm0 2.25h.008v.008H12v-.008ZM9.75 15h.008v.008H9.75V15Zm0 2.25h.008v.008H9.75v-.008ZM7.5 15h.008v.008H7.5V15Zm0 2.25h.008v.008H7.5v-.008Zm6.75-4.5h.008v.008h-.008v-.008Zm0 2.25h.008v.008h-.008V15Zm0 2.25h.008v.008h-.008v-.008Zm2.25-4.5h.008v.008H16.5v-.008Zm0 2.25h.008v.008H16.5V15Z" /></svg>',
|
||||
}
|
||||
|
||||
_PLAYED_ROW_TEMPLATE = """<div class="flex gap-2 items-center" x-data="{ open: false }">
|
||||
<span class="uppercase">Played</span>
|
||||
<div class="inline-flex rounded-md shadow-2xs" role="group" x-data="{ played: @@PLAYED_COUNT@@ }">
|
||||
<a href="@@ADD_PE@@">
|
||||
<button type="button" class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
|
||||
<span x-text="played"></span> times
|
||||
</button>
|
||||
</a>
|
||||
<button type="button" x-on:click="open = !open" @click.outside="open = false" class="relative px-4 py-2 text-sm font-medium text-gray-900 bg-white border-e border-b border-t border-gray-200 rounded-e-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white align-middle hover:cursor-pointer">
|
||||
@@ARROWDOWN@@
|
||||
<div
|
||||
class="absolute top-full -left-px w-auto whitespace-nowrap z-10 text-sm font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-tl-none border border-gray-200 dark:border-gray-700"
|
||||
x-show="open"
|
||||
>
|
||||
<ul
|
||||
class=""
|
||||
>
|
||||
<li class="px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-tr-md">
|
||||
<a href="@@ADD_PE_FOR_GAME@@">Add playthrough...</a>
|
||||
</li>
|
||||
<li
|
||||
x-on:click="createPlayEvent"
|
||||
class="relative px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-b-md"
|
||||
>
|
||||
Played times +1
|
||||
</li>
|
||||
<script>
|
||||
function createPlayEvent() {
|
||||
this.played++;
|
||||
// TODO: migrate to hx-post + hx-on::after-request for HTMX-native toast handling
|
||||
fetchWithHtmxTriggers('@@API_CREATE@@', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRFToken': '@@CSRF@@', 'Content-Type': 'application/json' },
|
||||
body: '{"game_id": @@GAME_ID@@}'
|
||||
})
|
||||
.catch(() => {
|
||||
this.played--;
|
||||
console.error('Failed to record play');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</ul>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>"""
|
||||
_PLAYED_BTN = (
|
||||
"px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 "
|
||||
"hover:bg-gray-100 hover:text-blue-700 dark:bg-gray-800 dark:border-gray-700 "
|
||||
"dark:text-white dark:hover:bg-gray-700 hover:cursor-pointer"
|
||||
)
|
||||
_PLAYED_MENU = (
|
||||
"absolute top-full -left-px w-auto whitespace-nowrap z-10 text-sm font-medium "
|
||||
"bg-gray-800/20 backdrop-blur-lg rounded-md rounded-tl-none border "
|
||||
"border-gray-200 dark:border-gray-700"
|
||||
)
|
||||
|
||||
|
||||
def _played_row(game: Game, request: HttpRequest) -> 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 _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 _stat_popover(popover_id: str, tooltip: str, svg_key: str, value: str) -> SafeText:
|
||||
@@ -412,17 +408,13 @@ 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=[mark_safe(_STAT_SVGS[svg_key]), str(value)],
|
||||
children=[Safe(_STAT_SVGS[svg_key]), str(value)],
|
||||
)
|
||||
|
||||
|
||||
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]
|
||||
),
|
||||
def _meta_row(label: str, value: Node | str, extra: Node | str = "") -> Node:
|
||||
children: list[Node | str] = [
|
||||
Span(attributes=[("class", "uppercase")], children=[label]),
|
||||
value,
|
||||
]
|
||||
if extra:
|
||||
@@ -445,27 +437,25 @@ 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 = Component(
|
||||
tag_name="a",
|
||||
attributes=[("href", reverse("games:edit_game", args=[game.id]))],
|
||||
edit_link = A(
|
||||
href=reverse("games:edit_game", args=[game.id]),
|
||||
children=[
|
||||
Component(
|
||||
tag_name="button",
|
||||
Element(
|
||||
"button",
|
||||
attributes=[("type", "button"), ("class", edit_class)],
|
||||
children=["Edit"],
|
||||
)
|
||||
],
|
||||
)
|
||||
delete_link = Component(
|
||||
tag_name="a",
|
||||
delete_link = A(
|
||||
href="#",
|
||||
attributes=[
|
||||
("href", "#"),
|
||||
("hx-get", reverse("games:delete_game_confirmation", args=[game.id])),
|
||||
("hx-target", "#global-modal-container"),
|
||||
],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="button",
|
||||
Element(
|
||||
"button",
|
||||
attributes=[("type", "button"), ("class", delete_class)],
|
||||
children=["Delete"],
|
||||
)
|
||||
@@ -492,21 +482,16 @@ def _game_history(statuschanges) -> SafeText:
|
||||
status=change.new_status,
|
||||
children=[change.get_new_status_display()],
|
||||
)
|
||||
edit = Component(
|
||||
tag_name="a",
|
||||
attributes=[("href", reverse("games:edit_statuschange", args=[change.id]))],
|
||||
edit = A(
|
||||
href=reverse("games:edit_statuschange", args=[change.id]),
|
||||
children=["Edit"],
|
||||
)
|
||||
delete = Component(
|
||||
tag_name="a",
|
||||
attributes=[
|
||||
("href", reverse("games:delete_statuschange", args=[change.id]))
|
||||
],
|
||||
delete = A(
|
||||
href=reverse("games:delete_statuschange", args=[change.id]),
|
||||
children=["Delete"],
|
||||
)
|
||||
items.append(
|
||||
Component(
|
||||
tag_name="li",
|
||||
Li(
|
||||
attributes=[("class", "text-slate-500")],
|
||||
children=[
|
||||
f"{prefix} status from ",
|
||||
@@ -521,8 +506,7 @@ def _game_history(statuschanges) -> SafeText:
|
||||
],
|
||||
)
|
||||
)
|
||||
return Component(
|
||||
tag_name="ul",
|
||||
return Ul(
|
||||
attributes=[("class", "list-disc list-inside")],
|
||||
children=items,
|
||||
)
|
||||
@@ -540,173 +524,46 @@ def _game_section(
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
||||
game = Game.objects.get(id=game_id)
|
||||
purchases = game.purchases.order_by("date_purchased")
|
||||
|
||||
def _game_overview_metrics(game: Game) -> dict[str, Any]:
|
||||
"""Request-free header metrics: total session count, play range, and the
|
||||
per-session average (excluding manually-logged sessions)."""
|
||||
sessions = game.sessions
|
||||
session_count = sessions.count()
|
||||
session_count_without_manual = game.sessions.without_manual().count()
|
||||
session_count_without_manual = sessions.without_manual().count()
|
||||
|
||||
if sessions.exists():
|
||||
playrange_start = local_strftime(sessions.earliest().timestamp_start, "%b %Y")
|
||||
latest_session = sessions.latest()
|
||||
playrange_end = local_strftime(latest_session.timestamp_start, "%b %Y")
|
||||
|
||||
playrange = (
|
||||
playrange_start
|
||||
if playrange_start == playrange_end
|
||||
else f"{playrange_start} — {playrange_end}"
|
||||
)
|
||||
start = local_strftime(sessions.earliest().timestamp_start, "%b %Y")
|
||||
end = local_strftime(sessions.latest().timestamp_start, "%b %Y")
|
||||
playrange = start if start == end else f"{start} — {end}"
|
||||
else:
|
||||
playrange = "N/A"
|
||||
latest_session = None
|
||||
|
||||
total_hours_without_manual = float(
|
||||
format_duration(sessions.calculated_duration_unformatted(), "%2.1H")
|
||||
)
|
||||
|
||||
purchase_data: dict[str, Any] = {
|
||||
"columns": ["Name", "Type", "Date", "Price", "Actions"],
|
||||
"rows": [
|
||||
[
|
||||
LinkedPurchase(purchase),
|
||||
purchase.get_type_display(),
|
||||
purchase.date_purchased.strftime(dateformat),
|
||||
PurchasePrice(purchase),
|
||||
ButtonGroup(
|
||||
[
|
||||
{
|
||||
"href": reverse("games:edit_purchase", args=[purchase.pk]),
|
||||
"slot": Icon("edit"),
|
||||
"color": "gray",
|
||||
},
|
||||
{
|
||||
"href": reverse(
|
||||
"games:delete_purchase", args=[purchase.pk]
|
||||
),
|
||||
"slot": Icon("delete"),
|
||||
"color": "red",
|
||||
},
|
||||
]
|
||||
),
|
||||
]
|
||||
for purchase in purchases
|
||||
],
|
||||
}
|
||||
|
||||
sessions_all = game.sessions.order_by("-timestamp_start")
|
||||
|
||||
last_session = None
|
||||
if sessions_all.exists():
|
||||
last_session = sessions_all.latest()
|
||||
session_count = sessions_all.count()
|
||||
session_paginator = Paginator(sessions_all, 5)
|
||||
page_number = request.GET.get("page", 1)
|
||||
session_page_obj = session_paginator.get_page(page_number)
|
||||
sessions = session_page_obj.object_list
|
||||
|
||||
session_data: dict[str, Any] = {
|
||||
"header_action": Div(
|
||||
children=[
|
||||
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",
|
||||
args=[last_session.pk],
|
||||
),
|
||||
children=Popover(
|
||||
popover_content=last_session.game.name,
|
||||
children=[
|
||||
Button(
|
||||
icon=True,
|
||||
color="gray",
|
||||
size="xs",
|
||||
children=[
|
||||
Icon("play"),
|
||||
truncate(f"{last_session.game.name}"),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
if last_session
|
||||
else "",
|
||||
],
|
||||
),
|
||||
"columns": ["Game", "Date", "Duration", "Actions"],
|
||||
"rows": [
|
||||
[
|
||||
NameWithIcon(session=session),
|
||||
f"{local_strftime(session.timestamp_start)}{f' — {local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
|
||||
session.duration_formatted_with_mark(),
|
||||
ButtonGroup(
|
||||
[
|
||||
{
|
||||
"href": reverse(
|
||||
"games:list_sessions_end_session", args=[session.pk]
|
||||
),
|
||||
"slot": Icon("end"),
|
||||
"title": "Finish session now",
|
||||
"color": "green",
|
||||
}
|
||||
if session.timestamp_end is None
|
||||
else {},
|
||||
{
|
||||
"href": reverse("games:edit_session", args=[session.pk]),
|
||||
"slot": Icon("edit"),
|
||||
"color": "gray",
|
||||
},
|
||||
{
|
||||
"href": reverse("games:delete_session", args=[session.pk]),
|
||||
"slot": Icon("delete"),
|
||||
"color": "red",
|
||||
},
|
||||
]
|
||||
),
|
||||
]
|
||||
for session in sessions
|
||||
],
|
||||
}
|
||||
|
||||
playevents = game.playevents.all()
|
||||
playevent_count = playevents.count()
|
||||
playevent_data = create_playevent_tabledata(playevents, exclude_columns=["Game"])
|
||||
|
||||
statuschanges = game.status_changes.all()
|
||||
statuschange_count = statuschanges.count()
|
||||
|
||||
purchase_count = game.purchases.count()
|
||||
status_selector_html = GameStatusSelector(
|
||||
game, Game.Status.choices, get_token(request)
|
||||
)
|
||||
session_average_without_manual = round(
|
||||
safe_division(total_hours_without_manual, int(session_count_without_manual)),
|
||||
1,
|
||||
safe_division(total_hours_without_manual, int(session_count_without_manual)), 1
|
||||
)
|
||||
return {
|
||||
"session_count": session_count,
|
||||
"playrange": playrange,
|
||||
"session_average_without_manual": session_average_without_manual,
|
||||
}
|
||||
|
||||
|
||||
def _game_header(game: Game, request: HttpRequest, metrics: dict[str, Any]) -> SafeText:
|
||||
grey_value_class = "text-black dark:text-slate-300"
|
||||
title_span = Component(
|
||||
tag_name="span",
|
||||
title_span = Span(
|
||||
attributes=[("class", "text-balance max-w-120 text-4xl")],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="span",
|
||||
Span(
|
||||
attributes=[("class", "font-bold font-serif")],
|
||||
children=[game.name],
|
||||
),
|
||||
]
|
||||
+ (
|
||||
[
|
||||
mark_safe(" "),
|
||||
Safe(" "),
|
||||
Popover(
|
||||
popover_content="Original release year",
|
||||
wrapped_classes="text-slate-500 text-2xl",
|
||||
@@ -718,8 +575,6 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
||||
else []
|
||||
),
|
||||
)
|
||||
title_row = Div([("class", "flex gap-5 mb-3")], [title_span])
|
||||
|
||||
stats_row = Div(
|
||||
[("class", "flex gap-4 dark:text-slate-400 mb-3")],
|
||||
[
|
||||
@@ -730,76 +585,187 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
||||
game.playtime_formatted(),
|
||||
),
|
||||
_stat_popover(
|
||||
"popover-sessions", "Number of sessions", "sessions", session_count
|
||||
"popover-sessions",
|
||||
"Number of sessions",
|
||||
"sessions",
|
||||
metrics["session_count"],
|
||||
),
|
||||
_stat_popover(
|
||||
"popover-average",
|
||||
"Average playtime per session",
|
||||
"average",
|
||||
session_average_without_manual,
|
||||
metrics["session_average_without_manual"],
|
||||
),
|
||||
_stat_popover(
|
||||
"popover-playrange",
|
||||
"Earliest and latest dates played",
|
||||
"playrange",
|
||||
playrange,
|
||||
metrics["playrange"],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
metadata = Div(
|
||||
[("class", "flex flex-col mb-6 text-gray-600 dark:text-slate-400 gap-y-4")],
|
||||
[
|
||||
_meta_row(
|
||||
"Original year",
|
||||
Component(
|
||||
tag_name="span",
|
||||
Span(
|
||||
attributes=[("class", grey_value_class)],
|
||||
children=[str(game.original_year_released)],
|
||||
),
|
||||
),
|
||||
_meta_row("Status", status_selector_html, "👑" if game.mastered else ""),
|
||||
_meta_row(
|
||||
"Status",
|
||||
GameStatusSelector(game, Game.Status.choices, get_token(request)),
|
||||
"👑" if game.mastered else "",
|
||||
),
|
||||
_played_row(game, request),
|
||||
_meta_row(
|
||||
"Platform",
|
||||
Component(
|
||||
tag_name="span",
|
||||
Span(
|
||||
attributes=[("class", grey_value_class)],
|
||||
children=[str(game.platform)],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
game_info = Div(
|
||||
return Div(
|
||||
[("id", "game-info"), ("class", "mb-10")],
|
||||
[title_row, stats_row, metadata, _game_action_buttons(game)],
|
||||
[
|
||||
Div([("class", "flex gap-5 mb-3")], [title_span]),
|
||||
stats_row,
|
||||
metadata,
|
||||
_game_action_buttons(game),
|
||||
],
|
||||
)
|
||||
|
||||
session_elided_page_range = (
|
||||
session_page_obj.paginator.get_elided_page_range(
|
||||
page_number, on_each_side=1, on_ends=1
|
||||
)
|
||||
if session_page_obj and session_count > 5
|
||||
|
||||
def _purchases_section(game: Game) -> SafeText:
|
||||
purchases = game.purchases.order_by("date_purchased")
|
||||
rows = [
|
||||
[
|
||||
LinkedPurchase(purchase),
|
||||
purchase.get_type_display(),
|
||||
purchase.date_purchased.strftime(dateformat),
|
||||
PurchasePrice(purchase),
|
||||
ButtonGroup(
|
||||
[
|
||||
{
|
||||
"href": reverse("games:edit_purchase", args=[purchase.pk]),
|
||||
"slot": Icon("edit"),
|
||||
"color": "gray",
|
||||
},
|
||||
{
|
||||
"href": reverse("games:delete_purchase", args=[purchase.pk]),
|
||||
"slot": Icon("delete"),
|
||||
"color": "red",
|
||||
},
|
||||
]
|
||||
),
|
||||
]
|
||||
for purchase in purchases
|
||||
]
|
||||
table = SimpleTable(columns=["Name", "Type", "Date", "Price", "Actions"], rows=rows)
|
||||
return _game_section("Purchases", purchases.count(), table, "No purchases yet.")
|
||||
|
||||
|
||||
def _sessions_section(game: Game, request: HttpRequest) -> SafeText:
|
||||
sessions_all = game.sessions.order_by("-timestamp_start")
|
||||
session_count = sessions_all.count()
|
||||
last_session = sessions_all.latest() if sessions_all.exists() else None
|
||||
|
||||
page_number = request.GET.get("page", 1)
|
||||
page_obj = Paginator(sessions_all, 5).get_page(page_number)
|
||||
elided_page_range = (
|
||||
page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
|
||||
if session_count > 5
|
||||
else None
|
||||
)
|
||||
|
||||
purchases_table = SimpleTable(
|
||||
columns=purchase_data["columns"], rows=purchase_data["rows"]
|
||||
header_action = Div(
|
||||
children=[
|
||||
A(href=reverse("games:add_session"))[
|
||||
StyledButton(icon=True, color="blue", size="xs")[Icon("plus")]
|
||||
],
|
||||
A(
|
||||
href=reverse(
|
||||
"games:list_sessions_start_session_from_session",
|
||||
args=[last_session.pk],
|
||||
),
|
||||
children=Popover(
|
||||
popover_content=last_session.game.name,
|
||||
children=[
|
||||
StyledButton(
|
||||
icon=True,
|
||||
color="gray",
|
||||
size="xs",
|
||||
children=[
|
||||
Icon("play"),
|
||||
truncate(f"{last_session.game.name}"),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
if last_session
|
||||
else "",
|
||||
],
|
||||
)
|
||||
sessions_table = SimpleTable(
|
||||
columns=session_data["columns"],
|
||||
rows=session_data["rows"],
|
||||
header_action=session_data["header_action"],
|
||||
page_obj=session_page_obj,
|
||||
elided_page_range=session_elided_page_range,
|
||||
rows = [
|
||||
[
|
||||
NameWithIcon(session=session),
|
||||
f"{local_strftime(session.timestamp_start)}{f' — {local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
|
||||
session.duration_formatted_with_mark(),
|
||||
ButtonGroup(
|
||||
[
|
||||
{
|
||||
"href": reverse(
|
||||
"games:list_sessions_end_session", args=[session.pk]
|
||||
),
|
||||
"slot": Icon("end"),
|
||||
"title": "Finish session now",
|
||||
"color": "green",
|
||||
}
|
||||
if session.timestamp_end is None
|
||||
else {},
|
||||
{
|
||||
"href": reverse("games:edit_session", args=[session.pk]),
|
||||
"slot": Icon("edit"),
|
||||
"color": "gray",
|
||||
},
|
||||
{
|
||||
"href": reverse("games:delete_session", args=[session.pk]),
|
||||
"slot": Icon("delete"),
|
||||
"color": "red",
|
||||
},
|
||||
]
|
||||
),
|
||||
]
|
||||
for session in page_obj.object_list
|
||||
]
|
||||
table = SimpleTable(
|
||||
columns=["Game", "Date", "Duration", "Actions"],
|
||||
rows=rows,
|
||||
header_action=header_action,
|
||||
page_obj=page_obj,
|
||||
elided_page_range=elided_page_range,
|
||||
request=request,
|
||||
)
|
||||
playevents_table = SimpleTable(
|
||||
columns=playevent_data["columns"], rows=playevent_data["rows"]
|
||||
return _game_section("Sessions", session_count, table, "No sessions yet.")
|
||||
|
||||
|
||||
def _playevents_section(game: Game) -> SafeText:
|
||||
playevents = game.playevents.all()
|
||||
data = create_playevent_tabledata(playevents, exclude_columns=["Game"])
|
||||
table = SimpleTable(columns=data["columns"], rows=data["rows"])
|
||||
return _game_section(
|
||||
"Play Events", playevents.count(), table, "No play events yet."
|
||||
)
|
||||
|
||||
history = Div(
|
||||
|
||||
def _history_section(game: Game) -> SafeText:
|
||||
statuschanges = game.status_changes.all()
|
||||
return Div(
|
||||
[
|
||||
("class", "mb-6"),
|
||||
("id", "history-container"),
|
||||
@@ -809,36 +775,36 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
||||
("hx-swap", "outerHTML"),
|
||||
],
|
||||
[
|
||||
H1(children=["History"], badge=statuschange_count),
|
||||
H1(children=["History"], badge=statuschanges.count()),
|
||||
_game_history(statuschanges),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
_GET_SESSION_COUNT_SCRIPT = Safe(
|
||||
"<script>\n"
|
||||
" function getSessionCount() {\n"
|
||||
" return document.getElementById('session-count')"
|
||||
'.textContent.match("[0-9]+");\n'
|
||||
" }\n"
|
||||
" </script>"
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
||||
game = Game.objects.get(id=game_id)
|
||||
content = Div(
|
||||
[("class", "dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto")],
|
||||
[
|
||||
game_info,
|
||||
_game_section(
|
||||
"Purchases", purchase_count, purchases_table, "No purchases yet."
|
||||
),
|
||||
_game_section(
|
||||
"Sessions", session_count, sessions_table, "No sessions yet."
|
||||
),
|
||||
_game_section(
|
||||
"Play Events", playevent_count, playevents_table, "No play events yet."
|
||||
),
|
||||
history,
|
||||
mark_safe(
|
||||
"<script>\n"
|
||||
" function getSessionCount() {\n"
|
||||
" return document.getElementById('session-count')"
|
||||
'.textContent.match("[0-9]+");\n'
|
||||
" }\n"
|
||||
" </script>"
|
||||
),
|
||||
_game_header(game, request, _game_overview_metrics(game)),
|
||||
_purchases_section(game),
|
||||
_sessions_section(game, request),
|
||||
_playevents_section(game),
|
||||
_history_section(game),
|
||||
_GET_SESSION_COUNT_SCRIPT,
|
||||
],
|
||||
)
|
||||
|
||||
request.session["return_path"] = request.path
|
||||
return render_page(
|
||||
request,
|
||||
|
||||
+17
-474
@@ -3,39 +3,36 @@ from typing import Any, Callable
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import (
|
||||
Avg,
|
||||
Count,
|
||||
ExpressionWrapper,
|
||||
F,
|
||||
Max,
|
||||
OuterRef,
|
||||
Prefetch,
|
||||
Q,
|
||||
Subquery,
|
||||
Sum,
|
||||
fields,
|
||||
)
|
||||
from django.db.models.functions import TruncDate, TruncMonth
|
||||
|
||||
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import localtime
|
||||
from django.utils.timezone import now as timezone_now
|
||||
|
||||
from common.layout import render_page
|
||||
from common.time import available_stats_year_range, dateformat, format_duration
|
||||
from common.utils import safe_division
|
||||
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.
|
||||
|
||||
|
||||
def model_counts(request: HttpRequest) -> dict[str, bool]:
|
||||
now = timezone_now()
|
||||
this_day, this_month, this_year = now.day, now.month, now.year
|
||||
# Use a contiguous [midnight, next midnight) range in the active timezone
|
||||
# instead of day/month/year extracts: a range filter can use an index on
|
||||
# timestamp_start, whereas the extracts force a per-row datetime function.
|
||||
start_of_today = localtime(now).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
start_of_tomorrow = start_of_today + timedelta(days=1)
|
||||
today_played = Session.objects.filter(
|
||||
timestamp_start__day=this_day,
|
||||
timestamp_start__month=this_month,
|
||||
timestamp_start__year=this_year,
|
||||
timestamp_start__gte=start_of_today,
|
||||
timestamp_start__lt=start_of_tomorrow,
|
||||
).aggregate(time=Sum(F("duration_total")))["time"]
|
||||
last_7_played = Session.objects.filter(
|
||||
timestamp_start__gte=(now - timedelta(days=7))
|
||||
@@ -75,210 +72,9 @@ def use_custom_redirect(
|
||||
|
||||
@login_required
|
||||
def stats_alltime(request: HttpRequest) -> HttpResponse:
|
||||
year = "Alltime"
|
||||
this_year_sessions = Session.objects.all().prefetch_related(Prefetch("game"))
|
||||
this_year_sessions_with_durations = this_year_sessions.annotate(
|
||||
duration=ExpressionWrapper(
|
||||
F("timestamp_end") - F("timestamp_start"),
|
||||
output_field=fields.DurationField(),
|
||||
)
|
||||
)
|
||||
longest_session = this_year_sessions_with_durations.order_by("-duration").first()
|
||||
this_year_games = Game.objects.filter(sessions__in=this_year_sessions).distinct()
|
||||
this_year_games_with_session_counts = this_year_games.annotate(
|
||||
session_count=Count("sessions"),
|
||||
)
|
||||
game_highest_session_count = this_year_games_with_session_counts.order_by(
|
||||
"-session_count"
|
||||
).first()
|
||||
selected_currency = "CZK"
|
||||
unique_days = (
|
||||
this_year_sessions.annotate(date=TruncDate("timestamp_start"))
|
||||
.values("date")
|
||||
.distinct()
|
||||
.aggregate(dates=Count("date"))
|
||||
)
|
||||
this_year_played_purchases = Purchase.objects.filter(
|
||||
games__sessions__in=this_year_sessions
|
||||
).distinct()
|
||||
|
||||
this_year_purchases = Purchase.objects.all()
|
||||
this_year_purchases_with_currency = this_year_purchases.select_related("games")
|
||||
this_year_purchases_without_refunded = Purchase.objects.filter(date_refunded=None)
|
||||
this_year_purchases_refunded = Purchase.objects.refunded()
|
||||
|
||||
this_year_purchases_unfinished_dropped_nondropped = (
|
||||
this_year_purchases_without_refunded.filter(
|
||||
~Q(games__status=Game.Status.FINISHED)
|
||||
& ~Q(games__playevents__ended__isnull=False)
|
||||
)
|
||||
.filter(infinite=False)
|
||||
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
|
||||
) # do not count battle passes etc.
|
||||
|
||||
this_year_purchases_unfinished = (
|
||||
this_year_purchases_unfinished_dropped_nondropped.filter(
|
||||
~Q(games__status=Game.Status.RETIRED)
|
||||
& ~Q(games__status=Game.Status.ABANDONED)
|
||||
)
|
||||
)
|
||||
this_year_purchases_dropped = (
|
||||
this_year_purchases.filter(
|
||||
~Q(games__status=Game.Status.FINISHED)
|
||||
& ~Q(games__playevents__ended__isnull=False)
|
||||
)
|
||||
.filter(Q(games__status=Game.Status.ABANDONED) | Q(date_refunded__isnull=False))
|
||||
.filter(infinite=False)
|
||||
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
|
||||
)
|
||||
|
||||
this_year_purchases_without_refunded_count = (
|
||||
this_year_purchases_without_refunded.count()
|
||||
)
|
||||
this_year_purchases_unfinished_count = this_year_purchases_unfinished.count()
|
||||
this_year_purchases_unfinished_percent = int(
|
||||
safe_division(
|
||||
this_year_purchases_unfinished_count,
|
||||
this_year_purchases_without_refunded_count,
|
||||
)
|
||||
* 100
|
||||
)
|
||||
|
||||
_finished_purchases_qs = Purchase.objects.finished()
|
||||
_finished_with_date = _finished_purchases_qs.annotate(
|
||||
date_finished=Subquery(
|
||||
Purchase.objects.filter(pk=OuterRef("pk"))
|
||||
.annotate(max_ended=Max("games__playevents__ended"))
|
||||
.values("max_ended")[:1]
|
||||
)
|
||||
)
|
||||
purchases_finished_this_year = _finished_with_date
|
||||
purchases_finished_this_year_released_this_year = _finished_with_date.order_by(
|
||||
"-date_finished"
|
||||
)
|
||||
|
||||
this_year_spendings = this_year_purchases_without_refunded.aggregate(
|
||||
total_spent=Sum(F("converted_price"))
|
||||
)
|
||||
total_spent = this_year_spendings["total_spent"] or 0
|
||||
|
||||
games_with_playtime = (
|
||||
Game.objects.filter(sessions__in=this_year_sessions)
|
||||
.distinct()
|
||||
.annotate(total_playtime=Sum(F("sessions__duration_total")))
|
||||
.filter(total_playtime__gt=timedelta(0))
|
||||
)
|
||||
month_playtimes = (
|
||||
this_year_sessions.annotate(month=TruncMonth("timestamp_start"))
|
||||
.values("month")
|
||||
.annotate(playtime=Sum("duration_total"))
|
||||
.order_by("month")
|
||||
)
|
||||
for month in month_playtimes:
|
||||
month["playtime"] = format_duration(month["playtime"], "%2.0H")
|
||||
|
||||
highest_session_average_game = (
|
||||
Game.objects.filter(sessions__in=this_year_sessions)
|
||||
.annotate(session_average=Avg("sessions__duration_calculated"))
|
||||
.order_by("-session_average")
|
||||
.first()
|
||||
)
|
||||
top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime")[:10]
|
||||
|
||||
total_playtime_per_platform = (
|
||||
this_year_sessions.values("game__platform__name")
|
||||
.annotate(playtime=Sum(F("duration_total")))
|
||||
.annotate(platform_name=F("game__platform__name"))
|
||||
.values("platform_name", "playtime")
|
||||
.order_by("-playtime")
|
||||
)
|
||||
|
||||
backlog_decrease_count = purchases_finished_this_year.count()
|
||||
|
||||
first_play_date = "N/A"
|
||||
last_play_date = "N/A"
|
||||
first_play_game = None
|
||||
last_play_game = None
|
||||
if this_year_sessions:
|
||||
first_session = this_year_sessions.earliest()
|
||||
first_play_game = first_session.game
|
||||
first_play_date = first_session.timestamp_start.strftime(dateformat)
|
||||
last_session = this_year_sessions.latest()
|
||||
last_play_game = last_session.game
|
||||
last_play_date = last_session.timestamp_start.strftime(dateformat)
|
||||
|
||||
all_purchased_this_year_count = this_year_purchases_with_currency.count()
|
||||
all_purchased_refunded_this_year_count: int = this_year_purchases_refunded.count()
|
||||
|
||||
this_year_purchases_dropped_count = this_year_purchases_dropped.count()
|
||||
this_year_purchases_dropped_percentage = int(
|
||||
safe_division(this_year_purchases_dropped_count, all_purchased_this_year_count)
|
||||
* 100
|
||||
)
|
||||
context = {
|
||||
"total_hours": format_duration(
|
||||
this_year_sessions.total_duration_unformatted(), "%2.0H"
|
||||
),
|
||||
"total_year_games": this_year_played_purchases.all().count(),
|
||||
"top_10_games_by_playtime": top_10_games_by_playtime,
|
||||
"year": year,
|
||||
"total_playtime_per_platform": total_playtime_per_platform,
|
||||
"total_spent": total_spent,
|
||||
"total_spent_currency": selected_currency,
|
||||
"spent_per_game": int(
|
||||
safe_division(total_spent, this_year_purchases_without_refunded_count)
|
||||
),
|
||||
"this_year_finished_this_year_count": purchases_finished_this_year_released_this_year.count(),
|
||||
"total_sessions": this_year_sessions.count(),
|
||||
"unique_days": unique_days["dates"],
|
||||
"unique_days_percent": int(unique_days["dates"] / 365 * 100),
|
||||
"purchased_unfinished_count": this_year_purchases_unfinished_count,
|
||||
"unfinished_purchases_percent": this_year_purchases_unfinished_percent,
|
||||
"dropped_count": this_year_purchases_dropped_count,
|
||||
"dropped_percentage": this_year_purchases_dropped_percentage,
|
||||
"refunded_percent": int(
|
||||
safe_division(
|
||||
all_purchased_refunded_this_year_count,
|
||||
all_purchased_this_year_count,
|
||||
)
|
||||
* 100
|
||||
),
|
||||
"all_purchased_refunded_this_year": this_year_purchases_refunded,
|
||||
"all_purchased_refunded_this_year_count": all_purchased_refunded_this_year_count,
|
||||
"all_purchased_this_year_count": all_purchased_this_year_count,
|
||||
"backlog_decrease_count": backlog_decrease_count,
|
||||
"longest_session_time": (
|
||||
format_duration(longest_session.duration, "%2.0Hh %2.0mm")
|
||||
if longest_session
|
||||
else 0
|
||||
),
|
||||
"longest_session_game": (longest_session.game if longest_session else None),
|
||||
"highest_session_count": (
|
||||
game_highest_session_count.session_count
|
||||
if game_highest_session_count
|
||||
else 0
|
||||
),
|
||||
"highest_session_count_game": (
|
||||
game_highest_session_count if game_highest_session_count else None
|
||||
),
|
||||
"highest_session_average": (
|
||||
format_duration(
|
||||
highest_session_average_game.session_average, "%2.0Hh %2.0mm"
|
||||
)
|
||||
if highest_session_average_game
|
||||
else 0
|
||||
),
|
||||
"highest_session_average_game": highest_session_average_game,
|
||||
"first_play_game": first_play_game,
|
||||
"first_play_date": first_play_date,
|
||||
"last_play_game": last_play_game,
|
||||
"last_play_date": last_play_date,
|
||||
"title": f"{year} Stats",
|
||||
"stats_dropdown_year_range": available_stats_year_range(),
|
||||
}
|
||||
|
||||
request.session["return_path"] = request.path
|
||||
return render_page(request, stats_content(context), title=context["title"])
|
||||
data = compute_stats(None)
|
||||
return render_page(request, stats_content(data), title=data["title"])
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -290,262 +86,9 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
||||
)
|
||||
if year == 0:
|
||||
return HttpResponseRedirect(reverse("games:stats_alltime"))
|
||||
this_year_sessions = Session.objects.filter(
|
||||
timestamp_start__year=year
|
||||
).prefetch_related("game")
|
||||
this_year_sessions_with_durations = this_year_sessions.annotate(
|
||||
duration=ExpressionWrapper(
|
||||
F("timestamp_end") - F("timestamp_start"),
|
||||
output_field=fields.DurationField(),
|
||||
)
|
||||
)
|
||||
longest_session = this_year_sessions_with_durations.order_by("-duration").first()
|
||||
this_year_games = Game.objects.filter(sessions__in=this_year_sessions).distinct()
|
||||
this_year_games_with_session_counts = this_year_games.annotate(
|
||||
session_count=Count(
|
||||
"sessions",
|
||||
filter=Q(sessions__timestamp_start__year=year),
|
||||
)
|
||||
)
|
||||
game_highest_session_count = this_year_games_with_session_counts.order_by(
|
||||
"-session_count"
|
||||
).first()
|
||||
selected_currency = "CZK"
|
||||
unique_days = (
|
||||
this_year_sessions.annotate(date=TruncDate("timestamp_start"))
|
||||
.values("date")
|
||||
.distinct()
|
||||
.aggregate(dates=Count("date"))
|
||||
)
|
||||
this_year_played_purchases = Purchase.objects.filter(
|
||||
games__sessions__in=this_year_sessions
|
||||
).distinct()
|
||||
|
||||
this_year_played_games = Game.objects.filter(
|
||||
sessions__in=this_year_sessions
|
||||
).distinct()
|
||||
|
||||
this_year_purchases = Purchase.objects.filter(
|
||||
date_purchased__year=year
|
||||
).prefetch_related("games")
|
||||
# purchased this year
|
||||
# not refunded
|
||||
this_year_purchases_without_refunded = Purchase.objects.filter(
|
||||
date_refunded=None, date_purchased__year=year
|
||||
)
|
||||
|
||||
# purchased this year
|
||||
# not refunded
|
||||
# not finished
|
||||
# not infinite
|
||||
# only Game and DLC
|
||||
this_year_purchases_unfinished_dropped_nondropped = (
|
||||
this_year_purchases_without_refunded.filter(
|
||||
~Q(games__status=Game.Status.FINISHED)
|
||||
& ~Q(games__playevents__ended__year=year)
|
||||
)
|
||||
.filter(infinite=False)
|
||||
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
|
||||
)
|
||||
|
||||
# unfinished = not finished AND not dropped
|
||||
this_year_purchases_unfinished = (
|
||||
this_year_purchases_unfinished_dropped_nondropped.filter(
|
||||
~Q(games__status=Game.Status.RETIRED)
|
||||
& ~Q(games__status=Game.Status.ABANDONED)
|
||||
)
|
||||
)
|
||||
# dropped = abandoned OR retired OR refunded (OR logic for transition)
|
||||
this_year_purchases_dropped = (
|
||||
this_year_purchases.filter(
|
||||
~Q(games__status=Game.Status.FINISHED)
|
||||
& ~Q(games__playevents__ended__year=year)
|
||||
)
|
||||
.filter(Q(games__status=Game.Status.ABANDONED) | Q(date_refunded__isnull=False))
|
||||
.filter(infinite=False)
|
||||
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
|
||||
)
|
||||
|
||||
this_year_purchases_without_refunded_count = (
|
||||
this_year_purchases_without_refunded.count()
|
||||
)
|
||||
this_year_purchases_unfinished_count = this_year_purchases_unfinished.count()
|
||||
this_year_purchases_unfinished_percent = int(
|
||||
safe_division(
|
||||
this_year_purchases_unfinished_count,
|
||||
this_year_purchases_without_refunded_count,
|
||||
)
|
||||
* 100
|
||||
)
|
||||
|
||||
purchases_finished_this_year = (
|
||||
Purchase.objects.finished()
|
||||
.filter(games__playevents__ended__year=year)
|
||||
.annotate(
|
||||
game_name=F("games__name"), date_finished=F("games__playevents__ended")
|
||||
)
|
||||
)
|
||||
purchases_finished_this_year_released_this_year = (
|
||||
purchases_finished_this_year.filter(games__year_released=year).order_by(
|
||||
"games__playevents__ended"
|
||||
)
|
||||
)
|
||||
purchased_this_year_finished_this_year = (
|
||||
this_year_purchases_without_refunded.filter(
|
||||
games__playevents__ended__year=year
|
||||
).annotate(
|
||||
game_name=F("games__name"), date_finished=F("games__playevents__ended")
|
||||
)
|
||||
).order_by("games__playevents__ended")
|
||||
|
||||
this_year_spendings = this_year_purchases_without_refunded.aggregate(
|
||||
total_spent=Sum(F("converted_price"))
|
||||
)
|
||||
total_spent = this_year_spendings["total_spent"] or 0
|
||||
|
||||
games_with_playtime = (
|
||||
Game.objects.filter(sessions__timestamp_start__year=year)
|
||||
.annotate(
|
||||
total_playtime=Sum(
|
||||
F("sessions__duration_calculated"),
|
||||
)
|
||||
)
|
||||
.filter(total_playtime__gt=timedelta(0))
|
||||
)
|
||||
|
||||
month_playtimes = (
|
||||
this_year_sessions.annotate(month=TruncMonth("timestamp_start"))
|
||||
.values("month")
|
||||
.annotate(playtime=Sum("duration_total"))
|
||||
.order_by("month")
|
||||
)
|
||||
|
||||
highest_session_average_game = (
|
||||
Game.objects.filter(sessions__in=this_year_sessions)
|
||||
.annotate(session_average=Avg("sessions__duration_calculated"))
|
||||
.order_by("-session_average")
|
||||
.first()
|
||||
)
|
||||
top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime")
|
||||
|
||||
total_playtime_per_platform = (
|
||||
this_year_sessions.values("game__platform__name")
|
||||
.annotate(playtime=Sum(F("duration_total")))
|
||||
.annotate(platform_name=F("game__platform__name"))
|
||||
.values("platform_name", "playtime")
|
||||
.order_by("-playtime")
|
||||
)
|
||||
|
||||
backlog_decrease_count = (
|
||||
Purchase.objects.filter(date_purchased__year__lt=year)
|
||||
.filter(games__status=Game.Status.FINISHED)
|
||||
.filter(games__playevents__ended__year=year)
|
||||
.count()
|
||||
)
|
||||
|
||||
first_play_date = "N/A"
|
||||
last_play_date = "N/A"
|
||||
first_play_game = None
|
||||
last_play_game = None
|
||||
if this_year_sessions:
|
||||
first_session = this_year_sessions.earliest()
|
||||
first_play_game = first_session.game
|
||||
first_play_date = first_session.timestamp_start.strftime(dateformat)
|
||||
last_session = this_year_sessions.latest()
|
||||
last_play_game = last_session.game
|
||||
last_play_date = last_session.timestamp_start.strftime(dateformat)
|
||||
|
||||
all_purchased_this_year_count = this_year_purchases.count()
|
||||
this_year_purchases_refunded = Purchase.objects.exclude(date_refunded=None).filter(
|
||||
date_purchased__year=year
|
||||
)
|
||||
all_purchased_refunded_this_year_count = this_year_purchases_refunded.count()
|
||||
|
||||
this_year_purchases_dropped_count = this_year_purchases_dropped.count()
|
||||
this_year_purchases_dropped_percentage = int(
|
||||
safe_division(this_year_purchases_dropped_count, all_purchased_this_year_count)
|
||||
* 100
|
||||
)
|
||||
context = {
|
||||
"total_hours": format_duration(
|
||||
this_year_sessions.total_duration_unformatted(), "%2.0H"
|
||||
),
|
||||
"total_games": this_year_played_games.count(),
|
||||
"total_year_games": this_year_played_purchases.filter(
|
||||
games__year_released=year
|
||||
).count(),
|
||||
"top_10_games_by_playtime": top_10_games_by_playtime,
|
||||
"year": year,
|
||||
"total_playtime_per_platform": total_playtime_per_platform,
|
||||
"total_spent": total_spent,
|
||||
"total_spent_currency": selected_currency,
|
||||
"spent_per_game": int(
|
||||
safe_division(total_spent, this_year_purchases_without_refunded_count)
|
||||
),
|
||||
"all_finished_this_year": purchases_finished_this_year.prefetch_related(
|
||||
"games"
|
||||
).order_by("games__playevents__ended"),
|
||||
"all_finished_this_year_count": purchases_finished_this_year.count(),
|
||||
"this_year_finished_this_year": purchases_finished_this_year_released_this_year.prefetch_related(
|
||||
"games"
|
||||
).order_by("games__playevents__ended"),
|
||||
"this_year_finished_this_year_count": purchases_finished_this_year_released_this_year.count(),
|
||||
"purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.prefetch_related(
|
||||
"games"
|
||||
).order_by("games__playevents__ended"),
|
||||
"total_sessions": this_year_sessions.count(),
|
||||
"unique_days": unique_days["dates"],
|
||||
"unique_days_percent": int(unique_days["dates"] / 365 * 100),
|
||||
"purchased_unfinished": this_year_purchases_unfinished,
|
||||
"purchased_unfinished_count": this_year_purchases_unfinished_count,
|
||||
"unfinished_purchases_percent": this_year_purchases_unfinished_percent,
|
||||
"dropped_count": this_year_purchases_dropped_count,
|
||||
"dropped_percentage": this_year_purchases_dropped_percentage,
|
||||
"refunded_percent": int(
|
||||
safe_division(
|
||||
all_purchased_refunded_this_year_count,
|
||||
all_purchased_this_year_count,
|
||||
)
|
||||
* 100
|
||||
),
|
||||
"all_purchased_refunded_this_year": this_year_purchases_refunded,
|
||||
"all_purchased_refunded_this_year_count": all_purchased_refunded_this_year_count,
|
||||
"all_purchased_this_year": this_year_purchases.order_by("date_purchased"),
|
||||
"all_purchased_this_year_count": all_purchased_this_year_count,
|
||||
"backlog_decrease_count": backlog_decrease_count,
|
||||
"longest_session_time": (
|
||||
format_duration(longest_session.duration, "%2.0Hh %2.0mm")
|
||||
if longest_session
|
||||
else 0
|
||||
),
|
||||
"longest_session_game": (longest_session.game if longest_session else None),
|
||||
"highest_session_count": (
|
||||
game_highest_session_count.session_count
|
||||
if game_highest_session_count
|
||||
else 0
|
||||
),
|
||||
"highest_session_count_game": (
|
||||
game_highest_session_count if game_highest_session_count else None
|
||||
),
|
||||
"highest_session_average": (
|
||||
format_duration(
|
||||
highest_session_average_game.session_average, "%2.0Hh %2.0mm"
|
||||
)
|
||||
if highest_session_average_game
|
||||
else 0
|
||||
),
|
||||
"highest_session_average_game": highest_session_average_game,
|
||||
"first_play_game": first_play_game,
|
||||
"first_play_date": first_play_date,
|
||||
"last_play_game": last_play_game,
|
||||
"last_play_date": last_play_date,
|
||||
"title": f"{year} Stats",
|
||||
"month_playtimes": month_playtimes,
|
||||
"stats_dropdown_year_range": available_stats_year_range(),
|
||||
}
|
||||
|
||||
request.session["return_path"] = request.path
|
||||
return render_page(request, stats_content(context), title=context["title"])
|
||||
data = compute_stats(year)
|
||||
return render_page(request, stats_content(data), title=data["title"])
|
||||
|
||||
|
||||
@login_required
|
||||
|
||||
+27
-8
@@ -6,14 +6,17 @@ 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
|
||||
@@ -21,14 +24,20 @@ from games.views.general import use_custom_redirect
|
||||
|
||||
@login_required
|
||||
def list_platforms(request: HttpRequest) -> HttpResponse:
|
||||
platforms, page_obj, elided_page_range = paginate(
|
||||
request, Platform.objects.order_by("name")
|
||||
)
|
||||
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)
|
||||
|
||||
data = {
|
||||
"header_action": A(
|
||||
[], Button([], "Add platform"), url_name="games:add_platform"
|
||||
),
|
||||
"header_action": A(href=reverse("games:add_platform"))[
|
||||
StyledButton()["Add platform"]
|
||||
],
|
||||
"columns": [
|
||||
"Name",
|
||||
"Icon",
|
||||
@@ -68,7 +77,17 @@ def list_platforms(request: HttpRequest) -> HttpResponse:
|
||||
elided_page_range=elided_page_range,
|
||||
request=request,
|
||||
)
|
||||
return render_page(request, content, title="Manage platforms")
|
||||
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",
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
|
||||
+38
-10
@@ -12,14 +12,18 @@ 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
|
||||
|
||||
@@ -82,9 +86,9 @@ def create_playevent_tabledata(
|
||||
for row in row_list
|
||||
]
|
||||
return {
|
||||
"header_action": A(
|
||||
[], Button([], "Add play event"), url_name="games:add_playevent"
|
||||
),
|
||||
"header_action": A(href=reverse("games:add_playevent"))[
|
||||
StyledButton()["Add play event"]
|
||||
],
|
||||
"columns": list(filtered_column_list),
|
||||
"rows": filtered_row_list,
|
||||
}
|
||||
@@ -125,9 +129,15 @@ def _get_formatted_playtime_for_game_sessions_in_range(
|
||||
|
||||
@login_required
|
||||
def list_playevents(request: HttpRequest) -> HttpResponse:
|
||||
playevents, page_obj, elided_page_range = paginate(
|
||||
request, PlayEvent.objects.order_by("-created_at")
|
||||
)
|
||||
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)
|
||||
data = create_playevent_tabledata(playevents, request=request)
|
||||
content = paginated_table_content(
|
||||
data,
|
||||
@@ -135,7 +145,17 @@ def list_playevents(request: HttpRequest) -> HttpResponse:
|
||||
elided_page_range=elided_page_range,
|
||||
request=request,
|
||||
)
|
||||
return render_page(request, content, title="Manage play events")
|
||||
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",
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -193,7 +213,10 @@ def add_playevent(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
||||
return HttpResponseRedirect(reverse("games:view_game", args=[game_id]))
|
||||
|
||||
return render_page(
|
||||
request, AddForm(form, request=request), title="Add new playthrough"
|
||||
request,
|
||||
AddForm(form, request=request),
|
||||
title="Add new playthrough",
|
||||
scripts=ModuleScript("search_select.js"),
|
||||
)
|
||||
|
||||
|
||||
@@ -206,7 +229,12 @@ def edit_playevent(request: HttpRequest, playevent_id: int) -> HttpResponse:
|
||||
reverse("games:view_game", args=[playevent.game.id])
|
||||
)
|
||||
|
||||
return render_page(request, AddForm(form, request=request), title="Edit Play Event")
|
||||
return render_page(
|
||||
request,
|
||||
AddForm(form, request=request),
|
||||
title="Edit Play Event",
|
||||
scripts=ModuleScript("search_select.js"),
|
||||
)
|
||||
|
||||
|
||||
def delete_playevent(request: HttpRequest, playevent_id: int) -> HttpResponse:
|
||||
|
||||
+43
-38
@@ -6,32 +6,34 @@ from django.http import (
|
||||
HttpResponseRedirect,
|
||||
)
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
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.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 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
|
||||
@@ -100,6 +102,7 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
|
||||
filter_json = request.GET.get("filter", "")
|
||||
if filter_json:
|
||||
from games.filters import parse_purchase_filter
|
||||
|
||||
pf = parse_purchase_filter(filter_json)
|
||||
if pf is not None:
|
||||
purchases = purchases.filter(pf.to_q())
|
||||
@@ -107,9 +110,9 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
|
||||
purchases, page_obj, elided_page_range = paginate(request, purchases)
|
||||
|
||||
data = {
|
||||
"header_action": A(
|
||||
[], Button([], "Add purchase"), url_name="games:add_purchase"
|
||||
),
|
||||
"header_action": A(href=reverse("games:add_purchase"))[
|
||||
StyledButton()["Add purchase"]
|
||||
],
|
||||
"columns": [
|
||||
"Name",
|
||||
"Type",
|
||||
@@ -128,31 +131,29 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
|
||||
elided_page_range=elided_page_range,
|
||||
request=request,
|
||||
)
|
||||
from common.components import PurchaseFilterBar, ModuleScript
|
||||
from common.components import PurchaseFilterBar
|
||||
|
||||
filter_bar = PurchaseFilterBar(
|
||||
filter_json=filter_json,
|
||||
preset_list_url=reverse("games:list_presets"),
|
||||
preset_save_url=reverse("games:save_preset"),
|
||||
)
|
||||
content = mark_safe(str(filter_bar) + str(content))
|
||||
content = Fragment(filter_bar, 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 Component(
|
||||
tag_name="tr",
|
||||
return Tr(
|
||||
children=[
|
||||
Component(tag_name="td"),
|
||||
Component(
|
||||
tag_name="td",
|
||||
Td(),
|
||||
Td(
|
||||
children=[
|
||||
Button(
|
||||
StyledButton(
|
||||
[],
|
||||
"Submit & Create Session",
|
||||
color="gray",
|
||||
@@ -199,7 +200,9 @@ def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
||||
request,
|
||||
AddForm(form, request=request, additional_row=_purchase_additional_row()),
|
||||
title="Add New Purchase",
|
||||
scripts=ModuleScript("add_purchase.js"),
|
||||
scripts=mark_safe(
|
||||
ModuleScript("search_select.js") + ModuleScript("add_purchase.js")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -215,7 +218,9 @@ def edit_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
||||
request,
|
||||
AddForm(form, request=request, additional_row=_purchase_additional_row()),
|
||||
title="Edit Purchase",
|
||||
scripts=ModuleScript("add_purchase.js"),
|
||||
scripts=mark_safe(
|
||||
ModuleScript("search_select.js") + ModuleScript("add_purchase.js")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -254,8 +259,7 @@ def _view_purchase_content(purchase: Purchase) -> SafeText:
|
||||
Div(
|
||||
[("class", row_class)],
|
||||
[
|
||||
Component(
|
||||
tag_name="p",
|
||||
P(
|
||||
children=[
|
||||
"Price per game: ",
|
||||
PriceConverted([floatformat(purchase.price_per_game, 0)]),
|
||||
@@ -265,10 +269,9 @@ def _view_purchase_content(purchase: Purchase) -> SafeText:
|
||||
],
|
||||
),
|
||||
Div([("class", row_class)], ["Games included in this purchase:"]),
|
||||
Component(
|
||||
tag_name="ul",
|
||||
Ul(
|
||||
children=[
|
||||
Component(tag_name="li", children=[GameLink(game.id, game.name)])
|
||||
Li(children=[GameLink(game.id, game.name)])
|
||||
for game in purchase.games.all()
|
||||
],
|
||||
),
|
||||
@@ -299,9 +302,9 @@ def drop_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
||||
return redirect("games:list_purchases")
|
||||
|
||||
|
||||
def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> SafeText:
|
||||
form = Component(
|
||||
tag_name="form",
|
||||
def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> Node:
|
||||
form = Element(
|
||||
"form",
|
||||
attributes=[
|
||||
("hx-post", reverse("games:refund_purchase", args=[purchase_id])),
|
||||
("hx-target", f"#purchase-row-{purchase_id}"),
|
||||
@@ -309,22 +312,21 @@ def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> SafeTe
|
||||
],
|
||||
children=[
|
||||
CsrfInput(request),
|
||||
Component(
|
||||
tag_name="p",
|
||||
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")],
|
||||
[
|
||||
Button(
|
||||
StyledButton(
|
||||
[("class", "w-full")],
|
||||
"Refund",
|
||||
color="blue",
|
||||
size="lg",
|
||||
type="submit",
|
||||
),
|
||||
Button(
|
||||
StyledButton(
|
||||
[("class", "mt-0 w-full")],
|
||||
"Cancel",
|
||||
color="gray",
|
||||
@@ -338,8 +340,8 @@ def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> SafeTe
|
||||
return Modal(
|
||||
"refund-confirmation-modal",
|
||||
children=[
|
||||
Component(
|
||||
tag_name="h1",
|
||||
Element(
|
||||
"h1",
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
@@ -348,8 +350,7 @@ def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> SafeTe
|
||||
],
|
||||
children=["Confirm Refund"],
|
||||
),
|
||||
Component(
|
||||
tag_name="p",
|
||||
P(
|
||||
attributes=[("class", "dark:text-white text-center mt-5")],
|
||||
children=["Are you sure you want to mark this purchase as refunded?"],
|
||||
),
|
||||
@@ -397,9 +398,13 @@ def finish_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
||||
def related_purchase_by_game(request: HttpRequest) -> HttpResponse:
|
||||
games: list[str] = request.GET.getlist("games")
|
||||
if games:
|
||||
from games.forms import related_purchase_queryset
|
||||
|
||||
form = PurchaseForm()
|
||||
qs = Purchase.objects.filter(games__in=games, type=Purchase.GAME).order_by(
|
||||
"games__sort_name"
|
||||
qs = (
|
||||
related_purchase_queryset()
|
||||
.filter(games__in=games)
|
||||
.order_by("games__sort_name")
|
||||
)
|
||||
|
||||
form.fields["related_purchase"].queryset = qs
|
||||
|
||||
+46
-66
@@ -13,18 +13,22 @@ 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,
|
||||
@@ -45,6 +49,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
|
||||
filter_json = request.GET.get("filter", "")
|
||||
if filter_json:
|
||||
from games.filters import parse_session_filter
|
||||
|
||||
session_filter = parse_session_filter(filter_json)
|
||||
if session_filter is not None:
|
||||
sessions = sessions.filter(session_filter.to_q())
|
||||
@@ -72,13 +77,13 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
|
||||
Div(
|
||||
children=[
|
||||
A(
|
||||
url_name="games:add_session",
|
||||
children=Button(
|
||||
href=reverse("games:add_session"),
|
||||
)[
|
||||
StyledButton(
|
||||
icon=True,
|
||||
size="xs",
|
||||
children=[Icon("play"), "LOG"],
|
||||
),
|
||||
),
|
||||
)[Icon("play"), "LOG"]
|
||||
],
|
||||
A(
|
||||
href=reverse(
|
||||
"games:list_sessions_start_session_from_session",
|
||||
@@ -87,7 +92,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
|
||||
children=Popover(
|
||||
popover_content=last_session.game.name,
|
||||
children=[
|
||||
Button(
|
||||
StyledButton(
|
||||
icon=True,
|
||||
color="gray",
|
||||
size="xs",
|
||||
@@ -168,18 +173,18 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
|
||||
request=request,
|
||||
)
|
||||
from common.components import SessionFilterBar
|
||||
|
||||
filter_json = request.GET.get("filter", "")
|
||||
filter_bar = SessionFilterBar(
|
||||
filter_json=filter_json,
|
||||
preset_list_url=reverse("games:list_presets"),
|
||||
preset_save_url=reverse("games:save_preset"),
|
||||
)
|
||||
content = mark_safe(str(filter_bar) + str(content))
|
||||
content = Fragment(filter_bar, content)
|
||||
return render_page(
|
||||
request,
|
||||
content,
|
||||
title="Manage sessions",
|
||||
scripts=ModuleScript("range_slider.js") + ModuleScript("selectable_filter.js") + ModuleScript("filter_bar.js"),
|
||||
)
|
||||
|
||||
|
||||
@@ -188,62 +193,45 @@ def search_sessions(request: HttpRequest) -> HttpResponse:
|
||||
return list_sessions(request, search_string=request.GET.get("search_string", ""))
|
||||
|
||||
|
||||
def _session_fields(form) -> SafeText:
|
||||
def _session_fields(form) -> Fragment:
|
||||
"""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[SafeText] = []
|
||||
rows: list[Node] = []
|
||||
for field in form:
|
||||
children: list[SafeText | str] = [
|
||||
mark_safe(str(field.label_tag())),
|
||||
mark_safe(str(field)),
|
||||
children: list[Node | str] = [
|
||||
Safe(str(field.label_tag())),
|
||||
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(
|
||||
Component(
|
||||
tag_name="span",
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"form-row-button-group flex-row gap-3 justify-start mt-3",
|
||||
),
|
||||
("hx-boost", "false"),
|
||||
SessionTimestampButtons(
|
||||
class_="form-row-button-group flex-row gap-3 justify-start mt-3",
|
||||
hx_boost="false",
|
||||
)[
|
||||
StyledButton(data_target=field.name, data_type="now", size="xs")[
|
||||
"Set to now"
|
||||
],
|
||||
children=[
|
||||
Button(
|
||||
[("data-target", field.name), ("data-type", "now")],
|
||||
"Set to now",
|
||||
size="xs",
|
||||
),
|
||||
Button(
|
||||
[("data-target", field.name), ("data-type", "toggle")],
|
||||
"Toggle text",
|
||||
size="xs",
|
||||
),
|
||||
Button(
|
||||
[("data-target", field.name), ("data-type", "copy")],
|
||||
f"Copy {this_side} value to {other_side}",
|
||||
size="xs",
|
||||
),
|
||||
StyledButton(data_target=field.name, data_type="toggle", size="xs")[
|
||||
"Toggle text"
|
||||
],
|
||||
)
|
||||
StyledButton(data_target=field.name, data_type="copy", size="xs")[
|
||||
f"Copy {this_side} value to {other_side}"
|
||||
],
|
||||
]
|
||||
)
|
||||
rows.append(Div(children=children))
|
||||
return mark_safe("\n".join(rows))
|
||||
return Fragment(*rows, separator="\n")
|
||||
|
||||
|
||||
@login_required
|
||||
def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
||||
initial: dict[str, Any] = {"timestamp_start": timezone.now()}
|
||||
|
||||
last = Session.objects.last()
|
||||
if last is not None:
|
||||
initial["game"] = last.game
|
||||
|
||||
if request.method == "POST":
|
||||
form = SessionForm(request.POST or None, initial=initial)
|
||||
if form.is_valid():
|
||||
@@ -266,7 +254,7 @@ def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
||||
request,
|
||||
AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
|
||||
title="Add New Session",
|
||||
scripts=ModuleScript("add_session.js"),
|
||||
scripts=mark_safe(ModuleScript("search_select.js")),
|
||||
)
|
||||
|
||||
|
||||
@@ -281,15 +269,15 @@ def edit_session(request: HttpRequest, session_id: int) -> HttpResponse:
|
||||
request,
|
||||
AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
|
||||
title="Edit Session",
|
||||
scripts=ModuleScript("add_session.js"),
|
||||
scripts=mark_safe(ModuleScript("search_select.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 = Component(
|
||||
tag_name="a",
|
||||
name_link = A(
|
||||
href=reverse("games:view_game", args=[session.game.id]),
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
@@ -301,12 +289,10 @@ 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 = Component(
|
||||
tag_name="td",
|
||||
name_td = Td(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
@@ -315,15 +301,13 @@ def _session_row_fragment(session: Session) -> SafeText:
|
||||
)
|
||||
],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="span",
|
||||
Span(
|
||||
attributes=[("class", "inline-block relative")],
|
||||
children=[name_link],
|
||||
)
|
||||
],
|
||||
)
|
||||
start_td = Component(
|
||||
tag_name="td",
|
||||
start_td = Td(
|
||||
attributes=[
|
||||
("class", "px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden sm:table-cell")
|
||||
],
|
||||
@@ -332,10 +316,9 @@ 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 = Component(
|
||||
tag_name="a",
|
||||
end_inner: SafeText | str = A(
|
||||
href=end_url,
|
||||
attributes=[
|
||||
("href", end_url),
|
||||
("hx-get", end_url),
|
||||
("hx-target", "closest tr"),
|
||||
("hx-swap", "outerHTML"),
|
||||
@@ -347,8 +330,7 @@ def _session_row_fragment(session: Session) -> SafeText:
|
||||
),
|
||||
],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="span",
|
||||
Span(
|
||||
attributes=[("class", "text-yellow-300")],
|
||||
children=["Finish now?"],
|
||||
)
|
||||
@@ -358,19 +340,17 @@ def _session_row_fragment(session: Session) -> SafeText:
|
||||
end_inner = "--"
|
||||
else:
|
||||
end_inner = date_filter(session.timestamp_end, "d/m/Y H:i")
|
||||
end_td = Component(
|
||||
tag_name="td",
|
||||
end_td = 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 = Component(
|
||||
tag_name="td",
|
||||
duration_td = Td(
|
||||
attributes=[("class", "px-2 sm:px-4 md:px-6 md:py-2 font-mono")],
|
||||
children=[session.duration_formatted()],
|
||||
)
|
||||
return Component(tag_name="tr", children=[name_td, start_td, end_td, duration_td])
|
||||
return Tr(children=[name_td, start_td, end_td, duration_td])
|
||||
|
||||
|
||||
def clone_session_by_id(session_id: int) -> Session:
|
||||
|
||||
@@ -7,10 +7,21 @@ like the old `{% if key %}` blocks: a missing or empty value hides the section.
|
||||
|
||||
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 Component, Div, GameLink
|
||||
from common.components import (
|
||||
A,
|
||||
Div,
|
||||
Element,
|
||||
GameLink,
|
||||
Node,
|
||||
Safe,
|
||||
Td,
|
||||
Th,
|
||||
Tr,
|
||||
YearPicker,
|
||||
)
|
||||
from common.time import durationformat, format_duration
|
||||
|
||||
_CELL = "px-2 sm:px-4 md:px-6 md:py-2"
|
||||
@@ -18,41 +29,40 @@ _CELL_MONO = f"{_CELL} font-mono"
|
||||
_NAME_TH = f"{_CELL} purchase-name truncate max-w-20char"
|
||||
|
||||
|
||||
def _td(children, cls: str = _CELL_MONO) -> SafeText:
|
||||
def _td(children, cls: str = _CELL_MONO) -> Node:
|
||||
if not isinstance(children, list):
|
||||
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)
|
||||
return Td(attributes=[("class", cls)], children=children)
|
||||
|
||||
|
||||
def _th(text: str, cls: str = _CELL) -> SafeText:
|
||||
return Component(tag_name="th", attributes=[("class", cls)], children=[text])
|
||||
def _th(text: str, cls: str = _CELL) -> Node:
|
||||
return Th(attributes=[("class", cls)], children=[text])
|
||||
|
||||
|
||||
def _tr(cells: list) -> SafeText:
|
||||
return Component(tag_name="tr", children=cells)
|
||||
def _tr(cells: list) -> Node:
|
||||
return Tr(children=cells)
|
||||
|
||||
|
||||
def _kv(label, value) -> SafeText:
|
||||
def _kv(label, value) -> Node:
|
||||
"""A label/value row: plain label cell + mono value cell."""
|
||||
return _tr([_td(label, _CELL), _td(value)])
|
||||
|
||||
|
||||
def _h1(title: str) -> SafeText:
|
||||
return Component(
|
||||
tag_name="h1",
|
||||
attributes=[("class", "text-5xl text-center my-6")],
|
||||
def _h1(title: str) -> Node:
|
||||
return Element(
|
||||
"h1",
|
||||
attributes=[("class", "text-3xl text-heading text-center my-6")],
|
||||
children=[title],
|
||||
)
|
||||
|
||||
|
||||
def _table(rows: list, thead: SafeText | None = None) -> SafeText:
|
||||
def _table(rows: list, thead: Node | None = None) -> Node:
|
||||
children = []
|
||||
if thead is not None:
|
||||
children.append(thead)
|
||||
children.append(Component(tag_name="tbody", children=rows))
|
||||
return Component(
|
||||
tag_name="table",
|
||||
children.append(Element("tbody", children=rows))
|
||||
return Element(
|
||||
"table",
|
||||
attributes=[("class", "responsive-table")],
|
||||
children=children,
|
||||
)
|
||||
@@ -62,7 +72,7 @@ def _dur(value) -> str:
|
||||
return format_duration(value, durationformat)
|
||||
|
||||
|
||||
def _purchase_name(purchase) -> SafeText:
|
||||
def _purchase_name(purchase) -> Node:
|
||||
"""Mirror of the `purchase-name` partial in the old template."""
|
||||
game_name = getattr(purchase, "game_name", None)
|
||||
first_game = purchase.first_game
|
||||
@@ -70,47 +80,42 @@ def _purchase_name(purchase) -> SafeText:
|
||||
name = game_name or purchase.name
|
||||
link = GameLink(first_game.id, name)
|
||||
suffix = f" ({first_game.name} {purchase.get_type_display()})"
|
||||
return mark_safe(str(link) + conditional_escape(suffix))
|
||||
return Safe(str(link) + conditional_escape(suffix))
|
||||
name = game_name or first_game.name
|
||||
return GameLink(first_game.id, name)
|
||||
|
||||
|
||||
def _year_dropdown(year, year_range) -> SafeText:
|
||||
options = []
|
||||
for year_item in year_range or []:
|
||||
attrs = [("value", str(year_item))]
|
||||
if year == year_item:
|
||||
attrs.append(("selected", True))
|
||||
options.append(
|
||||
Component(tag_name="option", attributes=attrs, children=[str(year_item)])
|
||||
)
|
||||
select = Component(
|
||||
tag_name="select",
|
||||
attributes=[
|
||||
("name", "year"),
|
||||
("id", "yearSelect"),
|
||||
("onchange", "this.form.submit();"),
|
||||
("class", "mx-2"),
|
||||
],
|
||||
children=options,
|
||||
def _year_nav(year, year_range, url_template) -> Node:
|
||||
# `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.
|
||||
year_int = year if isinstance(year, int) else None
|
||||
is_alltime = year_int is None
|
||||
|
||||
alltime_classes = (
|
||||
"inline-flex items-center rounded-base px-4 py-2 mr-3 text-sm font-medium "
|
||||
)
|
||||
label = Component(
|
||||
tag_name="label",
|
||||
attributes=[
|
||||
("class", "text-5xl text-center inline-block mb-10"),
|
||||
("for", "yearSelect"),
|
||||
],
|
||||
children=["Stats for:"],
|
||||
alltime_classes += (
|
||||
"bg-brand text-white hover:bg-brand-strong"
|
||||
if is_alltime
|
||||
else "text-body hover:text-heading underline decoration-dotted"
|
||||
)
|
||||
form = Component(
|
||||
tag_name="form",
|
||||
attributes=[("method", "get"), ("class", "text-center")],
|
||||
children=[label, select],
|
||||
alltime_btn = A(
|
||||
href=reverse("games:stats_alltime"),
|
||||
class_=alltime_classes,
|
||||
)["All-time stats"]
|
||||
picker = YearPicker(
|
||||
year=year_int,
|
||||
available_years=tuple(year_range or []),
|
||||
url_template=url_template,
|
||||
)
|
||||
return Div(
|
||||
[("class", "flex justify-center items-center mb-12")],
|
||||
[alltime_btn, picker],
|
||||
)
|
||||
return Div([("class", "flex justify-center items-center")], [form])
|
||||
|
||||
|
||||
def _playtime_table(ctx) -> SafeText:
|
||||
def _playtime_table(ctx) -> Node:
|
||||
year = ctx.get("year")
|
||||
rows = [
|
||||
_kv("Hours", ctx.get("total_hours")),
|
||||
@@ -189,7 +194,7 @@ def _playtime_table(ctx) -> SafeText:
|
||||
return _table(rows)
|
||||
|
||||
|
||||
def _purchases_table(ctx) -> SafeText:
|
||||
def _purchases_table(ctx) -> Node:
|
||||
rows = [
|
||||
_kv("Total", ctx.get("all_purchased_this_year_count")),
|
||||
_kv(
|
||||
@@ -216,18 +221,18 @@ def _purchases_table(ctx) -> SafeText:
|
||||
return _table(rows)
|
||||
|
||||
|
||||
def _two_col_table(header: str, items, name_key, value_fn) -> SafeText:
|
||||
thead = Component(
|
||||
tag_name="thead",
|
||||
def _two_col_table(header: str, items, name_key, value_fn) -> Node:
|
||||
thead = Element(
|
||||
"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) -> SafeText:
|
||||
thead = Component(
|
||||
tag_name="thead",
|
||||
def _finished_table(purchases) -> Node:
|
||||
thead = Element(
|
||||
"thead",
|
||||
children=[_tr([_th("Name", _NAME_TH), _th("Date")])],
|
||||
)
|
||||
rows = [
|
||||
@@ -237,9 +242,9 @@ def _finished_table(purchases) -> SafeText:
|
||||
return _table(rows, thead)
|
||||
|
||||
|
||||
def _priced_table(purchases, currency) -> SafeText:
|
||||
thead = Component(
|
||||
tag_name="thead",
|
||||
def _priced_table(purchases, currency) -> Node:
|
||||
thead = Element(
|
||||
"thead",
|
||||
children=[
|
||||
_tr([_th("Name", _NAME_TH), _th(f"Price ({currency})"), _th("Date")])
|
||||
],
|
||||
@@ -257,11 +262,17 @@ def _priced_table(purchases, currency) -> SafeText:
|
||||
return _table(rows, thead)
|
||||
|
||||
|
||||
def stats_content(ctx: dict) -> SafeText:
|
||||
def stats_content(ctx: dict) -> Node:
|
||||
year = ctx.get("year")
|
||||
currency = ctx.get("total_spent_currency")
|
||||
# Build a navigation URL with an `__year__` placeholder the picker's JS
|
||||
# substitutes. Reverse a sentinel year, then swap it for the placeholder
|
||||
# (anchored on `stats/0` so the match is unambiguous).
|
||||
url_template = reverse("games:stats_by_year", args=[0]).replace(
|
||||
"stats/0", "stats/__year__"
|
||||
)
|
||||
sections: list = [
|
||||
_year_dropdown(year, ctx.get("stats_dropdown_year_range")),
|
||||
_year_nav(year, ctx.get("stats_dropdown_year_range"), url_template),
|
||||
_h1("Playtime"),
|
||||
_playtime_table(ctx),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
"""Request-free stats computation: the data half of the stats page.
|
||||
|
||||
`compute_stats(year)` returns a `StatsData` dict (the documented seam between
|
||||
*computing* metrics and *rendering* them in `stats_content`). Today it computes
|
||||
from the ORM; this is also the function a future materialization job would call,
|
||||
and the shape it would populate from a pre-calculated table.
|
||||
|
||||
`year=None` means all-time; otherwise the metrics are scoped to that calendar
|
||||
year. The two scopes genuinely diverge (different aggregations, and all-time
|
||||
hides the per-purchase list sections), so the differences are kept explicit.
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
from typing import Any, NotRequired, TypedDict
|
||||
|
||||
from django.db.models import (
|
||||
Avg,
|
||||
Count,
|
||||
ExpressionWrapper,
|
||||
F,
|
||||
Max,
|
||||
OuterRef,
|
||||
Q,
|
||||
Subquery,
|
||||
Sum,
|
||||
fields,
|
||||
)
|
||||
from django.db.models.functions import TruncDate, TruncMonth
|
||||
|
||||
from common.time import available_stats_year_range, dateformat, format_duration
|
||||
from common.utils import safe_division
|
||||
from games.models import Game, Purchase, Session
|
||||
|
||||
|
||||
class StatsData(TypedDict):
|
||||
# --- always present (both scopes) ---
|
||||
year: Any # int for a year, "Alltime" for all-time
|
||||
title: str
|
||||
total_hours: str
|
||||
total_sessions: int
|
||||
unique_days: int
|
||||
unique_days_percent: int
|
||||
total_year_games: int
|
||||
this_year_finished_this_year_count: int
|
||||
top_10_games_by_playtime: Any
|
||||
total_playtime_per_platform: Any
|
||||
total_spent: Any
|
||||
total_spent_currency: str
|
||||
spent_per_game: int
|
||||
all_purchased_this_year_count: int
|
||||
all_purchased_refunded_this_year: Any
|
||||
all_purchased_refunded_this_year_count: int
|
||||
refunded_percent: int
|
||||
dropped_count: int
|
||||
dropped_percentage: int
|
||||
purchased_unfinished_count: int
|
||||
unfinished_purchases_percent: int
|
||||
backlog_decrease_count: int
|
||||
longest_session_time: Any
|
||||
longest_session_game: Any
|
||||
highest_session_count: int
|
||||
highest_session_count_game: Any
|
||||
highest_session_average: Any
|
||||
highest_session_average_game: Any
|
||||
first_play_game: Any
|
||||
first_play_date: str
|
||||
last_play_game: Any
|
||||
last_play_date: str
|
||||
stats_dropdown_year_range: Any
|
||||
# --- per-year only (omitted for all-time, which hides these sections) ---
|
||||
total_games: NotRequired[int]
|
||||
month_playtimes: NotRequired[Any]
|
||||
all_finished_this_year: NotRequired[Any]
|
||||
all_finished_this_year_count: NotRequired[int]
|
||||
this_year_finished_this_year: NotRequired[Any]
|
||||
purchased_this_year_finished_this_year: NotRequired[Any]
|
||||
purchased_unfinished: NotRequired[Any]
|
||||
all_purchased_this_year: NotRequired[Any]
|
||||
|
||||
|
||||
def _days_played_percent(unique_days: int, first: date, last: date) -> int:
|
||||
"""Share of days played across the span actually played (all-time).
|
||||
|
||||
Unlike the per-year metric (``unique_days / 365``), the all-time span is the
|
||||
real number of days between the first and last session, so the result stays
|
||||
meaningful (and ≤100%) across multiple years.
|
||||
"""
|
||||
span = (last - first).days + 1
|
||||
if span <= 0:
|
||||
return 0
|
||||
return min(int(unique_days / span * 100), 100)
|
||||
|
||||
|
||||
def compute_stats(year: int | None = None) -> StatsData:
|
||||
is_alltime = year is None
|
||||
currency = "CZK"
|
||||
|
||||
# ── Scope ──────────────────────────────────────────────────────────────
|
||||
if is_alltime:
|
||||
sessions = Session.objects.all().prefetch_related("game")
|
||||
purchases = Purchase.objects.all()
|
||||
without_refunded = Purchase.objects.filter(date_refunded=None)
|
||||
refunded = Purchase.objects.refunded()
|
||||
ended_q = Q(games__playevents__ended__isnull=False)
|
||||
session_count = Count("sessions")
|
||||
else:
|
||||
sessions = Session.objects.filter(timestamp_start__year=year).prefetch_related(
|
||||
"game"
|
||||
)
|
||||
purchases = Purchase.objects.filter(date_purchased__year=year)
|
||||
without_refunded = Purchase.objects.filter(
|
||||
date_refunded=None, date_purchased__year=year
|
||||
)
|
||||
refunded = Purchase.objects.exclude(date_refunded=None).filter(
|
||||
date_purchased__year=year
|
||||
)
|
||||
ended_q = Q(games__playevents__ended__year=year)
|
||||
session_count = Count(
|
||||
"sessions", filter=Q(sessions__timestamp_start__year=year)
|
||||
)
|
||||
|
||||
not_finished_q = ~Q(games__status=Game.Status.FINISHED) & ~ended_q
|
||||
|
||||
# ── Session superlatives ─────────────────────────────────────────────────
|
||||
longest_session = (
|
||||
sessions.annotate(
|
||||
duration=ExpressionWrapper(
|
||||
F("timestamp_end") - F("timestamp_start"),
|
||||
output_field=fields.DurationField(),
|
||||
)
|
||||
)
|
||||
.order_by("-duration")
|
||||
.first()
|
||||
)
|
||||
games_in_scope = Game.objects.filter(sessions__in=sessions).distinct()
|
||||
highest_session_count_game = (
|
||||
games_in_scope.annotate(session_count=session_count)
|
||||
.order_by("-session_count")
|
||||
.first()
|
||||
)
|
||||
highest_session_average_game = (
|
||||
Game.objects.filter(sessions__in=sessions)
|
||||
.annotate(session_average=Avg("sessions__duration_calculated"))
|
||||
.order_by("-session_average")
|
||||
.first()
|
||||
)
|
||||
|
||||
# ── Days played + play range ─────────────────────────────────────────────
|
||||
unique_days = (
|
||||
sessions.annotate(date=TruncDate("timestamp_start"))
|
||||
.values("date")
|
||||
.distinct()
|
||||
.aggregate(dates=Count("date"))["dates"]
|
||||
)
|
||||
first_session = sessions.earliest() if sessions.exists() else None
|
||||
last_session = sessions.latest() if sessions.exists() else None
|
||||
first_play_game = first_session.game if first_session else None
|
||||
last_play_game = last_session.game if last_session else None
|
||||
first_play_date = (
|
||||
first_session.timestamp_start.strftime(dateformat) if first_session else "N/A"
|
||||
)
|
||||
last_play_date = (
|
||||
last_session.timestamp_start.strftime(dateformat) if last_session else "N/A"
|
||||
)
|
||||
if is_alltime:
|
||||
unique_days_percent = (
|
||||
_days_played_percent(
|
||||
unique_days,
|
||||
first_session.timestamp_start.date(),
|
||||
last_session.timestamp_start.date(),
|
||||
)
|
||||
if first_session
|
||||
else 0
|
||||
)
|
||||
else:
|
||||
unique_days_percent = int(unique_days / 365 * 100)
|
||||
|
||||
# ── Spending ─────────────────────────────────────────────────────────────
|
||||
total_spent = (
|
||||
without_refunded.aggregate(total=Sum(F("converted_price")))["total"] or 0
|
||||
)
|
||||
without_refunded_count = without_refunded.count()
|
||||
|
||||
# ── Purchase breakdown ───────────────────────────────────────────────────
|
||||
only_games_and_dlc = Q(type=Purchase.GAME) | Q(type=Purchase.DLC)
|
||||
unfinished = (
|
||||
without_refunded.filter(not_finished_q)
|
||||
.filter(infinite=False)
|
||||
.filter(only_games_and_dlc)
|
||||
.filter(
|
||||
~Q(games__status=Game.Status.RETIRED)
|
||||
& ~Q(games__status=Game.Status.ABANDONED)
|
||||
)
|
||||
)
|
||||
dropped = (
|
||||
purchases.filter(not_finished_q)
|
||||
.filter(Q(games__status=Game.Status.ABANDONED) | Q(date_refunded__isnull=False))
|
||||
.filter(infinite=False)
|
||||
.filter(only_games_and_dlc)
|
||||
)
|
||||
unfinished_count = unfinished.count()
|
||||
dropped_count = dropped.count()
|
||||
all_purchased_count = purchases.count()
|
||||
refunded_count = refunded.count()
|
||||
|
||||
# ── Finished purchases (scope-divergent) ─────────────────────────────────
|
||||
if is_alltime:
|
||||
finished = Purchase.objects.finished().annotate(
|
||||
date_finished=Subquery(
|
||||
Purchase.objects.filter(pk=OuterRef("pk"))
|
||||
.annotate(max_ended=Max("games__playevents__ended"))
|
||||
.values("max_ended")[:1]
|
||||
)
|
||||
)
|
||||
finished_released = finished.order_by("-date_finished")
|
||||
backlog_decrease_count = finished.count()
|
||||
else:
|
||||
finished = (
|
||||
Purchase.objects.finished()
|
||||
.filter(games__playevents__ended__year=year)
|
||||
.annotate(
|
||||
game_name=F("games__name"), date_finished=F("games__playevents__ended")
|
||||
)
|
||||
)
|
||||
finished_released = finished.filter(games__year_released=year).order_by(
|
||||
"games__playevents__ended"
|
||||
)
|
||||
purchased_finished = (
|
||||
without_refunded.filter(games__playevents__ended__year=year)
|
||||
.annotate(
|
||||
game_name=F("games__name"), date_finished=F("games__playevents__ended")
|
||||
)
|
||||
.order_by("games__playevents__ended")
|
||||
)
|
||||
backlog_decrease_count = (
|
||||
Purchase.objects.filter(date_purchased__year__lt=year)
|
||||
.filter(games__status=Game.Status.FINISHED)
|
||||
.filter(games__playevents__ended__year=year)
|
||||
.count()
|
||||
)
|
||||
|
||||
# ── Games / platforms by playtime (unified on duration_total) ────────────
|
||||
if is_alltime:
|
||||
games_with_playtime = (
|
||||
Game.objects.filter(sessions__in=sessions)
|
||||
.distinct()
|
||||
.annotate(total_playtime=Sum("sessions__duration_total"))
|
||||
.filter(total_playtime__gt=timedelta(0))
|
||||
)
|
||||
top_games = games_with_playtime.order_by("-total_playtime")[:10]
|
||||
else:
|
||||
games_with_playtime = (
|
||||
Game.objects.filter(sessions__timestamp_start__year=year)
|
||||
.annotate(total_playtime=Sum("sessions__duration_total"))
|
||||
.filter(total_playtime__gt=timedelta(0))
|
||||
)
|
||||
top_games = games_with_playtime.order_by("-total_playtime")
|
||||
|
||||
total_playtime_per_platform = (
|
||||
sessions.values("game__platform__name")
|
||||
.annotate(playtime=Sum(F("duration_total")))
|
||||
.annotate(platform_name=F("game__platform__name"))
|
||||
.values("platform_name", "playtime")
|
||||
.order_by("-playtime")
|
||||
)
|
||||
|
||||
played_purchases = Purchase.objects.filter(games__sessions__in=sessions).distinct()
|
||||
total_year_games = (
|
||||
played_purchases.count()
|
||||
if is_alltime
|
||||
else played_purchases.filter(games__year_released=year).count()
|
||||
)
|
||||
|
||||
year_label = "Alltime" if is_alltime else year
|
||||
data: StatsData = {
|
||||
"year": year_label,
|
||||
"title": f"{year_label} Stats",
|
||||
"total_hours": format_duration(sessions.total_duration_unformatted(), "%2.0H"),
|
||||
"total_sessions": sessions.count(),
|
||||
"unique_days": unique_days,
|
||||
"unique_days_percent": unique_days_percent,
|
||||
"total_year_games": total_year_games,
|
||||
"this_year_finished_this_year_count": finished_released.count(),
|
||||
"top_10_games_by_playtime": top_games,
|
||||
"total_playtime_per_platform": total_playtime_per_platform,
|
||||
"total_spent": total_spent,
|
||||
"total_spent_currency": currency,
|
||||
"spent_per_game": int(safe_division(total_spent, without_refunded_count)),
|
||||
"all_purchased_this_year_count": all_purchased_count,
|
||||
"all_purchased_refunded_this_year": refunded,
|
||||
"all_purchased_refunded_this_year_count": refunded_count,
|
||||
"refunded_percent": int(
|
||||
safe_division(refunded_count, all_purchased_count) * 100
|
||||
),
|
||||
"dropped_count": dropped_count,
|
||||
"dropped_percentage": int(
|
||||
safe_division(dropped_count, all_purchased_count) * 100
|
||||
),
|
||||
"purchased_unfinished_count": unfinished_count,
|
||||
"unfinished_purchases_percent": int(
|
||||
safe_division(unfinished_count, without_refunded_count) * 100
|
||||
),
|
||||
"backlog_decrease_count": backlog_decrease_count,
|
||||
"longest_session_time": (
|
||||
format_duration(longest_session.duration, "%2.0Hh %2.0mm")
|
||||
if longest_session
|
||||
else 0
|
||||
),
|
||||
"longest_session_game": longest_session.game if longest_session else None,
|
||||
"highest_session_count": (
|
||||
highest_session_count_game.session_count
|
||||
if highest_session_count_game
|
||||
else 0
|
||||
),
|
||||
"highest_session_count_game": highest_session_count_game,
|
||||
"highest_session_average": (
|
||||
format_duration(
|
||||
highest_session_average_game.session_average, "%2.0Hh %2.0mm"
|
||||
)
|
||||
if highest_session_average_game
|
||||
else 0
|
||||
),
|
||||
"highest_session_average_game": highest_session_average_game,
|
||||
"first_play_game": first_play_game,
|
||||
"first_play_date": first_play_date,
|
||||
"last_play_game": last_play_game,
|
||||
"last_play_date": last_play_date,
|
||||
"stats_dropdown_year_range": available_stats_year_range(),
|
||||
}
|
||||
|
||||
if not is_alltime:
|
||||
data["total_games"] = games_in_scope.count()
|
||||
data["month_playtimes"] = (
|
||||
sessions.annotate(month=TruncMonth("timestamp_start"))
|
||||
.values("month")
|
||||
.annotate(playtime=Sum("duration_total"))
|
||||
.order_by("month")
|
||||
)
|
||||
data["all_finished_this_year"] = finished.prefetch_related("games").order_by(
|
||||
"games__playevents__ended"
|
||||
)
|
||||
data["all_finished_this_year_count"] = finished.count()
|
||||
data["this_year_finished_this_year"] = finished_released.prefetch_related(
|
||||
"games"
|
||||
).order_by("games__playevents__ended")
|
||||
data["purchased_this_year_finished_this_year"] = (
|
||||
purchased_finished.prefetch_related("games").order_by(
|
||||
"games__playevents__ended"
|
||||
)
|
||||
)
|
||||
data["purchased_unfinished"] = unfinished
|
||||
data["all_purchased_this_year"] = purchases.order_by("date_purchased")
|
||||
|
||||
return data
|
||||
@@ -7,12 +7,13 @@ 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
|
||||
@@ -75,22 +76,21 @@ def _delete_statuschange_content(statuschange, request: HttpRequest) -> SafeText
|
||||
inner = Div(
|
||||
[],
|
||||
[
|
||||
Component(
|
||||
tag_name="p",
|
||||
P(
|
||||
children=["Are you sure you want to delete this status change?"],
|
||||
),
|
||||
Button(
|
||||
StyledButton(
|
||||
[("class", "w-full")], "Delete", color="red", type="submit", size="lg"
|
||||
),
|
||||
A(
|
||||
[("class", "")],
|
||||
Button([("class", "w-full")], "Cancel", color="gray"),
|
||||
StyledButton([("class", "w-full")], "Cancel", color="gray"),
|
||||
href=reverse("games:view_game", args=[statuschange.game.id]),
|
||||
),
|
||||
],
|
||||
)
|
||||
form = Component(
|
||||
tag_name="form",
|
||||
form = Element(
|
||||
"form",
|
||||
attributes=[("method", "post"), ("class", "dark:text-white")],
|
||||
children=[CsrfInput(request), inner],
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user