# UCL Manager League — plan wdrożenia

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Aplikacja prowadząca prywatne rozgrywki w formacie Ligi Mistrzów, w których o wyniku meczu decydują punkty uczestników z gry fantasy na gaming.uefa.com, wklejane ręcznie przez admina.

**Architecture:** Punkty uczestnika za kolejkę (`ManagerRoundScore`) są jedynym źródłem prawdy; wyniki meczów i tabela są z nich wyprowadzane przez przeliczenie od zera, więc import jest idempotentny. Logika rozgrywek (tabela, drabinka, parsowanie wklejki) mieszka w czystych klasach bez Doctrine i jest testowana jednostkowo; warstwa Doctrine tylko je karmi i utrwala wynik.

**Tech Stack:** Symfony 7.4 LTS, PHP 8.3+, Doctrine ORM 3 + migracje, Twig, Tabler (CSS), MySQL (dev, testy w bazie `ucl_test`), PHPUnit.

**Spec:** `docs/superpowers/specs/2026-09-03-ucl-manager-league-design.md`

## Global Constraints

- Symfony **7.4 LTS**, PHP **>= 8.3**. Nie podbijać do 8.x bez ustaleń.
- Katalog projektu: `/Users/gladki/projects/ucl`. Repozytorium git już zainicjowane.
- Nazwa migracji musi zawierać sekundy i milisekundy: `VersionYYYYMMDDHHMMSSmmm`. Jeśli generator nada zaokrąglony numer — zmienić nazwę pliku i klasy **przed pierwszym commitem**.
- **Raz utworzona migracja jest niezmienna.** Każda zmiana schematu = nowa migracja.
- Edycja ma dokładnie 13 kolejek: 1–8 faza ligowa, 9 baraże, 10 1/8, 11 ćwierć, 12 półfinał, 13 finał. Runda pucharowa to **jeden mecz**, nie dwumecz.
- Punktacja meczu: 3 / 1 / 0. Sortowanie tabeli: punkty → różnica punktów fantasy → punkty fantasy zdobyte → nazwa klubu rosnąco.
- Remis w meczu pucharowym: awansuje klub wyżej rozstawiony (lepsze miejsce w końcowej tabeli fazy ligowej).
- Wynik klubu w kolejce = suma punktów **wszystkich** przypisanych do niego uczestników (1 w trybie SOLO, 2 w PAIRS). Jedna ścieżka obliczeniowa dla obu trybów.
- Interfejs publiczny bez logowania; logowanie wyłącznie dla admina, bez 2FA i bez rejestracji.
- Język interfejsu: polski.

---

### Task 1: Szkielet projektu

**Files:**
- Create: cały szkielet Symfony w `/Users/gladki/projects/ucl`
- Create: `.env.local`, `.env.test`, `phpunit.xml.dist`
- Create: `tests/SmokeTest.php`

**Interfaces:**
- Consumes: nic
- Produces: działający `bin/console`, `bin/phpunit`, autoloading `App\` z `src/`, `App\Tests\` z `tests/`

- [ ] **Step 1: Utworzyć szkielet w istniejącym katalogu z gitem**

`composer create-project` odmawia pracy w katalogu, w którym jest `.git` i `docs/`. Dlatego szkielet powstaje obok i jest przenoszony:

```bash
cd /Users/gladki/projects
composer create-project symfony/skeleton:"7.4.*" ucl-skeleton --no-interaction
cp -R ucl-skeleton/. ucl/
rm -rf ucl-skeleton
cd ucl
ls composer.json bin/console
```

- [ ] **Step 2: Doinstalować pakiety**

```bash
cd /Users/gladki/projects/ucl
composer require --no-interaction symfony/orm-pack symfony/twig-bundle symfony/form symfony/validator symfony/security-bundle symfony/asset symfony/translation doctrine/doctrine-migrations-bundle
composer require --no-interaction --dev symfony/maker-bundle symfony/test-pack symfony/browser-kit symfony/css-selector symfony/dom-crawler
```

- [ ] **Step 3: Skonfigurować bazy**

`.env.local` (dev — baza MySQL obok istniejącej bazy `fantasy`):

```
APP_ENV=dev
APP_SECRET=zmien-mnie-na-losowy-ciag
DATABASE_URL="mysql://root:polka7@127.0.0.1:3306/ucl?serverVersion=5.7&charset=utf8mb4"
```

`.env.test.local` (testy — ta sama MySQL, baza z sufiksem `_test` dokładanym przez `config/packages/doctrine.yaml`):

```
DATABASE_URL="mysql://root:polka7@127.0.0.1:3306/ucl?serverVersion=5.7&charset=utf8mb4"
```

Uwaga: Symfony celowo nie wczytuje `.env.local` w środowisku `test`, dlatego dane dostępowe do testów muszą trafić do `.env.test.local` (plik jest w `.gitignore`). Testy chodzą na tej samej MySQL co dev, w bazie `ucl_test` — dzięki temu nie ma rozjazdu dialektów między testami a produkcją.

- [ ] **Step 4: Test dymny**

`tests/SmokeTest.php`:

```php
<?php

namespace App\Tests;

use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

class SmokeTest extends KernelTestCase
{
    public function testKernelBoots(): void
    {
        self::bootKernel();

        self::assertSame('test', self::$kernel->getEnvironment());
    }
}
```

- [ ] **Step 5: Uruchomić testy**

Run: `cd /Users/gladki/projects/ucl && php bin/phpunit`
Expected: PASS, 1 test.

- [ ] **Step 6: Commit**

```bash
cd /Users/gladki/projects/ucl
git add -A
git commit -m "chore: szkielet Symfony 7.4 z Doctrine, Twig i PHPUnit"
```

---

### Task 2: Enumy i encje + migracja inicjalna

**Files:**
- Create: `src/Enum/CompetitionMode.php`, `src/Enum/RoundStage.php`, `src/Enum/ScoreSource.php`
- Create: `src/Entity/{Competition,Club,Manager,Round,Fixture,ManagerRoundScore,StandingRow,User}.php`
- Create: `src/Repository/{CompetitionRepository,ClubRepository,ManagerRepository,RoundRepository,FixtureRepository,ManagerRoundScoreRepository,StandingRowRepository,UserRepository}.php`
- Create: `migrations/VersionYYYYMMDDHHMMSSmmm.php`
- Test: `tests/Entity/SchemaTest.php`

**Interfaces:**
- Produces:
  - `CompetitionMode::Solo`, `CompetitionMode::Pairs` (backed string: `solo`, `pairs`)
  - `RoundStage::League|Playoff|R16|Qf|Sf|Final` (backed string: `league`, `playoff`, `r16`, `qf`, `sf`, `final`); metoda `RoundStage::isKnockout(): bool`, `RoundStage::label(): string`
  - `ScoreSource::Import|Manual` (backed string: `import`, `manual`)
  - `Competition`: `getId/getName/getSlug/getSeason/getMode/getCurrentRoundNumber` + settery, `getClubs(): Collection`, `getRounds(): Collection`
  - `Club`: `getId/getName/getShortName/getLogo/getCompetition` + settery, `getManagers(): Collection`
  - `Manager`: `getId/getName/getUefaTeamName/getUefaUrl/getClub/getCompetition` + settery
  - `Round`: `getId/getNumber/getStage/getCompetition/getClosedAt` + settery, `getFixtures(): Collection`
  - `Fixture`: `getId/getRound/getHomeClub/getAwayClub/getHomePoints/getAwayPoints/getBracketSlot` + settery
  - `ManagerRoundScore`: `getId/getManager/getRound/getPoints/getSource` + settery
  - `StandingRow`: `getId/getCompetition/getRound/getClub/getPlayed/getWon/getDrawn/getLost/getPointsFor/getPointsAgainst/getPointsDiff/getPoints/getPosition/getPrevPosition` + settery
  - `User`: implementuje `UserInterface`, `PasswordAuthenticatedUserInterface`; `getEmail/setEmail`, `getRoles`, `getPassword/setPassword`

- [ ] **Step 1: Napisać enumy**

`src/Enum/CompetitionMode.php`:

```php
<?php

namespace App\Enum;

enum CompetitionMode: string
{
    case Solo = 'solo';
    case Pairs = 'pairs';

    public function label(): string
    {
        return match ($this) {
            self::Solo => 'Pojedynczo',
            self::Pairs => 'Pary',
        };
    }

    public function managersPerClub(): int
    {
        return match ($this) {
            self::Solo => 1,
            self::Pairs => 2,
        };
    }
}
```

`src/Enum/RoundStage.php`:

```php
<?php

namespace App\Enum;

enum RoundStage: string
{
    case League = 'league';
    case Playoff = 'playoff';
    case R16 = 'r16';
    case Qf = 'qf';
    case Sf = 'sf';
    case Final = 'final';

    public function isKnockout(): bool
    {
        return $this !== self::League;
    }

    public function label(): string
    {
        return match ($this) {
            self::League => 'Faza ligowa',
            self::Playoff => 'Baraże',
            self::R16 => '1/8 finału',
            self::Qf => 'Ćwierćfinały',
            self::Sf => 'Półfinały',
            self::Final => 'Finał',
        };
    }

    /** Etap kolejki o podanym numerze (1-13). */
    public static function forRoundNumber(int $number): self
    {
        return match (true) {
            $number >= 1 && $number <= 8 => self::League,
            $number === 9 => self::Playoff,
            $number === 10 => self::R16,
            $number === 11 => self::Qf,
            $number === 12 => self::Sf,
            $number === 13 => self::Final,
            default => throw new \InvalidArgumentException(sprintf('Kolejka %d jest poza zakresem 1-13.', $number)),
        };
    }
}
```

`src/Enum/ScoreSource.php`:

```php
<?php

