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"