Form controls were styled "at a distance": Django renders bare
<input>/<select>/<textarea>/<label>, so input.css reached in with ID-scoped
#add-form descendant rules plus a global form *:disabled rule and .errorlist.
The #add-form ID specificity forced state rules to climb, needed
:not([data-search-select-search]) carve-outs, and broke on markup changes — it
surfaced as the add_purchase Name/related_game fields not reading as disabled.
Components now own all form styling via utilities on the elements themselves:
- PrimitiveWidgetsMixin stamps INPUT/SELECT/TEXTAREA_CLASS (incl. disabled:
variants) onto native widgets by type, skipping SearchSelect (self-styled)
and checkboxes.
- New FormFields(form, *, extras=...) renders label + control + errors + row
layout with their own classes (replaces form.as_div()); the <form> owns its
flex layout. extras appends a node into a named field's row (session
timestamp buttons).
- AddForm/purchase/session render via FormFields; login too — a new
LoginForm(PrimitiveWidgetsMixin, AuthenticationForm) styles its inputs and
auth.py renders it via FormFields + a StyledButton (was as_table).
- input.css loses the entire #add-form block, the global :disabled rule, and
.errorlist. State (disabled:) now lives on the element — no specificity wars,
no carve-outs, robust to markup edits.
Tests: error rendering uses the component class (not .errorlist); add-form
labels/inputs carry their own classes; e2e login fixtures click the Login
button by text (submit is now a <button>); Name disabled cursor asserted.
CLAUDE.md documents the no-styling-at-a-distance + FormFields conventions.
513 passed; lint/format/ts-check clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Excluding the inner search box from the global disabled rule also dropped its
cursor: not-allowed, so the pointer flickered between not-allowed (wrapper) and
the text I-beam (input) when moving across the disabled widget. Add
disabled:cursor-not-allowed to the search input so the cursor stays consistent.
e2e: assert the disabled inner input computes cursor: not-allowed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The disabled widget showed two clashing surfaces in dark mode: the wrapper
(faded via has-[:disabled]) plus the inner search input, which picked up the
global disabled-input fill from common/input.css
(`form input:disabled { background: neutral-secondary-strong }`). That rule is
unlayered, so it beat any utility override on the input.
Exclude the SearchSelect's inner search box from that global rule
(`:not([data-search-select-search])`) so it stays transparent — the wrapper is
then the single faded surface. Standalone inputs (e.g. the Name field) keep
their distinct disabled surface, unchanged.
e2e: assert the disabled inner input computes transparent background (one
element), alongside the existing wrapper-opacity check.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-ups on the add-form fixes:
- syncSelectInputUntilChanged now actually stops mirroring once the user edits
the target (the "UntilChanged" contract). The old focus-based stop was a
no-op (wrong removeEventListener reference), so live sync kept clobbering a
manually-edited Sort name. Track dirty targets in a Set keyed by syncData
index; programmatic writes don't fire "input", so only real user edits mark a
target dirty. Drops the dead focus listener.
- SearchSelect now greys itself when disabled, via has-[:disabled]: utilities on
its container class — the visible "box" is the wrapper <div>, so disabling the
transparent inner input alone left it looking active. The component owns its
disabled appearance; callers only toggle the inner control's `disabled`.
- Document the composite-widget disabling approach in CLAUDE.md and the
SearchSelect docstring.
Extends the e2e tests: sync drops after a manual Sort name edit; disabled
related-game wrapper computes opacity 0.5 (and 1 when re-enabled).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two bugs in the add forms, both root-caused via the e2e harness:
1. add_game Name → Sort name never synced. syncSelectInputUntilChanged was
scoped to "form", but the first <form> on every page is the navbar logout
form — the add-form fields live in a later form, so the delegated listener
never heard their events. Scope to "#add-form" (the add-form wrapper). Also
switch the sync from the "change" event to "input" so Sort name mirrors Name
live as you type, not only on blur.
2. add_purchase Related game not disabled when Type == Game.
disableElementsWhenTrue set `.disabled` on #id_related_game, which is the
SearchSelect wrapper <div> (a <div> ignores `disabled`). Target the inner
[data-search-select-search] input instead, so the widget is actually disabled.
Adds two e2e regression tests (live sync; type-game disables the related-game
search input and re-enables it for other types).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Add ts/htmx-redirect-toast.ts: typed port of the hx-redirect-toast htmx
extension. Stays a classic (non-module) script — only touches the global
htmx and registers an extension; layout.py now serves dist/htmx-redirect-toast.js
- Delete games/static/js/utils.js: the legacy hand-written copy is dead — every
compiled module imports dist/utils.js (from ts/utils.ts); nothing references
the old path
With this, the only first-party JS served is compiled from ts/; the sole
remaining hand-written .js in static is the vendored datepicker.umd.js bundle.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add ts/year_picker.ts: typed onSwap port of the year-picker glue. Datepicker
declared as an ambient global (vendored UMD); PickerElement types the
_pickerInstance prop the Alpine toggle button reaches
- Remove the duplicate inline <script> from the YearPicker component (was a JS
blob in a Python f-string — the CLAUDE.md anti-pattern) and the orphaned
games/static/js/year_picker.js that nothing loaded; the component now declares
dist/year_picker.js as media alongside the datepicker UMD bundle
- Module defer semantics keep the classic UMD bundle running before the
deferred year_picker module, so Datepicker is defined in time
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add ts/toast.ts: typed port of the Alpine toast store + window.toast +
window.fetchWithHtmxTriggers. Toast / ToastStore / ToastMessage interfaces
type the store and the show-toast CustomEvent detail; Alpine declared as a
type-only ambient global
- Declare window.toast in ts/globals.d.ts
- Stays a classic (non-module) script — no import/export — so it keeps defining
globals; layout.py now serves dist/toast.js
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add ts/date_range_picker.ts: typed port. CalendarState interface (with the
dynamically-assigned refreshFromField) and an Anchor union replace the loose
state object; date helpers and DOM queries fully typed; var → const/let
- Replace the DOMContentLoaded + per-element guard-flag + window global with
onSwap("[data-date-range-picker]", ...), the documented init pattern — so the
picker now also initializes inside htmx-swapped fragments. Drops the dead
window.initDateRangePickers export
- Point the DateRangePicker component Media at dist/date_range_picker.js and load
it as an ES module in the e2e page (was a deferred classic script)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add ts/range_slider.ts: typed port of the custom range-slider widget. Number
inputs typed as HTMLInputElement; setTargetValue coerces via String(); mouse
handlers typed MouseEvent; var → const/let
- Point the RangeSlider component Media and every e2e/test reference at the
compiled dist/range_slider.js
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add ts/filter_bar.ts: typed port of the filter bar. Criterion / PillEntry /
RangeField / DeselectableRadio interfaces replace the loose objects and the
radio.wasChecked custom property; var → const/let throughout
- Window entry points (applyFilterBar/clearFilterBar/toggleStringFilterInput/
showPresetNameInput/savePreset) declared in ts/globals.d.ts; readSearchSelect
now called as window.readSearchSelect
- Drop the dead selectValue helper; factor the repeated path→mode mapping into
presetMode()
- Point the FilterBar component Media and every e2e/test reference at the
compiled dist/filter_bar.js
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add ts/search_select.ts: typed port of the SearchSelect/FilterSelect widget.
Exports SearchSelectOption / SearchSelectChangeDetail as the single source of
truth for the "search-select:change" event contract
- add_purchase.ts now imports those types via `import type` (no runtime
coupling), instead of redefining them locally
- Declare window.readSearchSelect in ts/globals.d.ts
- Point the SearchSelect component Media and every view/e2e/test reference at
the compiled dist/search_select.js
- Update doc comments in common/components/search_select.py to name the TS source
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add ts/add_purchase.ts: typed port of add_purchase.js. Replaces getEl with
document.querySelector; types the search-select:change CustomEvent detail
(SearchSelectChangeDetail / SearchSelectOption)
- Point add_purchase / edit_purchase views at compiled dist/add_purchase.js
- Delete add_edition.js: no Edition model/view/url/template references it
(feature was removed; the script was dead)
- Delete the now-superseded add_game.js / add_purchase.js source files
- Tighten test_rendered_pages assertions to the dist/ script paths
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Branch names that hit the 30-char cut boundary can end with a dash,
which Fly.io rejects. Strip trailing dashes after cut in both deploy
and teardown jobs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
make server and make dev were starting tsc --watch cold, so new element
registrations never landed in ts/generated/props.ts until make ts was run
manually. Adding gen-element-types as a dependency ensures props.ts is
always fresh before the watcher starts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
floatformat() returns a str; saving that to ExchangeRate.rate (FloatField)
via create() leaves the Python instance attribute as a str. Reading it back
on the same instance (rate = exchange_rate.rate) then caused
`purchase.price * rate` to fail with "can't multiply sequence by non-int
of type 'float'".
Fix: pass the raw float from the API directly to ExchangeRate.objects.create.
Also replace floatformat(..., 0) on the converted price with round(..., 2)
to keep a numeric type throughout.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A multi-game Purchase is now treated as an *unsplittable* bundle (one
price, whole-purchase refund). Independently-refundable multi-item orders
(e.g. a Steam cart) are instead recorded as N separate single-game
purchases, so per-game pricing and per-game refunds work with the
existing single-purchase machinery — no through-model needed.
Add-purchase form (single form, single endpoint):
- 1 game: unchanged.
- 2+ games: a "Separate price per game" toggle appears (default off =
one bundle price). On, the bundle Price hides and one price input per
game appears; the view creates one single-game Purchase each from
price_for_game_<id>. `price` is now optional so combined mode still
validates.
Split action:
- A Split button on multi-game purchase rows opens a confirmation modal
that replaces the bundle with one single-game purchase per game (price
split evenly, needs_price_update set), then HX-Redirects to the list.
New general-purpose `selection-fields` custom element renders one synced
form field per selected item of a source SearchSelect (consuming the
existing search-select:change contract); it knows nothing about prices,
so it is reusable. Behavior in ts/elements/selection-fields.ts.
Adds the bundle-vs-separate-purchases convention to CLAUDE.md, a split
icon, and unit + Playwright e2e coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remove two unused `*Props` imports flagged by ruff (F401) and apply
`ruff format` line-wrapping. Pure cleanup, no behavior change — unblocks
`make check` independently of the purchase changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add-on purchases (DLC, Season Pass, Battle Pass) previously linked to a
parent *purchase* via the `related_purchase` self-FK. When the base game
was bought inside a multi-game purchase (e.g. a bundle), there was no
per-game purchase to point at — only the whole bundle.
Replace it with a `related_game` FK (Game -> Game): an add-on belongs to
a *game*, which is unambiguous regardless of how the base game was bought.
- models: drop `related_purchase`; add `related_game`
(SET_NULL, related_name="addon_purchases"); require it for non-GAME
types in `save()`.
- forms: replace the parent-purchase picker with a flat `related_game`
game search (reusing SearchSelectWidget/_game_options); drop the now
unused related_purchase_queryset/RelatedPurchaseChoiceField.
- views/urls: remove the obsolete related_purchase_by_game endpoint.
- add_purchase.js: drop the parent-dropdown refetch; keep platform
auto-fill; retarget the type toggle to #id_related_game.
- migration 0020: add -> backfill (related_game = parent's first game by
sort_name) -> remove related_purchase.
- tests: model validation unit tests + an e2e test for the flat picker.
related_game is deliberately game->game so it can later be synced from
IGDB's parent_game without schema changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Echo the staging URL into the deploy log (not just the step summary),
and comment it when a PR is opened for an already-deployed branch
instead of waiting for the next push.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When a branch's staging volume doesn't exist yet, take a WAL-safe
online snapshot of the prod SQLite database (sqlite3.backup() in a
throwaway container, prod is only read) into the new volume. Later
pushes keep the staging data; deleting the branch (or the volume)
causes a fresh seed next time.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SECRET_KEY, APP_URL, and DEBUG were hardcoded/missing in the compose
environment block, so passing SECRET_KEY from the host env had no effect
and the container always raised ImproperlyConfigured in production mode.
All three are now forwarded via ${VAR} substitution, consistent with
the other configurable values.
Introduce timetracker/config.py with a single config() helper that resolves
settings from a fixed priority chain: NAME__FILE (opt-in secret) -> env var
-> .env -> settings.ini -> in-code default. Supports type casting
(bool/list/int/Path), file-based secrets with .strip(), and required_in_prod
validation.
Migrate settings.py off the previous ad-hoc idioms:
- DEBUG via config() (PROD kept as deprecated alias)
- SECRET_KEY required in prod, supports SECRET_KEY__FILE
- APP_URL derives ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS (kept separate,
each independently overridable); ALLOWED_HOSTS is now configurable
- TZ and DATA_DIR via config()
Fix DATA_DIR inconsistency: entrypoint.sh now reads DATA_DIR (was hardcoded)
so the bash bootstrap and Django agree on the database directory.
Document the container/entrypoint-only flags (PUID/PGID/
CREATE_DEFAULT_SUPERUSER/STAGING/LOAD_SAMPLE_DATA) as bash concerns.
Update deployment configs to set APP_URL (and DEBUG), add docs/configuration.md,
settings.ini.example, regrouped .env.example, CLAUDE.md, and tests.
https://claude.ai/code/session_01FFn8BiGrQpEJarC8xGse8s
CI installed pnpm with 'npm install -g pnpm', which pulls the latest
release and ignores the pnpm@10.33.0 pin in package.json's
packageManager field. This let CI drift to a different pnpm major than
the Docker image and local dev, the exact drift Corepack prevents.
Switch both the GitHub and Gitea build workflows to 'corepack enable',
matching the Dockerfile assets stage and the documented model where
local, CI, and Docker all follow the packageManager field.
https://claude.ai/code/session_01VWXYQxUPWdhoV4otwr6Cyk
Three issues from when the fixture was created before schema evolved:
- Game and Platform lacked created_at (auto_now_add bypassed by loaddata)
- Purchase lacked created_at/updated_at
- Purchase used 'game' FK that no longer exists; field is now the M2M
'games', serialized as a list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The github-script PR comment step needs this permission; without it
the GITHUB_TOKEN gets Resource not accessible by integration.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The tar override lives in pnpm-workspace.yaml, which pnpm-lock.yaml
records. Copying only package.json + pnpm-lock.yaml left pnpm without
the overrides config, causing ERR_PNPM_LOCKFILE_CONFIG_MISMATCH on
frozen install.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TestCase wraps each test in a savepoint — when scrub_staging deletes
all django_session rows inside that savepoint, the rollback restores
any sessions committed by earlier tests (e.g. force_login in
test_paths_return_200). Those restored rows then leaked into the e2e
live-server tests, causing intermittent Session.MultipleObjectsReturned
errors.
TransactionTestCase flushes the DB before each test instead of using
savepoints, giving scrub_staging a clean slate and removing the leakage.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Address issue #20 and the CI divergence between Gitea and GitHub.
Issue #20 (staging seeded from a prod snapshot):
- Read SECRET_KEY from the environment with the insecure dev key as
fallback, so each deployment can have its own key.
- Add a `scrub_staging` management command that clears django_session and
the django-q schedule/queue/results, removing copied prod sessions and
the inherited convert_prices() schedule.
- Run the scrub from entrypoint.sh when STAGING=true, and wire STAGING plus
a per-branch SECRET_KEY into the Gitea staging deploy.
CI parity (both systems kept, independent):
- Add the Node/pnpm/TypeScript build steps to the Gitea build workflow to
match the GitHub test job.
- Add a GitHub staging workflow that deploys per-branch ephemeral instances
to Fly.io (*.fly.dev) with a fresh database seeded from sample fixtures
and its own SECRET_KEY, never production data. Tears the app down on
branch delete and comments the URL on the open PR via github-script.
- Add fly.staging.toml and a LOAD_SAMPLE_DATA entrypoint hook for the
fresh-database public staging.
https://claude.ai/code/session_01KYjUcNjLfZ8Hq1GAC8J4oZ
Replace the npm-based pnpm bootstrap in the Docker assets stage with
Corepack (ships with Node, no npm needed) and pin the pnpm version via
package.json's packageManager field for reproducible builds.
pnpm v11 (installed in CI via `npm install -g pnpm`) no longer reads the
`pnpm.overrides` field from package.json, which caused
ERR_PNPM_LOCKFILE_CONFIG_MISMATCH during the frozen install. Move the
override to pnpm-workspace.yaml, the new home for the setting, so CI's
pnpm reads it and matches the lockfile.
https://claude.ai/code/session_01NPQ9AiNNnapeoTQFAR1ShY
tar@6.2.1 was pulled in transitively via npm-check-updates' toolchain
(cacache, node-gyp, pacote). Add a pnpm override forcing tar >=7.5.11
to resolve the security advisory. Now resolves to tar@7.5.16.
https://claude.ai/code/session_01NPQ9AiNNnapeoTQFAR1ShY