namespace App\Enum;

enum ScoreSource: string
{
    case Import = 'import';
    case Manual = 'manual';
}
```

- [ ] **Step 2: Napisać test schematu**

`tests/Entity/SchemaTest.php`:

```php
<?php

namespace App\Tests\Entity;

use App\Entity\Club;
use App\Entity\Competition;
use App\Entity\Manager;
use App\Enum\CompetitionMode;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Tools\SchemaTool;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

class SchemaTest extends KernelTestCase
{
    private EntityManagerInterface $em;

    protected function setUp(): void
    {
        self::bootKernel();
        $this->em = self::getContainer()->get(EntityManagerInterface::class);

        $tool = new SchemaTool($this->em);
        $metadata = $this->em->getMetadataFactory()->getAllMetadata();
        $tool->dropSchema($metadata);
        $tool->createSchema($metadata);
    }

    public function testCompetitionWithClubAndManagerPersists(): void
    {
        $competition = (new Competition())
            ->setName('LM 2025/26')
            ->setSlug('lm-2025-26')
            ->setSeason('2025/26')
            ->setMode(CompetitionMode::Pairs);

        $club = (new Club())
            ->setCompetition($competition)
            ->setName('Real Madryt')
            ->setShortName('RMA');

        $manager = (new Manager())
            ->setCompetition($competition)
            ->setClub($club)
            ->setName('Kuba')
            ->setUefaTeamName('Kuba FC');

        $this->em->persist($competition);
        $this->em->persist($club);
        $this->em->persist($manager);
        $this->em->flush();
        $this->em->clear();

        $loaded = $this->em->getRepository(Competition::class)->findOneBy(['slug' => 'lm-2025-26']);

        self::assertNotNull($loaded);
        self::assertSame(CompetitionMode::Pairs, $loaded->getMode());
        self::assertCount(1, $loaded->getClubs());
        self::assertSame('Kuba FC', $loaded->getClubs()->first()->getManagers()->first()->getUefaTeamName());
    }
}
```

- [ ] **Step 3: Uruchomić test — musi nie przejść**

Run: `php bin/phpunit tests/Entity/SchemaTest.php`
Expected: FAIL — `Class "App\Entity\Competition" not found`.

- [ ] **Step 4: Napisać encje**

Wszystkie encje w `src/Entity/`, atrybuty Doctrine, settery zwracające `$this` (fluent — test tego wymaga). Kluczowe ustalenia mapowania:

- `Competition`: `slug` unikalny (`#[ORM\UniqueConstraint]`), `mode` jako `Types::STRING` z `enumType: CompetitionMode::class`, `currentRoundNumber` int domyślnie 1. Relacje `OneToMany` do `Club` i `Round` z `cascade: ['persist']`, `orphanRemoval: true`.
- `Club`: `ManyToOne` do `Competition` (`inversedBy: 'clubs'`, `nullable: false`), `OneToMany` do `Manager`. `logo` nullable string.
- `Manager`: `ManyToOne` do `Competition` i do `Club` (`inversedBy: 'managers'`). Unikalny indeks złożony na (`competition_id`, `uefa_team_name`) — nazwa drużyny UEFA identyfikuje uczestnika przy imporcie. `uefaUrl` nullable.
- `Round`: `ManyToOne` do `Competition` (`inversedBy: 'rounds'`), `number` int, `stage` enumType `RoundStage::class`, `closedAt` nullable `DATETIME_IMMUTABLE`. Unikalny indeks (`competition_id`, `number`). `OneToMany` do `Fixture`.
- `Fixture`: `ManyToOne` do `Round` (`inversedBy: 'fixtures'`), dwa `ManyToOne` do `Club` (`homeClub`, `awayClub`, oba `nullable: false`), `homePoints`/`awayPoints` int domyślnie 0, `bracketSlot` nullable string(10).
- `ManagerRoundScore`: `ManyToOne` do `Manager` i `Round`, `points` int, `source` enumType `ScoreSource::class`, `updatedAt` `DATETIME_IMMUTABLE`. Unikalny indeks (`manager_id`, `round_id`).
- `StandingRow`: `ManyToOne` do `Competition`, `Round`, `Club`; pola int: `played`, `won`, `drawn`, `lost`, `pointsFor`, `pointsAgainst`, `pointsDiff`, `points`, `position`, `prevPosition` (nullable). Unikalny indeks (`round_id`, `club_id`).
- `User`: `email` unikalny, `roles` jako `Types::JSON`, `password` string. `getRoles()` zawsze dokłada `ROLE_USER`. `getUserIdentifier()` zwraca e-mail.

Repozytoria generowane wzorem Symfony (`extends ServiceEntityRepository`), na razie bez własnych metod.

- [ ] **Step 5: Uruchomić test — musi przejść**

Run: `php bin/phpunit tests/Entity/SchemaTest.php`
Expected: PASS.

- [ ] **Step 6: Wygenerować migrację inicjalną i poprawić jej nazwę**

```bash
php bin/console doctrine:database:create --if-not-exists
php bin/console doctrine:migrations:diff --no-interaction
ls migrations/
```

Generator nada nazwę `VersionYYYYMMDDHHMMSS`. Zmienić nazwę pliku **i nazwę klasy w środku** na `VersionYYYYMMDDHHMMSSmmm` (dopisać trzy cyfry milisekund), zanim cokolwiek zostanie zacommitowane. Potem:

```bash
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console doctrine:schema:validate
```

Expected: `[OK] The mapping files are correct.` oraz `[OK] The database schema is in sync with the mapping files.`

- [ ] **Step 7: Commit**

```bash
git add -A
git commit -m "feat: encje rozgrywek, uczestników i tabeli wraz z migracją inicjalną"
```

---

### Task 3: StandingsTable — czysta logika tabeli

**Files:**
- Create: `src/Domain/Standings/MatchResult.php`
- Create: `src/Domain/Standings/StandingEntry.php`
- Create: `src/Domain/Standings/StandingsTable.php`
- Test: `tests/Domain/Standings/StandingsTableTest.php`

**Interfaces:**
- Consumes: nic (klasy czyste, bez Doctrine)
- Produces:
  - `MatchResult::__construct(string $homeClub, string $awayClub, int $homePoints, int $awayPoints)` — publiczne readonly właściwości o tych nazwach
  - `StandingEntry` — readonly: `clubKey`, `played`, `won`, `drawn`, `lost`, `pointsFor`, `pointsAgainst`, `pointsDiff`, `points`, `position`
  - `StandingsTable::build(array $clubNames, array $matches): array` — `$clubNames` to mapa `klucz => nazwa klubu`, `$matches` to lista `MatchResult`; zwraca listę `StandingEntry` posortowaną od 1. miejsca, z ustawionym `position`

- [ ] **Step 1: Napisać testy**

`tests/Domain/Standings/StandingsTableTest.php`:

```php
<?php

namespace App\Tests\Domain\Standings;

use App\Domain\Standings\MatchResult;
use App\Domain\Standings\StandingsTable;
use PHPUnit\Framework\TestCase;

class StandingsTableTest extends TestCase
{
    private const CLUBS = ['a' => 'Arsenal', 'b' => 'Barcelona', 'c' => 'Celtic', 'd' => 'Dortmund'];

    public function testEmptyTableListsEveryClubWithZeros(): void
    {
        $table = (new StandingsTable())->build(self::CLUBS, []);

        self::assertCount(4, $table);
        self::assertSame(0, $table[0]->played);
        self::assertSame(0, $table[0]->points);
        self::assertSame(1, $table[0]->position);
        self::assertSame(4, $table[3]->position);
    }

    public function testEmptyTableIsOrderedByClubName(): void
    {
        $table = (new StandingsTable())->build(self::CLUBS, []);

        self::assertSame(['a', 'b', 'c', 'd'], array_map(fn ($e) => $e->clubKey, $table));
    }

    public function testWinGivesThreePointsAndLossGivesNone(): void
    {
        $table = (new StandingsTable())->build(self::CLUBS, [
            new MatchResult('a', 'b', 60, 40),
        ]);

        $byKey = $this->byKey($table);

        self::assertSame(3, $byKey['a']->points);
        self::assertSame(1, $byKey['a']->won);
        self::assertSame(60, $byKey['a']->pointsFor);
        self::assertSame(40, $byKey['a']->pointsAgainst);
        self::assertSame(20, $byKey['a']->pointsDiff);

        self::assertSame(0, $byKey['b']->points);
        self::assertSame(1, $byKey['b']->lost);
        self::assertSame(-20, $byKey['b']->pointsDiff);
    }

    public function testDrawGivesOnePointToBothSides(): void
    {
        $table = (new StandingsTable())->build(self::CLUBS, [
            new MatchResult('a', 'b', 50, 50),
        ]);

        $byKey = $this->byKey($table);

        self::assertSame(1, $byKey['a']->points);
        self::assertSame(1, $byKey['a']->drawn);
        self::assertSame(1, $byKey['b']->points);
        self::assertSame(0, $byKey['a']->pointsDiff);
    }

    public function testEqualPointsAreBrokenByPointsDifference(): void
    {
        // a i c mają po 3 pkt; a wygrał 60:40 (+20), c wygrał 45:44 (+1)
        $table = (new StandingsTable())->build(self::CLUBS, [
            new MatchResult('a', 'b', 60, 40),
            new MatchResult('c', 'd', 45, 44),
        ]);

        self::assertSame('a', $table[0]->clubKey);
        self::assertSame('c', $table[1]->clubKey);
    }

    public function testEqualPointsAndDifferenceAreBrokenByPointsScored(): void
    {
        // oba kluby +10, ale c zdobył więcej punktów fantasy
        $table = (new StandingsTable())->build(self::CLUBS, [
            new MatchResult('a', 'b', 30, 20),
            new MatchResult('c', 'd', 70, 60),
        ]);

        self::assertSame('c', $table[0]->clubKey);
        self::assertSame('a', $table[1]->clubKey);
    }

    public function testFullyEqualClubsAreOrderedByName(): void
    {
        $table = (new StandingsTable())->build(self::CLUBS, [
            new MatchResult('b', 'a', 50, 50),
            new MatchResult('c', 'd', 50, 50),
        ]);

        self::assertSame(['a', 'b', 'c', 'd'], array_map(fn ($e) => $e->clubKey, $table));
    }

    public function testMatchWithUnknownClubIsRejected(): void
    {
        $this->expectException(\InvalidArgumentException::class);

        (new StandingsTable())->build(self::CLUBS, [
            new MatchResult('a', 'zzz', 10, 5),
        ]);
    }

    /** @return array<string, \App\Domain\Standings\StandingEntry> */
    private function byKey(array $table): array
    {
        $result = [];
        foreach ($table as $entry) {
            $result[$entry->clubKey] = $entry;
        }

        return $result;
    }
}
```

