diff --git a/.claude/fixtures.md b/.claude/fixtures.md index ded2fb3a0..f915c94d9 100644 --- a/.claude/fixtures.md +++ b/.claude/fixtures.md @@ -185,6 +185,7 @@ Seller settings: `PLAYER_WITH_STRIPE` has ISO currency **GBP** (listings eligibl | `SERIES_EJJ` | Euro Jigsaw Jam | Yes | - | Yes | one past, one upcoming | | `SERIES_OFFLINE` | Puzzle Meetup Prague | No | cz | Yes | one upcoming | | `SERIES_PAST_ONLY` | Berlin Puzzle Cup | No | de | Yes | only past — must never appear as "upcoming" | +| `SERIES_UNAPPROVED` | Pending Puzzle League | Yes | - | **No** (`approvedAt` null) | one upcoming — its edition must never be publicly visible (`IsCompetitionPubliclyVisible` false) | ### Series Editions (Competitions with series_id) @@ -194,6 +195,7 @@ Seller settings: `PLAYER_WITH_STRIPE` has ISO currency **GBP** (listings eligibl | `EDITION_EJJ_69` | SERIES_EJJ | EJJ #69 — May 2026 | +30 days | | `EDITION_OFFLINE_1` | SERIES_OFFLINE | Puzzle Meetup #1 | +14 days | | `EDITION_PAST_ONLY_1` | SERIES_PAST_ONLY | Berlin Puzzle Cup 2026 | -45 days | +| `EDITION_UNAPPROVED_1` | SERIES_UNAPPROVED | Pending Puzzle League #1 | +7 days (no rounds) | ### Competition Rounds diff --git a/docs/features/api/README.md b/docs/features/api/README.md index 648adfaf8..d19fa358d 100644 --- a/docs/features/api/README.md +++ b/docs/features/api/README.md @@ -115,9 +115,9 @@ hand-typed `SOLVING_TIMES` variant silently matched nothing until 2026-08 (PR #1 | GET | `/api/v1/competitions?status=all\|live\|upcoming\|past&online=true&country=cz` | Any valid PAT or OAuth2 token (no specific scope) | | GET | `/api/v1/competitions/{id}` | Any valid PAT or OAuth2 token (no specific scope) | -- **List** returns basic info for **approved, standalone** competitions only (mirrors the public website listing). Optional filters: `status` (default `all`), `online` (default `false`), `country` (ISO 3166-1 alpha-2). Response shape: `{ "count": N, "competitions": [ ... ] }`. Participants are never returned. -- **Detail** returns the competition metadata plus its `rounds`. Each round exposes `id`, `name`, `starts_at`, `minutes_limit`, `category`, and `puzzles`. **Participants are never returned.** -- **Unapproved or rejected competitions return `404`** — they must not leak through the API (the underlying `byId()` query does not filter on approval, so the provider gates on `approvedAt`). +- **List** returns basic info for **approved, standalone** competitions only (mirrors the public website listing). Series editions are not listed, but they are reachable by id through the detail endpoint. Optional filters: `status` (default `all`), `online` (default `false`), `country` (ISO 3166-1 alpha-2). Response shape: `{ "count": N, "competitions": [ ... ] }`. Participants are never returned. +- **Detail** returns the competition metadata plus its `rounds` and a `series` object. Each round exposes `id`, `name`, `starts_at`, `minutes_limit`, `category`, and `puzzles`. `series` is `{ "id", "name", "slug" }` for an edition of a competition series and `null` for a standalone competition (an edition's own `slug` is only unique within its series — build links as `/series/{series.slug}/{slug}`). **Participants are never returned.** +- **Unapproved or rejected competitions return `404`** — they must not leak through the API (the underlying `byId()` query does not filter on approval, so the provider gates on `IsCompetitionPubliclyVisible`). An **edition** is readable when its **series** is approved and not rejected; the edition's own `approved_at` is irrelevant (editions are never approved individually). - **Puzzle-reveal privacy (critical):** a round puzzle flagged *hide until round starts* is governed by the same single-source-of-truth rule used on the website (`GetEditionRounds`). Until `round.startsAt + 10 minutes`: - `hideMode = Entirely` → the puzzle is **omitted entirely** from the round's `puzzles`. - `hideMode = ImageOnly` → the puzzle is returned but `image` is `null` (name, pieces count, manufacturer remain visible). diff --git a/docs/features/competitions-management/README.md b/docs/features/competitions-management/README.md index bda121fd4..5711ced26 100644 --- a/docs/features/competitions-management/README.md +++ b/docs/features/competitions-management/README.md @@ -116,6 +116,16 @@ CompetitionSeries ("Euro Jigsaw Jam") - Series appear in a dedicated "Recurring" section as single cards, showing the next upcoming edition date - Editions (competitions with `series_id`) are excluded from Live/Upcoming/Past +### Event badge on solving times + +A solving time may be linked to a standalone competition **or to a series edition** (`puzzle_solving_time.competition_id` points at the edition's `Competition` row). Every list that shows a time's event (player results, puzzle leaderboards, ladders, recent activity) renders the one partial `templates/_competition_badge.html.twig` (`{{ include('_competition_badge.html.twig', {time: x}) }}`, where `x` is a `SolvedPuzzle`, `PuzzleSolver`, `PuzzleSolversGroup` or `RecentActivityItem` — all carry `competitionName/Shortcut/Slug` plus `competitionSeriesName/Shortcut/Slug`, selected by every read model via `LEFT JOIN competition_series cs ON cs.id = competition.series_id`): + +- **Label** — standalone: `shortcut ?? name` (e.g. `WJPC24`); edition: ` · ` (e.g. `Euro Jigsaw Jam · EJJ #68 — February 2026`); when the edition is named exactly like its series (competitions converted to a series keep the name) only the series label is shown. +- **Link** — standalone: `event_detail` (`/en/events/{slug}`); edition: `edition_detail` (`/en/series/{seriesSlug}/{editionSlug}`) — **never** `event_detail`, an edition slug is only unique within its series. An edition whose series has no slug renders the badge unlinked. +- Nothing is rendered when the time has no competition. + +**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. + ## Round Management A competition has multiple **rounds**, each with: diff --git a/src/Api/V1/CompetitionDetailResponse.php b/src/Api/V1/CompetitionDetailResponse.php index 4d9c63526..7655d229b 100644 --- a/src/Api/V1/CompetitionDetailResponse.php +++ b/src/Api/V1/CompetitionDetailResponse.php @@ -43,6 +43,7 @@ public function __construct( public null|string $registrationLink, public null|string $resultsLink, array $rounds, + public null|CompetitionSeriesSummaryResponse $series = null, ) { $this->rounds = $rounds; } diff --git a/src/Api/V1/CompetitionDetailResponseProvider.php b/src/Api/V1/CompetitionDetailResponseProvider.php index 251d33b94..31795c86f 100644 --- a/src/Api/V1/CompetitionDetailResponseProvider.php +++ b/src/Api/V1/CompetitionDetailResponseProvider.php @@ -8,7 +8,9 @@ use ApiPlatform\State\ProviderInterface; use SpeedPuzzling\Web\Exceptions\CompetitionNotFound; use SpeedPuzzling\Web\Query\GetCompetitionEvents; +use SpeedPuzzling\Web\Query\GetCompetitionSeries; use SpeedPuzzling\Web\Query\GetEditionRounds; +use SpeedPuzzling\Web\Query\IsCompetitionPubliclyVisible; use SpeedPuzzling\Web\Results\EditionRoundDetail; use SpeedPuzzling\Web\Results\EditionRoundPuzzle; @@ -20,6 +22,8 @@ public function __construct( private GetCompetitionEvents $getCompetitionEvents, private GetEditionRounds $getEditionRounds, + private GetCompetitionSeries $getCompetitionSeries, + private IsCompetitionPubliclyVisible $isCompetitionPubliclyVisible, ) { } @@ -30,16 +34,30 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $competition = $this->getCompetitionEvents->byId($competitionId); - // Privacy gate: only approved, non-rejected competitions are publicly readable through - // the API. GetCompetitionEvents::byId() intentionally does NOT filter on approval (it - // serves the owner/admin web flows too), so an unapproved or rejected competition — and - // its not-yet-revealed puzzles — must 404 here instead of leaking. A competition can be - // both approved and rejected (approve() and reject() do not clear each other), so the - // rejected state must veto a stale approval. - if ($competition->approvedAt === null || $competition->rejectedAt !== null) { + // Privacy gate: only publicly visible competitions are readable through the API. + // GetCompetitionEvents::byId() intentionally does NOT filter on approval (it serves the + // owner/admin web flows too), so an unapproved or rejected competition — and its + // not-yet-revealed puzzles — must 404 here instead of leaking. A competition can be both + // approved and rejected (approve() and reject() do not clear each other), so the rejected + // state must veto a stale approval. Editions of a series are never approved individually + // (their own approved_at stays NULL) — they are visible iff their SERIES is approved and + // not rejected. IsCompetitionPubliclyVisible is the single source of truth for both rules. + if ($this->isCompetitionPubliclyVisible->check($competitionId) === false) { throw new CompetitionNotFound(); } + $series = null; + + if ($competition->seriesId !== null) { + $seriesOverview = $this->getCompetitionSeries->byId($competition->seriesId); + + $series = new CompetitionSeriesSummaryResponse( + id: $seriesOverview->id, + name: $seriesOverview->name, + slug: $seriesOverview->slug, + ); + } + // Rounds (and their puzzles) come from GetEditionRounds, the single source of truth for // the puzzle-reveal rule: puzzles flagged hide-until-round-starts are omitted (Entirely) // or stripped of their image (ImageOnly) until round.startsAt + 10 minutes. Participants @@ -65,6 +83,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c registrationLink: $competition->registrationLink, resultsLink: $competition->resultsLink, rounds: $rounds, + series: $series, ); } diff --git a/src/Api/V1/CompetitionSeriesSummaryResponse.php b/src/Api/V1/CompetitionSeriesSummaryResponse.php new file mode 100644 index 000000000..63f33b7d8 --- /dev/null +++ b/src/Api/V1/CompetitionSeriesSummaryResponse.php @@ -0,0 +1,19 @@ + 'puzzlers') WITH ORDINALITY AS player_elem(player, ordinality) LEFT JOIN player p ON p.id = (player_elem.player ->> 'player_id')::UUID LEFT JOIN player_skill ps_member ON ps_member.player_id = p.id - GROUP BY puzzle.id, player.id, manufacturer.id, pst.id, competition.id, ps_main.skill_tier + GROUP BY puzzle.id, player.id, manufacturer.id, pst.id, competition.id, cs.id, ps_main.skill_tier HAVING bool_or(p.is_private = false) ) SELECT * @@ -145,6 +149,9 @@ public function perPiecesCount(int $piecesCount, int $howManyPlayers, null|Count * competition_name: null|string, * competition_shortcut: null|string, * competition_slug: null|string, + * competition_series_name: null|string, + * competition_series_shortcut: null|string, + * competition_series_slug: null|string, * skill_tier: null|int, * ranking_opted_out: bool, * } $row diff --git a/src/Query/GetFastestPairs.php b/src/Query/GetFastestPairs.php index 89b011415..4b11178b7 100644 --- a/src/Query/GetFastestPairs.php +++ b/src/Query/GetFastestPairs.php @@ -63,6 +63,9 @@ public function perPiecesCount(int $piecesCount, int $howManyPlayers, null|Count competition.shortcut AS competition_shortcut, competition.name AS competition_name, competition.slug AS competition_slug, + cs.name AS competition_series_name, + cs.shortcut AS competition_series_shortcut, + cs.slug AS competition_series_slug, ps_main.skill_tier, player.ranking_opted_out, JSON_AGG( @@ -82,11 +85,12 @@ public function perPiecesCount(int $piecesCount, int $howManyPlayers, null|Count INNER JOIN player ON pst.player_id = player.id INNER JOIN manufacturer ON manufacturer.id = puzzle.manufacturer_id LEFT JOIN competition ON pst.competition_id = competition.id + LEFT JOIN competition_series cs ON cs.id = competition.series_id LEFT JOIN player_skill ps_main ON ps_main.player_id = player.id, LATERAL json_array_elements(pst.team -> 'puzzlers') WITH ORDINALITY AS player_elem(player, ordinality) LEFT JOIN player p ON p.id = (player_elem.player ->> 'player_id')::UUID LEFT JOIN player_skill ps_member ON ps_member.player_id = p.id - GROUP BY puzzle.id, player.id, manufacturer.id, pst.id, competition.id, ps_main.skill_tier + GROUP BY puzzle.id, player.id, manufacturer.id, pst.id, competition.id, cs.id, ps_main.skill_tier HAVING bool_or(p.is_private = false) ) SELECT * @@ -145,6 +149,9 @@ public function perPiecesCount(int $piecesCount, int $howManyPlayers, null|Count * competition_name: null|string, * competition_shortcut: null|string, * competition_slug: null|string, + * competition_series_name: null|string, + * competition_series_shortcut: null|string, + * competition_series_slug: null|string, * skill_tier: null|int, * ranking_opted_out: bool, * } $row diff --git a/src/Query/GetFastestPlayers.php b/src/Query/GetFastestPlayers.php index 2fcab3436..c16fc819e 100644 --- a/src/Query/GetFastestPlayers.php +++ b/src/Query/GetFastestPlayers.php @@ -79,6 +79,9 @@ public function perPiecesCount(int $piecesCount, int $limit, null|CountryCode $c competition.shortcut AS competition_shortcut, competition.name AS competition_name, competition.slug AS competition_slug, + cs.name AS competition_series_name, + cs.shortcut AS competition_series_shortcut, + cs.slug AS competition_series_slug, ps.skill_tier, player.ranking_opted_out FROM FastestTimes @@ -87,8 +90,9 @@ public function perPiecesCount(int $piecesCount, int $limit, null|CountryCode $c INNER JOIN player ON player.id = puzzle_solving_time.player_id INNER JOIN manufacturer ON manufacturer.id = puzzle.manufacturer_id LEFT JOIN competition ON puzzle_solving_time.competition_id = competition.id +LEFT JOIN competition_series cs ON cs.id = competition.series_id LEFT JOIN player_skill ps ON ps.player_id = player.id -GROUP BY player.id, puzzle.id, manufacturer.id, puzzle_solving_time.id, competition.id, ps.skill_tier +GROUP BY player.id, puzzle.id, manufacturer.id, puzzle_solving_time.id, competition.id, cs.id, ps.skill_tier ORDER BY puzzle_solving_time.seconds_to_solve SQL; @@ -130,6 +134,9 @@ public function perPiecesCount(int $piecesCount, int $limit, null|CountryCode $c * competition_name: null|string, * competition_shortcut: null|string, * competition_slug: null|string, + * competition_series_name: null|string, + * competition_series_shortcut: null|string, + * competition_series_slug: null|string, * skill_tier: null|int, * ranking_opted_out: bool, * } $row diff --git a/src/Query/GetLastSolvedPuzzle.php b/src/Query/GetLastSolvedPuzzle.php deleted file mode 100644 index 5a16c849d..000000000 --- a/src/Query/GetLastSolvedPuzzle.php +++ /dev/null @@ -1,347 +0,0 @@ - - */ - public function forPlayer(string $playerId, int $limit): array - { - if (Uuid::isValid($playerId) === false) { - throw new PlayerNotFound(); - } - - $query = << :now::timestamp THEN NULL ELSE puzzle.image END AS puzzle_image, - CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > :now::timestamp THEN NULL ELSE puzzle.image_ratio END AS puzzle_image_ratio, - puzzle_solving_time.seconds_to_solve AS time, - puzzle_solving_time.player_id AS player_id, - player.name AS player_name, - player.code AS player_code, - player.country AS player_country, - pieces_count, - puzzle_solving_time.comment, - manufacturer.name AS manufacturer_name, - puzzle.identification_number AS puzzle_identification_number, - puzzle_solving_time.tracked_at AS tracked_at, - finished_at, - puzzle_solving_time.finished_puzzle_photo AS finished_puzzle_photo, - puzzle_solving_time.team ->> 'team_id' AS team_id, - first_attempt, - puzzle_solving_time.unboxed, - is_private, - competition.id AS competition_id, - competition.shortcut AS competition_shortcut, - competition.name AS competition_name, - competition.slug AS competition_slug, - CASE WHEN puzzle_solving_time.team IS NOT NULL THEN - (SELECT JSON_AGG(JSON_BUILD_OBJECT( - 'player_id', elem.player ->> 'player_id', - 'player_name', COALESCE(p.name, elem.player ->> 'player_name'), - 'player_code', p.code, - 'player_country', p.country, - 'is_private', p.is_private - ) ORDER BY elem.ordinality) - FROM json_array_elements(puzzle_solving_time.team -> 'puzzlers') WITH ORDINALITY AS elem(player, ordinality) - LEFT JOIN player p ON p.id = (elem.player ->> 'player_id')::UUID) - ELSE NULL END AS players -FROM puzzle_solving_time -INNER JOIN puzzle ON puzzle.id = puzzle_solving_time.puzzle_id -INNER JOIN player ON puzzle_solving_time.player_id = player.id -INNER JOIN manufacturer ON manufacturer.id = puzzle.manufacturer_id -LEFT JOIN competition ON puzzle_solving_time.competition_id = competition.id -WHERE - (puzzle_solving_time.player_id = :playerId OR (team::jsonb -> 'puzzlers') @> jsonb_build_array(jsonb_build_object('player_id', CAST(:playerId AS UUID)))) -ORDER BY puzzle_solving_time.tracked_at DESC -LIMIT :limit -SQL; - - $data = $this->database - ->executeQuery($query, [ - 'limit' => $limit, - 'playerId' => $playerId, - 'now' => $this->clock->now()->format('Y-m-d H:i:s'), - ]) - ->fetchAllAssociative(); - - return array_map(static function (array $row): SolvedPuzzle { - /** - * @var array{ - * time_id: string, - * player_id: string, - * player_name: null|string, - * player_code: string, - * player_country: null|string, - * puzzle_id: string, - * puzzle_name: string, - * puzzle_alternative_name: null|string, - * manufacturer_name: string, - * puzzle_image: null|string, - * puzzle_image_ratio: null|string, - * time: int, - * pieces_count: int, - * comment: null|string, - * tracked_at: string, - * finished_puzzle_photo: null|string, - * team_id: null|string, - * puzzle_identification_number: null|string, - * finished_at: null|string, - * first_attempt: bool, - * unboxed: bool, - * is_private: bool, - * competition_id: null|string, - * competition_name: null|string, - * competition_shortcut: null|string, - * competition_slug: null|string, - * players: null|string, - * } $row - */ - - return SolvedPuzzle::fromDatabaseRow($row); - }, $data); - } - - /** - * @return array - */ - public function limit(int $limit): array - { - $query = << :now::timestamp THEN NULL ELSE puzzle.image END AS puzzle_image, - CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > :now::timestamp THEN NULL ELSE puzzle.image_ratio END AS puzzle_image_ratio, - puzzle_solving_time.seconds_to_solve AS time, - puzzle_solving_time.player_id AS player_id, - player.name AS player_name, - player.code AS player_code, - player.country AS player_country, - pieces_count, - puzzle_solving_time.comment, - manufacturer.name AS manufacturer_name, - puzzle.identification_number AS puzzle_identification_number, - puzzle_solving_time.tracked_at AS tracked_at, - finished_at, - puzzle_solving_time.finished_puzzle_photo AS finished_puzzle_photo, - puzzle_solving_time.team ->> 'team_id' AS team_id, - first_attempt, - puzzle_solving_time.unboxed, - is_private, - competition.id AS competition_id, - competition.shortcut AS competition_shortcut, - competition.name AS competition_name, - competition.slug AS competition_slug, - CASE WHEN puzzle_solving_time.team IS NOT NULL THEN - (SELECT JSON_AGG(JSON_BUILD_OBJECT( - 'player_id', elem.player ->> 'player_id', - 'player_name', COALESCE(p.name, elem.player ->> 'player_name'), - 'player_code', p.code, - 'player_country', p.country, - 'is_private', p.is_private - ) ORDER BY elem.ordinality) - FROM json_array_elements(puzzle_solving_time.team -> 'puzzlers') WITH ORDINALITY AS elem(player, ordinality) - LEFT JOIN player p ON p.id = (elem.player ->> 'player_id')::UUID) - ELSE NULL END AS players -FROM puzzle_solving_time -INNER JOIN puzzle ON puzzle.id = puzzle_solving_time.puzzle_id -INNER JOIN player ON puzzle_solving_time.player_id = player.id -INNER JOIN manufacturer ON manufacturer.id = puzzle.manufacturer_id -LEFT JOIN competition ON puzzle_solving_time.competition_id = competition.id -WHERE player.is_private = false -ORDER BY puzzle_solving_time.tracked_at DESC -LIMIT :limit -SQL; - - $data = $this->database - ->executeQuery($query, [ - 'limit' => $limit, - 'now' => $this->clock->now()->format('Y-m-d H:i:s'), - ]) - ->fetchAllAssociative(); - - return array_map(static function (array $row): SolvedPuzzle { - /** - * @var array{ - * time_id: string, - * player_id: string, - * player_name: null|string, - * player_code: string, - * player_country: null|string, - * puzzle_id: string, - * puzzle_name: string, - * puzzle_alternative_name: null|string, - * manufacturer_name: string, - * puzzle_image: null|string, - * puzzle_image_ratio: null|string, - * time: int, - * pieces_count: int, - * comment: null|string, - * tracked_at: string, - * finished_puzzle_photo: null|string, - * team_id: null|string, - * puzzle_identification_number: null|string, - * finished_at: null|string, - * first_attempt: bool, - * unboxed: bool, - * is_private: bool, - * competition_id: null|string, - * competition_name: null|string, - * competition_shortcut: null|string, - * competition_slug: null|string, - * players: null|string, - * } $row - */ - - return SolvedPuzzle::fromDatabaseRow($row); - }, $data); - } - - /** - * @return array - */ - public function ofPlayerFavorites(int $limit, string $playerId): array - { - $query = << 'puzzlers') AS player_elem(player) - WHERE (player_elem.player ->> 'player_id')::UUID = ANY(fpi.favorite_ids) - ) - ) - ORDER BY pst.tracked_at DESC - LIMIT :limit -) -SELECT - pst.id as time_id, - puzzle.id AS puzzle_id, - puzzle.name AS puzzle_name, - puzzle.alternative_name AS puzzle_alternative_name, - CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > :now::timestamp THEN NULL ELSE puzzle.image END AS puzzle_image, - CASE WHEN puzzle.hide_image_until IS NOT NULL AND puzzle.hide_image_until > :now::timestamp THEN NULL ELSE puzzle.image_ratio END AS puzzle_image_ratio, - pst.seconds_to_solve AS time, - pst.player_id AS player_id, - player.name AS player_name, - player.code AS player_code, - player.country AS player_country, - puzzle.pieces_count, - pst.comment, - manufacturer.name AS manufacturer_name, - puzzle.identification_number AS puzzle_identification_number, - pst.tracked_at AS tracked_at, - pst.finished_at, - pst.finished_puzzle_photo AS finished_puzzle_photo, - pst.team ->> 'team_id' AS team_id, - first_attempt, - pst.unboxed, - is_private, - competition.id AS competition_id, - competition.shortcut AS competition_shortcut, - competition.name AS competition_name, - competition.slug AS competition_slug, - CASE WHEN pst.team IS NOT NULL THEN - (SELECT JSON_AGG(JSON_BUILD_OBJECT( - 'player_id', elem.player ->> 'player_id', - 'player_name', COALESCE(p.name, elem.player ->> 'player_name'), - 'player_code', p.code, - 'player_country', p.country, - 'is_private', p.is_private - ) ORDER BY elem.ordinality) - FROM json_array_elements(pst.team -> 'puzzlers') WITH ORDINALITY AS elem(player, ordinality) - LEFT JOIN player p ON p.id = (elem.player ->> 'player_id')::UUID) - ELSE NULL END AS players -FROM - filtered_puzzle_solving_time fpt -INNER JOIN puzzle_solving_time pst ON pst.id = fpt.id -INNER JOIN puzzle ON puzzle.id = pst.puzzle_id -INNER JOIN player ON pst.player_id = player.id -INNER JOIN manufacturer ON manufacturer.id = puzzle.manufacturer_id -LEFT JOIN competition ON competition.id = pst.competition_id -WHERE is_private = false -ORDER BY pst.tracked_at DESC -SQL; - - $data = $this->database - ->executeQuery($query, [ - 'limit' => $limit, - 'playerId' => $playerId, - 'now' => $this->clock->now()->format('Y-m-d H:i:s'), - ]) - ->fetchAllAssociative(); - - return array_map(static function (array $row): SolvedPuzzle { - /** - * @var array{ - * time_id: string, - * player_id: string, - * player_name: null|string, - * player_code: string, - * player_country: null|string, - * puzzle_id: string, - * puzzle_name: string, - * puzzle_alternative_name: null|string, - * manufacturer_name: string, - * puzzle_image: null|string, - * puzzle_image_ratio: null|string, - * time: int, - * pieces_count: int, - * comment: null|string, - * tracked_at: string, - * finished_puzzle_photo: null|string, - * team_id: null|string, - * puzzle_identification_number: null|string, - * finished_at: null|string, - * first_attempt: bool, - * unboxed: bool, - * is_private: bool, - * competition_id: null|string, - * competition_name: null|string, - * competition_shortcut: null|string, - * competition_slug: null|string, - * players: null|string, - * } $row - */ - - return SolvedPuzzle::fromDatabaseRow($row); - }, $data); - } -} diff --git a/src/Query/GetPlayerSolvedPuzzles.php b/src/Query/GetPlayerSolvedPuzzles.php index a52679f0c..37875ed28 100644 --- a/src/Query/GetPlayerSolvedPuzzles.php +++ b/src/Query/GetPlayerSolvedPuzzles.php @@ -196,6 +196,9 @@ public function soloByPlayerId( competition.shortcut AS competition_shortcut, competition.name AS competition_name, competition.slug AS competition_slug, + cs.name AS competition_series_name, + cs.shortcut AS competition_series_shortcut, + cs.slug AS competition_series_slug, puzzle_solving_time.suspicious FROM puzzle_solving_time INNER JOIN puzzle ON puzzle.id = puzzle_solving_time.puzzle_id @@ -203,6 +206,7 @@ public function soloByPlayerId( INNER JOIN manufacturer ON manufacturer.id = puzzle.manufacturer_id LEFT JOIN solved_counts ON solved_counts.puzzle_id = puzzle_solving_time.puzzle_id LEFT JOIN competition ON competition.id = puzzle_solving_time.competition_id + LEFT JOIN competition_series cs ON cs.id = competition.series_id WHERE puzzle_solving_time.player_id = :playerId AND puzzle_solving_time.puzzling_type = 'solo' @@ -273,6 +277,9 @@ public function soloByPlayerId( * competition_name: null|string, * competition_shortcut: null|string, * competition_slug: null|string, + * competition_series_name: null|string, + * competition_series_shortcut: null|string, + * competition_series_slug: null|string, * suspicious: bool, * } $row */ @@ -328,6 +335,9 @@ public function soloByPlayerIdAndPuzzleId(string $playerId, string $puzzleId): a competition.shortcut AS competition_shortcut, competition.name AS competition_name, competition.slug AS competition_slug, + cs.name AS competition_series_name, + cs.shortcut AS competition_series_shortcut, + cs.slug AS competition_series_slug, puzzle_solving_time.suspicious FROM puzzle_solving_time INNER JOIN puzzle ON puzzle.id = puzzle_solving_time.puzzle_id @@ -335,6 +345,7 @@ public function soloByPlayerIdAndPuzzleId(string $playerId, string $puzzleId): a INNER JOIN manufacturer ON manufacturer.id = puzzle.manufacturer_id LEFT JOIN solved_counts ON solved_counts.puzzle_id = puzzle_solving_time.puzzle_id LEFT JOIN competition ON competition.id = puzzle_solving_time.competition_id + LEFT JOIN competition_series cs ON cs.id = competition.series_id WHERE puzzle_solving_time.player_id = :playerId AND puzzle_solving_time.puzzle_id = :puzzleId @@ -378,6 +389,9 @@ public function soloByPlayerIdAndPuzzleId(string $playerId, string $puzzleId): a * competition_name: null|string, * competition_shortcut: null|string, * competition_slug: null|string, + * competition_series_name: null|string, + * competition_series_shortcut: null|string, + * competition_series_slug: null|string, * suspicious: bool, * } $row */ @@ -460,12 +474,16 @@ public function duoByPlayerId( competition.shortcut AS competition_shortcut, competition.name AS competition_name, competition.slug AS competition_slug, + cs.name AS competition_series_name, + cs.shortcut AS competition_series_shortcut, + cs.slug AS competition_series_slug, pst.suspicious FROM filtered_pst_ids fids INNER JOIN puzzle_solving_time pst ON pst.id = fids.id INNER JOIN puzzle ON puzzle.id = pst.puzzle_id INNER JOIN manufacturer ON manufacturer.id = puzzle.manufacturer_id LEFT JOIN competition ON competition.id = pst.competition_id +LEFT JOIN competition_series cs ON cs.id = competition.series_id ORDER BY pst.seconds_to_solve ASC SQL; @@ -511,6 +529,9 @@ public function duoByPlayerId( * competition_name: null|string, * competition_shortcut: null|string, * competition_slug: null|string, + * competition_series_name: null|string, + * competition_series_shortcut: null|string, + * competition_series_slug: null|string, * suspicious: bool, * } $row */ @@ -600,12 +621,16 @@ public function teamByPlayerId( competition.shortcut AS competition_shortcut, competition.name AS competition_name, competition.slug AS competition_slug, + cs.name AS competition_series_name, + cs.shortcut AS competition_series_shortcut, + cs.slug AS competition_series_slug, pst.suspicious FROM filtered_pst_ids fids INNER JOIN puzzle_solving_time pst ON pst.id = fids.id INNER JOIN puzzle ON puzzle.id = pst.puzzle_id INNER JOIN manufacturer ON manufacturer.id = puzzle.manufacturer_id LEFT JOIN competition ON competition.id = pst.competition_id +LEFT JOIN competition_series cs ON cs.id = competition.series_id ORDER BY pst.seconds_to_solve ASC SQL; @@ -651,6 +676,9 @@ public function teamByPlayerId( * competition_name: null|string, * competition_shortcut: null|string, * competition_slug: null|string, + * competition_series_name: null|string, + * competition_series_shortcut: null|string, + * competition_series_slug: null|string, * suspicious: bool, * } $row */ diff --git a/src/Query/GetPuzzleSolvers.php b/src/Query/GetPuzzleSolvers.php index 43a56a8bb..6b4f052fb 100644 --- a/src/Query/GetPuzzleSolvers.php +++ b/src/Query/GetPuzzleSolvers.php @@ -46,11 +46,15 @@ public function soloByPuzzleId(string $puzzleId): array competition.shortcut AS competition_shortcut, competition.name AS competition_name, competition.slug AS competition_slug, + cs.name AS competition_series_name, + cs.shortcut AS competition_series_shortcut, + cs.slug AS competition_series_slug, ps.skill_tier, player.ranking_opted_out FROM puzzle_solving_time INNER JOIN player ON puzzle_solving_time.player_id = player.id LEFT JOIN competition ON competition.id = puzzle_solving_time.competition_id +LEFT JOIN competition_series cs ON cs.id = competition.series_id LEFT JOIN player_skill ps ON ps.player_id = player.id WHERE puzzle_solving_time.puzzle_id = :puzzleId AND puzzle_solving_time.puzzling_type = 'solo' @@ -84,6 +88,9 @@ public function soloByPuzzleId(string $puzzleId): array * competition_shortcut: null|string, * competition_name: null|string, * competition_slug: null|string, + * competition_series_name: null|string, + * competition_series_shortcut: null|string, + * competition_series_slug: null|string, * skill_tier: null|int, * ranking_opted_out: bool, * } $row @@ -123,6 +130,9 @@ public function duoByPuzzleId(string $puzzleId): array competition.shortcut AS competition_shortcut, competition.name AS competition_name, competition.slug AS competition_slug, + cs.name AS competition_series_name, + cs.shortcut AS competition_series_shortcut, + cs.slug AS competition_series_slug, JSON_AGG( JSON_BUILD_OBJECT( 'player_id', player_elem.player ->> 'player_id', @@ -136,7 +146,8 @@ public function duoByPuzzleId(string $puzzleId): array ) AS players FROM puzzle_solving_time pst - LEFT JOIN competition ON competition.id = pst.competition_id, + LEFT JOIN competition ON competition.id = pst.competition_id + LEFT JOIN competition_series cs ON cs.id = competition.series_id, LATERAL json_array_elements(pst.team -> 'puzzlers') WITH ORDINALITY AS player_elem(player, ordinality) LEFT JOIN player p ON p.id = (player_elem.player ->> 'player_id')::UUID LEFT JOIN player_skill ps_member ON ps_member.player_id = p.id @@ -146,7 +157,7 @@ public function duoByPuzzleId(string $puzzleId): array AND pst.seconds_to_solve IS NOT NULL AND pst.suspicious = false GROUP BY - pst.id, time, competition.id + pst.id, time, competition.id, cs.id ORDER BY time ASC SQL; @@ -174,6 +185,9 @@ public function duoByPuzzleId(string $puzzleId): array * competition_shortcut: null|string, * competition_name: null|string, * competition_slug: null|string, + * competition_series_name: null|string, + * competition_series_shortcut: null|string, + * competition_series_slug: null|string, * } $row */ @@ -207,6 +221,9 @@ public function teamByPuzzleId(string $puzzleId): array competition.shortcut AS competition_shortcut, competition.name AS competition_name, competition.slug AS competition_slug, + cs.name AS competition_series_name, + cs.shortcut AS competition_series_shortcut, + cs.slug AS competition_series_slug, JSON_AGG( JSON_BUILD_OBJECT( 'player_id', player_elem.player ->> 'player_id', @@ -220,7 +237,8 @@ public function teamByPuzzleId(string $puzzleId): array ) AS players FROM puzzle_solving_time pst - LEFT JOIN competition ON competition.id = pst.competition_id, + LEFT JOIN competition ON competition.id = pst.competition_id + LEFT JOIN competition_series cs ON cs.id = competition.series_id, LATERAL json_array_elements(pst.team -> 'puzzlers') WITH ORDINALITY AS player_elem(player, ordinality) LEFT JOIN player p ON p.id = (player_elem.player ->> 'player_id')::UUID LEFT JOIN player_skill ps_member ON ps_member.player_id = p.id @@ -230,7 +248,7 @@ public function teamByPuzzleId(string $puzzleId): array AND pst.seconds_to_solve IS NOT NULL AND pst.suspicious = false GROUP BY - pst.id, time, competition.id + pst.id, time, competition.id, cs.id ORDER BY time ASC SQL; @@ -258,6 +276,9 @@ public function teamByPuzzleId(string $puzzleId): array * competition_shortcut: null|string, * competition_name: null|string, * competition_slug: null|string, + * competition_series_name: null|string, + * competition_series_shortcut: null|string, + * competition_series_slug: null|string, * } $row */ diff --git a/src/Query/GetRecentActivity.php b/src/Query/GetRecentActivity.php index 5b5ca35b9..60850196e 100644 --- a/src/Query/GetRecentActivity.php +++ b/src/Query/GetRecentActivity.php @@ -57,6 +57,9 @@ public function forPlayer(string $playerId, int $limit): array competition.shortcut AS competition_shortcut, competition.name AS competition_name, competition.slug AS competition_slug, + cs.name AS competition_series_name, + cs.shortcut AS competition_series_shortcut, + cs.slug AS competition_series_slug, ps.skill_tier, player.ranking_opted_out, CASE WHEN puzzle_solving_time.team IS NOT NULL THEN @@ -78,6 +81,7 @@ public function forPlayer(string $playerId, int $limit): array INNER JOIN player ON puzzle_solving_time.player_id = player.id INNER JOIN manufacturer ON manufacturer.id = puzzle.manufacturer_id LEFT JOIN competition ON puzzle_solving_time.competition_id = competition.id +LEFT JOIN competition_series cs ON cs.id = competition.series_id LEFT JOIN player_skill ps ON ps.player_id = player.id WHERE (puzzle_solving_time.player_id = :playerId OR (team::jsonb -> 'puzzlers') @> jsonb_build_array(jsonb_build_object('player_id', CAST(:playerId AS UUID)))) @@ -122,6 +126,9 @@ public function forPlayer(string $playerId, int $limit): array * competition_name: null|string, * competition_shortcut: null|string, * competition_slug: null|string, + * competition_series_name: null|string, + * competition_series_shortcut: null|string, + * competition_series_slug: null|string, * players: null|string, * skill_tier: null|int, * ranking_opted_out: bool, @@ -169,6 +176,9 @@ public function latest(int $limit): array competition.shortcut AS competition_shortcut, competition.name AS competition_name, competition.slug AS competition_slug, + cs.name AS competition_series_name, + cs.shortcut AS competition_series_shortcut, + cs.slug AS competition_series_slug, ps.skill_tier, player.ranking_opted_out, CASE WHEN puzzle_solving_time.team IS NOT NULL THEN @@ -190,6 +200,7 @@ public function latest(int $limit): array INNER JOIN player ON puzzle_solving_time.player_id = player.id INNER JOIN manufacturer ON manufacturer.id = puzzle.manufacturer_id LEFT JOIN competition ON puzzle_solving_time.competition_id = competition.id +LEFT JOIN competition_series cs ON cs.id = competition.series_id LEFT JOIN player_skill ps ON ps.player_id = player.id WHERE player.is_private = false ORDER BY puzzle_solving_time.tracked_at DESC @@ -232,6 +243,9 @@ public function latest(int $limit): array * competition_name: null|string, * competition_shortcut: null|string, * competition_slug: null|string, + * competition_series_name: null|string, + * competition_series_shortcut: null|string, + * competition_series_slug: null|string, * players: null|string, * skill_tier: null|int, * ranking_opted_out: bool, @@ -303,6 +317,9 @@ public function ofPlayerFavorites(int $limit, string $playerId): array competition.shortcut AS competition_shortcut, competition.name AS competition_name, competition.slug AS competition_slug, + cs.name AS competition_series_name, + cs.shortcut AS competition_series_shortcut, + cs.slug AS competition_series_slug, ps.skill_tier, player.ranking_opted_out, CASE WHEN pst.team IS NOT NULL THEN @@ -326,6 +343,7 @@ public function ofPlayerFavorites(int $limit, string $playerId): array INNER JOIN player ON pst.player_id = player.id INNER JOIN manufacturer ON manufacturer.id = puzzle.manufacturer_id LEFT JOIN competition ON competition.id = pst.competition_id +LEFT JOIN competition_series cs ON cs.id = competition.series_id LEFT JOIN player_skill ps ON ps.player_id = player.id WHERE is_private = false ORDER BY pst.tracked_at DESC @@ -368,6 +386,9 @@ public function ofPlayerFavorites(int $limit, string $playerId): array * competition_name: null|string, * competition_shortcut: null|string, * competition_slug: null|string, + * competition_series_name: null|string, + * competition_series_shortcut: null|string, + * competition_series_slug: null|string, * players: null|string, * skill_tier: null|int, * ranking_opted_out: bool, diff --git a/src/Query/IsCompetitionPubliclyVisible.php b/src/Query/IsCompetitionPubliclyVisible.php new file mode 100644 index 000000000..a2bb73cfa --- /dev/null +++ b/src/Query/IsCompetitionPubliclyVisible.php @@ -0,0 +1,67 @@ +database + ->executeQuery($query, [ + 'competitionId' => $competitionId, + ]) + ->fetchOne(); + + return $result !== false; + } +} diff --git a/src/Results/PuzzleSolver.php b/src/Results/PuzzleSolver.php index 714446521..d9561e6ce 100644 --- a/src/Results/PuzzleSolver.php +++ b/src/Results/PuzzleSolver.php @@ -28,6 +28,9 @@ public function __construct( public null|string $competitionSlug, public null|string $skillTierName = null, public bool $rankingOptedOut = false, + public null|string $competitionSeriesName = null, + public null|string $competitionSeriesShortcut = null, + public null|string $competitionSeriesSlug = null, ) { } @@ -49,6 +52,9 @@ public function __construct( * competition_shortcut: null|string, * competition_name: null|string, * competition_slug: null|string, + * competition_series_name: null|string, + * competition_series_shortcut: null|string, + * competition_series_slug: null|string, * skill_tier_name?: null|string, * ranking_opted_out?: bool, * ... @@ -73,6 +79,9 @@ public static function fromDatabaseRow(array $row): self competitionShortcut: $row['competition_shortcut'], competitionName: $row['competition_name'], competitionSlug: $row['competition_slug'], + competitionSeriesName: $row['competition_series_name'], + competitionSeriesShortcut: $row['competition_series_shortcut'], + competitionSeriesSlug: $row['competition_series_slug'], skillTierName: $row['skill_tier_name'] ?? null, rankingOptedOut: $row['ranking_opted_out'] ?? false, ); diff --git a/src/Results/PuzzleSolversGroup.php b/src/Results/PuzzleSolversGroup.php index 0f5c926e8..79ec45cf9 100644 --- a/src/Results/PuzzleSolversGroup.php +++ b/src/Results/PuzzleSolversGroup.php @@ -23,6 +23,9 @@ public function __construct( public null|string $competitionShortcut, public null|string $competitionName, public null|string $competitionSlug, + public null|string $competitionSeriesName = null, + public null|string $competitionSeriesShortcut = null, + public null|string $competitionSeriesSlug = null, ) { } @@ -43,6 +46,9 @@ public function __construct( * competition_shortcut: null|string, * competition_name: null|string, * competition_slug: null|string, + * competition_series_name: null|string, + * competition_series_shortcut: null|string, + * competition_series_slug: null|string, * } $row */ public static function fromDatabaseRow(array $row): self @@ -62,6 +68,9 @@ public static function fromDatabaseRow(array $row): self competitionShortcut: $row['competition_shortcut'], competitionName: $row['competition_name'], competitionSlug: $row['competition_slug'], + competitionSeriesName: $row['competition_series_name'], + competitionSeriesShortcut: $row['competition_series_shortcut'], + competitionSeriesSlug: $row['competition_series_slug'], ); } diff --git a/src/Results/RecentActivityItem.php b/src/Results/RecentActivityItem.php index 841a6d85f..7318d557c 100644 --- a/src/Results/RecentActivityItem.php +++ b/src/Results/RecentActivityItem.php @@ -41,6 +41,9 @@ public function __construct( public null|string $competitionSlug, public null|string $skillTierName = null, public bool $rankingOptedOut = false, + public null|string $competitionSeriesName = null, + public null|string $competitionSeriesShortcut = null, + public null|string $competitionSeriesSlug = null, ) { } @@ -83,6 +86,9 @@ public function isSpeedMode(): bool * competition_name: null|string, * competition_shortcut: null|string, * competition_slug: null|string, + * competition_series_name: null|string, + * competition_series_shortcut: null|string, + * competition_series_slug: null|string, * skill_tier_name?: null|string, * ranking_opted_out?: bool, * ... @@ -128,6 +134,9 @@ public static function fromDatabaseRow(array $row): self competitionShortcut: $row['competition_shortcut'], competitionName: $row['competition_name'], competitionSlug: $row['competition_slug'], + competitionSeriesName: $row['competition_series_name'], + competitionSeriesShortcut: $row['competition_series_shortcut'], + competitionSeriesSlug: $row['competition_series_slug'], skillTierName: $row['skill_tier_name'] ?? null, rankingOptedOut: $row['ranking_opted_out'] ?? false, ); diff --git a/src/Results/SolvedPuzzle.php b/src/Results/SolvedPuzzle.php index b21e37d2d..1961dfd6e 100644 --- a/src/Results/SolvedPuzzle.php +++ b/src/Results/SolvedPuzzle.php @@ -43,6 +43,9 @@ public function __construct( public bool $suspicious = false, public null|string $skillTierName = null, public bool $rankingOptedOut = false, + public null|string $competitionSeriesName = null, + public null|string $competitionSeriesShortcut = null, + public null|string $competitionSeriesSlug = null, ) { } @@ -76,6 +79,9 @@ public function __construct( * competition_name: null|string, * competition_shortcut: null|string, * competition_slug: null|string, + * competition_series_name: null|string, + * competition_series_shortcut: null|string, + * competition_series_slug: null|string, * suspicious?: bool, * skill_tier_name?: null|string, * ranking_opted_out?: bool, @@ -123,6 +129,9 @@ public static function fromDatabaseRow(array $row): self competitionShortcut: $row['competition_shortcut'], competitionName: $row['competition_name'], competitionSlug: $row['competition_slug'], + competitionSeriesName: $row['competition_series_name'], + competitionSeriesShortcut: $row['competition_series_shortcut'], + competitionSeriesSlug: $row['competition_series_slug'], suspicious: $row['suspicious'] ?? false, skillTierName: $row['skill_tier_name'] ?? null, rankingOptedOut: $row['ranking_opted_out'] ?? false, diff --git a/templates/_competition_badge.html.twig b/templates/_competition_badge.html.twig new file mode 100644 index 000000000..f8c8bff3b --- /dev/null +++ b/templates/_competition_badge.html.twig @@ -0,0 +1,37 @@ +{# + Event badge of a solving time. + + `time` is any read model exposing competitionName / competitionShortcut / competitionSlug and + competitionSeriesName / competitionSeriesShortcut / competitionSeriesSlug + (SolvedPuzzle, PuzzleSolver, PuzzleSolversGroup, RecentActivityItem). + + - Standalone competition: label = shortcut ?? name, links to event_detail. + - Series edition: label = " · " (series label only when the + edition is named like its series, e.g. competitions converted to a series), links to edition_detail — + never to event_detail, an edition slug is only unique within its series. + - Renders nothing when the time is not linked to any competition. +#} +{% if time.competitionName %} + {% if time.competitionSeriesName %} + {% set series_label = time.competitionSeriesShortcut ?? time.competitionSeriesName %} + {% if time.competitionName|lower == time.competitionSeriesName|lower %} + {% set label = series_label %} + {% else %} + {% set label = series_label ~ ' · ' ~ time.competitionName %} + {% endif %} + {% set href = (time.competitionSeriesSlug and time.competitionSlug) + ? path('edition_detail', {seriesSlug: time.competitionSeriesSlug, editionSlug: time.competitionSlug}) + : null %} + {% else %} + {% set label = time.competitionShortcut ?? time.competitionName %} + {% set href = time.competitionSlug ? path('event_detail', {slug: time.competitionSlug}) : null %} + {% endif %} +
+ {% if href %} + + {% endif %} + {{ label }} + {% if href %} + + {% endif %} +{% endif %} diff --git a/templates/_ladder.html.twig b/templates/_ladder.html.twig deleted file mode 100644 index 971f3ed08..000000000 --- a/templates/_ladder.html.twig +++ /dev/null @@ -1,106 +0,0 @@ -
- - - {% for solving_time in puzzle_solving_times %} - - - - - - - - - - - {% endfor %} - -
- {{ loop.index }} - - - {{ 'puzzle_img_alt'|trans({'%puzzle%': solving_time.manufacturerName ~ ' ' ~ solving_time.puzzleName}) }} - - - - - {{ solving_time.manufacturerName }} -
- - - {{ solving_time.puzzleName }} - -
-
-

- {% if solving_time.players is null %} - {% if solving_time.isPrivate %} - {{ 'secret_puzzler_name'|trans }} - - {% else %} - - {% if logged_user.profile is not null and solving_time.playerId in logged_user.profile.favoritePlayers %} - - {% endif %} - {{ solving_time.playerName ?? ('#' ~ solving_time.playerCode) }} - {% if solving_time.playerCountry is not null %} - - {% endif %} - {% endif %} - {% else %} - {% for puzzle_solver in solving_time.players %} - {% if not loop.first %} -
- {% endif %} - {% if puzzle_solver.isPrivate %} - {{ 'secret_puzzler_name'|trans }} - - {% elseif puzzle_solver.playerId is not null %} - - {% if logged_user.profile is not null and puzzle_solver.playerId in logged_user.profile.favoritePlayers %} - - {% endif %} - {{ puzzle_solver.playerName ?? ('#' ~ puzzle_solver.playerCode) }} - - {% if puzzle_solver.playerCountry is not null %} - - {% endif %} - {% else %} - {{ puzzle_solver.playerName ?? ('#' ~ puzzle_solver.playerCode) }} - {% endif %} - {% endfor %} - {% endif %} - - {% if solving_time.solvedTimes > 1 %} - {{ 'puzzle_solved_times'|trans({ '%count%': solving_time.solvedTimes })|raw }} - {% endif %} -

- -

- {{ solving_time.manufacturerName }}
-

-
- {{ solving_time.time|puzzlingTime }} - {% if solving_time.finishedAt is not null %} -
- {{ solving_time.finishedAt|date('d.m.Y') }} - {% endif %} -
- - PPM {{ ppm(solving_time.time, solving_time.piecesCount) }} - {% if solving_time.players is not null %} -
({{ solving_time.players|length }}x {{ ppm(solving_time.time, solving_time.piecesCount, solving_time.players|length) }}) - {% endif %} -
- - {% if solving_time.competitionName %} -
- {% if solving_time.competitionSlug %} - - {% endif %} - {{ solving_time.competitionShortcut ?? solving_time.competitionName }} - {% if solving_time.competitionSlug %} - - {% endif %} - {% endif %} -
-
diff --git a/templates/_player_solvings.html.twig b/templates/_player_solvings.html.twig index b05847fa8..3f9c7d2fc 100644 --- a/templates/_player_solvings.html.twig +++ b/templates/_player_solvings.html.twig @@ -143,16 +143,7 @@ {% endif %} - {% if puzzle.competitionName %} -
- {% if puzzle.competitionSlug %} - - {% endif %} - {{ puzzle.competitionShortcut ?? puzzle.competitionName }} - {% if puzzle.competitionSlug %} - - {% endif %} - {% endif %} + {{ include('_competition_badge.html.twig', {time: puzzle}) }} {# Display edit icon only to current user #} diff --git a/templates/components/LadderTable.html.twig b/templates/components/LadderTable.html.twig index 1450971e5..30f0fe687 100644 --- a/templates/components/LadderTable.html.twig +++ b/templates/components/LadderTable.html.twig @@ -109,16 +109,7 @@ {% endif %} - {% if solving_time.competitionName %} -
- {% if solving_time.competitionSlug %} - - {% endif %} - {{ solving_time.competitionShortcut ?? solving_time.competitionName }} - {% if solving_time.competitionSlug %} - - {% endif %} - {% endif %} + {{ include('_competition_badge.html.twig', {time: solving_time}) }} {% endfor %} diff --git a/templates/components/PuzzleTimes.html.twig b/templates/components/PuzzleTimes.html.twig index c56363c5a..8d850ef70 100644 --- a/templates/components/PuzzleTimes.html.twig +++ b/templates/components/PuzzleTimes.html.twig @@ -401,18 +401,7 @@ {{ 'unboxed'|trans }} {% endif %} - {% if aggregated_solver[0].competitionName %} -
- {% if aggregated_solver[0].competitionSlug %} - - {% endif %} - - {{ aggregated_solver[0].competitionShortcut ?? aggregated_solver[0].competitionName }} - - {% if aggregated_solver[0].competitionSlug %} - - {% endif %} - {% endif %} + {{ include('_competition_badge.html.twig', {time: aggregated_solver[0]}) }} {% if aggregated_solver|length > 1 %}
@@ -449,16 +438,7 @@ {{ 'unboxed'|trans }} {% endif %} - {% if solving_time.competitionName %} -
- {% if solving_time.competitionSlug %} - - {% endif %} - {{ solving_time.competitionShortcut ?? aggregated_solver[0].competitionName }} - {% if solving_time.competitionSlug %} - - {% endif %} - {% endif %} + {{ include('_competition_badge.html.twig', {time: solving_time}) }} {% if not loop.last %} diff --git a/templates/components/RecentActivity.html.twig b/templates/components/RecentActivity.html.twig index 81b20037f..6f6390a4a 100644 --- a/templates/components/RecentActivity.html.twig +++ b/templates/components/RecentActivity.html.twig @@ -163,16 +163,7 @@ {{ 'unboxed'|trans }} {% endif %} - {% if item.competitionName %} -
- {% if item.competitionSlug %} - - {% endif %} - {{ item.competitionShortcut ?? item.competitionName }} - {% if item.competitionSlug %} - - {% endif %} - {% endif %} + {{ include('_competition_badge.html.twig', {time: item}) }} {% else %} {{ 'badge.relax'|trans }} diff --git a/tests/Component/PuzzleTimesCompetitionBadgeTest.php b/tests/Component/PuzzleTimesCompetitionBadgeTest.php new file mode 100644 index 000000000..69f20f1e0 --- /dev/null +++ b/tests/Component/PuzzleTimesCompetitionBadgeTest.php @@ -0,0 +1,54 @@ +get(Connection::class); + $database->executeStatement( + 'UPDATE puzzle_solving_time SET competition_id = :competitionId WHERE id = :timeId', + ['competitionId' => CompetitionSeriesFixture::EDITION_EJJ_68, 'timeId' => PuzzleSolvingTimeFixture::TIME_36], + ); + + $component = $this->createLiveComponent('PuzzleTimes', [ + 'puzzleId' => PuzzleFixture::PUZZLE_500_01, + 'piecesCount' => 500, + ], $client); + $component->setRouteLocale('en'); + + $html = $component->render()->toString(); + + // Edition: " · " badge, linked straight to the edition page. + self::assertStringContainsString('Euro Jigsaw Jam · EJJ #68 — February 2026', $html); + self::assertStringContainsString('href="/en/series/euro-jigsaw-jam-series/ejj-68-february-2026"', $html); + + // Standalone: unchanged shortcut badge linked to the event page. + self::assertStringContainsString(' WJPC24', $html); + self::assertStringContainsString('href="/en/events/wjpc-2024"', $html); + + // An edition must never be linked through the bare-slug event route. + self::assertStringNotContainsString('/en/events/ejj-68-february-2026', $html); + } +} diff --git a/tests/Controller/Api/V1/CompetitionDetailEndpointTest.php b/tests/Controller/Api/V1/CompetitionDetailEndpointTest.php index 1ee7e71dc..cd75818c7 100644 --- a/tests/Controller/Api/V1/CompetitionDetailEndpointTest.php +++ b/tests/Controller/Api/V1/CompetitionDetailEndpointTest.php @@ -4,8 +4,10 @@ namespace SpeedPuzzling\Web\Tests\Controller\Api\V1; +use Doctrine\DBAL\Connection; use SpeedPuzzling\Web\Tests\DataFixtures\CompetitionApiFixture; use SpeedPuzzling\Web\Tests\DataFixtures\CompetitionFixture; +use SpeedPuzzling\Web\Tests\DataFixtures\CompetitionSeriesFixture; use SpeedPuzzling\Web\Tests\DataFixtures\OAuth2ClientFixture; use SpeedPuzzling\Web\Tests\DataFixtures\PlayerFixture; use SpeedPuzzling\Web\Tests\OAuth2TestHelper; @@ -23,6 +25,10 @@ public function testWithValidTokenReturnsCompetitionWithRounds(): void $this->assertArrayHasKey('name', $response); $this->assertArrayHasKey('rounds', $response); + // A standalone competition has no parent series. + $this->assertArrayHasKey('series', $response); + $this->assertNull($response['series']); + /** @var array> $rounds */ $rounds = $response['rounds']; @@ -167,6 +173,51 @@ public function testRejectedCompetitionReturnsNotFound(): void $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); } + public function testEditionOfApprovedSeriesIsReadable(): void + { + // An edition is never approved on its own (approved_at stays NULL) — the approval of its + // series is what makes it public, and the detail carries the series so clients can + // build the /series/{seriesSlug}/{editionSlug} link. + $response = $this->requestDetail(CompetitionSeriesFixture::EDITION_EJJ_68); + + $this->assertSame(CompetitionSeriesFixture::EDITION_EJJ_68, $response['id']); + $this->assertSame('EJJ #68 — February 2026', $response['name']); + + /** @var array> $rounds */ + $rounds = $response['rounds']; + $this->assertContains(CompetitionSeriesFixture::ROUND_EJJ_68, array_column($rounds, 'id')); + + /** @var array{id: string, name: string, slug: null|string} $series */ + $series = $response['series']; + $this->assertSame(CompetitionSeriesFixture::SERIES_EJJ, $series['id']); + $this->assertSame('Euro Jigsaw Jam', $series['name']); + $this->assertSame('euro-jigsaw-jam-series', $series['slug']); + } + + public function testEditionOfRejectedSeriesReturnsNotFound(): void + { + $browser = $this->authenticatedClient(); + + /** @var Connection $database */ + $database = self::getContainer()->get(Connection::class); + $database->executeStatement( + 'UPDATE competition_series SET rejected_at = now() WHERE id = :seriesId', + ['seriesId' => CompetitionSeriesFixture::SERIES_EJJ], + ); + + $browser->request('GET', '/api/v1/competitions/' . CompetitionSeriesFixture::EDITION_EJJ_68); + + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + } + + public function testEditionOfUnapprovedSeriesReturnsNotFound(): void + { + $browser = $this->authenticatedClient(); + $browser->request('GET', '/api/v1/competitions/' . CompetitionSeriesFixture::EDITION_UNAPPROVED_1); + + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + } + /** * @return array */ diff --git a/tests/DataFixtures/CompetitionSeriesFixture.php b/tests/DataFixtures/CompetitionSeriesFixture.php index 9df767ec3..619f992a0 100644 --- a/tests/DataFixtures/CompetitionSeriesFixture.php +++ b/tests/DataFixtures/CompetitionSeriesFixture.php @@ -20,10 +20,12 @@ final class CompetitionSeriesFixture extends Fixture implements DependentFixture public const string SERIES_EJJ = '018d0005-0000-0000-0000-000000000001'; public const string SERIES_OFFLINE = '018d0005-0000-0000-0000-000000000002'; public const string SERIES_PAST_ONLY = '018d0005-0000-0000-0000-000000000003'; + public const string SERIES_UNAPPROVED = '018d0005-0000-0000-0000-000000000004'; public const string EDITION_EJJ_68 = '018d0005-0000-0000-0000-000000000010'; public const string EDITION_EJJ_69 = '018d0005-0000-0000-0000-000000000011'; public const string EDITION_OFFLINE_1 = '018d0005-0000-0000-0000-000000000012'; public const string EDITION_PAST_ONLY_1 = '018d0005-0000-0000-0000-000000000013'; + public const string EDITION_UNAPPROVED_1 = '018d0005-0000-0000-0000-000000000014'; public const string ROUND_EJJ_68 = '018d0005-0000-0000-0000-000000000020'; public const string ROUND_EJJ_69 = '018d0005-0000-0000-0000-000000000021'; public const string ROUND_OFFLINE_SOLO = '018d0005-0000-0000-0000-000000000022'; @@ -223,6 +225,43 @@ public function load(ObjectManager $manager): void ); $manager->persist($pastOnlyRound); + // Series still waiting for admin approval — its editions must stay publicly invisible + // (editions are never approved individually; the series approval governs them). + $unapprovedSeries = new CompetitionSeries( + id: Uuid::fromString(self::SERIES_UNAPPROVED), + name: 'Pending Puzzle League', + slug: 'pending-puzzle-league', + logo: null, + description: 'Online league awaiting approval', + link: null, + isOnline: true, + addedByPlayer: $adminPlayer, + approvedAt: null, + createdAt: $this->clock->now(), + ); + $manager->persist($unapprovedSeries); + $this->addReference(self::SERIES_UNAPPROVED, $unapprovedSeries); + + $unapprovedEdition = new Competition( + id: Uuid::fromString(self::EDITION_UNAPPROVED_1), + name: 'Pending Puzzle League #1', + slug: 'pending-puzzle-league-1', + shortcut: null, + logo: null, + description: null, + link: null, + registrationLink: null, + resultsLink: null, + location: null, + locationCountryCode: null, + dateFrom: $this->clock->now()->modify('+7 days'), + dateTo: $this->clock->now()->modify('+7 days'), + tag: null, + isOnline: true, + series: $unapprovedSeries, + ); + $manager->persist($unapprovedEdition); + $manager->flush(); } diff --git a/tests/Query/GetFastestPlayersTest.php b/tests/Query/GetFastestPlayersTest.php index 43152645d..aa764bbb0 100644 --- a/tests/Query/GetFastestPlayersTest.php +++ b/tests/Query/GetFastestPlayersTest.php @@ -4,7 +4,10 @@ namespace SpeedPuzzling\Web\Tests\Query; +use Doctrine\DBAL\Connection; use SpeedPuzzling\Web\Query\GetFastestPlayers; +use SpeedPuzzling\Web\Tests\DataFixtures\CompetitionFixture; +use SpeedPuzzling\Web\Tests\DataFixtures\CompetitionSeriesFixture; use SpeedPuzzling\Web\Tests\DataFixtures\PlayerFixture; use SpeedPuzzling\Web\Value\CountryCode; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; @@ -12,12 +15,14 @@ final class GetFastestPlayersTest extends KernelTestCase { private GetFastestPlayers $query; + private Connection $database; protected function setUp(): void { self::bootKernel(); $container = self::getContainer(); $this->query = $container->get(GetFastestPlayers::class); + $this->database = $container->get(Connection::class); } public function testPerPiecesCountReturnsOnlySoloTimes(): void @@ -102,4 +107,47 @@ public function testPerPiecesCountReturnsEmptyForNonExistentPiecesCount(): void self::assertEmpty($results); } + + public function testPerPiecesCountExposesSeriesOfEditionAndKeepsStandaloneShape(): void + { + // The ladder keeps each player's fastest time (CTE + GROUP BY). Point PLAYER_REGULAR's + // fastest 500-piece time at an EJJ edition and PLAYER_ADMIN's at the standalone WJPC 2024, + // then check both shapes survive the aggregation. + $byPlayerId = []; + foreach ($this->query->perPiecesCount(500, 20, null) as $result) { + $byPlayerId[$result->playerId] = $result; + } + + self::assertArrayHasKey(PlayerFixture::PLAYER_REGULAR, $byPlayerId); + self::assertArrayHasKey(PlayerFixture::PLAYER_ADMIN, $byPlayerId); + + $this->database->executeStatement( + 'UPDATE puzzle_solving_time SET competition_id = :competitionId WHERE id = :timeId', + ['competitionId' => CompetitionSeriesFixture::EDITION_EJJ_68, 'timeId' => $byPlayerId[PlayerFixture::PLAYER_REGULAR]->timeId], + ); + $this->database->executeStatement( + 'UPDATE puzzle_solving_time SET competition_id = :competitionId WHERE id = :timeId', + ['competitionId' => CompetitionFixture::COMPETITION_WJPC_2024, 'timeId' => $byPlayerId[PlayerFixture::PLAYER_ADMIN]->timeId], + ); + + $byPlayerId = []; + foreach ($this->query->perPiecesCount(500, 20, null) as $result) { + $byPlayerId[$result->playerId] = $result; + } + + $edition = $byPlayerId[PlayerFixture::PLAYER_REGULAR]; + self::assertSame('EJJ #68 — February 2026', $edition->competitionName); + self::assertSame('ejj-68-february-2026', $edition->competitionSlug); + self::assertSame('Euro Jigsaw Jam', $edition->competitionSeriesName); + self::assertSame('euro-jigsaw-jam-series', $edition->competitionSeriesSlug); + self::assertNull($edition->competitionSeriesShortcut); + + $standalone = $byPlayerId[PlayerFixture::PLAYER_ADMIN]; + self::assertSame('WJPC 2024', $standalone->competitionName); + self::assertSame('WJPC24', $standalone->competitionShortcut); + self::assertSame('wjpc-2024', $standalone->competitionSlug); + self::assertNull($standalone->competitionSeriesName); + self::assertNull($standalone->competitionSeriesShortcut); + self::assertNull($standalone->competitionSeriesSlug); + } } diff --git a/tests/Query/GetPlayerSolvedPuzzlesTest.php b/tests/Query/GetPlayerSolvedPuzzlesTest.php index 3b6e2b793..4790968d3 100644 --- a/tests/Query/GetPlayerSolvedPuzzlesTest.php +++ b/tests/Query/GetPlayerSolvedPuzzlesTest.php @@ -5,7 +5,9 @@ namespace SpeedPuzzling\Web\Tests\Query; use DateTimeImmutable; +use Doctrine\DBAL\Connection; use SpeedPuzzling\Web\Query\GetPlayerSolvedPuzzles; +use SpeedPuzzling\Web\Tests\DataFixtures\CompetitionSeriesFixture; use SpeedPuzzling\Web\Tests\DataFixtures\PlayerFixture; use SpeedPuzzling\Web\Tests\DataFixtures\PuzzleFixture; use SpeedPuzzling\Web\Tests\DataFixtures\PuzzleSolvingTimeFixture; @@ -14,12 +16,14 @@ final class GetPlayerSolvedPuzzlesTest extends KernelTestCase { private GetPlayerSolvedPuzzles $query; + private Connection $database; protected function setUp(): void { self::bootKernel(); $container = self::getContainer(); $this->query = $container->get(GetPlayerSolvedPuzzles::class); + $this->database = $container->get(Connection::class); } public function testSoloByPlayerIdReturnsOnlySoloSolves(): void @@ -178,4 +182,35 @@ public function testSoloByPlayerIdWithoutDateRangeIncludesNullFinishedAt(): void 'Entries with null finished_at should be included in all-time statistics', ); } + + public function testSoloByPlayerIdExposesSeriesOfEditionAndKeepsStandaloneShape(): void + { + // TIME_36 (PLAYER_REGULAR, no competition) is pointed at an edition of the EJJ series. + $this->database->executeStatement( + 'UPDATE puzzle_solving_time SET competition_id = :competitionId WHERE id = :timeId', + ['competitionId' => CompetitionSeriesFixture::EDITION_EJJ_68, 'timeId' => PuzzleSolvingTimeFixture::TIME_36], + ); + + $byTimeId = []; + foreach ($this->query->soloByPlayerId(PlayerFixture::PLAYER_REGULAR) as $solvedPuzzle) { + $byTimeId[$solvedPuzzle->timeId] = $solvedPuzzle; + } + + $edition = $byTimeId[PuzzleSolvingTimeFixture::TIME_36]; + self::assertSame('EJJ #68 — February 2026', $edition->competitionName); + self::assertSame('ejj-68-february-2026', $edition->competitionSlug); + self::assertNull($edition->competitionShortcut); + self::assertSame('Euro Jigsaw Jam', $edition->competitionSeriesName); + self::assertSame('euro-jigsaw-jam-series', $edition->competitionSeriesSlug); + self::assertNull($edition->competitionSeriesShortcut); + + // TIME_09 is linked to the standalone WJPC 2024 — the standalone shape is untouched. + $standalone = $byTimeId[PuzzleSolvingTimeFixture::TIME_09]; + self::assertSame('WJPC 2024', $standalone->competitionName); + self::assertSame('WJPC24', $standalone->competitionShortcut); + self::assertSame('wjpc-2024', $standalone->competitionSlug); + self::assertNull($standalone->competitionSeriesName); + self::assertNull($standalone->competitionSeriesShortcut); + self::assertNull($standalone->competitionSeriesSlug); + } } diff --git a/tests/Query/GetPuzzleSolversTest.php b/tests/Query/GetPuzzleSolversTest.php index 1c274dabe..c0f8ac00e 100644 --- a/tests/Query/GetPuzzleSolversTest.php +++ b/tests/Query/GetPuzzleSolversTest.php @@ -4,19 +4,32 @@ namespace SpeedPuzzling\Web\Tests\Query; +use Doctrine\DBAL\Connection; use SpeedPuzzling\Web\Query\GetPuzzleSolvers; +use SpeedPuzzling\Web\Tests\DataFixtures\CompetitionSeriesFixture; use SpeedPuzzling\Web\Tests\DataFixtures\PuzzleFixture; +use SpeedPuzzling\Web\Tests\DataFixtures\PuzzleSolvingTimeFixture; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; final class GetPuzzleSolversTest extends KernelTestCase { private GetPuzzleSolvers $query; + private Connection $database; protected function setUp(): void { self::bootKernel(); $container = self::getContainer(); $this->query = $container->get(GetPuzzleSolvers::class); + $this->database = $container->get(Connection::class); + } + + private function linkTimeToCompetition(string $timeId, string $competitionId): void + { + $this->database->executeStatement( + 'UPDATE puzzle_solving_time SET competition_id = :competitionId WHERE id = :timeId', + ['competitionId' => $competitionId, 'timeId' => $timeId], + ); } public function testSoloByPuzzleIdReturnsSoloSolvers(): void @@ -102,4 +115,50 @@ public function testRelaxCountsByPuzzleIdReturnsCorrectCounts(): void self::assertSame(0, $counts['duo']); self::assertSame(0, $counts['team']); } + + public function testSoloByPuzzleIdExposesSeriesOfEditionAndKeepsStandaloneShape(): void + { + // TIME_36 (PLAYER_REGULAR on PUZZLE_500_01, no competition) is pointed at an EJJ edition; + // TIME_09 on the same puzzle stays linked to the standalone WJPC 2024. + $this->linkTimeToCompetition(PuzzleSolvingTimeFixture::TIME_36, CompetitionSeriesFixture::EDITION_EJJ_68); + + $byTimeId = []; + foreach ($this->query->soloByPuzzleId(PuzzleFixture::PUZZLE_500_01) as $solver) { + $byTimeId[$solver->timeId] = $solver; + } + + $edition = $byTimeId[PuzzleSolvingTimeFixture::TIME_36]; + self::assertSame('EJJ #68 — February 2026', $edition->competitionName); + self::assertSame('ejj-68-february-2026', $edition->competitionSlug); + self::assertSame('Euro Jigsaw Jam', $edition->competitionSeriesName); + self::assertSame('euro-jigsaw-jam-series', $edition->competitionSeriesSlug); + self::assertNull($edition->competitionSeriesShortcut); + + $standalone = $byTimeId[PuzzleSolvingTimeFixture::TIME_09]; + self::assertSame('WJPC 2024', $standalone->competitionName); + self::assertSame('WJPC24', $standalone->competitionShortcut); + self::assertSame('wjpc-2024', $standalone->competitionSlug); + self::assertNull($standalone->competitionSeriesName); + self::assertNull($standalone->competitionSeriesShortcut); + self::assertNull($standalone->competitionSeriesSlug); + } + + public function testDuoByPuzzleIdExposesSeriesOfEdition(): void + { + // The duo block aggregates players (GROUP BY) — the series columns must survive it too. + $this->linkTimeToCompetition(PuzzleSolvingTimeFixture::TIME_12, CompetitionSeriesFixture::EDITION_EJJ_68); + + $byTimeId = []; + foreach ($this->query->duoByPuzzleId(PuzzleFixture::PUZZLE_1000_01) as $group) { + $byTimeId[$group->timeId] = $group; + } + + $edition = $byTimeId[PuzzleSolvingTimeFixture::TIME_12]; + self::assertCount(2, $edition->players); + self::assertSame('EJJ #68 — February 2026', $edition->competitionName); + self::assertSame('ejj-68-february-2026', $edition->competitionSlug); + self::assertSame('Euro Jigsaw Jam', $edition->competitionSeriesName); + self::assertSame('euro-jigsaw-jam-series', $edition->competitionSeriesSlug); + self::assertNull($edition->competitionSeriesShortcut); + } } diff --git a/tests/Query/GetRecentActivityTest.php b/tests/Query/GetRecentActivityTest.php new file mode 100644 index 000000000..149ca0306 --- /dev/null +++ b/tests/Query/GetRecentActivityTest.php @@ -0,0 +1,74 @@ +query = $container->get(GetRecentActivity::class); + $this->database = $container->get(Connection::class); + } + + public function testLatestExposesSeriesOfEditionAndKeepsStandaloneShape(): void + { + // TIME_36 (PLAYER_REGULAR, public profile, no competition) is pointed at an EJJ edition; + // TIME_09 stays linked to the standalone WJPC 2024. + $this->database->executeStatement( + 'UPDATE puzzle_solving_time SET competition_id = :competitionId WHERE id = :timeId', + ['competitionId' => CompetitionSeriesFixture::EDITION_EJJ_68, 'timeId' => PuzzleSolvingTimeFixture::TIME_36], + ); + + $byTimeId = []; + foreach ($this->query->latest(200) as $item) { + $byTimeId[$item->id] = $item; + } + + $edition = $byTimeId[PuzzleSolvingTimeFixture::TIME_36]; + self::assertSame('EJJ #68 — February 2026', $edition->competitionName); + self::assertSame('ejj-68-february-2026', $edition->competitionSlug); + self::assertSame('Euro Jigsaw Jam', $edition->competitionSeriesName); + self::assertSame('euro-jigsaw-jam-series', $edition->competitionSeriesSlug); + self::assertNull($edition->competitionSeriesShortcut); + + $standalone = $byTimeId[PuzzleSolvingTimeFixture::TIME_09]; + self::assertSame('WJPC 2024', $standalone->competitionName); + self::assertSame('WJPC24', $standalone->competitionShortcut); + self::assertSame('wjpc-2024', $standalone->competitionSlug); + self::assertNull($standalone->competitionSeriesName); + self::assertNull($standalone->competitionSeriesShortcut); + self::assertNull($standalone->competitionSeriesSlug); + } + + public function testForPlayerExposesSeriesOfEdition(): void + { + $this->database->executeStatement( + 'UPDATE puzzle_solving_time SET competition_id = :competitionId WHERE id = :timeId', + ['competitionId' => CompetitionSeriesFixture::EDITION_EJJ_68, 'timeId' => PuzzleSolvingTimeFixture::TIME_36], + ); + + $byTimeId = []; + foreach ($this->query->forPlayer(PlayerFixture::PLAYER_REGULAR, 200) as $item) { + $byTimeId[$item->id] = $item; + } + + $edition = $byTimeId[PuzzleSolvingTimeFixture::TIME_36]; + self::assertSame('Euro Jigsaw Jam', $edition->competitionSeriesName); + self::assertSame('euro-jigsaw-jam-series', $edition->competitionSeriesSlug); + self::assertSame('ejj-68-february-2026', $edition->competitionSlug); + } +} diff --git a/tests/Query/IsCompetitionPubliclyVisibleTest.php b/tests/Query/IsCompetitionPubliclyVisibleTest.php new file mode 100644 index 000000000..f18f1f3c7 --- /dev/null +++ b/tests/Query/IsCompetitionPubliclyVisibleTest.php @@ -0,0 +1,73 @@ +query = self::getContainer()->get(IsCompetitionPubliclyVisible::class); + $this->database = self::getContainer()->get(Connection::class); + } + + public function testApprovedStandaloneCompetitionIsVisible(): void + { + self::assertTrue($this->query->check(CompetitionFixture::COMPETITION_WJPC_2024)); + } + + public function testEditionsOfApprovedSeriesAreVisibleRegardlessOfTheirOwnApprovedAt(): void + { + // Editions never carry their own approval — the series approval governs them. + self::assertTrue($this->query->check(CompetitionSeriesFixture::EDITION_EJJ_68)); + self::assertTrue($this->query->check(CompetitionSeriesFixture::EDITION_EJJ_69)); + } + + public function testUnapprovedStandaloneCompetitionIsNotVisible(): void + { + self::assertFalse($this->query->check(CompetitionFixture::COMPETITION_UNAPPROVED)); + } + + public function testRejectedStandaloneCompetitionIsNotVisibleEvenWhenApproved(): void + { + // approve() and reject() do not clear each other — rejected must veto a stale approval. + self::assertFalse($this->query->check(CompetitionApiFixture::COMPETITION_API_REJECTED)); + } + + public function testEditionOfRejectedSeriesIsNotVisible(): void + { + $this->database->executeStatement( + 'UPDATE competition_series SET rejected_at = now() WHERE id = :seriesId', + ['seriesId' => CompetitionSeriesFixture::SERIES_EJJ], + ); + + self::assertFalse($this->query->check(CompetitionSeriesFixture::EDITION_EJJ_68)); + } + + public function testEditionOfUnapprovedSeriesIsNotVisible(): void + { + self::assertFalse($this->query->check(CompetitionSeriesFixture::EDITION_UNAPPROVED_1)); + } + + public function testUnknownCompetitionIsNotVisible(): void + { + self::assertFalse($this->query->check('00000000-0000-0000-0000-000000000000')); + } + + public function testInvalidUuidIsNotVisible(): void + { + self::assertFalse($this->query->check('not-a-uuid')); + } +} diff --git a/tests/Twig/CompetitionBadgeTemplateTest.php b/tests/Twig/CompetitionBadgeTemplateTest.php new file mode 100644 index 000000000..29875f1fe --- /dev/null +++ b/tests/Twig/CompetitionBadgeTemplateTest.php @@ -0,0 +1,142 @@ +twig = self::getContainer()->get(Environment::class); + } + + /** + * @param array $time + */ + private function render(array $time): string + { + $defaults = [ + 'competitionName' => null, + 'competitionShortcut' => null, + 'competitionSlug' => null, + 'competitionSeriesName' => null, + 'competitionSeriesShortcut' => null, + 'competitionSeriesSlug' => null, + ]; + + $html = $this->twig->render('_competition_badge.html.twig', [ + 'time' => array_merge($defaults, $time), + ]); + + // Collapse the template's indentation so assertions can target the markup itself. + return trim((string) preg_replace('/\s+/', ' ', $html)); + } + + public function testStandaloneWithShortcutShowsShortcutAndLinksToEvent(): void + { + $html = $this->render([ + 'competitionName' => 'WJPC 2024', + 'competitionShortcut' => 'WJPC24', + 'competitionSlug' => 'wjpc-2024', + ]); + + self::assertStringStartsWith('
', $html); + self::assertStringContainsString('', $html); + self::assertStringContainsString(' WJPC24', $html); + self::assertStringContainsString('', $html); + self::assertStringNotContainsString('WJPC 2024', $html); + } + + public function testStandaloneWithoutShortcutFallsBackToName(): void + { + $html = $this->render([ + 'competitionName' => 'Czech National Championship 2024', + 'competitionSlug' => 'czech-nationals-2024', + ]); + + self::assertStringContainsString('', $html); + self::assertStringContainsString(' Czech National Championship 2024', $html); + } + + public function testStandaloneWithoutSlugRendersBadgeWithoutLink(): void + { + $html = $this->render([ + 'competitionName' => 'Garage Puzzle Night', + 'competitionShortcut' => 'GPN', + ]); + + self::assertStringContainsString(' GPN', $html); + self::assertStringNotContainsString('', $html); + } + + public function testEditionCombinesSeriesNameAndEditionNameAndLinksToEdition(): void + { + $html = $this->render([ + 'competitionName' => 'EJJ #68 — February 2026', + 'competitionSlug' => 'ejj-68-february-2026', + 'competitionSeriesName' => 'Euro Jigsaw Jam', + 'competitionSeriesSlug' => 'euro-jigsaw-jam-series', + ]); + + self::assertStringContainsString('', $html); + self::assertStringContainsString(' Euro Jigsaw Jam · EJJ #68 — February 2026', $html); + self::assertStringNotContainsString('/en/events/', $html); + } + + public function testEditionPrefersSeriesShortcut(): void + { + $html = $this->render([ + 'competitionName' => 'EJJ #68 — February 2026', + 'competitionSlug' => 'ejj-68-february-2026', + 'competitionSeriesName' => 'Euro Jigsaw Jam', + 'competitionSeriesShortcut' => 'EJJ', + 'competitionSeriesSlug' => 'euro-jigsaw-jam-series', + ]); + + self::assertStringContainsString(' EJJ · EJJ #68 — February 2026', $html); + self::assertStringNotContainsString('Euro Jigsaw Jam', $html); + } + + public function testEditionNamedLikeItsSeriesShowsSeriesLabelOnly(): void + { + // Competitions converted to a series keep the series name on the edition. + $html = $this->render([ + 'competitionName' => 'euro jigsaw jam', + 'competitionSlug' => 'euro-jigsaw-jam', + 'competitionSeriesName' => 'Euro Jigsaw Jam', + 'competitionSeriesSlug' => 'euro-jigsaw-jam-series', + ]); + + self::assertStringContainsString(' Euro Jigsaw Jam', $html); + self::assertStringNotContainsString('·', $html); + self::assertStringContainsString('', $html); + } + + public function testEditionWithoutSeriesSlugNeverFallsBackToEventLink(): void + { + // A bare edition slug is only unique within its series, so without the series slug + // there is no safe URL — render the badge unlinked rather than pointing at event_detail. + $html = $this->render([ + 'competitionName' => 'EJJ #68 — February 2026', + 'competitionSlug' => 'ejj-68-february-2026', + 'competitionSeriesName' => 'Euro Jigsaw Jam', + ]); + + self::assertStringContainsString(' Euro Jigsaw Jam · EJJ #68 — February 2026', $html); + self::assertStringNotContainsString('render([])); + } +}