Commit Graph

828 Commits

Author SHA1 Message Date
lukas b3fa7fac96 Remove inline-handler → window.* contract (issue #28)
Finish the behavioural refactor from #28: no first-party JS lives on the
global object solely to be reachable from a server-rendered inline on*
attribute, and no inline Alpine blobs remain in the filter bar / year picker.

- Filter-bar collapse: drop the inline onclick for a delegated click listener
  on the persistent <filter-bar> custom element (data-filter-bar-toggle). The
  inner #filter-bar body is htmx-swapped while connectedCallback does not re-run,
  so delegation on the host preserves the swap-survival the inline handler had.
- YearPicker: convert the Alpine x-data/x-on/x-ref/_pickerInstance f-string into
  a <year-picker> custom element with typed props (YearPickerProps). Behavior
  moves to ts/elements/year-picker.ts; ts/year_picker.ts and _YEAR_PICKER_MEDIA
  are removed. The builder lives in primitives.py (next to YearPicker) to avoid a
  circular import; registration stays in custom_elements.py for codegen.
- Add bindPopupDismiss (ts/utils.ts): shared Escape + outside-click dismiss with
  a cleanup return and an extraInside hook for popups mounted on document.body.
  Adopted by date-range-picker.ts (1:1) and year-picker.ts (Datepicker popup is
  body-mounted, passed as an extra inside root).

Follow-up #49 tracks unifying popup/dismiss/positioning across the remaining
dropdown/search-select/Flowbite cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:54:00 +02:00
lukas 82416e149d Convert onSwap widgets to custom elements (issue #18)
Replaces the four onSwap-based widgets with TypeScript custom elements
following the pattern from PR #16. Each widget gets a class extending
HTMLElement with connectedCallback/disconnectedCallback, typed props via
register_element + gen_element_types codegen, and lives in ts/elements/.

- range-slider: RangeSliderElement; Python uses _RangeSlider builder
- date-range-picker: DateRangePickerElement; Python uses _DateRangePicker builder
- search-select: SearchSelectElement; Python uses _SearchSelect builder;
  data-* attrs become plain attrs (data-name -> name, data-search-url -> search-url, etc.)
- filter-bar: FilterBarElement; props carry preset URLs; onclick/onsubmit
  attrs replaced with data-filter-bar-* sentinel attrs; all window.* globals removed

Deletes ts/range_slider.ts, ts/search_select.ts, ts/date_range_picker.ts,
ts/filter_bar.ts. Updates all tests and e2e pages to use the new element
selectors and script paths (dist/elements/<tag>.js).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 14:22:59 +02:00
lukas 4652f1ff55 Unify input font size and padding (text-sm, px-3 py-2.5)
Inputs rendered at different sizes: the SearchSelect was 54px tall with a 16px
font (the wrapper's p-2 and the inner box's forms-plugin padding stacked, and
the wrapper had no text-sm), and the string-criterion filter input had no
text-sm (16px) with p-2. Native inputs are 42px / 14px / px-3 py-2.5.

Align both to the native size:
- SearchSelect wrapper supplies px-3 py-2.5 + text-sm and the inner search box
  zeroes its own padding (p-0), so the field is one 42px row like an input.
- String filter input gets text-sm + px-3 py-2.5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 08:22:40 +02:00
lukas 84925d4406 Fix string filter input focus "blink"; match other inputs
The string-criterion filter input (e.g. Playevent Note) had transition-all, so
on focus its border-color animated from the near-white default to the focus
blue — a visible white "blink". It also lacked an explicit border width and
focus:border-brand/ring-brand, so its focus came from the forms-plugin default.

Drop transition-all (the other inputs snap to focus), add border +
focus:border-brand focus:ring-brand, and use rounded-base — so it focuses
instantly to the same brand border as every other input.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 08:15:49 +02:00
lukas 9c25f02ddb Give SearchSelect the same border + focus as native inputs
The SearchSelect wrapper had no border and no focus styling, so it looked unlike
a native input (which has border-default-medium → border-brand on focus). Add
border-default-medium + focus-within:border-brand/ring to the shared container
class — focus-within because the focusable element is the inner search box. This
also makes filter-bar comboboxes consistent with the other filter inputs, which
already have borders.

e2e asserts the wrapper border matches a native input's at rest and turns brand
on focus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 08:03:08 +02:00
lukas 29ba3e66e9 Unify disabled appearance across all form controls
Disabled controls looked inconsistent: the SearchSelect faded (opacity-50)
while native inputs used a solid strong surface. Standardize on the faded look
(opacity-50) the user preferred, via shared constants so every form element
matches:

- DISABLED_CONTROL_CLASS (disabled:opacity-50 disabled:cursor-not-allowed) on
  the control — native inputs/select/textarea via PrimitiveWidgetsMixin, plus
  the Checkbox component (previously had no disabled style).
- DISABLED_WITHIN_CLASS (has-[:disabled]: wrapper variant) for composite
  controls like SearchSelect whose disabled state lives on an inner element.

e2e asserts a disabled SearchSelect and the Name input fade identically
(opacity 0.5) and return to 1 when enabled. CLAUDE.md documents the shared
disabled constants.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 07:52:44 +02:00
lukas 02798f8858 Own all form styling in components; remove form CSS from input.css
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>
2026-06-20 07:31:53 +02:00
lukas b13cc3c324 Keep not-allowed cursor on disabled SearchSelect input
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>
2026-06-19 18:42:52 +02:00
lukas b49b5f1cc3 Make disabled SearchSelect read as one element
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>
2026-06-19 18:35:28 +02:00
lukas 885e92b775 Honor UntilChanged in sync; let SearchSelect own its disabled look
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>
2026-06-19 18:24:13 +02:00
lukas 5bb8b92c05 Fix add-form JS: name→sort-name sync and related-game disable
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>
2026-06-19 18:11:49 +02:00
lukas 2e2dcdc3e7 Merge pull request #27 from KucharczykL/claude/typescript-utils-issue-17
Conver remaining JavaScript to TypeScript/components
2026-06-19 17:48:45 +02:00
lukas 893cb22bf8 Convert htmx-redirect-toast.js to TS; remove dead legacy utils.js (issue #17)
- 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>
2026-06-19 17:45:09 +02:00
lukas e72d44b9be Convert year_picker to TypeScript; drop inline f-string script (issue #17)
- 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>
2026-06-19 17:35:28 +02:00
lukas 1decf588c1 Convert toast.js to TypeScript (issue #17)
- 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>
2026-06-19 13:58:43 +02:00
lukas 19e9fd1419 Convert date_range_picker.js to TypeScript (issue #17)
- 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>
2026-06-19 13:52:48 +02:00
lukas c809341064 Convert range_slider.js to TypeScript (issue #17)
- 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>
2026-06-19 13:48:36 +02:00
lukas cd17053860 Convert filter_bar.js to TypeScript (issue #17)
- 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>
2026-06-19 13:38:20 +02:00
lukas 541fb550ab Convert search_select.js to TypeScript (issue #17)
- 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>
2026-06-19 13:34:06 +02:00
lukas daae9b8944 Convert add_purchase.js to TypeScript; drop dead add_edition.js (issue #17)
- 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>
2026-06-19 13:27:05 +02:00
lukas b03d9241fc Fix staging slug trailing dash after cut
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>
2026-06-19 13:11:14 +02:00
lukas 4f535be270 Convert add_game.js to TypeScript (issue #17)
- Add ts/utils.ts: fully-typed port of utils.js (onSwap, toISOUTCString,
  syncSelectInputUntilChanged, conditionalElementHandler, disableElementsWhen*,
  getValueFromProperty). Uses document.querySelector throughout; fixes
  removeEventListener bug (was passing function reference instead of named
  listener); uses boolean disabled property instead of string "disabled"/""
- Add ts/add_game.ts: TypeScript conversion of add_game.js, imports
  syncSelectInputUntilChanged from ts/utils.ts (resolves to dist/utils.js
  at runtime)
- Update add_game view to serve compiled dist/add_game.js

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 13:11:14 +02:00
lukas ce5e5fb729 Run gen_element_types before tsc --watch in make server/dev
Staging deployment / deploy (push) Successful in 10s
Staging deployment / comment (push) Has been skipped
Staging deployment / teardown (push) Has been skipped
Django CI/CD / build-and-push (push) Successful in 3m32s
Django CI/CD / test (push) Successful in 3m45s
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>
2026-06-19 12:50:39 +02:00
lukas 846151d373 Fix converted price rounding (0 decimal places, not 2)
Django CI/CD / test (push) Failing after 5m49s
Staging deployment / deploy (push) Successful in 1m10s
Staging deployment / comment (push) Has been skipped
Staging deployment / teardown (push) Has been skipped
Django CI/CD / build-and-push (push) Has been skipped
round(..., 0) matches the original floatformat(..., 0) intent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 12:44:27 +02:00
lukas 1fcef255a6 Fix convert_prices storing exchange rate as string
Django CI/CD / test (push) Successful in 4m4s
Staging deployment / deploy (push) Failing after 31s
Staging deployment / comment (push) Has been skipped
Staging deployment / teardown (push) Has been skipped
Django CI/CD / build-and-push (push) Has been skipped
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>
2026-06-19 12:43:46 +02:00
lukas 97fff21b28 Ignore more SQLite database files 2026-06-19 12:37:25 +02:00
lukas c6aa3d25cc Update uv.lock 2026-06-19 12:36:40 +02:00
lukas 733da3419b Model refundable orders as separate purchases; add split action
Django CI/CD / test (push) Successful in 3m41s
Staging deployment / deploy (push) Successful in 1m7s
Staging deployment / comment (push) Has been skipped
Staging deployment / teardown (push) Has been skipped
Django CI/CD / build-and-push (push) Failing after 12m17s
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>
2026-06-19 11:36:47 +02:00
lukas f693f8280f Fix pre-existing lint and format issues in domain.py and layout.py
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>
2026-06-19 11:36:47 +02:00
lukas dfccfbff51 Anchor DLC purchases to a base game instead of a parent purchase
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>
2026-06-19 11:36:47 +02:00
lukas 62f0c6c261 Add tests for multiple APP_URLS
Django CI/CD / test (push) Successful in 3m20s
Django CI/CD / build-and-push (push) Successful in 3m48s
2026-06-19 11:28:16 +02:00
lukas d0d6b3f999 Make APP_URLS accept list 2026-06-19 11:28:16 +02:00
lukas 6f58eb3fde Surface the staging URL reliably
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>
2026-06-19 11:28:16 +02:00
lukas d45ae357c4 Seed staging databases from a prod snapshot on first deploy
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>
2026-06-19 11:28:16 +02:00
lukas ae7fa5bae7 Add CSS-less dev mode
Django CI/CD / test (push) Successful in 5m54s
Django CI/CD / build-and-push (push) Successful in 3m27s
2026-06-19 11:26:01 +02:00
lukas be95c32e7b Change the default APP_URL in docker-compose.yml 2026-06-19 11:26:01 +02:00
lukas 32588226de Fix docker-compose not forwarding SECRET_KEY into container
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.
2026-06-19 11:26:01 +02:00
Claude 2ae01bfecf Add unified config system (issue #24)
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
2026-06-19 11:26:01 +02:00
Claude 6a3f66b1a9 Provision pnpm via Corepack in CI to honor packageManager pin
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
2026-06-14 16:03:11 +00:00
lukas 1b0cccacf8 Merge pull request #23 from KucharczykL/claude/optimistic-volta-dx6xhd
Django CI/CD / test (push) Successful in 4m35s
Django CI/CD / build-and-push (push) Successful in 2m39s
Harden staging and bring GitHub/Gitea CI to parity
2026-06-14 16:46:54 +02:00
lukas 2b450c6d47 Fix sample.yaml fixture for current schema
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>
2026-06-14 16:36:39 +02:00
lukas 9d02121c5b Grant pull-requests: write to staging deploy job
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>
2026-06-14 16:27:58 +02:00
lukas d2bf6efdb4 Fix Docker assets stage missing pnpm-workspace.yaml
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>
2026-06-14 16:25:44 +02:00
lukas 227b1f674d Fix scrub_staging test isolation: use TransactionTestCase
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>
2026-06-14 16:21:46 +02:00
Claude 017e3a61a8 Harden staging and bring GitHub/Gitea CI to parity
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
2026-06-14 13:15:19 +00:00
lukas 2c699eb976 Merge pull request #22 from KucharczykL/claude/pnpm-npm-v12-compat-9xyk8e
Provision pnpm via Corepack and pin the version
2026-06-14 14:52:19 +02:00
Claude f19d24ee98 Document Corepack-based pnpm provisioning in CLAUDE.md 2026-06-14 12:49:28 +00:00
Claude 263299ca52 Bootstrap pnpm via Corepack and pin packageManager version
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.
2026-06-14 12:48:30 +00:00
lukas 0b7ddc260f Merge pull request #21 from KucharczykL/claude/upgrade-tar-dependency-9fvn4d 2026-06-14 14:25:34 +02:00
Claude d9a8835696 Move tar override to pnpm-workspace.yaml
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
2026-06-14 12:22:22 +00:00