- [ ] **Step 2: Uruchomić test — musi nie przejść**

Run: `php bin/phpunit tests/Domain/Standings/StandingsTableTest.php`
Expected: FAIL — brak klasy `App\Domain\Standings\MatchResult`.

- [ ] **Step 3: Napisać implementację**

`src/Domain/Standings/MatchResult.php`:

```php
<?php

namespace App\Domain\Standings;

final readonly class MatchResult
{
    public function __construct(
        public string $homeClub,
        public string $awayClub,
        public int $homePoints,
        public int $awayPoints,
    ) {
    }
}
```

`src/Domain/Standings/StandingEntry.php`:

```php
<?php

namespace App\Domain\Standings;

final readonly class StandingEntry
{
    public function __construct(
        public string $clubKey,
        public int $played,
        public int $won,
        public int $drawn,
        public int $lost,
        public int $pointsFor,
        public int $pointsAgainst,
        public int $pointsDiff,
        public int $points,
        public int $position,
    ) {
    }
}
```

`src/Domain/Standings/StandingsTable.php`:

```php
<?php

namespace App\Domain\Standings;

/**
 * Buduje tabelę od zera na podstawie kompletu rozegranych meczów.
 * Klasa jest czysta: te same wejście zawsze daje ten sam wynik.
 */
final class StandingsTable
{
    /**
     * @param array<string, string> $clubNames mapa klucz klubu => nazwa klubu
     * @param list<MatchResult>     $matches
     *
     * @return list<StandingEntry> posortowane od pierwszego miejsca
     */
    public function build(array $clubNames, array $matches): array
    {
        $rows = [];
        foreach ($clubNames as $key => $name) {
            $rows[$key] = [
                'played' => 0, 'won' => 0, 'drawn' => 0, 'lost' => 0,
                'for' => 0, 'against' => 0, 'points' => 0,
            ];
        }

        foreach ($matches as $match) {
            foreach ([$match->homeClub, $match->awayClub] as $clubKey) {
                if (!isset($rows[$clubKey])) {
                    throw new \InvalidArgumentException(sprintf('Mecz odwołuje się do nieznanego klubu "%s".', $clubKey));
                }
            }

            $this->applySide($rows[$match->homeClub], $match->homePoints, $match->awayPoints);
            $this->applySide($rows[$match->awayClub], $match->awayPoints, $match->homePoints);
        }

        $keys = array_keys($rows);
        usort($keys, function (string $left, string $right) use ($rows, $clubNames): int {
            return [$rows[$right]['points'], $rows[$right]['for'] - $rows[$right]['against'], $rows[$right]['for']]
                <=> [$rows[$left]['points'], $rows[$left]['for'] - $rows[$left]['against'], $rows[$left]['for']]
                ?: strcmp($clubNames[$left], $clubNames[$right]);
        });

        $table = [];
        $position = 1;
        foreach ($keys as $key) {
            $row = $rows[$key];
            $table[] = new StandingEntry(
                clubKey: $key,
                played: $row['played'],
                won: $row['won'],
                drawn: $row['drawn'],
                lost: $row['lost'],
                pointsFor: $row['for'],
                pointsAgainst: $row['against'],
                pointsDiff: $row['for'] - $row['against'],
                points: $row['points'],
                position: $position,
            );
            ++$position;
        }

        return $table;
    }

    private function applySide(array &$row, int $scored, int $conceded): void
    {
        ++$row['played'];
        $row['for'] += $scored;
        $row['against'] += $conceded;

        if ($scored > $conceded) {
            ++$row['won'];
            $row['points'] += 3;
        } elseif ($scored === $conceded) {
            ++$row['drawn'];
            ++$row['points'];
        } else {
            ++$row['lost'];
        }
    }
}
```

Uwaga na pułapkę: `<=>` na tablicach porównuje elementy po kolei, ale `?:` zadziała tylko wtedy, gdy wynik `<=>` to `0`. To jest zamierzone — nazwa klubu rozstrzyga dopiero przy pełnej równości. Jeśli test `testFullyEqualClubsAreOrderedByName` nie przejdzie, rozbić wyrażenie na jawne `if`-y zamiast kombinować z operatorami.

- [ ] **Step 4: Uruchomić testy — muszą przejść**

Run: `php bin/phpunit tests/Domain/Standings/StandingsTableTest.php`
Expected: PASS, 7 testów.

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "feat: czysta logika budowania tabeli z regułami rozstrzygania remisów"
```

---

### Task 4: BracketDraw — czysta logika drabinki

**Files:**
- Create: `src/Domain/Bracket/BracketPair.php`
- Create: `src/Domain/Bracket/BracketDraw.php`
- Test: `tests/Domain/Bracket/BracketDrawTest.php`

**Interfaces:**
- Consumes: nic
- Produces:
  - `BracketPair` — readonly: `slot` (string), `homeClub` (string), `awayClub` (string)
  - `BracketDraw::playoffPairs(array $orderedClubKeys): array` — wejście: 36 kluczy w kolejności od 1. miejsca; wyjście: 8 `BracketPair` w slotach `PO1`…`PO8`
  - `BracketDraw::roundOf16Pairs(array $orderedClubKeys, array $playoffWinners): array` — `$playoffWinners` to mapa `slot => klucz klubu`; wyjście: sloty `R16-1`…`R16-8`
  - `BracketDraw::quarterFinalPairs(array $winners, array $seedPositions): array` — `$winners` mapa `R16-1..R16-8 => klucz`; `$seedPositions` mapa `klucz => miejsce w tabeli`; wyjście `QF1`…`QF4`
  - `BracketDraw::semiFinalPairs(array $winners, array $seedPositions): array` — wejście `QF1..QF4`; wyjście `SF1`, `SF2`
  - `BracketDraw::finalPair(array $winners, array $seedPositions): array` — wejście `SF1`, `SF2`; wyjście jeden `BracketPair` w slocie `F`
  - `BracketDraw::eliminatedAfterLeaguePhase(array $orderedClubKeys): array` — kluby z miejsc 25–36

- [ ] **Step 1: Napisać testy**

`tests/Domain/Bracket/BracketDrawTest.php`:

```php
<?php

namespace App\Tests\Domain\Bracket;

use App\Domain\Bracket\BracketDraw;
use PHPUnit\Framework\TestCase;

class BracketDrawTest extends TestCase
{
    /** @return list<string> kluby "c1".."c36", gdzie c1 zajął 1. miejsce */
    private function ordered(): array
    {
        return array_map(static fn (int $i) => 'c'.$i, range(1, 36));
    }

    /** @return array<string,int> mapa klucz => miejsce w tabeli */
    private function seeds(): array
    {
        $seeds = [];
        foreach ($this->ordered() as $index => $key) {
            $seeds[$key] = $index + 1;
        }

        return $seeds;
    }

    public function testPlayoffPairsFollowUefaSeeding(): void
    {
        $pairs = (new BracketDraw())->playoffPairs($this->ordered());

        self::assertCount(8, $pairs);
        self::assertSame('PO1', $pairs[0]->slot);
        self::assertSame('c9', $pairs[0]->homeClub);
        self::assertSame('c24', $pairs[0]->awayClub);
        self::assertSame('PO8', $pairs[7]->slot);
        self::assertSame('c16', $pairs[7]->homeClub);
        self::assertSame('c17', $pairs[7]->awayClub);
    }

    public function testPlayoffRequiresThirtySixClubs(): void
    {
        $this->expectException(\InvalidArgumentException::class);

        (new BracketDraw())->playoffPairs(['c1', 'c2']);
    }

    public function testClubsBelowTwentyFourAreEliminated(): void
    {
        $eliminated = (new BracketDraw())->eliminatedAfterLeaguePhase($this->ordered());

        self::assertCount(12, $eliminated);
        self::assertSame('c25', $eliminated[0]);
        self::assertSame('c36', $eliminated[11]);
    }

