diff --git a/docs/features/competitions-management/README.md b/docs/features/competitions-management/README.md index 7881ea86..6da3488b 100644 --- a/docs/features/competitions-management/README.md +++ b/docs/features/competitions-management/README.md @@ -135,6 +135,8 @@ The "Competition / event" picker on the add-time form (`PuzzleAddFormType`, rout - **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. +- **Deep link** `puzzle_add?competition=` (`/en/puzzle-add?competition=…`, built with `path('puzzle_add', {competition: id})`): `PuzzleAddController` pre-selects the competition in the picker when the form opens in speed-puzzling mode and `IsCompetitionPubliclyVisible::check()` passes — the `_solving_time_form` template then renders the competition section expanded. Any other value (not a uuid, unknown, unapproved, edition of an unapproved series, `?mode=relax|collection`) is ignored silently: no flash, no error, the form just opens without a pre-selection. It only seeds the GET render; on POST `handleRequest()` overwrites the data, so a cleared field is never re-filled from the URL. +- **"Add my time from this event" CTA** (`events.add_my_time`) on the standalone event page (`EventDetailController` → `event_detail.html.twig`, next to the "I'm going" / "You are going" buttons) and the edition page (`EditionDetailController` → `edition_detail.html.twig`, in the registration/results link row) links to that deep link. Shown only when `can_add_time` = signed in **and** the competition row is publicly visible (`IsCompetitionPubliclyVisible::check()`) **and** the event has started — `CompetitionEvent::startsAfter(now)` is false, i.e. `COALESCE(date_from, date_to)` is not a later calendar day than today (`ClockInterface`; an undated event is perpetual and always qualifies). No per-edition CTA on the series page or in the editions table — a time links to a concrete edition, so the CTA lives on the edition page. ## Round Management diff --git a/src/Controller/EditionDetailController.php b/src/Controller/EditionDetailController.php index a4ed5c00..79e5da68 100644 --- a/src/Controller/EditionDetailController.php +++ b/src/Controller/EditionDetailController.php @@ -4,11 +4,13 @@ namespace SpeedPuzzling\Web\Controller; +use Psr\Clock\ClockInterface; use SpeedPuzzling\Web\Query\GetCompetitionEvents; use SpeedPuzzling\Web\Query\GetCompetitionSeries; use SpeedPuzzling\Web\Query\GetEditionRounds; use SpeedPuzzling\Web\Query\GetPuzzleOverview; use SpeedPuzzling\Web\Query\GetUserPuzzleStatuses; +use SpeedPuzzling\Web\Query\IsCompetitionPubliclyVisible; use SpeedPuzzling\Web\Repository\CompetitionRepository; use SpeedPuzzling\Web\Services\RetrieveLoggedUserProfile; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -27,6 +29,8 @@ public function __construct( readonly private GetPuzzleOverview $getPuzzleOverview, readonly private GetUserPuzzleStatuses $getUserPuzzleStatuses, readonly private RetrieveLoggedUserProfile $retrieveLoggedUserProfile, + readonly private IsCompetitionPubliclyVisible $isCompetitionPubliclyVisible, + readonly private ClockInterface $clock, ) { } @@ -64,12 +68,19 @@ public function __invoke( $loggedPlayer = $this->retrieveLoggedUserProfile->getProfile(); $puzzleStatuses = $this->getUserPuzzleStatuses->byPlayerId($loggedPlayer?->playerId); + // "Add my time from this event" deep link: signed-in, the edition is publicly visible (its + // series approved, so the add-time picker offers it) and it has already started. + $canAddTime = $loggedPlayer !== null + && $competitionEvent->startsAfter($this->clock->now()) === false + && $this->isCompetitionPubliclyVisible->check($competitionId); + return $this->render('edition_detail.html.twig', [ 'series' => $seriesOverview, 'event' => $competitionEvent, 'rounds' => $rounds, 'puzzles' => $puzzles, 'puzzle_statuses' => $puzzleStatuses, + 'can_add_time' => $canAddTime, ]); } } diff --git a/src/Controller/EventDetailController.php b/src/Controller/EventDetailController.php index f69a09d5..63810fb9 100644 --- a/src/Controller/EventDetailController.php +++ b/src/Controller/EventDetailController.php @@ -4,12 +4,14 @@ namespace SpeedPuzzling\Web\Controller; +use Psr\Clock\ClockInterface; use SpeedPuzzling\Web\Entity\Competition; use SpeedPuzzling\Web\Query\GetCompetitionEvents; use SpeedPuzzling\Web\Query\GetCompetitionParticipants; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use SpeedPuzzling\Web\Query\GetPuzzleOverview; use SpeedPuzzling\Web\Query\GetUserPuzzleStatuses; +use SpeedPuzzling\Web\Query\IsCompetitionPubliclyVisible; use SpeedPuzzling\Web\Services\RetrieveLoggedUserProfile; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; @@ -25,6 +27,8 @@ public function __construct( readonly private GetPuzzleOverview $getPuzzleOverview, readonly private GetUserPuzzleStatuses $getUserPuzzleStatuses, readonly private RetrieveLoggedUserProfile $retrieveLoggedUserProfile, + readonly private IsCompetitionPubliclyVisible $isCompetitionPubliclyVisible, + readonly private ClockInterface $clock, ) { } @@ -69,11 +73,18 @@ public function __invoke( ); } + // "Add my time from this event" deep link: signed-in, the event is publicly visible (so the + // add-time picker offers it) and it has already started — no times for an upcoming event. + $canAddTime = $loggedPlayer !== null + && $competitionEvent->startsAfter($this->clock->now()) === false + && $this->isCompetitionPubliclyVisible->check($competitionEvent->id); + return $this->render('event_detail.html.twig', [ 'event' => $competitionEvent, 'puzzles' => $puzzles, 'puzzle_statuses' => $puzzleStatuses, 'is_going' => count($playerConnections) > 0, + 'can_add_time' => $canAddTime, ]); } } diff --git a/src/Controller/PuzzleAddController.php b/src/Controller/PuzzleAddController.php index cae2b036..63d52a2e 100644 --- a/src/Controller/PuzzleAddController.php +++ b/src/Controller/PuzzleAddController.php @@ -23,6 +23,7 @@ use SpeedPuzzling\Web\Query\GetPlayerCollections; use SpeedPuzzling\Web\Query\GetPuzzleOverview; use SpeedPuzzling\Web\Query\GetStopwatch; +use SpeedPuzzling\Web\Query\IsCompetitionPubliclyVisible; use SpeedPuzzling\Web\Services\RetrieveLoggedUserProfile; use SpeedPuzzling\Web\Value\PuzzleAddMode; use SpeedPuzzling\Web\Value\StopwatchStatus; @@ -50,6 +51,7 @@ public function __construct( readonly private GetFavoritePlayers $getFavoritePlayers, readonly private LoggerInterface $logger, readonly private GetPlayerCollections $getPlayerCollections, + readonly private IsCompetitionPubliclyVisible $isCompetitionPubliclyVisible, ) { } @@ -134,6 +136,20 @@ public function __invoke( $initialMode = 'relax'; } + // Deep link from an event page (`?competition=`): pre-select the competition in the picker. + // Only a publicly visible competition is honoured — anything else is ignored silently, the form + // simply opens without a pre-selection. On POST handleRequest() overwrites the data anyway. + $queryCompetition = $request->query->getString('competition'); + + if ( + $data->mode === PuzzleAddMode::SpeedPuzzling + && $queryCompetition !== '' + && Uuid::isValid($queryCompetition) + && $this->isCompetitionPubliclyVisible->check($queryCompetition) + ) { + $data->competition = $queryCompetition; + } + // Get player collections for form options (include system collection) $hasActiveMembership = $userProfile->activeMembership; $collections = []; diff --git a/src/Results/CompetitionEvent.php b/src/Results/CompetitionEvent.php index 3680ac11..b816e470 100644 --- a/src/Results/CompetitionEvent.php +++ b/src/Results/CompetitionEvent.php @@ -108,6 +108,22 @@ public static function fromDatabaseRow(array $row): self ); } + /** + * Whether the event only starts on a later calendar day than the given one — i.e. it is still + * "upcoming" on that day. The start is COALESCE(date_from, date_to), the same rule the event lists + * classify by; an undated event is perpetual and never upcoming. Compared by calendar day on purpose: + * `fromDatabaseRow` pins dateFrom to 09:00, so datetimes would disagree with the SQL `::date` rule. + */ + public function startsAfter(DateTimeImmutable $day): bool + { + $start = $this->dateFrom ?? $this->dateTo; + + if ($start === null) { + return false; + } + + return $start->format('Y-m-d') > $day->format('Y-m-d'); + } private function appendUtm(null|string $link): null|string { diff --git a/templates/edition_detail.html.twig b/templates/edition_detail.html.twig index 509e3688..43d15493 100644 --- a/templates/edition_detail.html.twig +++ b/templates/edition_detail.html.twig @@ -116,6 +116,11 @@ {{ 'events.results_link'|trans }} {% endif %} + {% if can_add_time %} + + {{ 'events.add_my_time'|trans }} + + {% endif %} diff --git a/templates/event_detail.html.twig b/templates/event_detail.html.twig index a431c103..c39f8359 100644 --- a/templates/event_detail.html.twig +++ b/templates/event_detail.html.twig @@ -178,6 +178,11 @@ {{ 'competition.join.im_going'|trans }} {% endif %} + {% if can_add_time %} + + {{ 'events.add_my_time'|trans }} + + {% endif %} diff --git a/tests/Controller/EditionDetailControllerTest.php b/tests/Controller/EditionDetailControllerTest.php new file mode 100644 index 00000000..dd3d2941 --- /dev/null +++ b/tests/Controller/EditionDetailControllerTest.php @@ -0,0 +1,64 @@ +request('GET', self::PAST_EDITION_URL); + + $this->assertResponseIsSuccessful(); + } + + public function testAddMyTimeLinkIsShownToLoggedInPlayerOnPastEdition(): void + { + $browser = self::createClient(); + + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_REGULAR); + + $browser->request('GET', self::PAST_EDITION_URL); + + $this->assertResponseIsSuccessful(); + $this->assertSelectorExists(self::addTimeLinkSelector(CompetitionSeriesFixture::EDITION_EJJ_68)); + } + + public function testAddMyTimeLinkIsHiddenFromAnonymousVisitor(): void + { + $browser = self::createClient(); + + $browser->request('GET', self::PAST_EDITION_URL); + + $this->assertResponseIsSuccessful(); + $this->assertSelectorNotExists(self::addTimeLinkSelector(CompetitionSeriesFixture::EDITION_EJJ_68)); + } + + public function testAddMyTimeLinkIsHiddenOnUpcomingEdition(): void + { + $browser = self::createClient(); + + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_REGULAR); + + $browser->request('GET', self::UPCOMING_EDITION_URL); + + $this->assertResponseIsSuccessful(); + $this->assertSelectorNotExists(self::addTimeLinkSelector(CompetitionSeriesFixture::EDITION_EJJ_69)); + } + + private static function addTimeLinkSelector(string $competitionId): string + { + return sprintf('a[href$="?competition=%s"]', $competitionId); + } +} diff --git a/tests/Controller/EventDetailControllerTest.php b/tests/Controller/EventDetailControllerTest.php index 65491bf1..477134bd 100644 --- a/tests/Controller/EventDetailControllerTest.php +++ b/tests/Controller/EventDetailControllerTest.php @@ -4,6 +4,7 @@ namespace SpeedPuzzling\Web\Tests\Controller; +use SpeedPuzzling\Web\Tests\DataFixtures\CompetitionFixture; use SpeedPuzzling\Web\Tests\DataFixtures\PlayerFixture; use SpeedPuzzling\Web\Tests\TestingLogin; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; @@ -29,4 +30,45 @@ public function testLoggedInUserCanAccessPage(): void $this->assertResponseIsSuccessful(); } + + public function testAddMyTimeLinkIsShownToLoggedInPlayerOnStartedEvent(): void + { + $browser = self::createClient(); + + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_REGULAR); + + // Euro Jigsaw Jam is approved and live today + $browser->request('GET', '/en/events/euro-jigsaw-jam'); + + $this->assertResponseIsSuccessful(); + $this->assertSelectorExists(self::addTimeLinkSelector(CompetitionFixture::COMPETITION_RECURRING_ONLINE)); + } + + public function testAddMyTimeLinkIsHiddenFromAnonymousVisitor(): void + { + $browser = self::createClient(); + + $browser->request('GET', '/en/events/euro-jigsaw-jam'); + + $this->assertResponseIsSuccessful(); + $this->assertSelectorNotExists(self::addTimeLinkSelector(CompetitionFixture::COMPETITION_RECURRING_ONLINE)); + } + + public function testAddMyTimeLinkIsHiddenOnUpcomingEvent(): void + { + $browser = self::createClient(); + + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_REGULAR); + + // WJPC 2024 starts in 30 days + $browser->request('GET', '/en/events/wjpc-2024'); + + $this->assertResponseIsSuccessful(); + $this->assertSelectorNotExists(self::addTimeLinkSelector(CompetitionFixture::COMPETITION_WJPC_2024)); + } + + private static function addTimeLinkSelector(string $competitionId): string + { + return sprintf('a[href$="?competition=%s"]', $competitionId); + } } diff --git a/tests/Controller/PuzzleAddControllerTest.php b/tests/Controller/PuzzleAddControllerTest.php index 076963cc..ae9b42b9 100644 --- a/tests/Controller/PuzzleAddControllerTest.php +++ b/tests/Controller/PuzzleAddControllerTest.php @@ -14,6 +14,7 @@ use SpeedPuzzling\Web\Tests\TestingLogin; use Symfony\Bundle\FrameworkBundle\KernelBrowser; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; +use Symfony\Component\DomCrawler\Crawler; final class PuzzleAddControllerTest extends WebTestCase { @@ -112,6 +113,77 @@ public function testCompetitionPickerOffersSeriesEditionsButNotUnapprovedEvents( self::assertStringNotContainsString(CompetitionSeriesFixture::EDITION_UNAPPROVED_1, $tomSelectOptions); } + /** + * @return array + */ + public static function provideVisibleCompetitionIds(): array + { + return [ + 'live standalone competition' => [CompetitionFixture::COMPETITION_RECURRING_ONLINE], + 'edition of an approved series' => [CompetitionSeriesFixture::EDITION_EJJ_68], + ]; + } + + #[DataProvider('provideVisibleCompetitionIds')] + public function testCompetitionQueryParamPrefillsVisibleCompetition(string $competitionId): void + { + $browser = self::createClient(); + + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_REGULAR); + + $crawler = $browser->request('GET', '/en/puzzle-add?competition=' . $competitionId); + $this->assertResponseIsSuccessful(); + + self::assertSame( + $competitionId, + $crawler->filter('input[name="puzzle_add_form[competition]"]')->attr('value'), + ); + self::assertFalse( + $this->isCompetitionSectionHidden($crawler), + 'The competition section must be expanded when the deep link pre-selects an event', + ); + } + + /** + * @return array + */ + public static function provideNonVisibleCompetitionQueryValues(): array + { + return [ + 'unapproved standalone competition' => [CompetitionFixture::COMPETITION_UNAPPROVED], + 'edition of an unapproved series' => [CompetitionSeriesFixture::EDITION_UNAPPROVED_1], + 'not a uuid' => ['not-a-uuid'], + 'unknown uuid' => ['019999aa-0000-7000-8000-000000000000'], + ]; + } + + #[DataProvider('provideNonVisibleCompetitionQueryValues')] + public function testCompetitionQueryParamIgnoresNonVisible(string $queryValue): void + { + $browser = self::createClient(); + + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_REGULAR); + + $crawler = $browser->request('GET', '/en/puzzle-add?competition=' . $queryValue); + $this->assertResponseIsSuccessful(); + + self::assertSame('', (string) $crawler->filter('input[name="puzzle_add_form[competition]"]')->attr('value')); + self::assertTrue($this->isCompetitionSectionHidden($crawler)); + } + + public function testCompetitionQueryParamIgnoredInRelaxMode(): void + { + $browser = self::createClient(); + + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_REGULAR); + + $crawler = $browser->request('GET', '/en/puzzle-add?mode=relax&competition=' . CompetitionFixture::COMPETITION_RECURRING_ONLINE); + $this->assertResponseIsSuccessful(); + + self::assertSame('', (string) $crawler->filter('input[name="puzzle_add_form[competition]"]')->attr('value')); + self::assertTrue($this->isCompetitionSectionHidden($crawler)); + } + public function testSubmitWithNotSelectableCompetitionIsRejected(): void { $browser = self::createClient(); @@ -183,6 +255,16 @@ private function validSpeedPuzzlingSubmission(KernelBrowser $browser, string $co ]; } + private function isCompetitionSectionHidden(Crawler $crawler): bool + { + $section = $crawler->filter('div[data-toggle-target="competition"]'); + self::assertCount(1, $section); + + $classes = explode(' ', (string) $section->attr('class')); + + return in_array('hidden', $classes, true); + } + private function countPlayerTimes(Connection $database): int { /** @var int|string $count */ diff --git a/translations/messages.en.yml b/translations/messages.en.yml index 2cd46af3..0b76aaf9 100644 --- a/translations/messages.en.yml +++ b/translations/messages.en.yml @@ -515,6 +515,7 @@ events: past_editions: "Past editions" edition_date: "Date" no_editions_yet: "No editions yet." + add_my_time: "Add my time from this event" filter: filters: "Filters" time_period: "Time period"