';
+
+ // Blur on select so the dropdown closes and the keyboard goes away on mobile
+ options.onChange = () => {
+ const tomSelect = event.target.tomselect;
+
+ if (tomSelect) {
+ tomSelect.blur();
+ }
+ };
+ }
+}
diff --git a/assets/controllers/time_form_autocomplete_controller.js b/assets/controllers/time_form_autocomplete_controller.js
index c34013bd1..416f6f0db 100644
--- a/assets/controllers/time_form_autocomplete_controller.js
+++ b/assets/controllers/time_form_autocomplete_controller.js
@@ -2,7 +2,7 @@ import { Controller } from '@hotwired/stimulus';
import * as bootstrap from 'bootstrap';
export default class extends Controller {
- static targets = ['brand', 'puzzle', 'competition', 'newPuzzle', 'scannerModal', 'scannerMessage', 'eanInput', 'hideOptions'];
+ static targets = ['brand', 'puzzle', 'newPuzzle', 'scannerModal', 'scannerMessage', 'eanInput', 'hideOptions'];
static values = {
eanSearchUrl: String,
@@ -21,7 +21,6 @@ export default class extends Controller {
_puzzleOptionsFetchPromise = null; // Track the current fetch promise for puzzle options
initialize() {
- this._onCompetitionConnect = this._onCompetitionConnect.bind(this);
this._onBrandConnect = this._onBrandConnect.bind(this);
this._onPuzzleConnect = this._onPuzzleConnect.bind(this);
this._handleBarcodeScanned = this._handleBarcodeScanned.bind(this);
@@ -37,11 +36,6 @@ export default class extends Controller {
this.brandTarget.addEventListener('autocomplete:pre-connect', this._onBrandConnect);
this.puzzleTarget.addEventListener('autocomplete:pre-connect', this._onPuzzleConnect);
- if (this.hasCompetitionTarget) {
- this.initialCompetitionValue = this.competitionTarget.value;
- this.competitionTarget.addEventListener('autocomplete:pre-connect', this._onCompetitionConnect);
- }
-
// Listen for barcode scanner events
document.addEventListener('barcode-scanner:scanned', this._handleBarcodeScanned);
@@ -55,10 +49,6 @@ export default class extends Controller {
this.brandTarget.removeEventListener('autocomplete:pre-connect', this._onBrandConnect);
this.puzzleTarget.removeEventListener('autocomplete:pre-connect', this._onPuzzleConnect);
- if (this.hasCompetitionTarget) {
- this.competitionTarget.removeEventListener('autocomplete:pre-connect', this._onCompetitionConnect);
- }
-
document.removeEventListener('barcode-scanner:scanned', this._handleBarcodeScanned);
document.removeEventListener('submit', this._onFormSubmit, true);
}
@@ -115,12 +105,6 @@ export default class extends Controller {
};
}
- _onCompetitionConnect(event) {
- event.detail.options.onChange = (value) => {
- this.onCompetitionValueChanged(value);
- };
- }
-
onBrandValueChanged(value) {
// Puzzle Tom Select may not be initialized yet (autocomplete is a lazy-loaded
// controller) - handleInitialValues() picks up the current brand value on init
@@ -186,10 +170,6 @@ export default class extends Controller {
}
}
- onCompetitionValueChanged(value) {
- this.competitionTarget.tomselect.blur();
- }
-
fetchPuzzleOptions(brandValue, openDropdown) {
const fetchUrl = this.brandTarget.getAttribute('data-fetch-url');
diff --git a/assets/styles/app.scss b/assets/styles/app.scss
index 88b9e6a2d..d4e5eec18 100755
--- a/assets/styles/app.scss
+++ b/assets/styles/app.scss
@@ -389,6 +389,28 @@ td.rank {
line-height: 110%;
}
+// "Competition / event" picker on the add-time / edit-time forms (CompetitionChoicesBuilder cards,
+// competition_picker_controller.js optgroup headers)
+.competition-option {
+ line-height: 110%;
+}
+
+.competition-option-logo {
+ max-width: 48px;
+ max-height: 48px;
+}
+
+.competition-optgroup-logo {
+ width: 24px;
+ height: 24px;
+ object-fit: contain;
+}
+
+[data-controller~="competition-picker"] .ts-dropdown-content {
+ // cards are ~48px tall; TomSelect's default 200px shows only 4 rows
+ max-height: 60vh;
+}
+
.navbar-tool .navbar-tool-label {
top: 1.6rem;
right: -0.1rem;
diff --git a/docs/features/competitions-management/README.md b/docs/features/competitions-management/README.md
index 5711ced26..7881ea86b 100644
--- a/docs/features/competitions-management/README.md
+++ b/docs/features/competitions-management/README.md
@@ -126,6 +126,16 @@ A solving time may be linked to a standalone competition **or to a series editio
**Public visibility of a competition row** (standalone or edition) is decided in one place, `IsCompetitionPubliclyVisible` (`check($competitionId)` + the reusable `SQL_CONDITION` fragment): a standalone competition is visible when approved and not rejected; an edition is visible iff its **series** is approved and not rejected — editions are never approved individually (their own `approved_at` stays `NULL`). The API competition detail uses this rule to decide what is readable.
+### Linking solving times to events
+
+The "Competition / event" picker on the add-time form (`PuzzleAddFormType`, routes `puzzle_add` + `finish_stopwatch`) and the edit-time form (`EditPuzzleSolvingTimeFormType`, route `edit_time`) is one TomSelect field whose options are baked server-side (no remote endpoint, no caching):
+
+- **Selectable set** = exactly `IsCompetitionPubliclyVisible::SQL_CONDITION`: every approved & not-rejected standalone competition regardless of its date (live, past, upcoming, undated) **plus every edition whose series is approved & not rejected** (the edition's own `approved_at` is ignored, its own `rejected_at` is respected). The series umbrella itself is never selectable — a time links to a concrete edition. Read model: `GetSelectableCompetitions::all(?$alwaysIncludeCompetitionId)` → `SelectableCompetition` DTOs.
+- **Include-current rule (edit form)**: `EditTimeController` passes the time's current `competition_id` (server-derived from the owner-checked row, never from the request) as the form option `current_competition_id`; the query adds that row unconditionally, so a link to a competition that is not (or no longer) publicly visible survives a re-save instead of rendering an empty control and silently detaching the time.
+- **Validation**: `CompetitionChoicesBuilder::build()` returns a `CompetitionChoices` value (`options`, `optgroups`, `contains(id)`); the form types' `POST_SUBMIT` rule rejects any non-null submitted id the picker did not offer with the generic `forms.competition_not_selectable` error (never echoes names). The handlers' `CompetitionNotFound → null` fallback stays only for the render→submit race and logs a warning.
+- **Ordering** (global, one SQL `ORDER BY`): live → undated standalone ("perpetual" online umbrellas, the most-used entries) → past (newest first) → upcoming (soonest first) → undated editions. Undated editions with rounds are dated by their first round (`MIN(competition_round.starts_at)`). Editions carry `optgroup` = series id and TomSelect renders a series' block where its best-ranked edition sits (`lockOptgroupOrder` off); standalone events are ungrouped.
+- **Rendering**: option cards are built in `CompetitionChoicesBuilder` (every organiser-authored string HTML-escaped, lazy-loaded 48px logo falling back to the series logo, series name on edition cards, "live" badge, `keywords` = series name/shortcut + name/shortcut + location as extra `searchField`). `assets/controllers/competition_picker_controller.js` patches the TomSelect config on `autocomplete:pre-connect` (`maxOptions: null`, optgroup header with series logo, blur on select) — ux-autocomplete forces `maxOptions: 50` and its own `render` for ``-based pickers, so these cannot come from PHP.
+
## Round Management
A competition has multiple **rounds**, each with:
@@ -294,3 +304,4 @@ All emails use the `transactional` mailer transport and follow the standard Inky
15. **Editions never auto-create rounds** — the edition form creates only the Competition, rounds are always managed separately via the round management UI
16. **Round category defaults to solo** — existing rounds get `solo` category via migration default
18. **Teams are scoped to rounds** — `CompetitionTeam` belongs to a `CompetitionRound`, participants are assigned to teams via `CompetitionParticipantRound.team_id`
+19. **A solving time can be linked to any publicly visible competition row** — the add/edit-time picker offers every approved & not-rejected standalone competition (any date) and every edition of an approved & not-rejected series (`IsCompetitionPubliclyVisible::SQL_CONDITION`), never the series umbrella itself; the edit form additionally keeps the currently linked competition selectable; the submitted id is validated against exactly that set
diff --git a/src/Controller/CompetitionAutocompleteController.php b/src/Controller/CompetitionAutocompleteController.php
deleted file mode 100644
index 2b879bd3c..000000000
--- a/src/Controller/CompetitionAutocompleteController.php
+++ /dev/null
@@ -1,84 +0,0 @@
-getCompetitionEvents->all() as $competition) {
- $img = '';
-
- if ($competition->logo !== null) {
- $img = <<