    public function testRoundOf16PairsTopSeedWithWeakestPlayoffSlot(): void
    {
        $winners = [];
        foreach (range(1, 8) as $i) {
            $winners['PO'.$i] = 'w'.$i;
        }

        $pairs = (new BracketDraw())->roundOf16Pairs($this->ordered(), $winners);

        self::assertCount(8, $pairs);
        self::assertSame('R16-1', $pairs[0]->slot);
        self::assertSame('c1', $pairs[0]->homeClub);
        self::assertSame('w8', $pairs[0]->awayClub);
        self::assertSame('R16-8', $pairs[7]->slot);
        self::assertSame('c8', $pairs[7]->homeClub);
        self::assertSame('w1', $pairs[7]->awayClub);
    }

    public function testQuarterFinalsKeepTopTwoSeedsApart(): void
    {
        $winners = [
            'R16-1' => 'c1', 'R16-2' => 'c2', 'R16-3' => 'c3', 'R16-4' => 'c4',
            'R16-5' => 'c5', 'R16-6' => 'c6', 'R16-7' => 'c7', 'R16-8' => 'c8',
        ];

        $pairs = (new BracketDraw())->quarterFinalPairs($winners, $this->seeds());

        self::assertCount(4, $pairs);
        self::assertSame('QF1', $pairs[0]->slot);
        self::assertSame('c1', $pairs[0]->homeClub);
        self::assertSame('c8', $pairs[0]->awayClub);
        self::assertSame('QF2', $pairs[1]->slot);
        self::assertSame('c4', $pairs[1]->homeClub);
        self::assertSame('c5', $pairs[1]->awayClub);
        self::assertSame('QF3', $pairs[2]->slot);
        self::assertSame('c2', $pairs[2]->homeClub);
        self::assertSame('c7', $pairs[2]->awayClub);
        self::assertSame('QF4', $pairs[3]->slot);
        self::assertSame('c3', $pairs[3]->homeClub);
        self::assertSame('c6', $pairs[3]->awayClub);
    }

    public function testHigherSeedIsAlwaysHomeSide(): void
    {
        // zwycięzca R16-1 to klub z 20. miejsca, zwycięzca R16-8 to klub z 8. miejsca
        $winners = [
            'R16-1' => 'c20', 'R16-2' => 'c2', 'R16-3' => 'c3', 'R16-4' => 'c4',
            'R16-5' => 'c5', 'R16-6' => 'c6', 'R16-7' => 'c7', 'R16-8' => 'c8',
        ];

        $pairs = (new BracketDraw())->quarterFinalPairs($winners, $this->seeds());

        self::assertSame('c8', $pairs[0]->homeClub);
        self::assertSame('c20', $pairs[0]->awayClub);
    }

    public function testSemiFinalsAndFinalFollowTheBracket(): void
    {
        $draw = new BracketDraw();

        $semis = $draw->semiFinalPairs(
            ['QF1' => 'c1', 'QF2' => 'c4', 'QF3' => 'c2', 'QF4' => 'c3'],
            $this->seeds(),
        );

        self::assertCount(2, $semis);
        self::assertSame('SF1', $semis[0]->slot);
        self::assertSame('c1', $semis[0]->homeClub);
        self::assertSame('c4', $semis[0]->awayClub);
        self::assertSame('SF2', $semis[1]->slot);
        self::assertSame('c2', $semis[1]->homeClub);
        self::assertSame('c3', $semis[1]->awayClub);

        $final = $draw->finalPair(['SF1' => 'c4', 'SF2' => 'c2'], $this->seeds());

        self::assertCount(1, $final);
        self::assertSame('F', $final[0]->slot);
        self::assertSame('c2', $final[0]->homeClub);
        self::assertSame('c4', $final[0]->awayClub);
    }

    public function testMissingWinnerIsRejected(): void
    {
        $this->expectException(\InvalidArgumentException::class);

        (new BracketDraw())->quarterFinalPairs(['R16-1' => 'c1'], $this->seeds());
    }
}
```

- [ ] **Step 2: Uruchomić test — musi nie przejść**

Run: `php bin/phpunit tests/Domain/Bracket/BracketDrawTest.php`
Expected: FAIL — brak klasy `App\Domain\Bracket\BracketDraw`.

- [ ] **Step 3: Napisać implementację**

`src/Domain/Bracket/BracketPair.php`:

```php
<?php

namespace App\Domain\Bracket;

final readonly class BracketPair
{
    public function __construct(
        public string $slot,
        public string $homeClub,
        public string $awayClub,
    ) {
    }
}
```

`src/Domain/Bracket/BracketDraw.php`:

```php
<?php

namespace App\Domain\Bracket;

/**
 * Deterministyczne odwzorowanie drabinki Ligi Mistrzów.
 * Zamiast losowania stosujemy stałe reguły opisane w specyfikacji.
 */
final class BracketDraw
{
    private const CLUBS_IN_COMPETITION = 36;

    /** QF: slot => [slot R16 gospodarza, slot R16 gościa] wg drabinki 1-8 / 4-5 / 2-7 / 3-6 */
    private const QF_SOURCES = [
        'QF1' => ['R16-1', 'R16-8'],
        'QF2' => ['R16-4', 'R16-5'],
        'QF3' => ['R16-2', 'R16-7'],
        'QF4' => ['R16-3', 'R16-6'],
    ];

    private const SF_SOURCES = [
        'SF1' => ['QF1', 'QF2'],
        'SF2' => ['QF3', 'QF4'],
    ];

    private const FINAL_SOURCES = [
        'F' => ['SF1', 'SF2'],
    ];

    /**
     * @param list<string> $orderedClubKeys 36 kluczy w kolejności od 1. miejsca
     *
     * @return list<BracketPair> sloty PO1..PO8
     */
    public function playoffPairs(array $orderedClubKeys): array
    {
        $this->assertFullTable($orderedClubKeys);

        $pairs = [];
        for ($i = 0; $i < 8; ++$i) {
            // PO1 = 9 z 24, PO2 = 10 z 23, ... PO8 = 16 z 17
            $higher = $orderedClubKeys[8 + $i];
            $lower = $orderedClubKeys[23 - $i];

            $pairs[] = new BracketPair('PO'.($i + 1), $higher, $lower);
        }

        return $pairs;
    }

    /**
     * @param list<string> $orderedClubKeys
     *
     * @return list<string> kluby z miejsc 25-36
     */
    public function eliminatedAfterLeaguePhase(array $orderedClubKeys): array
    {
        $this->assertFullTable($orderedClubKeys);

        return array_values(array_slice($orderedClubKeys, 24));
    }

    /**
     * Rozstawiony nr 1 trafia na zwycięzcę PO8, nr 2 na PO7 i tak dalej.
     *
     * @param list<string>          $orderedClubKeys
     * @param array<string, string> $playoffWinners mapa PO1..PO8 => klucz klubu
     *
     * @return list<BracketPair> sloty R16-1..R16-8
     */
    public function roundOf16Pairs(array $orderedClubKeys, array $playoffWinners): array
    {
        $this->assertFullTable($orderedClubKeys);

        $pairs = [];
        for ($i = 1; $i <= 8; ++$i) {
            $playoffSlot = 'PO'.(9 - $i);
            if (!isset($playoffWinners[$playoffSlot])) {
                throw new \InvalidArgumentException(sprintf('Brak zwycięzcy pary %s.', $playoffSlot));
            }

            $pairs[] = new BracketPair('R16-'.$i, $orderedClubKeys[$i - 1], $playoffWinners[$playoffSlot]);
        }

        return $pairs;
    }

    /**
     * @param array<string, string> $winners        mapa slot poprzedniej rundy => klucz klubu
     * @param array<string, int>    $seedPositions  mapa klucz klubu => miejsce w tabeli fazy ligowej
     *
     * @return list<BracketPair>
     */
    public function quarterFinalPairs(array $winners, array $seedPositions): array
    {
        return $this->pairsFrom(self::QF_SOURCES, $winners, $seedPositions);
    }

    /** @return list<BracketPair> */
    public function semiFinalPairs(array $winners, array $seedPositions): array
    {
        return $this->pairsFrom(self::SF_SOURCES, $winners, $seedPositions);
    }

    /** @return list<BracketPair> */
    public function finalPair(array $winners, array $seedPositions): array
    {
        return $this->pairsFrom(self::FINAL_SOURCES, $winners, $seedPositions);
    }

    /**
     * @param array<string, array{string, string}> $sources
     * @param array<string, string>                $winners
     * @param array<string, int>                   $seedPositions
     *
     * @return list<BracketPair>
     */
    private function pairsFrom(array $sources, array $winners, array $seedPositions): array
    {
        $pairs = [];
        foreach ($sources as $slot => [$leftSlot, $rightSlot]) {
            foreach ([$leftSlot, $rightSlot] as $sourceSlot) {
                if (!isset($winners[$sourceSlot])) {
                    throw new \InvalidArgumentException(sprintf('Brak zwycięzcy pary %s.', $sourceSlot));
                }
            }

            $left = $winners[$leftSlot];
            $right = $winners[$rightSlot];

            $pairs[] = $this->seedOrderedPair($slot, $left, $right, $seedPositions);
        }

        return $pairs;
    }

    /** Gospodarzem jest klub wyżej rozstawiony, czyli o niższym numerze miejsca. */
    private function seedOrderedPair(string $slot, string $left, string $right, array $seedPositions): BracketPair
    {
        foreach ([$left, $right] as $clubKey) {
            if (!isset($seedPositions[$clubKey])) {
                throw new \InvalidArgumentException(sprintf('Nieznane rozstawienie klubu "%s".', $clubKey));
            }
        }

        return $seedPositions[$left] <= $seedPositions[$right]
            ? new BracketPair($slot, $left, $right)
            : new BracketPair($slot, $right, $left);
    }

