diff --git a/CLAUDE.md b/CLAUDE.md index 5c58a5f..b9a739c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,7 @@ docs/ — Additional documentation - **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_game` (the base Game the add-on belongs to; reverse accessor `game.addon_purchases`) +- **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_game` (the base Game the add-on belongs to; reverse accessor `game.addon_purchases`). **A multi-game Purchase is an *unsplittable* bundle** (one price, whole-purchase refund — e.g. a Humble Bundle). Independently-refundable multi-item orders (e.g. a Steam cart) are modeled as **separate single-game purchases**, not one bundle: the add-purchase form's "separate price per game" mode (≥2 games) creates them, and the row's **Split** action breaks an existing bundle into per-game purchases (price split evenly as a starting point). This is why per-game refund/price need no through-model — each refundable unit is its own 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` diff --git a/common/components/__init__.py b/common/components/__init__.py index a97c062..945a970 100644 --- a/common/components/__init__.py +++ b/common/components/__init__.py @@ -18,7 +18,11 @@ from common.components.core import ( randomid, render, ) -from common.components.custom_elements import SessionTimestampButtons, register_element +from common.components.custom_elements import ( + SelectionFields, + SessionTimestampButtons, + register_element, +) from common.components.date_range_picker import ( DateRangeCalendar, DateRangeField, @@ -94,6 +98,7 @@ __all__ = [ "truncate", "BaseComponent", "register_element", + "SelectionFields", "SessionTimestampButtons", "custom_element_builder", "Element", diff --git a/common/components/custom_elements.py b/common/components/custom_elements.py index 1ae485a..172777e 100644 --- a/common/components/custom_elements.py +++ b/common/components/custom_elements.py @@ -11,8 +11,14 @@ 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 +from common.components.core import Node +from common.components.primitives import ( + Div, + Input, + Label, + Template, + custom_element_builder, +) @dataclass(frozen=True) @@ -125,3 +131,51 @@ _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") + + +class SelectionFieldsProps(TypedDict): + source: str # data-name of the source SearchSelect to mirror + name_prefix: str # each rendered input is named f"{name_prefix}{item_id}" + field_type: str # input type, e.g. "number" + min_items: int # render nothing until at least this many items are selected + active: bool # when false, render nothing (but preserve typed values) + + +register_element("selection-fields", "SelectionFields", SelectionFieldsProps) + +_SelectionFields = custom_element_builder("selection-fields") + + +def SelectionFields( + *, + source: str, + name_prefix: str, + field_type: str = "text", + min_items: int = 1, + active: bool = False, + input_attributes: list[tuple[str, str]] | None = None, +) -> Node: + """Render one synced form field per selected item of a source SearchSelect. + + General-purpose: it mirrors the SearchSelect named ``source`` and emits an + input named ``f"{name_prefix}{item_id}"`` per selected item. Behavior lives + in ``ts/elements/selection-fields.ts``; this is just the server-rendered + light DOM (an empty rows container + a row ``