Compare commits

..

25 Commits

Author SHA1 Message Date
Marijn 20c97d9cb5 Improve quiz page layouts: WIDM-style answers and responsive centering
- Add green square answer buttons styled after the TV show
- Two-column answer grid for 6+ answers, single column on mobile
- fit-content centering for question pages so block matches question width
- Narrow fixed-width centering for form pages (enter name, select season)
2026-07-01 22:25:43 +02:00
Marijn e5198507ae Fix quiz page vertical centering regression
The CSS cleanup broke vertical centering: flex on body causes main to
stretch full-width; place-items:center on a grid body only centers
items within their auto-sized track (not the track within body).

Fix: move background/color to html (full-viewport grid that centers
body), give body height:100% + display:grid + align-content:center
(centers the content track within full-height body) + justify-self:center
(shrink-wraps body width). Matches production behavior exactly.
2026-07-01 21:35:45 +02:00
Marijn d94eeced8c Add unit tests for QuizSpreadsheetService
7 tests covering generateTemplate(), quizToXlsx(), and xlsxToQuiz():
- valid XLSX output and MIME type
- template without example reimports as empty
- template example data survives a reimport
- round-trip (export → reimport) preserves questions, answers, and correct flags
- empty quiz exports and reimports cleanly
- invalid MIME type throws InvalidArgumentException
- question with no answers throws SpreadsheetDataException with error list
2026-07-01 21:11:23 +02:00
Marijn 7e09fcdafb Implement quizToXlsx() export and add export button
- QuizSpreadsheetService: implement quizToXlsx() as the inverse of
  fillQuizFromArray() — writes quiz questions and answers to XLSX using
  the same column layout as the import template
