Compare commits

..

10 Commits

Author SHA1 Message Date
Marijn 466f0aeb54 Improve quiz layout: add fixed topbar, include navigation, and clean up unused elements
- Add `.quiz-topbar` with fixed positioning and spacing in `quiz.scss`
- Update `base.html.twig` to include `quiz/nav.html.twig` in a new `nav` block
- Remove unused "Manage Quiz" button from `select_season.html.twig`
2026-07-02 21:48:37 +02:00
Marijn d55cced5f7 Fix Sass healthcheck 2026-07-02 20:48:16 +02:00
Marijn 9c22345d2d Fix quizToXlsx to support unlimited answers and add header count tests
- Replace hardcoded 6-column arrays with dynamic Coordinate arithmetic
- Write data rows first to determine max answer count, write headers last
- Replace try/catch ErrorException in fillQuizFromArray with array_key_exists
- Add data-provider test covering 2, 6, 7, and 10 answers
- Add cross-question max-header and 7-answer round-trip tests
2026-07-01 22:48:28 +02:00
Marijn 0f18e4afe5 Use HeaderUtils::makeDisposition() for safe Content-Disposition filename 2026-07-01 22:33:16 +02:00
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
6 changed files with 62 additions and 221 deletions
+15 -60
View File
@@ -17,11 +17,12 @@ permissions:
contents: read contents: read
jobs: jobs:
quality: tests:
name: Code Quality name: Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20
permissions: permissions:
checks: write
pull-requests: write
contents: read contents: read
steps: steps:
- name: Checkout - name: Checkout
@@ -39,68 +40,26 @@ jobs:
compose.yaml compose.yaml
compose.override.yaml compose.override.yaml
set: | set: |
*.cache-from=type=gha,scope=${{github.ref}}-quality *.cache-from=type=gha,scope=${{github.ref}}
*.cache-from=type=gha,scope=refs/heads/main *.cache-from=type=gha,scope=refs/heads/main
*.cache-to=type=gha,scope=${{github.ref}}-quality,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }} *.cache-to=type=gha,scope=${{github.ref}},mode=max
- name: Start services - name: Start services
run: docker compose up php database --wait --no-build run: docker compose up php database --wait --no-build
- name: Warm up dev cache
run: docker compose exec -T php bin/console cache:warmup --env=dev
- name: Lint Twig templates - name: Lint Twig templates
id: twig_lint
continue-on-error: true
run: docker compose exec -T php bin/console lint:twig --format=github templates run: docker compose exec -T php bin/console lint:twig --format=github templates
- name: Coding Style - name: Coding Style
id: cs
continue-on-error: true
run: docker compose exec -T php vendor/bin/php-cs-fixer check --diff --show-progress=none run: docker compose exec -T php vendor/bin/php-cs-fixer check --diff --show-progress=none
- name: Twig Coding Style - name: Twig Coding Style
id: twig_cs
continue-on-error: true
run: docker compose exec -T php vendor/bin/twig-cs-fixer check run: docker compose exec -T php vendor/bin/twig-cs-fixer check
- name: Static Analysis (PHPStan) - name: Static Analysis (PHPStan)
id: phpstan
continue-on-error: true
run: docker compose exec -T php vendor/bin/phpstan analyse --no-progress --no-ansi --error-format=github run: docker compose exec -T php vendor/bin/phpstan analyse --no-progress --no-ansi --error-format=github
- name: Rector - name: Rector
id: rector
continue-on-error: true
run: docker compose exec -T php vendor/bin/rector process --dry-run --no-progress-bar --output-format=github run: docker compose exec -T php vendor/bin/rector process --dry-run --no-progress-bar --output-format=github
- name: Check HTTP reachability - name: Check HTTP reachability
run: curl -v --fail-with-body http://localhost run: curl -v --fail-with-body http://localhost
- name: Assert all checks passed - name: Check Mercure reachability
if: always() if: false
run: | run: curl -vkI --fail-with-body https://localhost/.well-known/mercure?topic=test
outcomes="${{ steps.twig_lint.outcome }} ${{ steps.cs.outcome }} ${{ steps.twig_cs.outcome }} ${{ steps.phpstan.outcome }} ${{ steps.rector.outcome }}"
if echo "$outcomes" | grep -q "failure"; then exit 1; fi
tests:
name: Tests
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
checks: write
pull-requests: write
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker images
uses: docker/bake-action@v5
with:
pull: true
load: true
files: |
compose.yaml
compose.override.yaml
set: |
*.cache-from=type=gha,scope=${{github.ref}}-tests
*.cache-from=type=gha,scope=refs/heads/main
*.cache-to=type=gha,scope=${{github.ref}}-tests,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }}
- name: Start services
run: docker compose up php database --wait --no-build
- name: Create test database - name: Create test database
run: docker compose exec -T php bin/console -e test doctrine:database:create run: docker compose exec -T php bin/console -e test doctrine:database:create
- name: Run migrations - name: Run migrations
@@ -119,16 +78,15 @@ jobs:
run: docker compose exec -T php bin/console -e test doctrine:schema:validate run: docker compose exec -T php bin/console -e test doctrine:schema:validate
build-deploy: build-deploy:
name: Build and Deploy name: Build and deploy to ${{ startsWith(github.ref, 'refs/tags/') && 'production' || (github.ref == 'refs/heads/main' && 'acceptance' || '') }}
permissions: permissions:
contents: read contents: read
packages: write packages: write
environment: environment:
name: ${{ startsWith(github.ref, 'refs/tags/') && 'production' || 'acceptance' }} name: ${{ startsWith(github.ref, 'refs/tags/') && 'production' || (github.ref == 'refs/heads/main' && 'acceptance' || '') }}
url: ${{ vars.URL }} url: ${{ vars.URL }}
needs: [quality, tests] needs: tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15
if: (github.ref == 'refs/heads/main' && false) || startsWith(github.ref, 'refs/tags/') if: (github.ref == 'refs/heads/main' && false) || startsWith(github.ref, 'refs/tags/')
steps: steps:
- name: Checkout - name: Checkout
@@ -148,17 +106,14 @@ jobs:
REPO_LOWER=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]') REPO_LOWER=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
if [[ "${{ github.ref }}" == refs/tags/* ]]; then if [[ "${{ github.ref }}" == refs/tags/* ]]; then
TAG="${GITHUB_REF#refs/tags/}" TAG="${GITHUB_REF#refs/tags/}"
SENTRY_VERSION="${TAG#v}"
{ {
echo "tag=$TAG" echo "tag=$TAG"
echo "sentry_version=$SENTRY_VERSION"
echo "full_name=ghcr.io/${REPO_LOWER}:$TAG" echo "full_name=ghcr.io/${REPO_LOWER}:$TAG"
} >> "$GITHUB_OUTPUT" } >> "$GITHUB_OUTPUT"
else else
SHORT_SHA=$(git rev-parse --short HEAD) SHORT_SHA=$(git rev-parse --short HEAD)
{ {
echo "tag=$SHORT_SHA" echo "tag=$SHORT_SHA"
echo "sentry_version=$SHORT_SHA"
echo "full_name=ghcr.io/${REPO_LOWER}:$SHORT_SHA" echo "full_name=ghcr.io/${REPO_LOWER}:$SHORT_SHA"
} >> "$GITHUB_OUTPUT" } >> "$GITHUB_OUTPUT"
fi fi
@@ -184,12 +139,12 @@ jobs:
SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
with: with:
release: ${{steps.meta.outputs.sentry_version}} release: ${{steps.meta.outputs.tag}}
environment: ${{ startsWith(github.ref, 'refs/tags/') && 'production' || 'acceptance' }} environment: ${{ startsWith(github.ref, 'refs/tags/') && 'production' || (github.ref == 'refs/heads/main' && 'acceptance' || '') }}
- name: Trigger Portainer Deployment - name: Trigger Portainer Deployment
shell: bash shell: bash
env: env:
PORTAINER_WEBHOOK: ${{secrets.PORTAINER_WEBHOOK}} PORTAINER_WEBHOOK: ${{secrets.PORTAINER_WEBHOOK}}
run: | run: |
curl -v -X POST "${PORTAINER_WEBHOOK}?IMAGE_TAG=${{steps.meta.outputs.tag}}" --fail-with-body curl -v -X POST "$PORTAINER_WEBHOOK"?IMAGE_TAG=${{steps.meta.outputs.tag}} --fail-with-body
+1 -1
View File
@@ -8,7 +8,7 @@
failOnNotice="true" failOnNotice="true"
failOnWarning="true" failOnWarning="true"
bootstrap="tests/bootstrap.php" bootstrap="tests/bootstrap.php"
cacheDirectory="/tmp/phpunit.cache" cacheDirectory=".phpunit.cache"
> >
<php> <php>
<ini name="display_errors" value="1" /> <ini name="display_errors" value="1" />
+29 -30
View File
@@ -18,40 +18,39 @@ class QuizSpreadsheetService
{ {
public function generateTemplate(bool $fillExample = true): \Closure public function generateTemplate(bool $fillExample = true): \Closure
{ {
$quiz = new Quiz(); $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);
}
if ($fillExample) { if ($fillExample) {
$geslacht = new Question(); $sheet->setCellValue('B2', 'Man');
$geslacht->question = 'Is de mol een man of een vrouw?'; $sheet->setCellValue('C2', true);
$geslacht->ordering = 1;
$geslacht->addAnswer(new Answer('Man', true));
$geslacht->addAnswer(new Answer('Vrouw'));
$quiz->addQuestion($geslacht);
$identiteit = new Question(); $sheet->setCellValue('D2', 'Vrouw');
$identiteit->question = 'Wie is de mol?'; $sheet->setCellValue('E2', false);
$identiteit->ordering = 2;
foreach ([ $sheet->setCellValue('A2', 'Is de mol een man of een vrouw?');
['Emma', false],
['Jan', false],
['Sara', false],
['Piet', false],
['Lisa', true],
['Kees', false],
['Anna', false],
['Henk', false],
['Nina', false],
['Joost', false],
] as $i => [$name, $correct]) {
$answer = new Answer($name, $correct);
$answer->ordering = $i + 1;
$identiteit->addAnswer($answer);
} }
$quiz->addQuestion($identiteit); return $this->toXlsx($spreadsheet);
}
return $this->quizToXlsx($quiz);
} }
/** @throws SpreadsheetDataException */ /** @throws SpreadsheetDataException */
@@ -103,7 +102,7 @@ class QuizSpreadsheetService
} }
if (1 === $answerCounter) { if (1 === $answerCounter) {
$errors[] = \sprintf('Question %d has no answers', $question->ordering); $errors[] = \sprintf('Question %d has no answers', $answerCounter);
} }
$quiz->addQuestion($question); $quiz->addQuestion($question);
-16
View File
@@ -1,16 +0,0 @@
{% if is_granted('IS_AUTHENTICATED') or app.current_route() == 'tvdt_quiz_select_season' %}
<div class="quiz-topbar">
{% if is_granted('IS_AUTHENTICATED') %}
<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">
{{ 'Logout'|trans }}
</a>
{% else %}
<a href="{{ path('tvdt_backoffice_index') }}" class="btn btn-outline-secondary btn-sm">
{{ 'Manage Quiz'|trans }}
</a>
{% endif %}
</div>
{% endif %}
+16 -50
View File
@@ -5,8 +5,6 @@ declare(strict_types=1);
namespace Tvdt\Tests\Service; namespace Tvdt\Tests\Service;
use PhpOffice\PhpSpreadsheet\Reader; use PhpOffice\PhpSpreadsheet\Reader;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -71,17 +69,12 @@ final class QuizSpreadsheetServiceTest extends TestCase
$quiz = new Quiz(); $quiz = new Quiz();
$this->subject->xlsxToQuiz($quiz, new File($path)); $this->subject->xlsxToQuiz($quiz, new File($path));
$this->assertCount(2, $quiz->questions); $this->assertCount(1, $quiz->questions);
/** @var Question $first */ /** @var Question $question */
$first = $quiz->questions->first(); $question = $quiz->questions->first();
$this->assertSame('Is de mol een man of een vrouw?', $first->question); $this->assertSame('Is de mol een man of een vrouw?', $question->question);
$this->assertCount(2, $first->answers); $this->assertCount(2, $question->answers);
/** @var Question $second */
$second = $quiz->questions->last();
$this->assertSame('Wie is de mol?', $second->question);
$this->assertCount(10, $second->answers);
} }
public function testQuizToXlsxEmptyQuizImportsWithNoQuestions(): void public function testQuizToXlsxEmptyQuizImportsWithNoQuestions(): void
@@ -149,44 +142,17 @@ final class QuizSpreadsheetServiceTest extends TestCase
} }
} }
public function testXlsxToQuizStopsAtBlankRow(): void /** @return array<string, array{int, string, int, string, int, int}> */
{ public static function answerCountHeaderProvider(): array
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setCellValue('A1', 'Question');
$sheet->setCellValue('B1', 'Answer 1');
$sheet->setCellValue('C1', 'Correct');
$sheet->setCellValue('A2', 'First question');
$sheet->setCellValue('B2', 'Yes');
$sheet->setCellValue('C2', true);
// Row 3 intentionally blank — should halt parsing
$sheet->setCellValue('A4', 'Second question');
$sheet->setCellValue('B4', 'No');
$sheet->setCellValue('C4', false);
$path = $this->createTempPath('.xlsx');
ob_start();
new Writer\Xlsx($spreadsheet)->save('php://output');
file_put_contents($path, ob_get_clean());
$quiz = new Quiz();
$this->subject->xlsxToQuiz($quiz, new File($path));
$this->assertCount(1, $quiz->questions);
/** @var Question $first */
$first = $quiz->questions->first();
$this->assertSame('First question', $first->question);
}
/** @return \Iterator<string, array{int, string, int, string, int, int}> */
public static function answerCountHeaderProvider(): \Iterator
{ {
// Columns (0-based): Question=0, Answer1=1, Correct=2, Answer2=3, Correct=4, … // Columns (0-based): Question=0, Answer1=1, Correct=2, Answer2=3, Correct=4, …
// Answer N is at index 1+2*(N-1) = 2N-1, Correct N at 2+2*(N-1) = 2N. // Answer N is at index 1+2*(N-1) = 2N-1, Correct N at 2+2*(N-1) = 2N.
yield '2 answers → 2 header pairs' => [2, 'Answer 2', 3, 'Correct', 4, 5]; return [
yield '6 answers → 6 header pairs' => [6, 'Answer 6', 11, 'Correct', 12, 13]; '2 answers → 2 header pairs' => [2, 'Answer 2', 3, 'Correct', 4, 5],
yield '7 answers → 7 header pairs' => [7, 'Answer 7', 13, 'Correct', 14, 15]; '6 answers → 6 header pairs' => [6, 'Answer 6', 11, 'Correct', 12, 13],
yield '10 answers → 10 header pairs' => [10, 'Answer 10', 19, 'Correct', 20, 21]; '7 answers → 7 header pairs' => [7, 'Answer 7', 13, 'Correct', 14, 15],
'10 answers → 10 header pairs' => [10, 'Answer 10', 19, 'Correct', 20, 21],
];
} }
#[DataProvider('answerCountHeaderProvider')] #[DataProvider('answerCountHeaderProvider')]
@@ -262,7 +228,7 @@ final class QuizSpreadsheetServiceTest extends TestCase
{ {
$quiz = new Quiz(); $quiz = new Quiz();
foreach ($counts as $i => $count) { foreach ($counts as $i => $count) {
$quiz->addQuestion($this->makeQuestion('Question '.$i, $count)); $quiz->addQuestion($this->makeQuestion("Question $i", $count));
} }
return $quiz; return $quiz;
@@ -274,7 +240,7 @@ final class QuizSpreadsheetServiceTest extends TestCase
$question->question = $text; $question->question = $text;
$question->ordering = 1; $question->ordering = 1;
for ($i = 1; $i <= $answerCount; ++$i) { for ($i = 1; $i <= $answerCount; ++$i) {
$question->addAnswer(new Answer('Answer '.$i, isRightAnswer: false)); $question->addAnswer(new Answer("Answer $i", isRightAnswer: false));
} }
return $question; return $question;
@@ -283,7 +249,7 @@ final class QuizSpreadsheetServiceTest extends TestCase
/** @return array<int, string|null> */ /** @return array<int, string|null> */
private function readFirstRow(string $path): array private function readFirstRow(string $path): array
{ {
$rows = new Reader\Xlsx()->setReadDataOnly(true)->load($path)->getActiveSheet()->toArray(formatData: false); $rows = (new Reader\Xlsx())->setReadDataOnly(true)->load($path)->getActiveSheet()->toArray(formatData: false);
return $rows[0] ?? []; return $rows[0] ?? [];
} }
-63
View File
@@ -1,63 +0,0 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Twig;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use function Safe\file_get_contents;
use function Safe\preg_match_all;
final class TemplateReferencesTest extends TestCase
{
private static string $templatesDir;
public static function setUpBeforeClass(): void
{
self::$templatesDir = \dirname(__DIR__, 2).'/templates';
}
/** @return iterable<string, array{string, string}> */
public static function templateReferenceProvider(): iterable
{
$templatesDir = \dirname(__DIR__, 2).'/templates';
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($templatesDir, \RecursiveDirectoryIterator::SKIP_DOTS),
);
foreach ($iterator as $file) {
Assert::assertInstanceOf(\SplFileInfo::class, $file);
if ('twig' !== $file->getExtension()) {
continue;
}
$content = file_get_contents($file->getPathname());
$sourceFile = str_replace($templatesDir.'/', '', $file->getPathname());
// Match extends, include(), and embed tags — capture the quoted template name
preg_match_all(
'/(?:extends|include|embed)\s*\(?[\'"]([^\'"]+)[\'"]\)?/',
$content,
$matches,
);
foreach ($matches[1] as $referencedTemplate) {
yield \sprintf('%s → %s', $sourceFile, $referencedTemplate) => [$sourceFile, $referencedTemplate];
}
}
}
#[DataProvider('templateReferenceProvider')]
public function testReferencedTemplateExists(string $sourceFile, string $referencedTemplate): void
{
$absolutePath = self::$templatesDir.'/'.$referencedTemplate;
$this->assertFileExists(
$absolutePath,
\sprintf("Template '%s' references '%s' which does not exist at '%s'.", $sourceFile, $referencedTemplate, $absolutePath),
);
}
}