diff --git a/CLAUDE.md b/CLAUDE.md index aef60a1..01a09a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -**Tijd voor de test** is a PHP/Symfony 8.1 application for managing quizzes in the style of **Wie is de Mol?** (WIDM) — a Dutch TV show where contestants try to identify a saboteur ("de Mol") among them. At the end of each episode, participants take a quiz about the Mol's identity and actions; the candidate with the least correct answers is eliminated. This app replicates that quiz format with: +**Tijd voor de test** is a PHP/Symfony 8.1 application for managing quizzes in the style of **Wie is de Mol?** (WIDM) — +a Dutch TV show where contestants try to identify a saboteur ("de Mol") among them. At the end of each episode, +participants take a quiz about the Mol's identity and actions; the candidate with the least correct answers is +eliminated. This app replicates that quiz format with: + - Test creation with variable question counts - Season management with active test controls - Candidate answer tracking with automatic timing @@ -12,6 +16,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - Backoffice management for quiz administration and statistics Tech Stack: + - **Framework**: Symfony 8.1 - **PHP**: 8.5+ - **Database**: PostgreSQL 16 @@ -76,7 +81,7 @@ All code quality checks run in CI/CD (.github/workflows/ci.yml) and should pass ``` src/ - Controller/ # HTTP request handlers (attribute-routed) + Controller/ # HTTP request handlers (attribute-routed) Backoffice/ # Admin panel controllers Entity/ # Doctrine ORM entities Repository/ # Database queries @@ -122,59 +127,151 @@ tests/ - **Elimination**: Records red/green screens and forced results with joker adjustments. - **User**: Administrative accounts for managing the system. +## Domain Context: "De Test" (Wie is de Mol) + +**Wie is de Mol?** (WIDM) is a Dutch reality competition: a group of contestants ("kandidaten") travels together while +one of them, "de Mol", secretly sabotages assignments. Each episode ends with the fixed line: *"Tijd voor de test. +Twintig vragen over de identiteit en het doen en laten van de Mol. Degene die het minst weet, ligt uit het spel. Behalve +de Mol. Die hoeft nooit naar huis."* ("Time for the test. Twenty questions about the identity and the actions of the +Mol. Whoever knows the least is out of the game. Except the Mol — they never have to go home.") The contestant with the +worst score is eliminated ("afvallen"); the Mol is immune regardless of score, since they already know the answers. This +app is a generic engine for running that quiz format for private/fan seasons, not just modeling the TV show +incidentally — the entity model below exists specifically to reproduce WIDM's test mechanics. + +### What a test's 20 questions actually are + +Per the intro line, questions fall into two factual categories — never opinion ("who would you vote off") — plus a +third recurring format used on the show: + +1. **Identity of the Mol**: guessing which contestant is the Mol. +2. **The Mol's actions**: what the Mol did or where the Mol was during a specific assignment/moment. +3. **Candidate self-answered questions**: earlier, every contestant privately answered a question about themselves + (an interview-style question); the test then asks other contestants to guess what a *specific* candidate answered + about themselves. This tests how well contestants know each other, not just Mol-tracking. + +### Why answers can be bound to candidates + +All three categories above can have contestants themselves as the answer options rather than free text: "who is the +Mol" and "who did X" both need contestant names as options, and "what did candidate Y answer" needs Y's own submitted +answer among the options. In the domain model this is `Answer::$candidates` (a `ManyToMany` to `Candidate`, on both +sides): an answer option can *be* another contestant, not just text. + +Because the relationship is many-to-many on the answer side too, a single answer option can cover **more than one +candidate at once** — e.g. "Anna en Bram" as one option for "who missed the assignment together", or an option +representing everyone who gave a particular self-answer in category 3 above. So a candidate-bound answer isn't always +one candidate, it can be a group; treat `Answer::$candidates` as "the set of contestants this option represents", not +as a single foreign key. + +Combined with `GivenAnswer::$candidate` (who answered), every given answer on a candidate-bound question is a directed +relationship from the answering candidate to *every* candidate covered by the chosen option — a one-to-many edge when +the option is a group, not just candidate A pointed at candidate B. This is the mechanic behind any "who's suspected of +what" or sociogram-style statistic — it only applies to candidate-bound questions, plain trivia questions have no such +relationship. `Quiz::getQuestionErrors()` already relies on this distinction to validate that every active candidate is +covered exactly once per candidate-bound question (a candidate appearing across multiple group-options on the same +question counts as covered more than once). + +### Elimination mechanics + +- **Red/green screens**: at the end of a test, contestants are shown red or green screens one at a time to build tension + before the elimination is revealed. `Elimination::$data` stores the colour shown per candidate ( + `SCREEN_RED/SCREEN_GREEN` via `getScreenColour()`), independent of the actual quiz score. +- **Jokers / corrections**: contestants can hold a "joker" (an advantage, e.g. an extra correct answer) that adjusts + their effective score without changing what they actually answered. This is `QuizCandidate::$corrections` — a float + added to the raw score, kept separate from `GivenAnswer` so the audit trail of what was actually answered stays + untouched. +- **Dropouts**: `Quiz::$dropouts` controls how many contestants can be eliminated in a single test (normally 1, but some + episodes eliminate more). +- **Finalization/locking**: `Quiz::$isFinalized` and `$isLocked` gate when a quiz's questions/answers can still be + edited — a quiz becomes immutable once a candidate has started it or an admin explicitly finalizes it. Treat this as + the natural point where computed results (scores, statistics) can be cached indefinitely, since nothing that feeds + them can change afterward. + +### Terminology map (Dutch UI ↔ domain code) + +| UI/domain term (Dutch) | Code | +|------------------------------|-------------------------------| +| Test | `Quiz` | +| Vraag | `Question` | +| Antwoord | `Answer` | +| Kandidaat | `Candidate` | +| Ingevuld antwoord | `GivenAnswer` | +| Afvallen / rood-groen scherm | `Elimination` | +| Joker / correctie | `QuizCandidate::$corrections` | + ## Architecture Notes ### Routing + - Routes are **attribute-based** (PHP 8 attributes in controller methods) - Configured in `config/routes/attributes.yaml` for automatic discovery - Main entry point: `config/routes.yaml` ### Service Container & Dependency Injection + - Services in `src/` are automatically registered via PSR-4 namespace `Tvdt\` - Exclusions: Entity, DependencyInjection, Kernel classes - Autowiring and autoconfiguration enabled by default - Service definitions in `config/services.yaml` ### Database & Migrations + - PostgreSQL-based with Doctrine ORM -- Migrations in `migrations/` at project root, namespace `DoctrineMigrations` (intentionally not autoloaded); generate with `bin/console make:migration` +- Migrations in `migrations/` at project root, namespace `DoctrineMigrations` (intentionally not autoloaded); generate + with `bin/console make:migration` - Test fixtures in `src/DataFixtures/` (loaded with `--group=test`) - Test database configured separately via `.env.test` ### Testing Infrastructure + - **PHPUnit 13** with DAMA Doctrine Test Bundle for transaction rollback -- Bootstrap: `tests/bootstrap.php` loads env vars and autoloader; `tests/symfony-container.php` boots the test kernel/container (used by Rector) +- Bootstrap: `tests/bootstrap.php` loads env vars and autoloader; `tests/symfony-container.php` boots the test + kernel/container (used by Rector) - Symfony test utilities (BrowserKit, CSS selectors) available - Coverage excluded from: `src/DataFixtures/` - Test environment: `APP_ENV=test` (set in phpunit.dist.xml) ### Testing Conventions (TDD) -- **Write the failing test first.** When fixing any PHP-reachable bug, write a PHPUnit test that reproduces the failure before touching the production code. Fix the code until the test passes. + +- **Write the failing test first.** When fixing any PHP-reachable bug, write a PHPUnit test that reproduces the failure + before touching the production code. Fix the code until the test passes. - Only skip a test if the bug is purely in JavaScript/frontend where PHPUnit cannot reach it. -- Don't write tests for trivial presentational markup (e.g. asserting a tooltip/popover attribute or a CSS class exists in a template). Tests cover behavior: routing, forms, persistence, authorization. -- Follow the pattern in `tests/Controller/Backoffice/` for controller/integration tests: log in, GET for CSRF token, POST form data, assert redirect, clear entity manager, assert DB state. +- Don't write tests for trivial presentational markup (e.g. asserting a tooltip/popover attribute or a CSS class exists + in a template). Tests cover behavior: routing, forms, persistence, authorization. +- Follow the pattern in `tests/Controller/Backoffice/` for controller/integration tests: log in, GET for CSRF token, + POST form data, assert redirect, clear entity manager, assert DB state. +- **Prefer `TestCase` over `WebTestCase`/`KernelTestCase`.** Reach for the full kernel/DB boot only when the test + genuinely needs routing, persistence, or the container — pure logic (services, listeners, helpers) should be tested + with plain PHPUnit `TestCase` and mocked dependencies; it's faster and more isolated. +- **Boy Scout Rule**: when you're already touching a file for an unrelated change, fix small nearby issues in the same + commit (e.g. a test that unnecessarily extends `WebTestCase`, a stale comment) rather than leaving them for later — + but don't let this balloon into an unrelated refactor. ### Code Style & Standards + - **PHP-CS-Fixer**: Symfony ruleset + risky rules enabled - - Strict types declaration required - - Trailing commas in multiline structures - - No else-only blocks -- **Rector**: Aggressive modernization with all attribute sets + prepared sets (dead code, code quality, Doctrine, Symfony, PHPUnit) + - Strict types declaration required + - Trailing commas in multiline structures + - No else-only blocks +- **Rector**: Aggressive modernization with all attribute sets + prepared sets (dead code, code quality, Doctrine, + Symfony, PHPUnit) - **PHPStan**: Level 8 with extensions for Doctrine and Symfony - **Twig-CS-Fixer**: Template style enforcement -- **Safe functions**: Use `thecodingmachine/safe` wrappers for standard PHP functions that return `false` on failure — they throw exceptions instead +- **Safe functions**: Use `thecodingmachine/safe` wrappers for standard PHP functions that return `false` on failure — + they throw exceptions instead ### Environment Configuration + - `.env` - Local development defaults (uncommitted in .env.local) - `.env.dev` - Development overrides - `.env.test` - Test environment configuration - Production uses `composer dump-env prod` for compiled configuration - Key variables: - - `APP_ENV` - Environment (dev/test/prod) - - `DATABASE_URL` - PostgreSQL connection string - - `MAILER_SENDER` - From address for emails + - `APP_ENV` - Environment (dev/test/prod) + - `DATABASE_URL` - PostgreSQL connection string + - `MAILER_SENDER` - From address for emails ### Frontend Build + - Asset mapper (no Node.js/Webpack) for JS/CSS bundling; JS modules declared in `importmap.php` - **Stimulus** controllers in `assets/controllers/`, **Turbo** for SPA-like navigation - Sass sources in `assets/styles/`, compiled via `bin/console sass:build` @@ -187,27 +284,28 @@ GitHub Actions workflow (`.github/workflows/ci.yml`): 1. **Linting**: Dockerfile (hadolint), Twig templates 2. **Code Quality**: - - PHP-CS-Fixer style check - - Twig-CS-Fixer style check - - PHPStan static analysis - - Rector dry-run + - PHP-CS-Fixer style check + - Twig-CS-Fixer style check + - PHPStan static analysis + - Rector dry-run 3. **Integration Tests**: - - Docker image build and start services - - Database creation and migration - - Fixture loading - - Full PHPUnit test suite with JUnit XML output - - Doctrine schema validation + - Docker image build and start services + - Database creation and migration + - Fixture loading + - Full PHPUnit test suite with JUnit XML output + - Doctrine schema validation 4. **Build & Deploy** (on tags or main, disabled currently): - - Docker image push to GitHub Container Registry - - Sentry release creation - - Portainer webhook trigger for production deployment + - Docker image push to GitHub Container Registry + - Sentry release creation + - Portainer webhook trigger for production deployment Runs on all pushes to main and pull requests. Concurrency cancels old runs on new commits. ## Important Files & Conventions - **Kernel**: `src/Kernel.php` - Symfony kernel class -- **AbstractController**: Base class for all controllers — defines route parameter regexes (`SEASON_CODE_REGEX`, `CANDIDATE_HASH_REGEX`) and flash helpers +- **AbstractController**: Base class for all controllers — defines route parameter regexes (`SEASON_CODE_REGEX`, + `CANDIDATE_HASH_REGEX`) and flash helpers - **Flash Messages**: Use `FlashType` enum instead of string literals - **QuizSpreadsheetService**: Handles importing quizzes from XLSX files - **Rector container**: `tests/symfony-container.php` — boots a test kernel so Rector can resolve Symfony service types @@ -226,6 +324,7 @@ Runs on all pushes to main and pull requests. Concurrency cancels old runs on ne ## Composer Scripts Auto-executed scripts on install/update: + - `cache:clear` - Symfony cache clear - `assets:install` - Copy public assets - `importmap:install` - JS import map setup @@ -244,4 +343,5 @@ When writing Dutch help content in `templates/backoffice/help/nl/`: - The backoffice elimination logic is in `Controller/Backoffice/PrepareEliminationController.php` - Quiz timing logic starts on candidate start click and stops on final answer selection - Background music feature noted but not yet implemented (requirements only) -- Statistics functionality is marked TBD in README +- Statistics module (per-quiz statistics page, candidate accusation matrix, caching) is planned per GitHub issue #199 — + see "Domain Context" above for why candidate-bound answers matter to it diff --git a/assets/controllers/fullscreen_controller.js b/assets/controllers/fullscreen_controller.js new file mode 100644 index 0000000..fdf479b --- /dev/null +++ b/assets/controllers/fullscreen_controller.js @@ -0,0 +1,40 @@ +import {Controller} from '@hotwired/stimulus'; + +const STORAGE_KEY = 'tvdt-fullscreen'; + +export default class extends Controller { + connect() { + this.onFullscreenChange = this.onFullscreenChange.bind(this); + document.addEventListener('fullscreenchange', this.onFullscreenChange); + this.syncState(); + + if (sessionStorage.getItem(STORAGE_KEY) === '1' && !document.fullscreenElement) { + this.request(); + } + } + + disconnect() { + document.removeEventListener('fullscreenchange', this.onFullscreenChange); + } + + toggle() { + if (document.fullscreenElement) { + document.exitFullscreen(); + } else { + this.request(); + } + } + + request() { + document.documentElement.requestFullscreen().catch(() => {}); + } + + onFullscreenChange() { + sessionStorage.setItem(STORAGE_KEY, document.fullscreenElement ? '1' : '0'); + this.syncState(); + } + + syncState() { + document.documentElement.classList.toggle('is-fullscreen', Boolean(document.fullscreenElement)); + } +} diff --git a/assets/styles/quiz.scss b/assets/styles/quiz.scss index d815297..bb48be9 100644 --- a/assets/styles/quiz.scss +++ b/assets/styles/quiz.scss @@ -94,6 +94,32 @@ input.btn-check:checked + label.answer-btn { gap: 0.5rem; } +.fullscreen-btn { + position: fixed; + bottom: 0.75rem; + left: 0.75rem; + z-index: 1040; + width: 2.25rem; + height: 2.25rem; + padding: 0; + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.35); + font-size: 1.25rem; + line-height: 1; + cursor: pointer; + + &:hover, + &:focus { + color: rgba(255, 255, 255, 0.8); + outline: none; + } +} + +html.is-fullscreen .fullscreen-btn { + display: none; +} + .elimination-screen { position: fixed; top: 0; diff --git a/src/Security/LogoutRedirectListener.php b/src/Security/LogoutRedirectListener.php new file mode 100644 index 0000000..75f1eef --- /dev/null +++ b/src/Security/LogoutRedirectListener.php @@ -0,0 +1,48 @@ +getRequest()->query->get('target'); + + if (\is_string($target) && $this->isAllowedTarget($target)) { + $event->setResponse(new RedirectResponse($target)); + + return; + } + + $event->setResponse(new RedirectResponse($this->urlGenerator->generate('tvdt_quiz_select_season'))); + } + + public static function getSubscribedEvents(): array + { + // Must run before Symfony's DefaultLogoutListener (priority 64), which only + // sets a response if none is set yet. + return [ + LogoutEvent::class => ['onLogout', 128], + ]; + } + + private function isAllowedTarget(string $target): bool + { + if (!str_starts_with($target, '/') || str_starts_with($target, '//')) { + return false; + } + + return !array_any(self::BLOCKED_TARGET_PREFIXES, static fn (string $prefix): bool => str_starts_with($target, $prefix)); + } +} diff --git a/templates/quiz/base.html.twig b/templates/quiz/base.html.twig index 5603f9c..6ee79fa 100644 --- a/templates/quiz/base.html.twig +++ b/templates/quiz/base.html.twig @@ -1,3 +1,13 @@ {% extends 'base.html.twig' %} {% block importmap %}{{ importmap('quiz') }}{% endblock %} {% block nav %}{{ include('quiz/nav.html.twig') }}{% endblock %} +{% block main %} +