diff --git a/CLAUDE.md b/CLAUDE.md
index 41c96ce..169a022 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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,149 @@ 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
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 `
`, navbar, toast container, JS includes, and FOUC-prevention script. The navbar shows today's playtime and last-7-days playtime from the `model_counts` context processor.
-**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/`): Pure-Python HTML builders, split into four submodules re-exported via `common/components/__init__.py`:
+
+- **`core.py`** — `Component(tag_name, attributes, children)`: the fundamental builder. `_render_element()` is `@lru_cache`-memoized (4096 entries, always active). Attribute values are always HTML-escaped; children are escaped unless they are `SafeText`. `randomid()` generates stable hash-based IDs.
+- **`primitives.py`** — Generic HTML: `A()`, `Button()` (with color/size/icon params), `ButtonGroup()`, `Div()`, `Span()`, `Label()`, `Input()`, `Icon()`, `Popover()`, `PopoverTruncated()`, `SearchField()`, `H1()`, `Modal()`, `SimpleTable()`, `TableRow()`, `TableTd()`, `TableHeader()`, `paginated_table_content()`, `AddForm()`, `Pill()`, `CsrfInput()`, `ModuleScript()`
+- **`domain.py`** — Domain-specific: `GameLink()`, `GameStatus()` (colored dot + label), `GameStatusSelector()` (Alpine.js PATCH dropdown), `SessionDeviceSelector()` (Alpine.js PATCH dropdown), `LinkedPurchase()`, `NameWithIcon()`, `PriceConverted()`, `PurchasePrice()`
+- **`filters.py`** — Filter UI: `FilterBar()`, `SessionFilterBar()`, `PurchaseFilterBar()`, `SelectableFilter()` (clickable include/exclude chips)
+- **`search_select.py`** — `SearchSelect()` + `SearchSelectOption`: search-as-you-type dropdown with removable pill selection, wired by `games/static/js/search_select.js`
+
+**Filter system** (`games/filters.py` + `common/criteria.py`): Stash-inspired structured filtering.
+
+- `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/.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/.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** (CDN) — reactive dropdowns (`GameStatusSelector`, `SessionDeviceSelector`), toast store
+- **Flowbite** (CDN) — navbar collapse, dropdown toggles
+- **Tailwind CSS** — utility classes, compiled from `common/input.css` → `games/static/base.css`
+- **Custom JS** in `games/static/js/`:
+ - `toast.js` — Alpine.js toast store (listens for `show-toast` HTMX event)
+ - `selectable_filter.js` — SelectableFilter widget interaction
+ - `search_select.js` — SearchSelect widget (search-as-you-type, pills)
+ - `utils.js` — shared helpers (e.g., `fetchWithHtmxTriggers`)
### Deployment
-Docker-based: multi-stage Dockerfile (uv builder → 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 → slim runtime), Caddy as reverse proxy on port 8000, Gunicorn with UvicornWorker (ASGI), Supervisor to manage Caddy + Gunicorn + django-q2. `make dev-prod` mimics production locally. CI/CD via GitHub Actions (`.github/workflows/build-docker.yml`): builds Docker image; Drone CI (`.drone.yml`) also present for deployments via Portainer webhook.
### Database
-SQLite with WAL journal mode. Connection timeout 20s. The `DATA_DIR` 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` env var controls the database file location. Migrations live in `games/migrations/`. There are `GeneratedField`s on the models — these are computed by the database engine and cannot be written from application code.
### Configuration
- `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
+- `TIME_ZONE` defaults to `Europe/Prague` in debug, otherwise reads `TZ` env var (default `UTC`)
+- Django Admin, Debug Toolbar, and `django_extensions` are only available in `DEBUG` mode
- `CSRF_TRUSTED_ORIGINS` is parsed from a comma-separated env var
+- `DATA_DIR` env var sets the SQLite database location (defaults to `BASE_DIR`)
+- django-q2 cluster: 1 worker, 60s timeout, 120s retry, ORM broker
+
+### Testing
+
+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"`).
+
+## 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.
+- **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.
+- **Filter views** accept `?filter=` (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`.
+- **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/.html`. Add new ones there and reference them by slug in `Platform.icon`.
diff --git a/common/components/search_select.py b/common/components/search_select.py
index 4108b38..9d974dd 100644
--- a/common/components/search_select.py
+++ b/common/components/search_select.py
@@ -97,6 +97,7 @@ def SearchSelect(
placeholder: str = "Search…",
id: str = "",
sync_url: bool = False,
+ autofocus: bool = False,
) -> SafeText:
"""Render the search-select widget. See module docstring for the contract."""
selected = [_normalize_option(o) for o in (selected or [])]
@@ -139,17 +140,10 @@ def SearchSelect(
("autocomplete", "off"),
("class", _SEARCH_CLASS),
]
- if search_value:
- search_attrs.append(("value", search_value))
+ if autofocus:
+ search_attrs.append(("autofocus", ""))
search = Component(tag_name="input", attributes=search_attrs)
- # ── Field row: pills + search box combined into one visual field ──
- field = Component(
- tag_name="div",
- attributes=[("data-ss-field", ""), ("class", _FIELD_CLASS)],
- children=[pills, search],
- )
-
# ── Options panel (pre-rendered only when there is no search_url) ──
option_rows = [_option_row(o) for o in options] if not search_url else []
no_results = Component(
diff --git a/games/api.py b/games/api.py
index 8b205b6..edd6e10 100644
--- a/games/api.py
+++ b/games/api.py
@@ -7,11 +7,13 @@ 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
@@ -115,8 +117,26 @@ 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]]
+
+
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()
diff --git a/games/forms.py b/games/forms.py
index 15dfc0d..0101d09 100644
--- a/games/forms.py
+++ b/games/forms.py
@@ -46,6 +46,20 @@ def _game_options(values) -> list[SearchSelectOption]:
]
+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.
@@ -57,6 +71,7 @@ class SearchSelectWidget(forms.Widget):
self,
*,
search_url,
+ options_resolver,
multi_select=False,
items_visible=5,
items_scroll=10,
@@ -66,6 +81,7 @@ class SearchSelectWidget(forms.Widget):
):
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
@@ -81,7 +97,8 @@ class SearchSelectWidget(forms.Widget):
return [value] if value not in (None, "") else []
def render(self, name, value, attrs=None, renderer=None):
- selected = searchselect_selected(self._values(value), _game_options)
+ selected = searchselect_selected(self._values(value), self.options_resolver)
+ autofocus = bool((attrs or {}).get("autofocus"))
return SearchSelect(
name=name,
selected=selected,
@@ -93,6 +110,7 @@ class SearchSelectWidget(forms.Widget):
always_visible=self.always_visible,
placeholder=self.placeholder,
id=(attrs or {}).get("id", ""),
+ autofocus=autofocus,
)
def value_from_datadict(self, data, files, name):
@@ -109,7 +127,9 @@ class SearchSelectMultiple(SearchSelectWidget):
class SessionForm(forms.ModelForm):
game = SingleGameChoiceField(
queryset=Game.objects.order_by("sort_name"),
- widget=SearchSelectWidget(search_url="/api/games/search"),
+ widget=SearchSelectWidget(
+ search_url="/api/games/search", options_resolver=_game_options
+ ),
)
duration_manual = forms.DurationField(
@@ -120,7 +140,11 @@ class SessionForm(forms.ModelForm):
label="Manual duration",
)
device = forms.ModelChoiceField(
- queryset=Device.objects.order_by("name"), required=False
+ queryset=Device.objects.order_by("name"),
+ required=False,
+ widget=SearchSelectWidget(
+ search_url="/api/devices/search", options_resolver=_device_options
+ ),
)
mark_as_played = forms.BooleanField(
@@ -191,9 +215,18 @@ class PurchaseForm(forms.ModelForm):
games = MultipleGameChoiceField(
queryset=Game.objects.order_by("sort_name"),
- widget=SearchSelectMultiple(search_url="/api/games/search", multi_select=True),
+ widget=SearchSelectMultiple(
+ search_url="/api/games/search",
+ options_resolver=_game_options,
+ multi_select=True,
+ ),
+ )
+ platform = forms.ModelChoiceField(
+ queryset=Platform.objects.order_by("name"),
+ widget=SearchSelectWidget(
+ search_url="/api/platforms/search", options_resolver=_platform_options
+ ),
)
- platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
related_purchase = RelatedPurchaseChoiceField(
queryset=related_purchase_queryset(),
required=False,
@@ -270,7 +303,11 @@ class GameModelChoiceField(forms.ModelChoiceField):
class GameForm(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:
@@ -307,9 +344,13 @@ class DeviceForm(forms.ModelForm):
class PlayEventForm(forms.ModelForm):
- game = GameModelChoiceField(
+ 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(
diff --git a/games/views/game.py b/games/views/game.py
index fde70ee..06bdada 100644
--- a/games/views/game.py
+++ b/games/views/game.py
@@ -180,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"),
)
@@ -332,7 +332,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 -------------------------------------------
diff --git a/games/views/playevent.py b/games/views/playevent.py
index 5cda6fc..d522bc8 100644
--- a/games/views/playevent.py
+++ b/games/views/playevent.py
@@ -15,6 +15,7 @@ from common.components import (
Button,
ButtonGroup,
Icon,
+ ModuleScript,
paginated_table_content,
)
from common.layout import render_page
@@ -193,7 +194,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 +210,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: