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/