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: 2 additions & 0 deletions docs/features/competitions-management/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<input>`-based pickers, so these cannot come from PHP.
- **Deep link** `puzzle_add?competition=<uuid>` (`/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

Expand Down
11 changes: 11 additions & 0 deletions src/Controller/EditionDetailController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
) {
}

Expand Down Expand Up @@ -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,
]);
}
}
11 changes: 11 additions & 0 deletions src/Controller/EventDetailController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
) {
}

Expand Down Expand Up @@ -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,
]);
}
}
16 changes: 16 additions & 0 deletions src/Controller/PuzzleAddController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -50,6 +51,7 @@ public function __construct(
readonly private GetFavoritePlayers $getFavoritePlayers,
readonly private LoggerInterface $logger,
readonly private GetPlayerCollections $getPlayerCollections,
readonly private IsCompetitionPubliclyVisible $isCompetitionPubliclyVisible,
) {
}

Expand Down Expand Up @@ -134,6 +136,20 @@ public function __invoke(
$initialMode = 'relax';
}

// Deep link from an event page (`?competition=<uuid>`): 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 = [];
Expand Down
16 changes: 16 additions & 0 deletions src/Results/CompetitionEvent.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
5 changes: 5 additions & 0 deletions templates/edition_detail.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,11 @@
<i class="bi bi-trophy me-1"></i>{{ 'events.results_link'|trans }}
</a>
{% endif %}
{% if can_add_time %}
<a class="btn btn-sm btn-outline-primary" href="{{ path('puzzle_add', {competition: event.id}) }}">
<i class="bi bi-stopwatch me-1"></i>{{ 'events.add_my_time'|trans }}
</a>
{% endif %}
</div>
</div>

Expand Down
5 changes: 5 additions & 0 deletions templates/event_detail.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,11 @@
<i class="bi bi-person-plus me-1"></i>{{ 'competition.join.im_going'|trans }}
</a>
{% endif %}
{% if can_add_time %}
<a href="{{ path('puzzle_add', {competition: event.id}) }}" class="btn btn-sm btn-outline-primary ms-1">
<i class="bi bi-stopwatch me-1"></i>{{ 'events.add_my_time'|trans }}
</a>
{% endif %}
</div>

<twig:CompetitionParticipants competitionId="{{ event.id }}" eventSlug="{{ event.slug }}" />
Expand Down
64 changes: 64 additions & 0 deletions tests/Controller/EditionDetailControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

declare(strict_types=1);

namespace SpeedPuzzling\Web\Tests\Controller;

use SpeedPuzzling\Web\Tests\DataFixtures\CompetitionSeriesFixture;
use SpeedPuzzling\Web\Tests\DataFixtures\PlayerFixture;
use SpeedPuzzling\Web\Tests\TestingLogin;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

final class EditionDetailControllerTest extends WebTestCase
{
private const string PAST_EDITION_URL = '/en/series/euro-jigsaw-jam-series/ejj-68-february-2026';
private const string UPCOMING_EDITION_URL = '/en/series/euro-jigsaw-jam-series/ejj-69-may-2026';

public function testAnonymousUserCanAccessPage(): void
{
$browser = self::createClient();

$browser->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);
}
}
42 changes: 42 additions & 0 deletions tests/Controller/EventDetailControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
}
}
Loading