    private function assertFullTable(array $orderedClubKeys): void
    {
        if (self::CLUBS_IN_COMPETITION !== count($orderedClubKeys)) {
            throw new \InvalidArgumentException(sprintf(
                'Drabinka wymaga %d klubów, dostała %d.',
                self::CLUBS_IN_COMPETITION,
                count($orderedClubKeys),
            ));
        }
    }
}
```

- [ ] **Step 4: Uruchomić testy — muszą przejść**

Run: `php bin/phpunit tests/Domain/Bracket/BracketDrawTest.php`
Expected: PASS, 8 testów.

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "feat: deterministyczna drabinka pucharowa z rozstawieniem wg tabeli"
```

---

### Task 5: Parsery leaderboardu

**Files:**
- Create: `src/Import/ParsedScore.php`
- Create: `src/Import/LeaderboardParser.php`
- Create: `src/Import/TsvLeaderboardParser.php`
- Create: `src/Import/UefaHtmlLeaderboardParser.php`
- Create: `src/Import/LeaderboardParserRegistry.php`
- Test: `tests/Import/TsvLeaderboardParserTest.php`
- Test: `tests/Import/UefaHtmlLeaderboardParserTest.php`

**Interfaces:**
- Consumes: `symfony/dom-crawler` (przenieść z `require-dev` do `require`: `composer require symfony/dom-crawler`)
- Produces:
  - `ParsedScore` — readonly: `teamName` (string), `points` (int)
  - `LeaderboardParser` — interfejs: `getFormat(): string`, `getLabel(): string`, `parse(string $raw): array` zwracające `list<ParsedScore>`
  - `TsvLeaderboardParser::getFormat()` zwraca `tsv`
  - `UefaHtmlLeaderboardParser::getFormat()` zwraca `uefa_html`
  - `LeaderboardParserRegistry::get(string $format): LeaderboardParser`, `LeaderboardParserRegistry::all(): iterable<LeaderboardParser>`

- [ ] **Step 1: Napisać testy parsera TSV**

`tests/Import/TsvLeaderboardParserTest.php`:

```php
<?php

namespace App\Tests\Import;

use App\Import\TsvLeaderboardParser;
use PHPUnit\Framework\TestCase;

class TsvLeaderboardParserTest extends TestCase
{
    public function testParsesTabSeparatedLines(): void
    {
        $raw = "Kuba FC\t63\nAnia United\t41\n";

        $scores = (new TsvLeaderboardParser())->parse($raw);

        self::assertCount(2, $scores);
        self::assertSame('Kuba FC', $scores[0]->teamName);
        self::assertSame(63, $scores[0]->points);
        self::assertSame('Ania United', $scores[1]->teamName);
        self::assertSame(41, $scores[1]->points);
    }

    public function testAcceptsSemicolonAndMultipleSpacesAsSeparator(): void
    {
        $raw = "Kuba FC;63\nAnia United   41";

        $scores = (new TsvLeaderboardParser())->parse($raw);

        self::assertSame(63, $scores[0]->points);
        self::assertSame('Ania United', $scores[1]->teamName);
        self::assertSame(41, $scores[1]->points);
    }

    public function testSkipsBlankLines(): void
    {
        $raw = "\n\nKuba FC\t63\n\n";

        self::assertCount(1, (new TsvLeaderboardParser())->parse($raw));
    }

    public function testNegativePointsAreAllowed(): void
    {
        $scores = (new TsvLeaderboardParser())->parse("Kuba FC\t-4");

        self::assertSame(-4, $scores[0]->points);
    }

    public function testLineWithoutPointsIsRejected(): void
    {
        $this->expectException(\App\Import\LeaderboardParseException::class);

        (new TsvLeaderboardParser())->parse("Kuba FC");
    }

    public function testEmptyInputIsRejected(): void
    {
        $this->expectException(\App\Import\LeaderboardParseException::class);

        (new TsvLeaderboardParser())->parse("   \n  ");
    }
}
```

- [ ] **Step 2: Napisać testy parsera HTML UEFA**

`tests/Import/UefaHtmlLeaderboardParserTest.php`:

```php
<?php

namespace App\Tests\Import;

use App\Import\LeaderboardParseException;
use App\Import\UefaHtmlLeaderboardParser;
use PHPUnit\Framework\TestCase;

class UefaHtmlLeaderboardParserTest extends TestCase
{
    private const HTML = <<<'HTML'
        <div class="si-league-table">
          <div class="si-row">
            <span class="si-name-one">Kuba FC</span>
            <div class="si-cell--top">63</div>
            <div class="si-cell--total">402</div>
          </div>
          <div class="si-row">
            <span class="si-name-one">Ania United</span>
            <div class="si-cell--top">41</div>
            <div class="si-cell--total">377</div>
          </div>
        </div>
        HTML;

    public function testExtractsTeamNamesAndMatchdayPoints(): void
    {
        $scores = (new UefaHtmlLeaderboardParser())->parse(self::HTML);

        self::assertCount(2, $scores);
        self::assertSame('Kuba FC', $scores[0]->teamName);
        self::assertSame(63, $scores[0]->points);
        self::assertSame('Ania United', $scores[1]->teamName);
        self::assertSame(41, $scores[1]->points);
    }

    public function testTrimsWhitespaceAndNonBreakingSpaces(): void
    {
        $html = '<div class="si-row"><span class="si-name-one">  Kuba&nbsp;FC </span><div class="si-cell--top"> 63 </div></div>';

        $scores = (new UefaHtmlLeaderboardParser())->parse($html);

        self::assertSame('Kuba FC', $scores[0]->teamName);
        self::assertSame(63, $scores[0]->points);
    }

    public function testHtmlWithoutExpectedMarkupIsRejected(): void
    {
        $this->expectException(LeaderboardParseException::class);

        (new UefaHtmlLeaderboardParser())->parse('<div><p>nic tu nie ma</p></div>');
    }

    public function testRowWithoutPointsIsRejected(): void
    {
        $this->expectException(LeaderboardParseException::class);

        (new UefaHtmlLeaderboardParser())->parse('<div class="si-row"><span class="si-name-one">Kuba FC</span></div>');
    }
}
```

- [ ] **Step 3: Uruchomić testy — muszą nie przejść**

Run: `php bin/phpunit tests/Import`
Expected: FAIL — brak klas parserów.

- [ ] **Step 4: Napisać implementację**

`src/Import/ParsedScore.php`:

```php
<?php

namespace App\Import;

final readonly class ParsedScore
{
    public function __construct(
        public string $teamName,
        public int $points,
    ) {
    }
}
```

`src/Import/LeaderboardParseException.php`:

```php
<?php

namespace App\Import;

final class LeaderboardParseException extends \RuntimeException
{
}
```

`src/Import/LeaderboardParser.php`:

```php
<?php

namespace App\Import;

interface LeaderboardParser
{
    /** Techniczny identyfikator formatu, np. "tsv". */
    public function getFormat(): string;

    /** Nazwa pokazywana adminowi przy wyborze formatu. */
    public function getLabel(): string;

    /**
     * @return list<ParsedScore>
     *
     * @throws LeaderboardParseException gdy wklejki nie da się odczytać
     */
    public function parse(string $raw): array;
}
```

`src/Import/TsvLeaderboardParser.php` — dzieli wejście na wiersze, z każdego wycina ostatnią liczbę całkowitą jako punkty, a resztę jako nazwę drużyny. Separatorem może być tabulator, średnik albo co najmniej dwie spacje; nazwy drużyn zawierają pojedyncze spacje, więc pojedyncza spacja separatorem być nie może. Puste wiersze pomija. Gdy po odfiltrowaniu nie zostanie żaden wiersz albo któryś wiersz nie ma liczby — rzuca `LeaderboardParseException` z wierszem w komunikacie.

`src/Import/UefaHtmlLeaderboardParser.php` — używa `Symfony\Component\DomCrawler\Crawler`. Przed parsowaniem zamienia `&nbsp;` i U+00A0 na zwykłą spację. Dla każdego węzła `.si-name-one` bierze najbliższego przodka będącego wierszem i szuka w nim `.si-cell--top`; brak takiej komórki to `LeaderboardParseException`. Gdy w całej wklejce nie ma ani jednego `.si-name-one` — też wyjątek, z podpowiedzią: „Nie rozpoznano formatu UEFA — spróbuj wkleić dane jako nazwa + tabulator + punkty". Punkty przez `(int) preg_replace('/[^0-9\-]/', '', $text)`.

`src/Import/LeaderboardParserRegistry.php` — kolekcja parserów wstrzykiwana przez `#[AutowireIterator]` po interfejsie `LeaderboardParser`; `get()` rzuca `\InvalidArgumentException` dla nieznanego formatu.

- [ ] **Step 5: Uruchomić testy — muszą przejść**

Run: `php bin/phpunit tests/Import`
Expected: PASS, 10 testów.

- [ ] **Step 6: Commit**

```bash
git add -A
git commit -m "feat: parsery leaderboardu UEFA - HTML oraz awaryjny TSV"
```

---

### Task 6: StandingsCalculator — przeliczanie edycji od zera

**Files:**
- Create: `src/Service/StandingsCalculator.php`
- Modify: `src/Repository/ManagerRoundScoreRepository.php` (metoda `pointsByClubForRound`)
- Modify: `src/Repository/StandingRowRepository.php` (metoda `deleteForCompetition`)
- Test: `tests/Service/StandingsCalculatorTest.php`