- BackofficeController: add exportQuiz() action at GET /backoffice/quiz/{quiz}/export
- tab_overview.html.twig: add Export to XLSX button in Quick actions
2026-07-01 20:27:48 +02:00
Marijn d8b671046b Clean up templates and CSS
- season.html.twig: remove dead empty column, drop redundant flex-row
- tab_overview.html.twig: extract Twig macro for confirm modals, fix duplicate aria-labelledby IDs
- tab_result.html.twig: remove dead comment, replace inline widths with CSS classes, simplify nested row/col forms to d-flex gap-1
- backoffice.scss: add col-result-xs/sm/md column width classes
- quiz.scss: replace broken display:grid + justify-self:center with flexbox centering
2026-07-01 18:32:57 +02:00
Marijn cd63ef339f Add CLAUDE.md, replace Makefile with Justfile, remove .junie
- Add CLAUDE.md with project overview, commands, architecture, and domain entity docs
- Remove Makefile in favour of the existing Justfile
- Remove .junie/AGENTS.md (knowledge transferred to CLAUDE.md)
- Update .gitignore: drop .junie/ entries, add .claude/settings.local.json
- Minor doc fixes in config/reference.php (typo, type correction)
2026-07-01 17:47:45 +02:00
dependabot[bot] 685544ffff Bump sentry/sentry from 4.28.0 to 4.29.0 (#160)
Bumps [sentry/sentry](https://github.com/getsentry/sentry-php) from 4.28.0 to 4.29.0.
- [Release notes](https://github.com/getsentry/sentry-php/releases)
- [Changelog](https://github.com/getsentry/sentry-php/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-php/compare/4.28.0...4.29.0)

---
updated-dependencies:
- dependency-name: sentry/sentry
  dependency-version: 4.29.0
  dependency-type: indirect
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-01 17:27:09 +02:00
dependabot[bot] 6b616465b2 Bump guzzlehttp/psr7 from 2.12.1 to 2.12.3 (#157)
Bumps [guzzlehttp/psr7](https://github.com/guzzle/psr7) from 2.12.1 to 2.12.3.
- [Release notes](https://github.com/guzzle/psr7/releases)
- [Changelog](https://github.com/guzzle/psr7/blob/2.12/CHANGELOG.md)
- [Commits](https://github.com/guzzle/psr7/compare/2.12.1...2.12.3)

---
updated-dependencies:
- dependency-name: guzzlehttp/psr7
  dependency-version: 2.12.3
  dependency-type: indirect
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-01 16:09:37 +02:00
dependabot[bot] 381c2119e7 Bump the symfony group with 32 updates (#159)
Bumps the symfony group with 32 updates:

| Package | From | To |
| --- | --- | --- |
| [symfony/asset-mapper](https://github.com/symfony/asset-mapper) | `8.1.0` | `8.1.1` |
| [symfony/console](https://github.com/symfony/console) | `8.1.0` | `8.1.1` |
| [symfony/form](https://github.com/symfony/form) | `8.1.0` | `8.1.1` |
| [symfony/framework-bundle](https://github.com/symfony/framework-bundle) | `8.1.0` | `8.1.1` |
| [symfony/mailer](https://github.com/symfony/mailer) | `8.1.0` | `8.1.1` |
| [symfony/security-bundle](https://github.com/symfony/security-bundle) | `8.1.0` | `8.1.1` |
| [symfony/serializer](https://github.com/symfony/serializer) | `8.1.0` | `8.1.1` |
| [symfony/translation](https://github.com/symfony/translation) | `8.1.0` | `8.1.1` |
| [symfony/validator](https://github.com/symfony/validator) | `8.1.0` | `8.1.1` |
| [symfony/yaml](https://github.com/symfony/yaml) | `8.1.0` | `8.1.1` |
| [symfony/cache](https://github.com/symfony/cache) | `8.1.0` | `8.1.1` |
| [symfony/cache-contracts](https://github.com/symfony/cache-contracts) | `3.7.0` | `3.7.1` |
| [symfony/config](https://github.com/symfony/config) | `8.1.0` | `8.1.1` |
| [symfony/dependency-injection](https://github.com/symfony/dependency-injection) | `8.1.0` | `8.1.1` |
| [symfony/deprecation-contracts](https://github.com/symfony/deprecation-contracts) | `3.7.0` | `3.7.1` |
| [symfony/doctrine-bridge](https://github.com/symfony/doctrine-bridge) | `8.1.0` | `8.1.1` |
| [symfony/event-dispatcher](https://github.com/symfony/event-dispatcher) | `8.1.0` | `8.1.1` |
| [symfony/event-dispatcher-contracts](https://github.com/symfony/event-dispatcher-contracts) | `3.7.0` | `3.7.1` |
| [symfony/finder](https://github.com/symfony/finder) | `8.1.0` | `8.1.1` |
| [symfony/http-client](https://github.com/symfony/http-client) | `8.1.0` | `8.1.1` |
| [symfony/http-client-contracts](https://github.com/symfony/http-client-contracts) | `3.7.0` | `3.7.1` |
| [symfony/http-foundation](https://github.com/symfony/http-foundation) | `8.1.0` | `8.1.1` |
| [symfony/http-kernel](https://github.com/symfony/http-kernel) | `8.1.0` | `8.1.1` |
| [symfony/intl](https://github.com/symfony/intl) | `8.1.0` | `8.1.1` |
| [symfony/polyfill-deepclone](https://github.com/symfony/polyfill-deepclone) | `1.38.2` | `1.40.0` |
| [symfony/security-core](https://github.com/symfony/security-core) | `8.1.0` | `8.1.1` |
| [symfony/security-http](https://github.com/symfony/security-http) | `8.1.0` | `8.1.1` |
| [symfony/service-contracts](https://github.com/symfony/service-contracts) | `3.7.0` | `3.7.1` |
| [symfony/translation-contracts](https://github.com/symfony/translation-contracts) | `3.7.0` | `3.7.1` |
| [symfony/twig-bridge](https://github.com/symfony/twig-bridge) | `8.1.0` | `8.1.1` |
| [symfony/var-dumper](https://github.com/symfony/var-dumper) | `8.1.0` | `8.1.1` |
| [symfony/var-exporter](https://github.com/symfony/var-exporter) | `8.1.0` | `8.1.1` |


Updates `symfony/asset-mapper` from 8.1.0 to 8.1.1
- [Release notes](https://github.com/symfony/asset-mapper/releases)
- [Changelog](https://github.com/symfony/asset-mapper/blob/8.2/CHANGELOG.md)
- [Commits](https://github.com/symfony/asset-mapper/compare/v8.1.0...v8.1.1)

Updates `symfony/console` from 8.1.0 to 8.1.1
- [Release notes](https://github.com/symfony/console/releases)
- [Changelog](https://github.com/symfony/console/blob/8.2/CHANGELOG.md)
- [Commits](https://github.com/symfony/console/compare/v8.1.0...v8.1.1)

Updates `symfony/form` from 8.1.0 to 8.1.1
- [Release notes](https://github.com/symfony/form/releases)
- [Changelog](https://github.com/symfony/form/blob/8.2/CHANGELOG.md)
- [Commits](https://github.com/symfony/form/compare/v8.1.0...v8.1.1)

Updates `symfony/framework-bundle` from 8.1.0 to 8.1.1
- [Release notes](https://github.com/symfony/framework-bundle/releases)
- [Changelog](https://github.com/symfony/framework-bundle/blob/8.2/CHANGELOG.md)
- [Commits](https://github.com/symfony/framework-bundle/compare/v8.1.0...v8.1.1)

Updates `symfony/mailer` from 8.1.0 to 8.1.1
- [Release notes](https://github.com/symfony/mailer/releases)
- [Changelog](https://github.com/symfony/mailer/blob/8.2/CHANGELOG.md)
- [Commits](https://github.com/symfony/mailer/compare/v8.1.0...v8.1.1)

Updates `symfony/security-bundle` from 8.1.0 to 8.1.1
- [Release notes](https://github.com/symfony/security-bundle/releases)
- [Changelog](https://github.com/symfony/security-bundle/blob/8.2/CHANGELOG.md)
- [Commits](https://github.com/symfony/security-bundle/compare/v8.1.0...v8.1.1)

Updates `symfony/serializer` from 8.1.0 to 8.1.1
- [Release notes](https://github.com/symfony/serializer/releases)
- [Changelog](https://github.com/symfony/serializer/blob/8.2/CHANGELOG.md)
- [Commits](https://github.com/symfony/serializer/compare/v8.1.0...v8.1.1)

Updates `symfony/translation` from 8.1.0 to 8.1.1
- [Release notes](https://github.com/symfony/translation/releases)
- [Changelog](https://github.com/symfony/translation/blob/8.2/CHANGELOG.md)
- [Commits](https://github.com/symfony/translation/compare/v8.1.0...v8.1.1)

Updates `symfony/validator` from 8.1.0 to 8.1.1
- [Release notes](https://github.com/symfony/validator/releases)
- [Changelog](https://github.com/symfony/validator/blob/8.2/CHANGELOG.md)
- [Commits](https://github.com/symfony/validator/compare/v8.1.0...v8.1.1)

Updates `symfony/yaml` from 8.1.0 to 8.1.1
- [Release notes](https://github.com/symfony/yaml/releases)
- [Changelog](https://github.com/symfony/yaml/blob/8.2/CHANGELOG.md)
- [Commits](https://github.com/symfony/yaml/compare/v8.1.0...v8.1.1)

Updates `symfony/cache` from 8.1.0 to 8.1.1
- [Release notes](https://github.com/symfony/cache/releases)
- [Changelog](https://github.com/symfony/cache/blob/8.2/CHANGELOG.md)
- [Commits](https://github.com/symfony/cache/compare/v8.1.0...v8.1.1)

Updates `symfony/cache-contracts` from 3.7.0 to 3.7.1
- [Release notes](https://github.com/symfony/cache-contracts/releases)
- [Changelog](https://github.com/symfony/cache-contracts/blob/main/CHANGELOG.md)
- [Commits](https://github.com/symfony/cache-contracts/compare/v3.7.0...v3.7.1)

Updates `symfony/config` from 8.1.0 to 8.1.1
- [Release notes](https://github.com/symfony/config/releases)
- [Changelog](https://github.com/symfony/config/blob/8.2/CHANGELOG.md)
- [Commits](https://github.com/symfony/config/compare/v8.1.0...v8.1.1)

Updates `symfony/dependency-injection` from 8.1.0 to 8.1.1
- [Release notes](https://github.com/symfony/dependency-injection/releases)
- [Changelog](https://github.com/symfony/dependency-injection/blob/8.2/CHANGELOG.md)
- [Commits](https://github.com/symfony/dependency-injection/compare/v8.1.0...v8.1.1)

Updates `symfony/deprecation-contracts` from 3.7.0 to 3.7.1
- [Release notes](https://github.com/symfony/deprecation-contracts/releases)
- [Changelog](https://github.com/symfony/deprecation-contracts/blob/main/CHANGELOG.md)
- [Commits](https://github.com/symfony/deprecation-contracts/compare/v3.7.0...v3.7.1)

Updates `symfony/doctrine-bridge` from 8.1.0 to 8.1.1
- [Release notes](https://github.com/symfony/doctrine-bridge/releases)
- [Changelog](https://github.com/symfony/doctrine-bridge/blob/8.2/CHANGELOG.md)
- [Commits](https://github.com/symfony/doctrine-bridge/compare/v8.1.0...v8.1.1)

Updates `symfony/event-dispatcher` from 8.1.0 to 8.1.1
- [Release notes](https://github.com/symfony/event-dispatcher/releases)
- [Changelog](https://github.com/symfony/event-dispatcher/blob/8.2/CHANGELOG.md)
- [Commits](https://github.com/symfony/event-dispatcher/compare/v8.1.0...v8.1.1)

Updates `symfony/event-dispatcher-contracts` from 3.7.0 to 3.7.1
- [Release notes](https://github.com/symfony/event-dispatcher-contracts/releases)
- [Changelog](https://github.com/symfony/event-dispatcher-contracts/blob/main/CHANGELOG.md)
- [Commits](https://github.com/symfony/event-dispatcher-contracts/compare/v3.7.0...v3.7.1)

Updates `symfony/finder` from 8.1.0 to 8.1.1
- [Release notes](https://github.com/symfony/finder/releases)
- [Changelog](https://github.com/symfony/finder/blob/8.2/CHANGELOG.md)
- [Commits](https://github.com/symfony/finder/compare/v8.1.0...v8.1.1)

Updates `symfony/http-client` from 8.1.0 to 8.1.1
- [Release notes](https://github.com/symfony/http-client/releases)
- [Changelog](https://github.com/symfony/http-client/blob/8.2/CHANGELOG.md)
- [Commits](https://github.com/symfony/http-client/compare/v8.1.0...v8.1.1)

Updates `symfony/http-client-contracts` from 3.7.0 to 3.7.1
- [Release notes](https://github.com/symfony/http-client-contracts/releases)
- [Changelog](https://github.com/symfony/http-client-contracts/blob/main/CHANGELOG.md)
- [Commits](https://github.com/symfony/http-client-contracts/compare/v3.7.0...v3.7.1)

Updates `symfony/http-foundation` from 8.1.0 to 8.1.1
- [Release notes](https://github.com/symfony/http-foundation/releases)
- [Changelog](https://github.com/symfony/http-foundation/blob/8.2/CHANGELOG.md)
- [Commits](https://github.com/symfony/http-foundation/compare/v8.1.0...v8.1.1)

Updates `symfony/http-kernel` from 8.1.0 to 8.1.1
- [Release notes](https://github.com/symfony/http-kernel/releases)
- [Changelog](https://github.com/symfony/http-kernel/blob/8.2/CHANGELOG.md)
- [Commits](https://github.com/symfony/http-kernel/compare/v8.1.0...v8.1.1)

Updates `symfony/intl` from 8.1.0 to 8.1.1
- [Release notes](https://github.com/symfony/intl/releases)
- [Changelog](https://github.com/symfony/intl/blob/8.2/CHANGELOG.md)
- [Commits](https://github.com/symfony/intl/compare/v8.1.0...v8.1.1)

Updates `symfony/polyfill-deepclone` from 1.38.2 to 1.40.0
- [Release notes](https://github.com/symfony/polyfill-deepclone/releases)
- [Commits](https://github.com/symfony/polyfill-deepclone/compare/v1.38.2...v1.40.0)

Updates `symfony/security-core` from 8.1.0 to 8.1.1
- [Release notes](https://github.com/symfony/security-core/releases)
- [Changelog](https://github.com/symfony/security-core/blob/8.2/CHANGELOG.md)
- [Commits](https://github.com/symfony/security-core/compare/v8.1.0...v8.1.1)

Updates `symfony/security-http` from 8.1.0 to 8.1.1
- [Release notes](https://github.com/symfony/security-http/releases)
- [Changelog](https://github.com/symfony/security-http/blob/8.2/CHANGELOG.md)
- [Commits](https://github.com/symfony/security-http/compare/v8.1.0...v8.1.1)

Updates `symfony/service-contracts` from 3.7.0 to 3.7.1
- [Release notes](https://github.com/symfony/service-contracts/releases)
- [Changelog](https://github.com/symfony/service-contracts/blob/main/CHANGELOG.md)
- [Commits](https://github.com/symfony/service-contracts/compare/v3.7.0...v3.7.1)

Updates `symfony/translation-contracts` from 3.7.0 to 3.7.1
- [Release notes](https://github.com/symfony/translation-contracts/releases)
- [Changelog](https://github.com/symfony/translation-contracts/blob/main/CHANGELOG.md)
- [Commits](https://github.com/symfony/translation-contracts/compare/v3.7.0...v3.7.1)

Updates `symfony/twig-bridge` from 8.1.0 to 8.1.1
- [Release notes](https://github.com/symfony/twig-bridge/releases)
- [Changelog](https://github.com/symfony/twig-bridge/blob/8.2/CHANGELOG.md)
- [Commits](https://github.com/symfony/twig-bridge/compare/v8.1.0...v8.1.1)

Updates `symfony/var-dumper` from 8.1.0 to 8.1.1
- [Release notes](https://github.com/symfony/var-dumper/releases)
- [Changelog](https://github.com/symfony/var-dumper/blob/8.2/CHANGELOG.md)
- [Commits](https://github.com/symfony/var-dumper/compare/v8.1.0...v8.1.1)

Updates `symfony/var-exporter` from 8.1.0 to 8.1.1
- [Release notes](https://github.com/symfony/var-exporter/releases)
- [Changelog](https://github.com/symfony/var-exporter/blob/8.2/CHANGELOG.md)
- [Commits](https://github.com/symfony/var-exporter/compare/v8.1.0...v8.1.1)

---
updated-dependencies:
- dependency-name: symfony/asset-mapper
  dependency-version: 8.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: symfony
- dependency-name: symfony/console
  dependency-version: 8.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: symfony
- dependency-name: symfony/form
  dependency-version: 8.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: symfony
- dependency-name: symfony/framework-bundle
  dependency-version: 8.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: symfony
- dependency-name: symfony/mailer
  dependency-version: 8.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: symfony
- dependency-name: symfony/security-bundle
  dependency-version: 8.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: symfony
- dependency-name: symfony/serializer
  dependency-version: 8.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: symfony
- dependency-name: symfony/translation
  dependency-version: 8.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: symfony
- dependency-name: symfony/validator
  dependency-version: 8.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: symfony
- dependency-name: symfony/yaml
  dependency-version: 8.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: symfony
- dependency-name: symfony/cache
  dependency-version: 8.1.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: symfony
- dependency-name: symfony/cache-contracts
  dependency-version: 3.7.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: symfony
- dependency-name: symfony/config
  dependency-version: 8.1.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: symfony
- dependency-name: symfony/dependency-injection
  dependency-version: 8.1.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: symfony
- dependency-name: symfony/deprecation-contracts
  dependency-version: 3.7.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: symfony
- dependency-name: symfony/doctrine-bridge
  dependency-version: 8.1.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: symfony
- dependency-name: symfony/event-dispatcher
  dependency-version: 8.1.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: symfony
- dependency-name: symfony/event-dispatcher-contracts
  dependency-version: 3.7.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: symfony
- dependency-name: symfony/finder
  dependency-version: 8.1.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: symfony
- dependency-name: symfony/http-client
  dependency-version: 8.1.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: symfony
- dependency-name: symfony/http-client-contracts
  dependency-version: 3.7.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: symfony
- dependency-name: symfony/http-foundation
  dependency-version: 8.1.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: symfony
- dependency-name: symfony/http-kernel
  dependency-version: 8.1.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: symfony
- dependency-name: symfony/intl
  dependency-version: 8.1.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: symfony
- dependency-name: symfony/polyfill-deepclone
  dependency-version: 1.40.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: symfony
- dependency-name: symfony/security-core
  dependency-version: 8.1.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: symfony
- dependency-name: symfony/security-http
  dependency-version: 8.1.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: symfony
- dependency-name: symfony/service-contracts
  dependency-version: 3.7.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: symfony
- dependency-name: symfony/translation-contracts
  dependency-version: 3.7.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: symfony
- dependency-name: symfony/twig-bridge
  dependency-version: 8.1.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: symfony
- dependency-name: symfony/var-dumper
  dependency-version: 8.1.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: symfony
- dependency-name: symfony/var-exporter
  dependency-version: 8.1.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: symfony
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 22:42:39 +02:00
dependabot[bot] 228a36be95 Bump doctrine/doctrine-bundle from 3.2.3 to 3.2.4 (#145)
Bumps [doctrine/doctrine-bundle](https://github.com/doctrine/DoctrineBundle) from 3.2.3 to 3.2.4.
- [Release notes](https://github.com/doctrine/DoctrineBundle/releases)
- [Commits](https://github.com/doctrine/DoctrineBundle/compare/3.2.3...3.2.4)

---
updated-dependencies:
- dependency-name: doctrine/doctrine-bundle
  dependency-version: 3.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-23 23:05:38 +02:00
dependabot[bot] 05ad1ebf2e Bump guzzlehttp/psr7 from 2.11.0 to 2.12.1 (#154)
Bumps [guzzlehttp/psr7](https://github.com/guzzle/psr7) from 2.11.0 to 2.12.1.
- [Release notes](https://github.com/guzzle/psr7/releases)
- [Changelog](https://github.com/guzzle/psr7/blob/2.12/CHANGELOG.md)
- [Commits](https://github.com/guzzle/psr7/compare/2.11.0...2.12.1)

---
updated-dependencies:
- dependency-name: guzzlehttp/psr7
  dependency-version: 2.12.1
  dependency-type: indirect
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-23 23:05:19 +02:00
dependabot[bot] 102b894134 Bump vincentlanglet/twig-cs-fixer in the dev-dependencies group (#153)
Bumps the dev-dependencies group with 1 update: [vincentlanglet/twig-cs-fixer](https://github.com/VincentLanglet/Twig-CS-Fixer).


Updates `vincentlanglet/twig-cs-fixer` from 4.0.0 to 4.0.1
- [Release notes](https://github.com/VincentLanglet/Twig-CS-Fixer/releases)
- [Commits](https://github.com/VincentLanglet/Twig-CS-Fixer/compare/4.0.0...4.0.1)

---
updated-dependencies:
- dependency-name: vincentlanglet/twig-cs-fixer
  dependency-version: 4.0.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-18 23:54:36 +00:00
dependabot[bot] 8609fc3552 Bump rector/rector from 2.4.5 to 2.4.6 in the dev-dependencies group (#152)
Bumps the dev-dependencies group with 1 update: [rector/rector](https://github.com/rectorphp/rector).


Updates `rector/rector` from 2.4.5 to 2.4.6
- [Release notes](https://github.com/rectorphp/rector/releases)
- [Commits](https://github.com/rectorphp/rector/compare/2.4.5...2.4.6)

---
updated-dependencies:
- dependency-name: rector/rector
  dependency-version: 2.4.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-17 23:54:34 +00:00
dependabot[bot] 8cec9faec0 Bump the dev-dependencies group with 2 updates (#150)
Bumps the dev-dependencies group with 2 updates: [friendsofphp/php-cs-fixer](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer) and [phpstan/phpstan-symfony](https://github.com/phpstan/phpstan-symfony).


Updates `friendsofphp/php-cs-fixer` from 3.95.7 to 3.95.8
- [Release notes](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/releases)
- [Changelog](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/compare/v3.95.7...v3.95.8)

Updates `phpstan/phpstan-symfony` from 2.0.19 to 2.0.20
- [Release notes](https://github.com/phpstan/phpstan-symfony/releases)
- [Commits](https://github.com/phpstan/phpstan-symfony/compare/2.0.19...2.0.20)

---
updated-dependencies:
- dependency-name: friendsofphp/php-cs-fixer
  dependency-version: 3.95.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: phpstan/phpstan-symfony
  dependency-version: 2.0.20
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-16 23:54:52 +00:00
dependabot[bot] bf36b1a6ac Bump sentry/sentry from 4.27.0 to 4.28.0 (#148)
Bumps [sentry/sentry](https://github.com/getsentry/sentry-php) from 4.27.0 to 4.28.0.
- [Release notes](https://github.com/getsentry/sentry-php/releases)
- [Changelog](https://github.com/getsentry/sentry-php/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-php/compare/4.27.0...4.28.0)

---
updated-dependencies:
- dependency-name: sentry/sentry
  dependency-version: 4.28.0
  dependency-type: indirect
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-16 13:16:48 +02:00
dependabot[bot] 0b5c535ed9 Bump the dev-dependencies group with 3 updates (#149)
Bumps the dev-dependencies group with 3 updates: [friendsofphp/php-cs-fixer](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer), [phpunit/phpunit](https://github.com/sebastianbergmann/phpunit) and [vincentlanglet/twig-cs-fixer](https://github.com/VincentLanglet/Twig-CS-Fixer).


Updates `friendsofphp/php-cs-fixer` from 3.95.5 to 3.95.7
- [Release notes](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/releases)
- [Changelog](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/compare/v3.95.5...v3.95.7)

Updates `phpunit/phpunit` from 13.2.0 to 13.2.1
- [Release notes](https://github.com/sebastianbergmann/phpunit/releases)
- [Changelog](https://github.com/sebastianbergmann/phpunit/blob/13.2.1/ChangeLog-13.2.md)
- [Commits](https://github.com/sebastianbergmann/phpunit/compare/13.2.0...13.2.1)

Updates `vincentlanglet/twig-cs-fixer` from 3.14.0 to 4.0.0
- [Release notes](https://github.com/VincentLanglet/Twig-CS-Fixer/releases)
- [Upgrade guide](https://github.com/VincentLanglet/Twig-CS-Fixer/blob/main/UPGRADE.md)
- [Commits](https://github.com/VincentLanglet/Twig-CS-Fixer/compare/3.14.0...4.0.0)

---
updated-dependencies:
- dependency-name: friendsofphp/php-cs-fixer
  dependency-version: 3.95.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: phpunit/phpunit
  dependency-version: 13.2.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: vincentlanglet/twig-cs-fixer
  dependency-version: 4.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 23:58:20 +00:00
dependabot[bot] dcb1f40b7e Bump phpstan/phpstan-doctrine in the dev-dependencies group (#146)
Bumps the dev-dependencies group with 1 update: [phpstan/phpstan-doctrine](https://github.com/phpstan/phpstan-doctrine).


Updates `phpstan/phpstan-doctrine` from 2.0.25 to 2.0.27
- [Release notes](https://github.com/phpstan/phpstan-doctrine/releases)
- [Commits](https://github.com/phpstan/phpstan-doctrine/compare/2.0.25...2.0.27)

---
updated-dependencies:
- dependency-name: phpstan/phpstan-doctrine
  dependency-version: 2.0.27
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-10 23:54:28 +00:00
dependabot[bot] 368790c073 Bump the dev-dependencies group with 2 updates (#144)
Bumps the dev-dependencies group with 2 updates: [friendsofphp/php-cs-fixer](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer) and [phpunit/phpunit](https://github.com/sebastianbergmann/phpunit).


Updates `friendsofphp/php-cs-fixer` from 3.95.4 to 3.95.5
- [Release notes](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/releases)
- [Changelog](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/compare/v3.95.4...v3.95.5)

Updates `phpunit/phpunit` from 13.1.14 to 13.2.0
- [Release notes](https://github.com/sebastianbergmann/phpunit/releases)
- [Changelog](https://github.com/sebastianbergmann/phpunit/blob/13.2.0/ChangeLog-13.2.md)
- [Commits](https://github.com/sebastianbergmann/phpunit/compare/13.1.14...13.2.0)

---
updated-dependencies:
- dependency-name: friendsofphp/php-cs-fixer
  dependency-version: 3.95.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: phpunit/phpunit
  dependency-version: 13.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-09 23:55:05 +00:00
dependabot[bot] 078f838700 Bump doctrine/doctrine-bundle from 3.2.2 to 3.2.3 (#141)
Bumps [doctrine/doctrine-bundle](https://github.com/doctrine/DoctrineBundle) from 3.2.2 to 3.2.3.
- [Release notes](https://github.com/doctrine/DoctrineBundle/releases)
- [Commits](https://github.com/doctrine/DoctrineBundle/compare/3.2.2...3.2.3)

---
updated-dependencies:
- dependency-name: doctrine/doctrine-bundle
  dependency-version: 3.2.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-09 11:37:53 +02:00
dependabot[bot] 841bd5b00d Bump phpunit/php-code-coverage in the dev-dependencies group (#142)
Bumps the dev-dependencies group with 1 update: [phpunit/php-code-coverage](https://github.com/sebastianbergmann/php-code-coverage).


Updates `phpunit/php-code-coverage` from 14.2.0 to 14.2.2
- [Release notes](https://github.com/sebastianbergmann/php-code-coverage/releases)
- [Changelog](https://github.com/sebastianbergmann/php-code-coverage/blob/main/ChangeLog-14.2.md)
- [Commits](https://github.com/sebastianbergmann/php-code-coverage/compare/14.2.0...14.2.2)

---
updated-dependencies:
- dependency-name: phpunit/php-code-coverage
  dependency-version: 14.2.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-09 11:37:40 +02:00
dependabot[bot] 2aea98a839 Bump phpoffice/phpspreadsheet from 5.7.0 to 5.8.0 (#143)
Bumps [phpoffice/phpspreadsheet](https://github.com/PHPOffice/PhpSpreadsheet) from 5.7.0 to 5.8.0.
- [Release notes](https://github.com/PHPOffice/PhpSpreadsheet/releases)
- [Changelog](https://github.com/PHPOffice/PhpSpreadsheet/blob/master/CHANGELOG.md)
- [Commits](https://github.com/PHPOffice/PhpSpreadsheet/compare/5.7.0...5.8.0)

---
updated-dependencies:
- dependency-name: phpoffice/phpspreadsheet
  dependency-version: 5.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-09 11:37:14 +02:00
dependabot[bot] 54668521f0 Bump the dev-dependencies group with 2 updates (#140)
---
updated-dependencies:
- dependency-name: phpstan/phpstan
  dependency-version: 2.2.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: phpunit/php-code-coverage
  dependency-version: 14.2.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-05 23:57:40 +00:00
dependabot[bot] 56d89e42d6 Bump phpunit/phpunit in the dev-dependencies group (#139)
Bumps the dev-dependencies group with 1 update: [phpunit/phpunit](https://github.com/sebastianbergmann/phpunit).


Updates `phpunit/phpunit` from 13.1.13 to 13.1.14
- [Release notes](https://github.com/sebastianbergmann/phpunit/releases)
- [Changelog](https://github.com/sebastianbergmann/phpunit/blob/13.1.14/ChangeLog-13.1.md)
- [Commits](https://github.com/sebastianbergmann/phpunit/compare/13.1.13...13.1.14)

---
updated-dependencies:
- dependency-name: phpunit/phpunit
  dependency-version: 13.1.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 23:56:53 +00:00
dependabot[bot] 2997323f0e Bump friendsofphp/php-cs-fixer in the dev-dependencies group (#138)
Bumps the dev-dependencies group with 1 update: [friendsofphp/php-cs-fixer](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer).


Updates `friendsofphp/php-cs-fixer` from 3.95.3 to 3.95.4
- [Release notes](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/releases)
- [Changelog](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/compare/v3.95.3...v3.95.4)

---
updated-dependencies:
- dependency-name: friendsofphp/php-cs-fixer
  dependency-version: 3.95.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 03:41:41 +00:00
dependabot[bot] 15e2a57943 Bump the dev-dependencies group with 3 updates (#136)
Bumps the dev-dependencies group with 3 updates: [phpstan/phpstan-doctrine](https://github.com/phpstan/phpstan-doctrine), [phpunit/php-code-coverage](https://github.com/sebastianbergmann/php-code-coverage) and [sebastian/global-state](https://github.com/sebastianbergmann/global-state).


Updates `phpstan/phpstan-doctrine` from 2.0.23 to 2.0.25
- [Release notes](https://github.com/phpstan/phpstan-doctrine/releases)
- [Commits](https://github.com/phpstan/phpstan-doctrine/compare/2.0.23...2.0.25)

Updates `phpunit/php-code-coverage` from 14.1.9 to 14.1.10
- [Release notes](https://github.com/sebastianbergmann/php-code-coverage/releases)
- [Changelog](https://github.com/sebastianbergmann/php-code-coverage/blob/14.1.10/ChangeLog-14.1.md)
- [Commits](https://github.com/sebastianbergmann/php-code-coverage/compare/14.1.9...14.1.10)

Updates `sebastian/global-state` from 9.0.0 to 9.0.1
- [Release notes](https://github.com/sebastianbergmann/global-state/releases)
- [Changelog](https://github.com/sebastianbergmann/global-state/blob/main/ChangeLog.md)
- [Commits](https://github.com/sebastianbergmann/global-state/compare/9.0.0...9.0.1)

---
updated-dependencies:
- dependency-name: phpstan/phpstan-doctrine
  dependency-version: 2.0.25
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: phpunit/php-code-coverage
  dependency-version: 14.1.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: sebastian/global-state
  dependency-version: 9.0.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-03 17:49:30 +00:00
19 changed files with 1073 additions and 559 deletions
+2 -4
View File
@@ -72,10 +72,8 @@ Icon
/.idea/
/.vscode/
# Junie
!/.junie/
/.junie/memory/
/.junie/plans/
# Claude Code
/.claude/settings.local.json
# Windows
Thumbs.db
-82
View File
@@ -1,82 +0,0 @@
# Agent Guide: Tijd Voor De Test (Tvdt)
This document provides essential context and instructions for AI agents working on the **Tijd Voor De Test** project.
## Project Overview
A web application for managing "Wie is de Mol?" style tests, including seasons, quizzes, candidates, and eliminations.
- **Namespace**: `Tvdt`
- **PHP Version**: 8.5+
- **Framework**: Symfony 8.0
## Tech Stack
- **Server**: FrankenPHP (Caddy-based PHP server)
- **Database**: PostgreSQL
- **Frontend**: Symfony Asset Mapper (no Node.js/Webpack), Stimulus, Turbo
- **Styling**: Sass (via `symfonycasts/sass-bundle`)
- **Persistence**: Doctrine ORM 3.x
## Core Domain Entities
- **Season**: Groups quizzes and candidates for a specific period.
- **SeasonSettings**: Configuration for a season.
- **Quiz**: A test within a season containing multiple questions.
- **Question**: Questions belonging to a quiz.
- **Answer**: Possible answers for a question.
- **Candidate**: A participant in the season.
- **QuizCandidate**: Represents a candidate's attempt at a specific quiz (tracking start/end time).
- **GivenAnswer**: The specific answer a candidate selected during a quiz.
- **Elimination**: Records of red/green screens and forced results.
- **User**: Administrative accounts for managing the system.
## Development Workflow
The project uses `just` as the primary task runner. Always prefer `just` commands over manual docker calls.
### Common Commands
- `just up`: Start the environment.
- `just down`: Stop the environment.
- `just shell`: Enter the PHP container.
- `just migrate`: Run database migrations.
- `just fixtures`: Load development fixtures.
- `just fix-cs`: Run `php-cs-fixer` and `twig-cs-fixer`.
- `just phpstan`: Run static analysis.
- `just rector`: Run Rector for automated refactorings.
- `just reload-tests`: Reset the test database and load test fixtures.
## Coding Standards
- **PSR-12**: Follow standard PHP coding styles.
- **Strict Typing**: Use strict types in all PHP files.
- **Doctrine ORM 3**: Be aware of ORM 3 changes (e.g., lazy loading behavior, attribute-based mapping).
- **Symfony 8**: Use modern Symfony features (Attributes, Type-hinting).
- **Safe Functions**: Use `thecodingmachine/safe` for standard PHP functions that throw exceptions instead of returning false.
## Testing
- **Framework**: PHPUnit
- **Bundle**: `dama/doctrine-test-bundle` is used to wrap tests in transactions.
- **Location**: `tests/` directory mirroring `src/`.
- **Execution**: Run via `bin/phpunit` inside the container or `just reload-tests` to prepare the environment.
## Frontend Development
- JavaScript is managed via **Import Maps**.
- Stimulus controllers are located in `assets/controllers/`.
- CSS/Sass is in `assets/styles/`.
- Assets are compiled on-the-fly or mapped; do not look for a `node_modules` folder.
## Key Components
### Controllers
- **Backoffice**: Located in `src/Controller/Backoffice`, handles season and quiz management.
- **Quiz**: `src/Controller/QuizController` handles the candidate-facing side of quizzes.
- **Elimination**: `src/Controller/EliminationController` handles elimination screens.
### Services
- **QuizSpreadsheetService**: Handles importing quizzes from XLSX files.
### Base Classes & Enums
- **AbstractController**: Base class for all controllers, containing common regexes and flash helpers.
- **FlashType Enum**: Used for consistent flash messaging (`FlashType::Success`, `FlashType::Danger`, etc.).
## Key Files
- `composer.json`: Dependency management.
- `importmap.php`: JavaScript module mapping.
- `Justfile`: Automation shortcuts.
- `config/`: Application configuration.
- `templates/`: Twig templates.
+232
View File
@@ -0,0 +1,232 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 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:
- Test creation with variable question counts
- Season management with active test controls
- Candidate answer tracking with automatic timing
- Elimination tracking with joker adjustments
- Backoffice management for quiz administration and statistics
Tech Stack:
- **Framework**: Symfony 8.1
- **PHP**: 8.5+
- **Database**: PostgreSQL 16
- **ORM**: Doctrine
- **Server**: FrankenPHP with Caddy
- **Container**: Docker Compose
- **Frontend**: Twig templates with SASS (via asset mapper)
- **Testing**: PHPUnit 13 with DAMA Doctrine test bundle
## Build & Development Commands
All commands assume Docker is running. The project uses a [Justfile](https://just.systems) as the primary interface.
### Essential Commands
```bash
just up # Start Docker services (PHP, PostgreSQL)
just stop # Stop services
just down # Stop and remove containers/orphans
just shell # Interactive shell inside the PHP container
just shell-run # Shell in a fresh one-off container
```
### Database
```bash
just migrate # Run Doctrine migrations (starts services first)
just fixtures # Load dev fixtures (truncates first)
just reload-tests # Drop/recreate test DB, migrate, load test fixtures
```
### Testing
```bash
just test # Run full PHPUnit suite
just test tests/Path/To/TestFile.php # Run a specific test file
just test --coverage-html var/coverage # Generate HTML coverage report
```
### Code Quality & Linting
```bash
just fix-cs # Auto-fix PHP-CS-Fixer + Twig-CS-Fixer
just phpstan # PHPStan static analysis (level 9)
just phpstan --no-progress # Without progress output
just rector # Apply Rector modernizations
just rector --dry-run # Preview Rector changes
```
### Other
```bash
just translations # Extract/update nl translation strings
just clean # Nuke containers (volumes) + all generated files (prompts for confirmation)
just trust-cert # Trust the local Caddy TLS certificate (macOS)
just exec <cmd> # Run any command inside the PHP container
```
All code quality checks run in CI/CD (.github/workflows/ci.yml) and should pass before merging.
## Project Structure
```
src/
Controller/ # HTTP request handlers (attribute-routed)
Backoffice/ # Admin panel controllers
Entity/ # Doctrine ORM entities
Repository/ # Database queries
Service/ # Business logic
Command/ # CLI commands
Form/ # Symfony form types
Dto/ # Data transfer objects
Enum/ # Enumerations (FlashType, etc.)
Exception/ # Custom exceptions
Factory/ # Object factories
Helpers/ # Utility functions
Security/ # Auth and voter classes
Voter/ # Authorization voters
DataFixtures/ # Test data loaders
config/
packages/ # Symfony bundle configurations
routes/ # Route definitions
services.yaml # Service container configuration
routes.yaml # Main route entry point
templates/
backoffice/ # Admin UI templates
quiz/ # Public quiz UI templates
base.html.twig # Main layout
tests/
Command/ # Command tests
Controller/ # Controller/integration tests
Repository/ # Repository tests
Security/ # Auth tests
Helpers/ # Utility tests
bootstrap.php # PHPUnit bootstrap with test container setup
```
## Core Domain Entities
- **Season**: Groups quizzes and candidates for a specific period, with a linked `SeasonSettings`.
- **Quiz**: A test within a season containing multiple `Question`s, each with multiple `Answer`s.
- **Candidate**: A participant in the season.
- **QuizCandidate**: Represents a candidate's attempt at a specific quiz (tracks start/end time).
- **GivenAnswer**: The specific answer a candidate selected during a quiz.
- **Elimination**: Records red/green screens and forced results with joker adjustments.
- **User**: Administrative accounts for managing the system.
## 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`
- 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)
- Symfony test utilities (BrowserKit, CSS selectors) available
- Coverage excluded from: `src/DataFixtures/`
- Test environment: `APP_ENV=test` (set in phpunit.dist.xml)
### 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)
- **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
### 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
### 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`
- Production: Assets precompiled during Docker build
- Development: Watch mode enabled in FrankenPHP container
## CI/CD Pipeline
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
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
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
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
- **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
- **.gitignore**: Excludes var/, vendor/, .env.local, .phpunit.cache
- **Dockerfile**: Multi-stage build with dev/prod separation, FrankenPHP-based
- **Docker Compose**: PHP service with Caddy, PostgreSQL database, persistent volumes
## Security & Authorization
- Doctrine extensions enabled (timestamps, slugs, etc.)
- Voter-based authorization in `src/Security/Voter/`
- User entity with security encoding configured
- CSRF protection enabled
- Email verification available via SymfonyCasts bundle
## Composer Scripts
Auto-executed scripts on install/update:
- `cache:clear` - Symfony cache clear
- `assets:install` - Copy public assets
- `importmap:install` - JS import map setup
## Notes for Future Work
- 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
-16
View File
@@ -1,16 +0,0 @@
.DEFAULT_GOAL := help
.PHONY: up
up: ## Start application
@docker compose up -d
stop: ## Stop application
@docker compose stop
.PHONY: shell
shell: ## Start a shell inside the container
@docker compose exec php bash
.PHONY: help
help:
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-10s\033[0m %s\n", $$1, $$2}'
+3
View File
@@ -0,0 +1,3 @@
.col-result-xs { width: 10%; }
.col-result-sm { width: 15%; }
.col-result-md { width: 20%; }
+78 -4
View File
@@ -1,14 +1,88 @@
html, body {
html {
height: 100%;
background-image: url("../img/background.png");
background-position: center center;
background-position: center;
background-repeat: no-repeat;
background-color: black;
color: white;
display: grid;
align-items: center;
justify-self: center;
}
body {
height: 100%;
background-image: url("../img/background.png");
background-position: center;
background-repeat: no-repeat;
background-color: black;
display: grid;
align-content: center;
}
.quiz-form-narrow {
max-width: 320px;
width: 100%;
margin: 0 auto;
}
.quiz-content {
width: fit-content;
max-width: min(760px, 100%);
margin: 0 auto;
}
.quiz-answers-grid {
display: grid;
grid-template-columns: repeat(2, auto);
column-gap: 2rem;
@media (max-width: 767px) {
grid-template-columns: 1fr;
}
}
.answer-btn {
display: flex;
align-items: center;
gap: 0.8rem;
background: transparent;
border: none;
color: white;
font-size: 1.1rem;
padding: 0.4rem 0;
cursor: pointer;
text-align: left;
line-height: 1.3;
&::before {
content: '';
display: block;
flex-shrink: 0;
width: 2rem;
height: 2rem;
background: radial-gradient(ellipse at 35% 30%, #6abf4b, #2d7a1f);
border-radius: 4px;
box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.25), 0 2px 6px rgba(0, 0, 0, 0.6);
}
&:hover,
&:focus {
color: #7ed45a;
outline: none;
&::before {
background: radial-gradient(ellipse at 35% 30%, #86d45f, #3d9c2a);
}
}
}
input.btn-check:checked + label.answer-btn {
color: #7ed45a;
&::before {
background: radial-gradient(ellipse at 35% 30%, #86d45f, #3d9c2a);
box-shadow: 0 0 10px rgba(106, 191, 75, 0.7), inset 0 1px 2px rgba(255, 255, 255, 0.3);
}
}
.elimination-screen {
+1 -1
View File
@@ -65,7 +65,7 @@
"symfony/stopwatch": "8.1.*",
"symfony/web-profiler-bundle": "8.1.*",
"thecodingmachine/phpstan-safe-rule": "^1.4.3",
"vincentlanglet/twig-cs-fixer": "^3.14.0"
"vincentlanglet/twig-cs-fixer": "^4.0.0"
},
"config": {
"allow-plugins": {
Generated
+403 -334
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -127,7 +127,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* }
* @psalm-type ServicesConfig = array{
* _defaults?: DefaultsType,
* _instanceof?: InstanceofType,
* _instanceof?: array<class-string, InstanceofType>,
* ...<string, DefinitionType|AliasType|PrototypeType|StackType|ArgumentsType|null>
* }
* @psalm-type ExtensionType = array<string, mixed>
@@ -727,7 +727,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* servicename?: scalar|Param|null, // Overrules dbname parameter if given and used as SERVICE_NAME or SID connection parameter for Oracle depending on the service parameter.
* sessionMode?: scalar|Param|null, // The session mode to use for the oci8 driver
* server?: scalar|Param|null, // The name of a running database server to connect to for SQL Anywhere.
* default_dbname?: scalar|Param|null, // Override the default database (postgres) to connect to for PostgreSQL connexion.
* default_dbname?: scalar|Param|null, // Override the default database (postgres) to connect to for PostgreSQL connection.
* sslmode?: scalar|Param|null, // Determines whether or with what priority a SSL TCP/IP connection will be negotiated with the server for PostgreSQL.
* sslrootcert?: scalar|Param|null, // The name of a file containing SSL certificate authority (CA) certificate(s). If the file exists, the server's certificate will be verified to be signed by one of these authorities.
* sslcert?: scalar|Param|null, // The path to the SSL client certificate file for PostgreSQL.
@@ -773,7 +773,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* servicename?: scalar|Param|null, // Overrules dbname parameter if given and used as SERVICE_NAME or SID connection parameter for Oracle depending on the service parameter.
* sessionMode?: scalar|Param|null, // The session mode to use for the oci8 driver
* server?: scalar|Param|null, // The name of a running database server to connect to for SQL Anywhere.
* default_dbname?: scalar|Param|null, // Override the default database (postgres) to connect to for PostgreSQL connexion.
* default_dbname?: scalar|Param|null, // Override the default database (postgres) to connect to for PostgreSQL connection.
* sslmode?: scalar|Param|null, // Determines whether or with what priority a SSL TCP/IP connection will be negotiated with the server for PostgreSQL.
* sslrootcert?: scalar|Param|null, // The name of a file containing SSL certificate authority (CA) certificate(s). If the file exists, the server's certificate will be verified to be signed by one of these authorities.
* sslcert?: scalar|Param|null, // The path to the SSL client certificate file for PostgreSQL.
@@ -852,7 +852,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* lock_path?: scalar|Param|null, // Default: "%kernel.cache_dir%/doctrine/orm/slc/filelock"
* lock_lifetime?: scalar|Param|null, // Default: 60
* type?: scalar|Param|null, // Default: "default"
* lifetime?: scalar|Param|null, // Default: 0
* lifetime?: scalar|Param|null, // Default: null
* service?: scalar|Param|null,
* name?: scalar|Param|null,
* }>,
@@ -11,12 +11,15 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Requirement\Requirement;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Tvdt\Controller\AbstractController;
use Tvdt\Entity\Quiz;
use Tvdt\Entity\Season;
use Tvdt\Entity\User;
use Tvdt\Form\CreateSeasonFormType;
use Tvdt\Repository\SeasonRepository;
use Tvdt\Security\Voter\SeasonVoter;
use Tvdt\Service\QuizSpreadsheetService;
#[AsController]
@@ -78,4 +81,20 @@ final class BackofficeController extends AbstractController
return $response;
}
#[IsGranted(SeasonVoter::EDIT, subject: 'quiz')]
#[Route(
'/backoffice/quiz/{quiz}/export',
name: 'tvdt_backoffice_quiz_export',
requirements: ['quiz' => Requirement::UUID],
methods: ['GET'],
)]
public function exportQuiz(Quiz $quiz): StreamedResponse
{
$response = new StreamedResponse($this->excel->quizToXlsx($quiz));
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->headers->set('Content-Disposition', 'attachment; filename="'.$quiz->name.'.xlsx"');
return $response;
}
}
+3 -3
View File
@@ -31,14 +31,14 @@ class GivenAnswer
public function __construct(
#[ORM\JoinColumn(nullable: false)]
#[ORM\ManyToOne(inversedBy: 'givenAnswers')]
private(set) Candidate $candidate,
public private(set) Candidate $candidate,
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[ORM\ManyToOne]
private(set) Quiz $quiz,
public private(set) Quiz $quiz,
#[ORM\JoinColumn(nullable: false)]
#[ORM\ManyToOne(inversedBy: 'givenAnswers')]
private(set) Answer $answer,
public private(set) Answer $answer,
) {}
}
+40 -2
View File
@@ -120,9 +120,47 @@ class QuizSpreadsheetService
}
}
public function quizToXlsx(Quiz $quiz): void
public function quizToXlsx(Quiz $quiz): \Closure
{
throw new \Exception('Not implemented');
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->getStyle('1:1')->getFont()->setBold(true);
$sheet->setCellValue('A1', 'Question');
$sheet->getColumnDimension('A')->setWidth(30);
$sheet->getStyle('A:A')->getAlignment()->setWrapText(true);
$counter = 1;
foreach (range('B', 'L', 2) as $column) {
$sheet->setCellValue($column.'1', 'Answer '.$counter++);
$sheet->getColumnDimension($column)->setWidth(30);
$sheet->getStyle($column.':'.$column)->getAlignment()->setWrapText(true);
}
foreach (range('C', 'M', 2) as $column) {
$sheet->setCellValue($column.'1', 'Correct');
$sheet->getColumnDimension($column)->setAutoSize(true);
}
$answerColumns = range('B', 'L', 2);
$correctColumns = range('C', 'M', 2);
$row = 2;
foreach ($quiz->questions as $question) {
$sheet->setCellValue('A'.$row, $question->question);
$col = 0;
foreach ($question->answers as $answer) {
$sheet->setCellValue($answerColumns[$col].$row, $answer->text);
$sheet->setCellValue($correctColumns[$col].$row, $answer->isRightAnswer);
++$col;
}
++$row;
}
return $this->toXlsx($spreadsheet);
}
private function toXlsx(Spreadsheet $spreadsheet): \Closure
@@ -1,3 +1,27 @@
{% macro confirm_modal(id, target, body, formAction, csrfToken) %}
<div class="modal fade" id="{{ id }}" data-bs-backdrop="static"
tabindex="-1"
data-bo--quiz-target="{{ target }}"
aria-labelledby="{{ id }}Label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="{{ id }}Label">{{ 'Please Confirm'|trans }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">{{ body }}</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'No'|trans }}</button>
<form action="{{ formAction }}" method="POST">
<input type="hidden" name="_token" value="{{ csrfToken }}">
<button type="submit" class="btn btn-danger">{{ 'Yes'|trans }}</button>
</form>
</div>
</div>
</div>
</div>
{% endmacro %}
<div data-controller="bo--quiz">
<h4 class="mb-3">{{ 'Quick actions'|trans }}</h4>
<div class="mb-3 btn-group">
@@ -25,6 +49,11 @@
</button>
</div>
<a class="btn btn-outline-secondary mb-3"
href="{{ path('tvdt_backoffice_quiz_export', {quiz: quiz.id}) }}">
{{ 'Export to XLSX'|trans }}
</a>
<h4 class="mb-3">{{ 'Questions'|trans }}</h4>
<div class="accordion">
{%~ for question in quiz.questions ~%}
@@ -66,53 +95,19 @@
{% endfor %}
</div>
{# Modal Clear #}
<div class="modal fade" id="clearQuizModal" data-bs-backdrop="static"
tabindex="-1"
data-bo--quiz-target="clearModal"
aria-labelledby="staticBackdropLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="staticBackdropLabel">{{ 'Please Confirm'|trans }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{{ 'Are you sure you want to clear all the results? This will also delete all the eliminations.'|trans }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'No'|trans }}</button>
<form action="{{ path('tvdt_backoffice_quiz_clear', {quiz: quiz.id}) }}" method="POST">
<input type="hidden" name="_token" value="{{ csrf_token('clear_quiz') }}">
<button type="submit" class="btn btn-danger">{{ 'Yes'|trans }}</button>
</form>
</div>
</div>
</div>
</div>
{{ _self.confirm_modal(
'clearQuizModal',
'clearModal',
'Are you sure you want to clear all the results? This will also delete all the eliminations.'|trans,
path('tvdt_backoffice_quiz_clear', {quiz: quiz.id}),
csrf_token('clear_quiz'),
) }}
{# Modal Delete #}
<div class="modal fade" id="deleteQuizModal" data-bs-backdrop="static"
tabindex="-1"
data-bo--quiz-target="deleteModal"
aria-labelledby="staticBackdropLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="staticBackdropLabel">{{ 'Please Confirm'|trans }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{{ 'Are you sure you want to delete this quiz?'|trans }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'No'|trans }}</button>
<form action="{{ path('tvdt_backoffice_quiz_delete', {quiz: quiz.id}) }}" method="POST">
<input type="hidden" name="_token" value="{{ csrf_token('delete_quiz') }}">
<button type="submit" class="btn btn-danger">{{ 'Yes'|trans }}</button>
</form>
</div>
</div>
</div>
</div>
{{ _self.confirm_modal(
'deleteQuizModal',
'deleteModal',
'Are you sure you want to delete this quiz?'|trans,
path('tvdt_backoffice_quiz_delete', {quiz: quiz.id}),
csrf_token('delete_quiz'),
) }}
</div>
+8 -17
View File
@@ -1,7 +1,6 @@
<h4 class="mb-3">{{ 'Score'|trans }}</h4>
<div class="btn-toolbar mb-3" role="toolbar">
<div class="btn-group me-2">
{# <a class="btn btn-primary">{{ 'Start Elimination'|trans }}</a> #}
<form action="{{ path('tvdt_prepare_elimination', {seasonCode: season.seasonCode, quiz: quiz.id}) }}" method="POST">
<input type="hidden" name="_token" value="{{ csrf_token('prepare_elimination') }}">
<button type="submit" class="btn btn-secondary rounded-0 rounded-start">{{ 'Prepare Custom Elimination'|trans }}</button>
@@ -20,15 +19,15 @@
</div>
</div>
<p class="mb-3">{{ 'Number of dropouts:'|trans }} {{ quiz.dropouts }} </p>
<table class="table table-hover mb-3">
<table class="table table-hover table-result mb-3">
<thead>
<tr>
<th scope="col">{{ 'Candidate'|trans }}</th>
<th style="width: 15%" scope="col">{{ 'Correct Answers'|trans }}</th>
<th style="width: 20%" scope="col">{{ 'Corrections'|trans }}</th>
<th style="width: 20%" scope="col">{{ 'Penalty'|trans }}</th>
<th style="width: 10%" scope="col">{{ 'Score'|trans }}</th>
<th style="width: 20%" scope="col">{{ 'Time'|trans }}</th>
<th class="col-result-sm" scope="col">{{ 'Correct Answers'|trans }}</th>
<th class="col-result-md" scope="col">{{ 'Corrections'|trans }}</th>
<th class="col-result-md" scope="col">{{ 'Penalty'|trans }}</th>
<th class="col-result-xs" scope="col">{{ 'Score'|trans }}</th>
<th class="col-result-md" scope="col">{{ 'Time'|trans }}</th>
</tr>
</thead>
<tbody>
@@ -40,32 +39,24 @@
<form method="post"
action="{{ path('tvdt_backoffice_modify_correction', {quiz: quiz.id, candidate: candidate.id}) }}">
<input type="hidden" name="_token" value="{{ csrf_token('candidate_correction') }}">
<div class="row">
<div class="col-8">
<div class="d-flex gap-1">
<input class="form-control form-control-sm" type="number"
value="{{ candidate.corrections }}" step="0.5"
name="corrections">
</div>
<div class="col-2">
<button class="btn btn-sm btn-primary" type="submit">{{ 'Save'|trans }}</button>
</div>
</div>
</form>
</td>
<td>
<form method="post"
action="{{ path('tvdt_backoffice_modify_penalty', {quiz: quiz.id, candidate: candidate.id}) }}">
<input type="hidden" name="_token" value="{{ csrf_token('candidate_penalty') }}">
<div class="row">
<div class="col-8">
<div class="d-flex gap-1">
<input class="form-control form-control-sm" type="number"
value="{{ candidate.penaltySeconds }}" step="1"
name="penalty">
</div>
<div class="col-2">
<button class="btn btn-sm btn-primary" type="submit">{{ 'Save'|trans }}</button>
</div>
</div>
</form>
</td>
<td>{{ candidate.score|default('x') }}</td>
+3 -4
View File
@@ -14,7 +14,7 @@
<h2 class="mb-3">{{ 'Season'|trans }}: {{ season.name }}</h2>
<div class="row">
<div class="col-md-6 col-12">
<div class="d-flex flex-row align-items-center mb-3">
<div class="d-flex align-items-center mb-3">
<h4 class="mb-0 pe-2">{{ 'Quizzes'|trans }}</h4>
<a class="link"
href="{{ path('tvdt_backoffice_quiz_add', {seasonCode: season.seasonCode}) }}">{{ 'Add'|trans }}</a>
@@ -29,7 +29,7 @@
</div>
</div>
<div class="col-md-3 col-12">
<div class="d-flex flex-row align-items-center mb-3">
<div class="d-flex align-items-center mb-3">
<h4 class="mb-0 pe-2">{{ 'Candidates'|trans }}</h4>
<a class="link"
href="{{ path('tvdt_backoffice_add_candidates', {seasonCode: season.seasonCode}) }}">{{ 'Add Candidate'|trans }}
@@ -40,11 +40,10 @@
<li>{{ candidate.name }}</li>{% endfor %}
</ul>
<div class="d-flex flex-row align-items-center mb-3">
<div class="d-flex align-items-center mb-3">
<h4 class="mb-0 pe-2">{{ 'Settings'|trans }}</h4>
</div>
{{ form(form) }}
</div>
<div class="col-12 col-md-3"></div>
</div>
{% endblock body %}
+2
View File
@@ -1,4 +1,6 @@
{% extends 'quiz/base.html.twig' %}
{% block body %}
<div class="quiz-form-narrow">
{{ form(form) }}
</div>
{% endblock body %}
+9 -2
View File
@@ -1,5 +1,6 @@
{% extends 'quiz/base.html.twig' %}
{% block body %}
<div class="quiz-content">
<h4>
{% if season.settings.showNumbers %}
({{ question.ordering }}/{{ question.quiz.questions.count }})
@@ -7,24 +8,29 @@
</h4>
<form method="post">
<input type="hidden" name="token" value="{{ csrf_token('question') }}">
{% set twoCol = question.answers|length >= 6 %}
{% if season.settings.confirmAnswers == false %}
<div class="{{ twoCol ? 'quiz-answers-grid' : '' }}">
{% for answer in question.answers %}
<div class="py-2">
<button class="btn btn-outline-success"
<button class="answer-btn"
type="submit"
name="answer"
value="{{ answer.id }}">{{ answer.text }}</button>
</div>
{% endfor %}
</div>
{% else %}
<div class="{{ twoCol ? 'quiz-answers-grid' : '' }}">
{% for answer in question.answers %}
<div class="py-1">
<input required="required" type="radio" class="btn-check" name="answer"
id="answer-{{ loop.index0 }}" autocomplete="off"
value="{{ answer.id }}">
<label class="btn btn-outline-secondary" for="answer-{{ loop.index0 }}">{{ answer.text }}</label>
<label class="answer-btn" for="answer-{{ loop.index0 }}">{{ answer.text }}</label>
</div>
{% endfor %}
</div>
<div class="py-2">
<button class="btn btn-success"
type="submit"
@@ -33,4 +39,5 @@
{% endif %}
</form>
</div>
{% endblock body %}
+2
View File
@@ -1,5 +1,7 @@
{% extends 'quiz/base.html.twig' %}
{% block body %}
<div class="quiz-form-narrow">
{{ form(form) }}
<a href="{{ path('tvdt_backoffice_index') }}" class="btn btn-outline-secondary">{{ 'Manage Quiz'|trans }}</a>
</div>
{% endblock body %}
@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Service;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\File\File;
use Tvdt\Entity\Answer;
use Tvdt\Entity\Question;
use Tvdt\Entity\Quiz;
use Tvdt\Exception\SpreadsheetDataException;
use Tvdt\Service\QuizSpreadsheetService;
use function Safe\file_put_contents;
use function Safe\ob_get_clean;
use function Safe\ob_start;
use function Safe\unlink;
#[CoversClass(QuizSpreadsheetService::class)]
final class QuizSpreadsheetServiceTest extends TestCase
{
private QuizSpreadsheetService $subject;
/** @var list<string> */
private array $tempFiles = [];
protected function setUp(): void
{
$this->subject = new QuizSpreadsheetService();
}
protected function tearDown(): void
{
foreach ($this->tempFiles as $path) {
if (file_exists($path)) {
unlink($path);
}
}
}
public function testGenerateTemplateProducesValidXlsx(): void
{
$path = $this->captureXlsx($this->subject->generateTemplate());
$this->assertSame(
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
new File($path)->getMimeType(),
);
}
public function testGenerateTemplateWithoutExampleHasNoQuestions(): void
{
$path = $this->captureXlsx($this->subject->generateTemplate(fillExample: false));
$quiz = new Quiz();
$this->subject->xlsxToQuiz($quiz, new File($path));
$this->assertCount(0, $quiz->questions);
}
public function testGenerateTemplateExampleCanBeReimported(): void
{
$path = $this->captureXlsx($this->subject->generateTemplate(fillExample: true));
$quiz = new Quiz();
$this->subject->xlsxToQuiz($quiz, new File($path));
$this->assertCount(1, $quiz->questions);
/** @var Question $question */
$question = $quiz->questions->first();
$this->assertSame('Is de mol een man of een vrouw?', $question->question);
$this->assertCount(2, $question->answers);
}
public function testQuizToXlsxEmptyQuizImportsWithNoQuestions(): void
{
$path = $this->captureXlsx($this->subject->quizToXlsx(new Quiz()));
$imported = new Quiz();
$this->subject->xlsxToQuiz($imported, new File($path));
$this->assertCount(0, $imported->questions);
}
public function testQuizToXlsxRoundTrip(): void
{
$original = $this->makeQuiz();
$path = $this->captureXlsx($this->subject->quizToXlsx($original));
$imported = new Quiz();
$this->subject->xlsxToQuiz($imported, new File($path));
$this->assertCount(2, $imported->questions);
/** @var Question $first */
$first = $imported->questions->first();
$this->assertSame('Who is de Mol?', $first->question);
$this->assertCount(2, $first->answers);
$answers = $first->answers->toArray();
$this->assertSame('Alice', $answers[0]->text);
$this->assertFalse($answers[0]->isRightAnswer);
$this->assertSame('Bob', $answers[1]->text);
$this->assertTrue($answers[1]->isRightAnswer);
/** @var Question $second */
$second = $imported->questions->last();
$this->assertSame('What did de Mol sabotage?', $second->question);
$this->assertCount(3, $second->answers);
}
public function testXlsxToQuizThrowsOnInvalidMimeType(): void
{
$path = $this->createTempPath('.txt');
file_put_contents($path, 'not a spreadsheet');
$this->expectException(\InvalidArgumentException::class);
$this->subject->xlsxToQuiz(new Quiz(), new File($path));
}
public function testXlsxToQuizThrowsOnQuestionWithNoAnswers(): void
{
$quiz = new Quiz();
$question = new Question();
$question->question = 'Unanswered question';
$question->ordering = 1;
$quiz->addQuestion($question);
$path = $this->captureXlsx($this->subject->quizToXlsx($quiz));
try {
$this->subject->xlsxToQuiz(new Quiz(), new File($path));
$this->fail('Expected SpreadsheetDataException to be thrown');
} catch (SpreadsheetDataException $spreadsheetDataException) {
$this->assertNotEmpty($spreadsheetDataException->errors);
}
}
private function makeQuiz(): Quiz
{
$quiz = new Quiz();
$q1 = new Question();
$q1->question = 'Who is de Mol?';
$q1->ordering = 1;
$q1->addAnswer(new Answer('Alice', isRightAnswer: false));
$q1->addAnswer(new Answer('Bob', isRightAnswer: true));
$q2 = new Question();
$q2->question = 'What did de Mol sabotage?';
$q2->ordering = 2;
$q2->addAnswer(new Answer('The boat', isRightAnswer: true));
$q2->addAnswer(new Answer('The car', isRightAnswer: false));
$q2->addAnswer(new Answer('Nothing', isRightAnswer: false));
$quiz->addQuestion($q1);
$quiz->addQuestion($q2);
return $quiz;
}
private function captureXlsx(\Closure $closure): string
{
$path = $this->createTempPath('.xlsx');
ob_start();
$closure();
file_put_contents($path, ob_get_clean());
return $path;
}
private function createTempPath(string $suffix): string
{
$path = sys_get_temp_dir().\DIRECTORY_SEPARATOR.uniqid('tvdt_test_', more_entropy: true).$suffix;
$this->tempFiles[] = $path;
return $path;
}
}