Commit Graph

6 Commits

Author SHA1 Message Date
Marijn b4a27a7c0d Migrate frontend to TypeScript with Deno-based tooling (#209)
* Migrate frontend to TypeScript with Deno-based tooling (#206)

Compiles assets/*.ts via sensiolabs/typescript-bundle (standalone SWC
binary) and adds Deno for formatting, linting, type-checking, and
tests, keeping the project's no-Node/npm approach intact. Wires all
four into CI, the Justfile, and the pre-commit hook.

* fix: build TypeScript assets before running PHPUnit in CI

The tests job ran bin/console sass:build but never typescript:build,
so var/typescript/ didn't exist and any page rendering the importmap
(e.g. backoffice/base.html.twig) errored during tests.

* test: add regression test for backoffice navbar-toggler dead target

Guards against the navbar-toggler button pointing at a collapse
target (#navbarSupportedContent) that isn't rendered for the current
user — the bug hit on the login page before #210 restructured the
nav to always render at least one item (the Releases link) regardless
of auth state.

* fix: stop hand-enumerating TS files for deno check

deno check assets/*.ts assets/controllers/*.ts assets/controllers/bo/*.ts
was duplicated in CI and the Justfile, and silently misses any new
controller subdirectory (fmt/lint/test already recurse assets/ via
deno.json). Add a top-level exclude for assets/vendor/ (respected by
all deno subcommands, unlike the per-task include/exclude blocks) so
deno check assets/ can recurse safely instead.

* fix: GitHubReleasesService date parsing and falsy release name

- Move the release-mapping array_map inside the try block so a
  malformed published_at (or any other parse failure) degrades to the
  same empty-list fallback as an HTTP failure, instead of throwing
  uncaught out of the cache callback.
- Stop treating a release literally named "0" as unnamed — the old
  `?:` fallback is falsy for that string and silently substituted the
  tag name instead.
- Render release dates in UTC explicitly; Twig's date filter otherwise
  silently converts to the app's default timezone (Europe/Amsterdam),
  which could show the wrong calendar day for releases near midnight.

* docs: add scope-creep-as-a-service rule to CLAUDE.md

Per user instruction: when a review turns up a real bug outside the
current task's scope, fix it in the same MR (with a regression test)
rather than just reporting it, unless it needs a human design call.
2026-07-12 22:11:00 +02:00
Marijn 4e98909f11 Add GitHub Releases modal to backoffice (#210)
* Add GitHub Releases modal to backoffice

Shows release notes (fetched from the GitHub API, cached for an hour,
Markdown rendered via league/commonmark) from a top-right nav button
available whether logged in or out, with the current version shown in
the modal header. Also clears cache.app on container start so the
release cache can't carry stale data across redeploys of the
var/ Docker volume.

* Render release notes markdown via twig/markdown-extra instead of manual CommonMark

twig/extra-bundle already wires the markdown filter to league/commonmark
automatically once twig/markdown-extra is installed, so the service no
longer needs to instantiate and call CommonMarkConverter itself.

* Explicitly disallow unsafe link protocols in rendered markdown

twig/extra-bundle's default (allow_unsafe_links: true) permits
javascript:/vbscript:/file:/data: URLs in rendered links, kept for
backwards compatibility. Disable it explicitly since we now render
release note markdown through this filter.

* Autolink bare URLs in rendered release notes

The Twig CommonMark converter only registers CommonMarkCoreExtension by
default, which doesn't turn bare URLs into clickable links (unlike
GitHub's own renderer). Register CommonMark's AutolinkExtension via the
twig.markdown.league_extension tag so PR/compare URLs in release notes
render as actual hyperlinks instead of plain text.

* Address review feedback on GitHubReleasesService and tests

- Extract the cache callback into a named fetchReleases() method instead
  of an inline closure with all the fetch/parse logic.
- Simplify the User-Agent header to just "TijdVoorDeTest".
- Add a 5s HTTP timeout so a slow GitHub API can't block the request.
- Cache HTTP failures for only 60s instead of the full hour, so a brief
  GitHub outage doesn't suppress releases for as long.
- Replace deprecated word-wrap with overflow-wrap in release-notes CSS.
- Extract duplicated mock setup in ReleasesControllerTest into a helper.
- Reword the "could not load" translation ("Kan" instead of "Kon").

* Address second round of CodeRabbit feedback

- Escape raw HTML embedded in release-note markdown instead of passing
  it through, via twig_extra.commonmark.html_input: escape.
- Don't cache GitHub API failures at all (set $save = false in the
  cache callback) instead of caching them for a short TTL, so the very
  next request retries immediately.
- Sort releases by published_at descending before mapping, so the
  first entry (used for the current-version badge and default-expanded
  accordion item) is guaranteed to be the newest regardless of the
  order GitHub's API happens to return.
2026-07-12 18:59:36 +00:00
Marijn a6f1c3cecd Bold the correct answer in the raw answers export sheet (#208)
Makes it easy to visually spot which answer was correct when scanning
the GDPR export's per-candidate/per-question crosstab.
2026-07-12 18:56:46 +02:00
Marijn 938456087a fix: support more than 25 columns in data export sheets (#205)
range('A', ...) silently truncates its end argument to one byte once a
sheet needs a column past 'Z' (e.g. 26+ questions), triggering a PHP
warning and mis-sizing columns. Replace it with a helper that walks
column indexes via Coordinate::stringFromColumnIndex, which handles
multi-letter columns correctly.

Fixes PHP-SYMFONY-3Z
2026-07-10 19:30:37 +02:00
Marijn 0ee15e3cbb feat: add GDPR data export (download data) button (#198)
* feat: add GDPR data export (download data) button

Wires up the previously disabled "Download data" button on the settings
page. Downloads a zip with a profile.xlsx (account + owned seasons), and
per owned season a folder with each quiz's xlsx (questions, results,
eliminations tabs) and a candidates.xlsx (candidates + season info tabs).
Soft-deleted rows are included and flagged so the export reflects
everything the app still holds about the user.

* feat: include question bank in GDPR data export

Adds a question-bank.xlsx per season folder with Questions (bank
questions, answers, reusable/complete flags, labels, and which quizzes
they've been used in) and Labels tabs, since BankQuestion/BankAnswer
content was previously missing from the export.

* fix: hard-delete quiz/audit-log data when deleting an account

QuizCandidate, GivenAnswer, and Elimination are Gedmo\SoftDeleteable, so
cascading their removal through Season -> Quiz/Candidate only set
deletedAt instead of physically deleting the row. Since Candidate and
Answer are hard-deleted via orphanRemoval, this broke their foreign keys
and rolled back the entire account deletion whenever a candidate had
actually participated in a quiz. Bulk DQL deletes now purge these rows
before the cascade runs.

Also purge BankQuestion audit-log rows (ext_log_entries), which store
the editor's username/email but aren't foreign-keyed to the entity they
log, so they were never cleaned up and would otherwise keep a deleted
user's email around indefinitely.

* style: make the download data button primary (blue)

* feat: add raw answers crosstab to quiz export

Adds a "Raw answers" tab to each quiz xlsx: one row per candidate, one
column per question, with the given answer text in each cell — the raw
data behind the aggregated Results tab.

* i18n: translate new settings page string to Dutch

* refactor: sanitize filenames with Symfony's AsciiSlugger instead of a hand-rolled regex

Extracts a shared Tvdt\Helpers\FilenameSanitizer (backed by
symfony/string's AsciiSlugger) and uses it everywhere user-controlled
text (season/quiz names, account email) ends up in a zip entry path or
a downloaded filename. AsciiSlugger is allowlist-based (only A-Z/0-9
survive; everything else, including unicode and path-traversal
sequences, is folded or stripped) rather than a denylist of "unsafe"
characters, and it's an officially maintained Symfony component already
present as a transitive dependency.

Also fixes BackofficeController::exportQuiz(), a pre-existing endpoint
that built its Content-Disposition filename directly from an
unsanitized quiz name — the same class of risk the data export already
guarded against.

Naming note: sanitized names are now slugs (spaces become dashes,
unicode is ASCII-transliterated), e.g. "Krtek Weekend" -> "Krtek-Weekend".

* fix: check ZipArchive open/close results and clean up temp files on failure

Addresses CodeRabbit findings on the GDPR export:
- ZipArchive::open() and close() can both return false without
  throwing; neither was checked, so a failure silently produced an
  empty or corrupt zip, and the temp zip path was never cleaned up on
  a mid-build exception.
- writeToTempFile() leaked its tempnam()'d file if Writer\Xlsx::save()
  threw before the caller could track it for cleanup.

* fix: drop public link identifier from the candidates export sheet

The nameHash is a public quiz-access token, not something a data
export should hand out — remove it from candidates.xlsx.

* feat: add Quiz info tab covering dropouts, finalization, and disabled questions

An entity-by-entity audit of the export vs. delete flows found the
delete flow fully covered, but three Quiz/Question fields missing from
the export: dropouts, finalizedAt, and Question.enabled. Adds a new
"Quiz info" tab (first sheet) to each quiz's xlsx with this data,
without touching the shared fillQuestionsSheet() used by the existing
single-quiz template export/import feature.

Deliberately left out per user decision: the BankQuestion audit log
(would leak other owners' emails, consistent with hiding co-owner
identities elsewhere in this export) and a few low-value timestamp
fields already covered by existing Started/time-taken columns.

* feat: require a confirmed email before exporting data

Antispam measure: both the full data export (SettingsController::downloadData)
and the single-quiz export (BackofficeController::exportQuiz) now redirect
with a flash warning instead of exporting when the account's email isn't
verified yet. Adds a matching hint on the settings page next to the
download button.
2026-07-09 20:28:10 +00:00
Marijn 404c0dcc26 Summer cleanup: XLSX export, WIDM-style quiz UI, CSS fixes (#162)
* 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)

* 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

* 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

* 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

* 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.

* 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)

* Use HeaderUtils::makeDisposition() for safe Content-Disposition filename

* 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

* Fix Sass healthcheck

* 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`

* Refactor generateTemplate to reuse quizToXlsx and add second example question

- generateTemplate now builds an in-memory Quiz entity and delegates to
  quizToXlsx, eliminating duplicate spreadsheet-building logic
- Adds a second example question "Wie is de mol?" with 10 Dutch names
  (5 male, 5 female) to better illustrate the import format
- Updates tests to assert both example questions and adds a test for the
  blank-row halt behaviour in fillQuizFromArray (achieving 100% coverage)

* Move PHPUnit cache to /tmp to avoid writing into the mounted volume

* Update src/Service/QuizSpreadsheetService.php

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-07-02 20:25:02 +00:00