**Interfaces:**
- Consumes: `StandingsTable`, `MatchResult`, encje z Task 2
- Produces:
  - `StandingsCalculator::recalculate(Competition $competition): void` — kasuje i buduje od zera punkty wszystkich meczów oraz wszystkie `StandingRow` dla edycji
  - `StandingsCalculator::clubPointsForRound(Round $round): array` — mapa `id klubu => suma punktów jego uczestników`
  - `ManagerRoundScoreRepository::pointsByClubForRound(Round $round): array` — mapa `id klubu => suma punktów`
  - `StandingRowRepository::deleteForCompetition(Competition $competition): void`

- [ ] **Step 1: Napisać test**

`tests/Service/StandingsCalculatorTest.php` — test integracyjny na bazie testowej (`KernelTestCase`, `SchemaTool` jak w Task 2). Buduje edycję w trybie `Pairs` z 4 klubami, po 2 uczestników na klub, 1 kolejką i 2 meczami, po czym sprawdza:

1. `testFixturePointsAreSumOfBothManagers` — klub z uczestnikami 30 i 20 pkt ma w meczu 50 pkt.
2. `testStandingRowsAreCreatedForEveryClub` — po przeliczeniu jest 4 wiersze tabeli dla tej kolejki.
3. `testRecalculatingTwiceGivesIdenticalTable` — dwukrotne `recalculate()` daje ten sam komplet wierszy (ta sama liczba wierszy i te same punkty). **To jest test na idempotentność — najważniejszy w całym projekcie.**
4. `testCorrectingOneScoreFlipsTheResult` — zmiana punktów jednego uczestnika i ponowne `recalculate()` odwraca wynik meczu i tabelę.
5. `testClubWithoutManagersScoresZero` — klub bez przypisanych uczestników dostaje 0, a nie błąd.
6. `testPrevPositionComesFromPreviousRound` — przy dwóch kolejkach wiersz z kolejki 2 ma `prevPosition` równe pozycji tego klubu po kolejce 1.

Każdy test woła `recalculate()` i czyta `StandingRow` przez repozytorium.

- [ ] **Step 2: Uruchomić test — musi nie przejść**

Run: `php bin/phpunit tests/Service/StandingsCalculatorTest.php`
Expected: FAIL — brak `App\Service\StandingsCalculator`.

- [ ] **Step 3: Napisać implementację**

`StandingsCalculator::recalculate()` działa tak:

1. Usuwa wszystkie `StandingRow` edycji (`deleteForCompetition`).
2. Dla każdej kolejki edycji rosnąco po numerze:
   a. liczy `clubPointsForRound()` — sumę punktów uczestników per klub (klub bez uczestników albo bez wyników = 0),
   b. zapisuje `homePoints`/`awayPoints` w każdym `Fixture` tej kolejki,
   c. jeśli kolejka jest z etapu `League`: dokłada jej mecze do listy narastającej i buduje `StandingsTable::build()` na wszystkich meczach ligowych **do tej kolejki włącznie**, a wynik zapisuje jako `StandingRow` z `round` = ta kolejka; `prevPosition` bierze z pozycji w tabeli policzonej po poprzedniej kolejce ligowej (dla kolejki 1 pozostaje `null`).
3. Jeden `flush()` na końcu.

Kluczem klubu w `StandingsTable` jest `(string) $club->getId()`; nazwą — `$club->getName()`.

Mecze rund pucharowych **nie** wchodzą do tabeli — mają tylko wyliczone punkty.

`ManagerRoundScoreRepository::pointsByClubForRound()` — jedno zapytanie DQL: `SELECT IDENTITY(m.club) AS clubId, SUM(s.points) AS points FROM ManagerRoundScore s JOIN s.manager m WHERE s.round = :round GROUP BY m.club`.

- [ ] **Step 4: Uruchomić testy — muszą przejść**

Run: `php bin/phpunit tests/Service/StandingsCalculatorTest.php`
Expected: PASS, 6 testów.

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "feat: idempotentne przeliczanie wynikow meczow i tabeli"
```

---

### Task 7: ScoreImporter — podgląd dopasowania i zapis

**Files:**
- Create: `src/Import/ImportPreview.php`
- Create: `src/Import/ImportPreviewRow.php`
- Create: `src/Import/ScoreImporter.php`
- Modify: `src/Repository/ManagerRepository.php` (metoda `byUefaTeamName`)
- Test: `tests/Import/ScoreImporterTest.php`

**Interfaces:**
- Consumes: `LeaderboardParserRegistry`, `StandingsCalculator`, encje z Task 2
- Produces:
  - `ImportPreviewRow` — readonly: `teamName` (string), `points` (?int), `managerName` (?string), `clubName` (?string), `status` (string: `matched`, `missing_manager`, `unknown_team`)
  - `ImportPreview` — readonly: `rows` (list<ImportPreviewRow>); metody `matched(): list<ImportPreviewRow>`, `problems(): list<ImportPreviewRow>`, `isClean(): bool`, `matchedCount(): int`
  - `ScoreImporter::preview(Round $round, string $format, string $raw): ImportPreview`
  - `ScoreImporter::commit(Round $round, string $format, string $raw): ImportPreview` — zapisuje punkty dopasowanych uczestników i woła `StandingsCalculator::recalculate()`
  - `ScoreImporter::setManualScore(Manager $manager, Round $round, int $points): void` — zapis ze źródłem `Manual` + przeliczenie
  - `ManagerRepository::byUefaTeamName(Competition $competition): array` — mapa `nazwa drużyny UEFA => Manager`

- [ ] **Step 1: Napisać test**

`tests/Import/ScoreImporterTest.php` — integracyjny na bazie testowej. Edycja `Solo`, 4 kluby, 4 uczestników o nazwach drużyn UEFA `T1`…`T4`, jedna kolejka. Testy:

1. `testPreviewMatchesManagersByUefaTeamName` — wklejka z `T1`…`T4` daje 4 wiersze `matched` i `isClean() === true`.
2. `testPreviewReportsManagerMissingFromPaste` — wklejka bez `T4` daje wiersz o statusie `missing_manager` dla uczestnika z `T4`, a `isClean()` to `false`.
3. `testPreviewReportsUnknownTeamInPaste` — wklejka z dodatkowym `Obcy FC` daje wiersz `unknown_team`.
4. `testPreviewDoesNotWriteAnything` — po `preview()` w bazie nie ma żadnego `ManagerRoundScore`.
5. `testCommitStoresScoresAndRecalculates` — po `commit()` są 4 wyniki i wyliczone punkty meczów.
6. `testCommitTwiceDoesNotDuplicateScores` — dwa `commit()` z tą samą wklejką dają nadal 4 wyniki i tę samą tabelę.
7. `testCommitUpdatesExistingScore` — drugi `commit()` z inną liczbą punktów nadpisuje wynik zamiast dokładać.
8. `testManualScoreOverridesImportedOne` — `setManualScore()` zmienia punkty i ustawia źródło `Manual`.

- [ ] **Step 2: Uruchomić test — musi nie przejść**

Run: `php bin/phpunit tests/Import/ScoreImporterTest.php`
Expected: FAIL — brak `App\Import\ScoreImporter`.

- [ ] **Step 3: Napisać implementację**

`preview()`: parsuje wklejkę wybranym parserem, pobiera mapę uczestników po `uefaTeamName` (porównanie **bez rozróżniania wielkości liter i po przycięciu białych znaków** — literówki w spacjach są w wklejkach nagminne), a następnie buduje wiersze:

- pozycja z wklejki pasująca do uczestnika → `matched` z punktami,
- uczestnik edycji nieobecny we wklejce → `missing_manager` z `points = null`,
- pozycja z wklejki bez odpowiednika → `unknown_team`.

Wiersze `matched` posortowane malejąco po punktach, problemy na końcu.

`commit()`: robi `preview()`, zapisuje **wyłącznie** wiersze `matched` (aktualizując istniejący `ManagerRoundScore` albo tworząc nowy, źródło `Import`), robi `flush()`, woła `StandingsCalculator::recalculate()` i zwraca podgląd. Uczestnicy nieobecni we wklejce zachowują poprzedni wynik — nie są zerowani, bo najczęstszy powód nieobecności to literówka, a nie zero punktów.

- [ ] **Step 4: Uruchomić testy — muszą przejść**

Run: `php bin/phpunit tests/Import/ScoreImporterTest.php`
Expected: PASS, 8 testów.

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "feat: import punktow z podgladem dopasowania uczestnikow"
```

---

### Task 8: BracketBuilder — generowanie rund pucharowych

**Files:**
- Create: `src/Service/BracketBuilder.php`
- Create: `src/Service/KnockoutWinnerResolver.php`
- Test: `tests/Service/BracketBuilderTest.php`

**Interfaces:**
- Consumes: `BracketDraw`, `StandingRowRepository`, `FixtureRepository`, encje z Task 2
- Produces:
  - `KnockoutWinnerResolver::winner(Fixture $fixture, array $seedPositions): Club` — więcej punktów wygrywa; przy remisie klub o niższym numerze rozstawienia
  - `BracketBuilder::seedPositions(Competition $competition): array` — mapa `id klubu => miejsce` z tabeli po kolejce 8
  - `BracketBuilder::buildNextStage(Competition $competition): int` — tworzy mecze najbliższej niewypełnionej rundy pucharowej i zwraca ich liczbę; rzuca `\DomainException`, gdy poprzednia runda nie jest kompletnie rozliczona

- [ ] **Step 1: Napisać test**

`tests/Service/BracketBuilderTest.php` — integracyjny na bazie testowej. Edycja `Solo` z 36 klubami, 13 kolejkami, wynikami fazy ligowej ustawionymi tak, żeby tabela była jednoznaczna. Testy:

