Skip to content

Add-time picker: every approved competition and every edition of an approved series is selectable - #207

Merged
JanMikes merged 1 commit into
mainfrom
fix/event-picker-editions
Aug 19, 2026
Merged

Add-time picker: every approved competition and every edition of an approved series is selectable#207
JanMikes merged 1 commit into
mainfrom
fix/event-picker-editions

Conversation

@JanMikes

Copy link
Copy Markdown
Member

Part 3 of 4 for #204 (after #205, #206) — the bug fix itself.

The bug

The "Competition / event" picker on the add-time and edit-time forms built its options from GetCompetitionEvents::allLive() + allPast(), both filtered approved_at IS NOT NULL AND series_id IS NULL. Editions of a recurring series are created with series_id set and approved_at = NULL by design (the series approval governs visibility), so no edition of any series was ever selectable — 14 approved series / 72 editions / 0 linked times on production, hence the reports. Upcoming events were excluded too, and a competition converted to a series silently dropped out of the picker.

Three latent defects sat in the same code path and are fixed with it: the edit form prefilled an id that might not be among the options → TomSelect rendered empty and a re-save detached the time; nothing validated the submitted id server-side; the option HTML interpolated organiser-authored names/locations unescaped.

What

  • GetSelectableCompetitions — one query embedding IsCompetitionPubliclyVisible::SQL_CONDITION (approved & not-rejected standalone regardless of date + every edition of an approved, not-rejected series) plus, on the edit form, the currently linked id (so a link to a since-rejected/hidden event survives a re-save). Ordered live → undated standalone (the "perpetual" online umbrellas like Euro Jigsaw Jam — the most-used entries, previously at the top only by accident of DESC NULLS FIRST) → past desc → upcoming asc → undated editions; undated editions are dated by their first round so they don't float to the top.
  • CompetitionChoicesBuilder + CompetitionChoices replace three copy-pasted builders: TomSelect payload with an optgroup per series (header with series logo), edition cards carry the series name, every dynamic string escaped, logos loading="lazy", a keywords field so typing the series name/shortcut matches its editions, and contains() backing the new server-side validation (forms.competition_not_selectable, generic — never echoes names).
  • competition_picker Stimulus controller on the widget wrapper: unlimited maxOptions (ux-autocomplete forces 50 for <input>-based pickers), optgroup header renderer, blur-on-select. The old blur hook lived in time-form-autocomplete, which is only attached when no puzzle is preselected — it never ran on the edit form.
  • Handlers keep the CompetitionNotFound → null fallback (only reachable by a render/submit race; saving the time beats losing the upload) but log a warning.
  • Dead code removed: PuzzleSolvingTimeFormType + FormData, CompetitionAutocompleteController (unfiltered all()), AddPuzzle(SolvingTime)::fromFormData(), GetCompetitionEvents::all/allLive/allPast/allUpcoming.
  • Docs: README "Linking solving times to events" + Key Business Rule; CLAUDE.md pointer.

UX

Type "ou la la" / "canadian" → the series block with its editions, nearest date first; live events first in the list; the perpetual online umbrellas stay right under them. Hint under the field: "For recurring events, pick the specific edition (date) you took part in."

Perf / security

Options stay inline (≈135 → ≈210 cards ≈ +8 KB gzipped; no extra round-trip on the hot add-time page; no caching — one ~200-row query + string building is single-digit ms and a cache would need date keying). Any logged-in player may link any publicly visible competition (as before), never a pending/rejected one; the edit form's include-current id is server-derived from the owner-checked row.

Tests

+27 tests: GetSelectableCompetitionsTest (11 — inclusion/exclusion, rejected series/edition, full ordering chain, round-dated undated edition, include-current exactly once, invalid include id), CompetitionChoicesBuilderTest (7 — optgroups, escaping <b>x</b>, keywords, lazy logos, live badge), PuzzleAddControllerTest (+3 — options contain an edition and not an unapproved one; POST unapproved → 422 + error, nothing stored; POST edition → linked), new EditTimeControllerTest (6 — linked edition offered and survives re-save; linked unapproved competition kept on re-save; switching to unapproved → 422 with the link unchanged). Full gates green locally (cs, phpstan max, 2237 tests, schema:validate, prod warmup); a throwaway Encore build confirmed the new controller compiles into app.js.

Refs #204

🤖 Generated with Claude Code

https://claude.ai/code/session_01QUegPi2i9SjwkcrNWyENnL

…pproved series is selectable

The "Competition / event" picker built its options from
GetCompetitionEvents::allLive() + allPast(), both filtered
series_id IS NULL AND approved_at IS NOT NULL. Editions of a recurring
series are created with series_id set and approved_at NULL by design (the
series approval governs visibility), so no edition was ever selectable;
upcoming events were excluded too; a prefilled id missing from the
options rendered empty and re-save detached the time; nothing validated
the submitted id; option HTML interpolated organiser-authored names
unescaped.

- GetSelectableCompetitions: one query embedding
  IsCompetitionPubliclyVisible::SQL_CONDITION (+ the currently linked id
  on the edit form), ordered live → undated standalone (the perpetual
  online umbrellas) → past desc → upcoming asc → undated editions;
  undated editions are dated by their first round.
- CompetitionChoicesBuilder + CompetitionChoices: TomSelect payload with
  an optgroup per series, escaped cards, lazy logos, searchable keywords
  (series name/shortcut); contains() backs the new server-side validation
  (forms.competition_not_selectable). Replaces three copy-pasted builders.
- competition_picker Stimulus controller (maxOptions unlimited, optgroup
  header with logo, blur on select) — the old hook lived in
  time-form-autocomplete, which is not attached on the edit form.
- Handlers keep the CompetitionNotFound → null fallback but log a warning.
- Dead code removed: PuzzleSolvingTimeFormType/FormData,
  CompetitionAutocompleteController, AddPuzzle(SolvingTime)::fromFormData,
  GetCompetitionEvents::all/allLive/allPast/allUpcoming.

Refs #204

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QUegPi2i9SjwkcrNWyENnL
@JanMikes
JanMikes merged commit 010356f into main Aug 19, 2026
5 checks passed
@JanMikes
JanMikes deleted the fix/event-picker-editions branch August 19, 2026 18:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant