Compare commits

...

1 Commits

Author SHA1 Message Date
Marijn 5e7028c972 Add fullscreen toggle and return-to-page logout redirect (#200)
* feat: add fullscreen toggle and return-to-page logout redirect

Add a Stimulus-driven fullscreen button/keypress to the quiz and
elimination screens, and redirect admins back to the page they were on
after logging out (unless it was a backoffice or elimination page).

* docs: expand CLAUDE.md domain context and testing conventions

Document the WIDM quiz/elimination domain model in more depth, and add
guidance to prefer plain TestCase over WebTestCase/KernelTestCase and to
apply the Boy Scout Rule for small nearby cleanups.

* fix: drop f-keypress fullscreen shortcut

Candidate names can start with "f" while typing, so the keypress
conflicted with the enter-name form. Button-only toggle remains.
2026-07-09 21:30:06 +00:00
8 changed files with 329 additions and 30 deletions
+110 -10
View File
@@ -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
@@ -122,49 +127,140 @@ 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)
- **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
@@ -175,6 +271,7 @@ tests/
- `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`
@@ -207,7 +304,8 @@ Runs on all pushes to main and pull requests. Concurrency cancels old runs on ne
## 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
@@ -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));
}
}
+26
View File
@@ -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;
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Tvdt\Security;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Http\Event\LogoutEvent;
final readonly class LogoutRedirectListener implements EventSubscriberInterface
{
private const array BLOCKED_TARGET_PREFIXES = ['/backoffice', '/elimination'];
public function __construct(private UrlGeneratorInterface $urlGenerator) {}
public function onLogout(LogoutEvent $event): void
{
$target = $event->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));
}
}
+10
View File
@@ -1,3 +1,13 @@
{% extends 'base.html.twig' %}
{% block importmap %}{{ importmap('quiz') }}{% endblock %}
{% block nav %}{{ include('quiz/nav.html.twig') }}{% endblock %}
{% block main %}
<div data-controller="fullscreen">
<button type="button"
class="fullscreen-btn"
data-action="fullscreen#toggle"
aria-label="{{ 'Fullscreen'|trans }}"
title="{{ 'Fullscreen'|trans }}">⛶</button>
{{ parent() }}
</div>
{% endblock %}
+1 -1
View File
@@ -4,7 +4,7 @@
<a href="{{ path('tvdt_backoffice_index') }}" class="btn btn-outline-secondary btn-sm">
{{ 'Backoffice'|trans }}
</a>
<a href="{{ path('tvdt_login_logout') }}" class="btn btn-outline-secondary btn-sm">
<a href="{{ path('tvdt_login_logout', {target: app.request.pathInfo}) }}" class="btn btn-outline-secondary btn-sm">
{{ 'Logout'|trans }}
</a>
{% else %}
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Security;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Http\Event\LogoutEvent;
use Tvdt\Security\LogoutRedirectListener;
#[CoversClass(LogoutRedirectListener::class)]
final class LogoutRedirectListenerTest extends TestCase
{
public function testLogoutRedirectsBackToTheGivenTarget(): void
{
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator->expects($this->never())->method('generate');
$listener = new LogoutRedirectListener($urlGenerator);
$event = new LogoutEvent(Request::create('/logout?target=/krtek'), null);
$listener->onLogout($event);
$this->assertSame('/krtek', $event->getResponse()?->headers->get('Location'));
}
public function testLogoutWithoutTargetRedirectsToSeasonSelect(): void
{
$listener = new LogoutRedirectListener($this->seasonSelectUrlGenerator());
$event = new LogoutEvent(Request::create('/logout'), null);
$listener->onLogout($event);
$this->assertSame('/', $event->getResponse()?->headers->get('Location'));
}
#[DataProvider('blockedTargetProvider')]
public function testLogoutIgnoresBlockedOrUnsafeTargets(string $target): void
{
$listener = new LogoutRedirectListener($this->seasonSelectUrlGenerator());
$event = new LogoutEvent(Request::create('/logout?target='.urlencode($target)), null);
$listener->onLogout($event);
$this->assertSame('/', $event->getResponse()?->headers->get('Location'));
}
/** @return iterable<string, array{string}> */
public static function blockedTargetProvider(): iterable
{
yield 'backoffice page' => ['/backoffice/season/krtek'];
yield 'elimination page' => ['/elimination/00000000-0000-0000-0000-000000000000'];
yield 'protocol-relative url' => ['//evil.example.org'];
yield 'absolute url' => ['https://evil.example.org'];
}
private function seasonSelectUrlGenerator(): UrlGeneratorInterface&MockObject
{
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator->expects($this->once())
->method('generate')
->with('tvdt_quiz_select_season')
->willReturn('/');
return $urlGenerator;
}
}
+4
View File
@@ -369,6 +369,10 @@
<source>Forgot your password?</source>
<target>Wachtwoord vergeten?</target>
</trans-unit>
<trans-unit id="3cWWP_q" resname="Fullscreen">
<source>Fullscreen</source>
<target>Volledig scherm</target>
</trans-unit>
<trans-unit id="MebBrmp" resname="Gray">
<source>Gray</source>
<target>Grijs</target>