1. `testPlayoffRoundGetsEightFixtures` — po `buildNextStage()` kolejka 9 ma 8 meczów ze slotami `PO1`…`PO8`, a pierwszy z nich zestawia 9. z 24. miejscem.
2. `testTopEightAreNotInPlayoffs` — żaden z klubów z miejsc 1–8 nie występuje w kolejce 9.
3. `testBottomTwelveAreNotInAnyKnockoutFixture` — kluby z miejsc 25–36 nie występują nigdzie w kolejce 9.
4. `testRoundOf16UsesPlayoffWinners` — po rozliczeniu kolejki 9 i kolejnym `buildNextStage()` kolejka 10 ma 8 meczów, a gospodarzami są kluby z miejsc 1–8.
5. `testDrawInKnockoutAdvancesHigherSeed` — mecz barażowy zakończony remisem awansuje klub o lepszym miejscu w tabeli.
6. `testBuildingNextStageBeforePreviousIsScoredIsRejected` — `\DomainException`, gdy w poprzedniej rundzie brakuje wyników.
7. `testBuildingTwiceDoesNotDuplicateFixtures` — powtórne `buildNextStage()` dla tej samej rundy nie tworzy drugiego kompletu meczów.

- [ ] **Step 2: Uruchomić test — musi nie przejść**

Run: `php bin/phpunit tests/Service/BracketBuilderTest.php`
Expected: FAIL — brak `App\Service\BracketBuilder`.

- [ ] **Step 3: Napisać implementację**

`buildNextStage()`:

1. Ustala pierwszą rundę pucharową bez meczów (kolejność: 9, 10, 11, 12, 13).
2. Pobiera `seedPositions()` z `StandingRow` po kolejce 8; brak tabeli = `\DomainException` z komunikatem „Najpierw rozlicz fazę ligową".
3. Dla kolejki 9 woła `BracketDraw::playoffPairs()` z kluczami klubów w kolejności tabeli.
4. Dla kolejek 10–13 zbiera zwycięzców poprzedniej rundy przez `KnockoutWinnerResolver` (mapa `slot => id klubu`) i woła odpowiednią metodę `BracketDraw`. Mecz poprzedniej rundy z sumą punktów `0:0` traktujemy jako nierozliczony i przerywamy `\DomainException` — samo zero po obu stronach nigdy nie jest prawdziwym wynikiem kolejki fantasy.
5. Tworzy `Fixture` z `bracketSlot` i zapisuje.

- [ ] **Step 4: Uruchomić testy — muszą przejść**

Run: `php bin/phpunit tests/Service/BracketBuilderTest.php`
Expected: PASS, 7 testów.

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "feat: generowanie kolejnych rund pucharowych na podstawie tabeli"
```

---

### Task 9: Logowanie admina i szkielet widoków

**Files:**
- Create: `src/Controller/SecurityController.php`
- Create: `src/Command/CreateAdminCommand.php`
- Create: `templates/base.html.twig`, `templates/security/login.html.twig`
- Create: `templates/_partial/nav.html.twig`
- Modify: `config/packages/security.yaml`
- Create: `public/css/app.css` (albo assety Tabler)
- Test: `tests/Controller/SecurityControllerTest.php`

**Interfaces:**
- Consumes: encja `User` z Task 2
- Produces: trasa `app_login` (`/login`) i `app_logout` (`/logout`); komenda `app:admin:create`; blok Twig `{% block content %}` w `base.html.twig`

- [ ] **Step 1: Sprawdzić dostępność Tablera dla Symfony 7.4**

```bash
cd /Users/gladki/projects/ucl
composer require kevinpapst/tabler-bundle --no-interaction --dry-run
```

Jeśli composer zgłasza konflikt z Symfony 7.4 — **nie walczyć**. Pobrać Tabler jako statyczne assety (`public/css/tabler.min.css` z CDN jsdelivr, wersja przypięta) i użyć samego CSS. Zapisać w commit message, która droga została wybrana.

- [ ] **Step 2: Skonfigurować bezpieczeństwo**

`config/packages/security.yaml`: provider `entity` po `App\Entity\User` z polem `email`, firewall `main` z `form_login` (trasa `app_login`), `logout` (trasa `app_logout`), hasher `auto`. Reguły dostępu: `- { path: ^/admin, roles: ROLE_ADMIN }` i nic więcej — cała reszta aplikacji jest publiczna.

- [ ] **Step 3: Napisać komendę tworzenia admina**

`src/Command/CreateAdminCommand.php` — `#[AsCommand(name: 'app:admin:create')]`, argumenty `email` i `password`, ustawia `ROLE_ADMIN`, haszuje przez `UserPasswordHasherInterface`, odmawia utworzenia duplikatu e-maila.

- [ ] **Step 4: Napisać test funkcjonalny**

`tests/Controller/SecurityControllerTest.php`:

```php
<?php

namespace App\Tests\Controller;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class SecurityControllerTest extends WebTestCase
{
    public function testLoginPageIsPublic(): void
    {
        $client = static::createClient();
        $client->request('GET', '/login');

        self::assertResponseIsSuccessful();
        self::assertSelectorExists('form input[name="_password"]');
    }

    public function testAdminAreaRedirectsAnonymousToLogin(): void
    {
        $client = static::createClient();
        $client->request('GET', '/admin');

        self::assertResponseRedirects();
        self::assertStringContainsString('/login', $client->getResponse()->headers->get('Location'));
    }
}
```

- [ ] **Step 5: Uruchomić testy**

Run: `php bin/phpunit tests/Controller/SecurityControllerTest.php`
Expected: PASS, 2 testy.

- [ ] **Step 6: Commit**

```bash
git add -A
git commit -m "feat: logowanie admina, komenda zakladania konta i szkielet szablonow"
```

---

### Task 10: Panel admina — edycje, kluby, uczestnicy, terminarz

**Files:**
- Create: `src/Controller/Admin/CompetitionController.php`, `ClubController.php`, `ManagerController.php`, `FixtureImportController.php`
- Create: `src/Form/CompetitionType.php`, `ClubType.php`, `ManagerType.php`, `BulkClubsType.php`, `FixtureImportType.php`
- Create: `src/Service/CompetitionFactory.php`, `src/Service/FixtureImporter.php`
- Create: `templates/admin/*.html.twig`
- Test: `tests/Service/CompetitionFactoryTest.php`, `tests/Service/FixtureImporterTest.php`

**Interfaces:**
- Consumes: encje z Task 2
- Produces:
  - `CompetitionFactory::create(string $name, string $season, CompetitionMode $mode): Competition` — zakłada edycję **wraz z kompletem 13 kolejek** o właściwych etapach i unikalnym slugiem
  - `FixtureImporter::importLeaguePhase(Competition $competition, string $raw): int` — czyta wklejony terminarz i tworzy mecze kolejek 1–8, zwraca liczbę utworzonych meczów
  - trasy `/admin`, `/admin/competition/{slug}`, `/admin/competition/{slug}/clubs`, `/admin/competition/{slug}/managers`, `/admin/competition/{slug}/fixtures`

- [ ] **Step 1: Napisać testy**

`tests/Service/CompetitionFactoryTest.php`:
1. `testCreatesThirteenRounds` — nowa edycja ma 13 kolejek.
2. `testRoundStagesFollowTheFormat` — kolejki 1–8 mają etap `League`, 9 `Playoff`, 10 `R16`, 11 `Qf`, 12 `Sf`, 13 `Final`.
3. `testSlugIsUniqueAcrossCompetitions` — dwie edycje o tej samej nazwie dostają różne slugi.

`tests/Service/FixtureImporterTest.php`:
1. `testImportsTabSeparatedFixtureLines` — wklejka `1<TAB>Real Madryt<TAB>Arsenal` tworzy mecz w kolejce 1.
2. `testUnknownClubNameIsRejectedWithItsName` — wyjątek z nazwą klubu w komunikacie.
3. `testRoundOutsideLeaguePhaseIsRejected` — numer kolejki 9 lub wyższy odrzucony (terminarz pucharu generuje `BracketBuilder`, nie wklejka).
4. `testImportingTwiceReplacesFixturesInsteadOfDuplicating` — powtórny import daje ten sam komplet meczów.

- [ ] **Step 2: Uruchomić testy — muszą nie przejść**

Run: `php bin/phpunit tests/Service`
Expected: FAIL — brak `CompetitionFactory` i `FixtureImporter`.

- [ ] **Step 3: Napisać implementację**

`CompetitionFactory` — tworzy edycję i pętlą 1–13 zakłada kolejki z `RoundStage::forRoundNumber()`. Slug z `AsciiSlugger`, przy kolizji dokleja `-2`, `-3` itd.

`FixtureImporter::importLeaguePhase()` — format wiersza: `numer kolejki<TAB>gospodarz<TAB>gość`. Nazwy klubów dopasowywane bez rozróżniania wielkości liter i po przycięciu spacji. Przed zapisem kasuje istniejące mecze kolejek 1–8 tej edycji, więc import jest powtarzalny. Nieznana nazwa klubu albo numer kolejki spoza 1–8 to `\InvalidArgumentException` z treścią wiersza.

Kontrolery admina: listy i formularze CRUD w Tablerowym layoucie. Ekran klubów przyjmuje też wklejkę 36 nazw naraz (`BulkClubsType`). Ekran uczestników pozwala przypisać uczestnika do klubu listą rozwijaną i pilnuje limitu z `CompetitionMode::managersPerClub()` — przekroczenie to komunikat walidacji, nie wyjątek.

