Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ Feature design documents and implementation plans are in `docs/features/`. Each
- **API & OAuth2**: `docs/features/api/` — Public REST API (V1), OAuth2 server, Swagger docs, internal APIs, deprecated V0
- **Stripe Payments**: `docs/features/stripe.md` — Stripe integration for premium membership
- **Opt-Out Features**: `docs/features/opt-out.md` — Streak and ranking opt-out for players
- **Competitions Management**: `docs/features/competitions-management/` — Community-driven event creation with admin approval, round management, puzzle assignment, table layout planning, and live stopwatch
- **Competitions Management**: `docs/features/competitions-management/` — Community-driven event creation with admin approval, round management, puzzle assignment, table layout planning, and live stopwatch. Linking solving times to events (the add/edit-time "Competition / event" picker: selectable set = `IsCompetitionPubliclyVisible::SQL_CONDITION` incl. series editions, include-current rule on the edit form, server-side validation, `GetSelectableCompetitions` + `CompetitionChoicesBuilder`) — see README §"Linking solving times to events"
- **Referral Program**: `docs/features/referral-program.md` — Members earn 10% of referred subscription revenue. No separate entity — `player.referralProgramJoinedAt` + `player.referralProgramSuspended`. Code = player code. Cookie-based + code-input attribution. Payouts per currency, manual admin payout marking
- **Activity Analytics**: `docs/features/activity-analytics.md` — daily player presence (`player_activity_day`, UTC days, 24-month prune) + immortal per-locale aggregates (`activity_daily_summary`); written by `PlayerActivitySubscriber` on kernel.terminate with Redis dedup; crons `myspeedpuzzling:snapshot-activity-summary` + `myspeedpuzzling:prune-player-activity` (daily)
- **Auth Hardening**: `docs/features/auth-hardening/README.md` — DB auth audit trail (`auth_audit_log` table, `RecordAuthAuditEvent` via `AuthAuditRecorder` — never breaks login), user-facing `/account/recent-activity` page, 24-month prune cron `myspeedpuzzling:prune-auth-audit-log`, GDPR deletion now removes `UserAccount`. Social login (PR 2): Google/Apple/Facebook via league provider libraries, `oauth_identity` table (D13), per-provider authenticators on `main`, cache-backed OAuth state (`social_login_state_cache` pool — Apple's form_post callback has no session), 5 settled linking rules in `SocialAccountResolver`, rule-4 interstitial `/register/social`, "Connected sign-in methods" on edit-profile, ≥1-sign-in-method invariant in `UnlinkOauthIdentityHandler`; flags `SOCIAL_LOGIN_{GOOGLE,FACEBOOK,APPLE}_ENABLED` (OFF) + `SOCIAL_LOGIN_ADMIN_ONLY` (ON) — see `docs/features/feature_flags.md`
Expand Down
49 changes: 49 additions & 0 deletions assets/controllers/competition_picker_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Controller } from '@hotwired/stimulus';

/**
* Tunes the TomSelect instance of the "Competition / event" picker (add-time + edit-time forms).
*
* Placed on the wrapper of the autocomplete input: the Symfony UX Autocomplete controller dispatches
* a bubbling `autocomplete:pre-connect` event with the TomSelect config right before instantiation,
* so the config can be patched here. These settings cannot come from PHP (`tom_select_options`)
* because ux-autocomplete merges its own `maxOptions`/`render` on top of them for `<input>`-based
* pickers.
*/
export default class extends Controller {
initialize() {
this._onPreConnect = this._onPreConnect.bind(this);
}

connect() {
this.element.addEventListener('autocomplete:pre-connect', this._onPreConnect);
}

disconnect() {
this.element.removeEventListener('autocomplete:pre-connect', this._onPreConnect);
}

_onPreConnect(event) {
const options = event.detail.options;

// ux-autocomplete forces 50 for <input>-based pickers; the whole set must be browsable
options.maxOptions = null;

options.render = options.render || {};
options.render.optgroup_header = (data, escape) =>
'<div class="optgroup-header d-flex align-items-center fw-semibold">'
+ (data.logo
? '<img alt="" class="rounded-1 me-2 competition-optgroup-logo" src="' + escape(data.logo) + '" loading="lazy" width="24" height="24">'
: '')
+ escape(data.label)
+ '</div>';

// 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();
}
};
}
}
22 changes: 1 addition & 21 deletions assets/controllers/time_form_autocomplete_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand All @@ -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);

Expand All @@ -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);
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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');

Expand Down
22 changes: 22 additions & 0 deletions assets/styles/app.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions docs/features/competitions-management/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<input>`-based pickers, so these cannot come from PHP.

## Round Management

A competition has multiple **rounds**, each with:
Expand Down Expand Up @@ -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
84 changes: 0 additions & 84 deletions src/Controller/CompetitionAutocompleteController.php

This file was deleted.

6 changes: 5 additions & 1 deletion src/Controller/EditTimeController.php
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,11 @@ public function __invoke(Request $request, #[CurrentUser] UserInterface $user, s
}
}

$editTimeForm = $this->createForm(EditPuzzleSolvingTimeFormType::class, $data);
$editTimeForm = $this->createForm(EditPuzzleSolvingTimeFormType::class, $data, [
// Server-derived from the owner-checked row, never from the request: the picker must
// keep offering the linked competition even when it is not publicly selectable
'current_competition_id' => $solvedPuzzle->competitionId,
]);
$editTimeForm->handleRequest($request);

if ($isGroupPuzzlersValid === true && $editTimeForm->isSubmitted() && $editTimeForm->isValid()) {
Expand Down
51 changes: 0 additions & 51 deletions src/FormData/PuzzleSolvingTimeFormData.php

This file was deleted.

Loading