Add-time picker: every approved competition and every edition of an approved series is selectable - #207
Merged
Merged
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 filteredapproved_at IS NOT NULL AND series_id IS NULL. Editions of a recurring series are created withseries_idset andapproved_at = NULLby 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 embeddingIsCompetitionPubliclyVisible::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 ofDESC 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+CompetitionChoicesreplace 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, logosloading="lazy", akeywordsfield so typing the series name/shortcut matches its editions, andcontains()backing the new server-side validation (forms.competition_not_selectable, generic — never echoes names).competition_pickerStimulus controller on the widget wrapper: unlimitedmaxOptions(ux-autocomplete forces 50 for<input>-based pickers), optgroup header renderer, blur-on-select. The old blur hook lived intime-form-autocomplete, which is only attached when no puzzle is preselected — it never ran on the edit form.CompetitionNotFound → nullfallback (only reachable by a render/submit race; saving the time beats losing the upload) but log a warning.PuzzleSolvingTimeFormType+FormData,CompetitionAutocompleteController(unfilteredall()),AddPuzzle(SolvingTime)::fromFormData(),GetCompetitionEvents::all/allLive/allPast/allUpcoming.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), newEditTimeControllerTest(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 intoapp.js.Refs #204
🤖 Generated with Claude Code
https://claude.ai/code/session_01QUegPi2i9SjwkcrNWyENnL