- [ ] **Step 4: Uruchomić testy — muszą przejść**

Run: `php bin/phpunit tests/Service`
Expected: PASS (testy z Task 6 i 8 też nadal zielone).

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "feat: panel admina - edycje, kluby, uczestnicy i import terminarza"
```

---

### Task 11: Panel admina — import punktów i prowadzenie rozgrywek

**Files:**
- Create: `src/Controller/Admin/ScoreController.php`, `src/Controller/Admin/RoundController.php`
- Create: `src/Form/ScoreImportType.php`, `src/Form/ManualScoreType.php`
- Create: `templates/admin/score_import.html.twig`, `templates/admin/round.html.twig`
- Test: `tests/Controller/Admin/ScoreControllerTest.php`

**Interfaces:**
- Consumes: `ScoreImporter`, `StandingsCalculator`, `BracketBuilder`, `LeaderboardParserRegistry`
- Produces: trasy `/admin/competition/{slug}/round/{number}`, `/admin/competition/{slug}/round/{number}/import`, `/admin/competition/{slug}/round/{number}/close`, `/admin/competition/{slug}/bracket/build`

- [ ] **Step 1: Napisać test funkcjonalny**

`tests/Controller/Admin/ScoreControllerTest.php` — loguje się jako admin (`$client->loginUser($admin)`), a potem:
1. `testImportFormShowsPreviewWithoutSaving` — POST z wklejką i przyciskiem „Podgląd" pokazuje tabelę dopasowania i **nie** zapisuje wyników.
2. `testConfirmingImportStoresScores` — POST z przyciskiem „Zatwierdź" zapisuje wyniki i przekierowuje na widok kolejki.
3. `testPreviewHighlightsUnmatchedManagers` — wklejka bez jednego uczestnika pokazuje ostrzeżenie z jego nazwą.
4. `testImportRequiresAdmin` — anonimowy dostaje przekierowanie na logowanie.

- [ ] **Step 2: Uruchomić test — musi nie przejść**

Run: `php bin/phpunit tests/Controller/Admin/ScoreControllerTest.php`
Expected: FAIL — brak trasy importu.

- [ ] **Step 3: Napisać implementację**

Ekran importu jest dwustopniowy w obrębie jednej trasy: formularz ma pole wyboru formatu (`uefa_html` domyślnie, `tsv` awaryjnie), duże pole tekstowe i dwa przyciski — „Podgląd" oraz „Zatwierdź import". „Podgląd" renderuje `ImportPreview` z trzema sekcjami: dopasowani (nazwa drużyny, uczestnik, klub, punkty), nieodnalezieni uczestnicy, nierozpoznane pozycje wklejki. „Zatwierdź" wywołuje `commit()` i przekierowuje z komunikatem, ilu uczestników zaktualizowano.

Widok kolejki w panelu: lista meczów z wynikami, lista wyników uczestników z możliwością korekty pojedynczej wartości (`ManualScoreType`), przycisk „Przelicz od nowa" (`StandingsCalculator::recalculate()`), przycisk „Zamknij kolejkę" (ustawia `closedAt` i podnosi `currentRoundNumber`) oraz — po kolejce 8 i po każdej rundzie pucharowej — przycisk „Wygeneruj następną rundę" (`BracketBuilder::buildNextStage()`). Wyjątek `\DomainException` z buildera pokazujemy jako komunikat błędu, nie jako 500.

- [ ] **Step 4: Uruchomić testy — muszą przejść**

Run: `php bin/phpunit`
Expected: PASS, cały zestaw.

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "feat: ekran importu punktow z podgladem oraz prowadzenie kolejek"
```

---

### Task 12: Widoki publiczne

**Files:**
- Create: `src/Controller/PublicController.php`
- Create: `templates/public/{index,table,round,fixture,club,bracket}.html.twig`
- Test: `tests/Controller/PublicControllerTest.php`

**Interfaces:**
- Consumes: repozytoria z Task 2
- Produces: trasy `app_home` (`/`), `app_table` (`/{slug}/tabela`), `app_round` (`/{slug}/kolejka/{number}`), `app_fixture` (`/{slug}/mecz/{id}`), `app_club` (`/{slug}/klub/{id}`), `app_bracket` (`/{slug}/drabinka`)

- [ ] **Step 1: Napisać test funkcjonalny**

`tests/Controller/PublicControllerTest.php` — bez logowania:
1. `testHomeListsCompetitions` — strona główna pokazuje nazwę edycji.
2. `testTableShowsEveryClubInOrder` — tabela zawiera wszystkie kluby, pierwszy wiersz to lider.
3. `testRoundShowsFixturesWithPoints` — widok kolejki pokazuje punkty obu stron.
4. `testFixtureShowsPointsOfEachManager` — karta meczu wymienia uczestników obu klubów z ich punktami (w trybie `Pairs` po dwóch na stronę).
5. `testBracketIsReachableAfterKnockoutStarts` — widok drabinki zwraca 200.
6. `testPublicPagesRequireNoLogin` — żadna z powyższych tras nie przekierowuje na logowanie.

- [ ] **Step 2: Uruchomić test — musi nie przejść**

Run: `php bin/phpunit tests/Controller/PublicControllerTest.php`
Expected: FAIL — brak tras publicznych.

- [ ] **Step 3: Napisać implementację**

Widoki w Tablerze, po polsku:

- **Tabela** — pozycja ze strzałką zmiany względem poprzedniej kolejki, klub z herbem, uczestnicy klubu, M / W / R / P, punkty fantasy zdobyte i stracone, różnica, punkty. Wiersze 1–8, 9–24 i 25–36 rozdzielone wizualnie, bo to granice awansu.
- **Kolejka** — karty meczów z punktami i nawigacją do sąsiednich kolejek.
- **Mecz** — obie strony z rozbiciem na uczestników i ich punkty, czyli dokładnie to, z czego wynika wynik.
- **Klub** — komplet meczów klubu, jego uczestnicy, wykres punktów po kolejkach.
- **Drabinka** — kolumny: baraże, 1/8, ćwierćfinały, półfinały, finał; awansujący wyróżniony. Kolumna pojawia się dopiero wtedy, gdy jej mecze istnieją.

- [ ] **Step 4: Uruchomić testy — muszą przejść**

Run: `php bin/phpunit`
Expected: PASS, cały zestaw.

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "feat: publiczne widoki tabeli, kolejek, meczow i drabinki"
```

---

### Task 13: Herby klubów i dane startowe

**Files:**
- Create: `src/Command/ImportClubLogosCommand.php`
- Create: `src/Command/LoadDemoCompetitionCommand.php`
- Create: `README.md`
- Test: `tests/Command/LoadDemoCompetitionCommandTest.php`

**Interfaces:**
- Consumes: `CompetitionFactory`, `FixtureImporter`, `ScoreImporter`
- Produces: komendy `app:clubs:logos` oraz `app:demo:load`

- [ ] **Step 1: Napisać test**

`tests/Command/LoadDemoCompetitionCommandTest.php` — uruchamia `app:demo:load` przez `CommandTester` i sprawdza, że powstała edycja z 36 klubami, 13 kolejkami i kompletem uczestników oraz że `StandingsCalculator` policzył tabelę bez wyjątku.

- [ ] **Step 2: Uruchomić test — musi nie przejść**

Run: `php bin/phpunit tests/Command/LoadDemoCompetitionCommandTest.php`
Expected: FAIL — brak komendy `app:demo:load`.

- [ ] **Step 3: Napisać implementację**

`app:demo:load` — zakłada edycję demonstracyjną: 36 klubów Ligi Mistrzów (lista nazw wpisana w komendzie), po jednym lub dwóch uczestnikach zależnie od trybu podanego opcją `--mode`, losowy terminarz fazy ligowej spełniający warunek „każdy z ośmioma różnymi rywalami" i losowe punkty. Służy do obejrzenia aplikacji przed startem prawdziwego sezonu.

`app:clubs:logos` — pobiera herby z `img.uefa.com` na podstawie danych JSON-LD ze strony rozgrywek UEFA i zapisuje do `public/images/clubs/`. Nazwa pliku = slug klubu. Komenda musi łagodnie znieść brak dostępu do sieci: wypisuje, czego nie udało się pobrać, i kończy się kodem 0, jeśli pobrał cokolwiek.

`README.md` — jak postawić projekt, jak założyć admina, jak wkleić terminarz, jak zaimportować punkty, jak zamknąć kolejkę i wygenerować drabinkę.

- [ ] **Step 4: Uruchomić pełny zestaw testów**

Run: `php bin/phpunit`
Expected: PASS, cały zestaw.

- [ ] **Step 5: Sprawdzić aplikację w przeglądarce**

```bash
php bin/console app:demo:load --mode=pairs
symfony server:start -d
open http://127.0.0.1:8000/
```

Sprawdzić: strona główna, tabela, widok kolejki, karta meczu z rozbiciem na uczestników.

- [ ] **Step 6: Commit**

```bash
git add -A
git commit -m "feat: dane demonstracyjne, pobieranie herbow i README"
```

---

## Kolejność i zależności

Zadania 1–2 są fundamentem. Zadania 3, 4 i 5 są od siebie **niezależne** — można je robić równolegle. Zadanie 6 potrzebuje 3, zadanie 7 potrzebuje 5 i 6, zadanie 8 potrzebuje 4 i 6. Zadania 9–13 idą po kolei.

Po każdym zadaniu cały zestaw testów musi być zielony — nie tylko test bieżącego zadania.
