Print-ready scorecards, name tags and staff sheets for WCA competitions, generated in the browser.
By Speedcubing Canada.
Delegates and organizers sign in with their WCA account, pick a competition they manage, choose what to print, and download the PDFs. The PDFs are built entirely in the browser: the WCIF (v2) is never uploaded, and there is no database. One anonymous usage event is sent per generation so we can see which competitions the tool is used on and which settings people pick, see Usage analytics.
| File | Contents |
|---|---|
{id}_round1.pdf |
Named scorecards for round 1, plus delegate/scoretaker cover cards (each shows the group's planned time, with write-ins for the actual time and scramble set) |
{id}_round2.pdf |
Round 2 of events with 3+ rounds; named, prefilled or blank |
{id}_semis.pdf |
Round 3 of events with 4 rounds |
{id}_finals.pdf |
Final round of every multi-round event |
{id}_extras.pdf |
One blank spare scorecard per round per event, a sample 3x3 card for the competitor/judging tutorial, and a last page of pure-blank cards (competition header only) to reprint as needed |
{id}_checklist.pdf |
Round Checklist: one table per day tracking each round's data flow |
{id}_schedule.pdf |
Schedule tracker: estimated times, blank columns for actuals |
{id}_group_overview.pdf |
Group Overview: one table per group, listing everyone assigned to it |
{id}_venue_schedule.pdf |
Venue Schedule: the WCA competition PDF's events table and daily schedule, to post around the venue |
{id}_nametags.pdf |
Competitor name tags with duty assignments and QR codes, plus non-competing Delegates, organizers and volunteers (badge "Staff", QR codes on both sides, toggle in name tag settings) |
{id}_first_timers.pdf |
Confirmation slips for competitors with no WCA ID |
{id}_custom_{name}.pdf |
One file per custom/bonus event |
Empty documents are omitted. Two or more files download as {id}_pdfs.zip; a single file
downloads on its own so it can be printed straight away.
![]() |
![]() |
| Bilingual, custom logo, cutoff, stage | Simple: English, no logo, newcomer |
The top-left of each scorecard shows an uploaded logo alone, the Speedcubing Canada logo next to the competition name, or the competition name only. A custom logo drops the name, so for logos that don't contain it, "Show the competition name on scorecards" (settings, logo section) prints it in small type above the competitor's name. The line shrinks to fit long names (5pt minimum).
Scorecard names are measured with real Helvetica-Bold widths and sized up to 18pt. A name splits over two lines, at a space or after a hyphen, when one line would drop under 11pt or two lines print at least 3pt bigger; the originals keep 13pt+ names on one line. Name tags, vertical and horizontal, keep the originals' length-based size on one line and split once two lines print at least 2pt bigger (up to 20pt), within the height left above the badge (horizontal tags budget two 14pt lines), so long Brazilian and Latin American names wrap rather than shrink.
The PDFs use the built-in Helvetica, which only covers Western European letters. Any other Latin
or Greek letter (Polish Ł, Vietnamese ư, Czech ř...) falls back, letter by letter, to
Scorecard Sans, a Helvetica clone embedded only in PDFs that need it. This applies to every PDF
text, competition names included. Cyrillic, CJK and other scripts are still not covered. The font
is TeX Gyre Heros (GUST Font License, src/pdf/fonts/LICENSE.txt) with its line height set to
match react-pdf's built-in Helvetica, built by scripts/makeScorecardSans.py.
Competitions model stages two ways, and both are recognised. Most give each stage its own WCIF
room (Blue Stage, Red Stage), and the stage name is whatever distinguishes those room names.
The rest pack every stage into one room and name the stage in each group activity instead
(NAC 2026: one Hall B holding Red/Blue/Green/Orange, all reusing the codes 555-r1-g1..g3);
there the stage is read from the group name. A round that runs on several stages always names
its stage on the card, Red 2 of 3, or Red 1 when each stage runs a single group.
One table per scheduled group, chronologically, listing who competes, scrambles, runs and judges in it, with the group's time and room above it. Competitors print in station order when the WCIF assigns stations, so the printed ordinal is the station number; staff print alphabetically. It is meant to be posted at the stage, and it is unchecked by default.
A staff column that is empty for every group is dropped rather than printed blank, so a run made before staff are assigned still produces a usable competitor-per-group sheet. The check is document-wide on purpose: per-group would make the columns shift between blocks on one page.
A group nobody is assigned to produces no entry. Finals are scheduled with real group activities long before anyone qualifies for them, and a round scheduled as a bare time block has its groups synthesized so its scorecards still generate; either would otherwise print four empty columns. A final that has been assigned is kept, and so is a group with staff but no competitors yet.
Our take on the WCA's competitions/<id>.pdf, so organizers no longer print it separately: an
events table (format, cutoff, time limit, who proceeds), then one page per day listing every
activity in start order, lunch and check-in included. Landscape, without the WCA's cover page.
Headings are bilingual when a secondary language is set; activity names use the primary one, and
times use the venue's timezone (12h in English, 24h otherwise). A round running on several stages
at once is one row listing its rooms, and a time limit split across rounds names the rounds it is
shared with. Rows have fixed heights, so the page count shown before generating is exact.
Unchecked by default; the Québec preset turns it on.
"Scorecards on A6" (Settings, under the paper format) prints one scorecard per A6 page for
dedicated A6 printers. Only scorecard PDFs change; every other document keeps the paper format.
The card is the A4 card (249×373pt, same row heights) centred on the page, and the entries are
the plain sorted order (unstackQuadrants undoes the cut-and-stack reorder and drops padding
covers), so there is nothing to cut or restack. Nothing is padded to a sheet of 4 either: extras,
custom events and the pure-blank page print one blank card where 4-up prints four. The page caps
below count pages, so an A6 file reaches them with a quarter of the cards.
"One PDF per stage" (Settings > Advanced) turns each round into one file per stage,
{id}_round1_blue.pdf / {id}_round1_red.pdf, so every stage can be printed on its own paper
colour. The checkbox only appears when the competition actually runs across several stages, and
only rounds that span stages are split; a round confined to one stage keeps its single file.
Each stage file pads to a full 4-up sheet on its own, so no printed sheet ever straddles two
stages. Per-event and size-cap splitting still apply inside a stage file
({id}_round1_blue_333.pdf).
One exception: a round 2 set to "prefilled" is filled with the round 1 qualifiers, who are not on a stage until round 1 is over, so it stays a single PDF. The settings and generate pages both say so when the combination is selected.
"One PDF for all rounds" (Settings > Advanced, mergeScorecardRounds) puts round 1, round 2,
semis and finals in a single {id}_scorecards.pdf, for organizers who print from one file. It is
off by default and on in the Brasília preset. The rounds are joined in sorted order and stacked
once, so the cut deck reads round 1, round 2, semis, finals; a sheet can hold the end of one round
and the start of the next. A single round keeps its per-round filename. Extras stay in their own file.
Stage splitting and "Split large files" both win over it, because re-sorting a mixed-round deck
per stage or event would break the prefilled round 2 order. On a large competition the merged
file passes the page cap, and /generate warns that it can be slow or fail (see below).
A final run as one group on one stage prints "Station 01..N" on its blank scorecards instead of
the group label, but only when the field fits the room's stations. The station count comes
from, in order: the "Stations per stage" box (Settings > Advanced), Groupifier's
groupifier.RoomConfig room extension, then the highest stationNumber assigned in that room
(Delegate Dashboard numbers stations per assignment and stores no room count). With no count
anywhere the final stays stationary. The "Print station numbers" checkbox turns it off entirely.
People registered on the day are not in the WCIF. When the WCA API reports
on_the_spot_registration, the settings page ticks Add blanks for on-the-spot
registrations (a restored choice wins) and shows a notice on the settings and generate
pages. The setting adds:
- 4 blank name tags after the real ones (badge "Competitor"). Their QR codes open the competition on Competition Groups and on WCA Live / ILR rather than a person.
- One page of blank first-timer slips: every value is a line to write on, every competition event is listed to tick, and parental consent is always offered.
Left to the organizer by hand: adding each person on the WCA site / WCA Live before entering results (no ID on their cards until then), their group (group sizes, cover cards, Group Overview and Round Checklist do not count them), and prefilled Round 2, which only lists registered competitors.
npm install
npm run dev.env at the project root:
VITE_WCA_CLIENT_ID=your_wca_oauth_client_id
WCA_CLIENT_SECRET=your_wca_oauth_client_secret
VITE_WCA_REDIRECT_URI=http://localhost:5173/auth/callback # optional, defaults to origin/auth/callback
Only the client ID is bundled. The secret stays server-side: both the Vite dev server and
server.js expose one endpoint, POST /wca-token, that appends the secret and forwards to the
WCA token endpoint (which sends no CORS headers, so the browser cannot call it directly anyway).
WCA access tokens last two hours, so a tab left open outruns them. The session renews itself
rather than dead-ending: src/auth/AuthContext.tsx checks expiry on mount and on tab refocus,
and spends the refresh token through the same POST /wca-token proxy. A session with no usable
refresh token falls back to the authorize redirect, which needs no interaction because the WCA
still holds the sign-in cookie and the earlier consent, and returns the organizer to the step
they were on. Any 401 from an authed call triggers the same path, registered once in
src/auth/wca.ts so it covers every call rather than the pages that remember to check.
| Command | |
|---|---|
npm test |
Vitest, no network |
npm run test:coverage |
The same run, with coverage and its thresholds enforced |
npm run lint |
ESLint |
npm run typecheck |
tsc -b (also the first half of npm run build) |
npm run test:e2e |
Playwright browser journeys against the production build (see below) |
npm run render:fixtures |
Render every PDF headlessly to ../current-output/ (override with FIXTURE_OUT_DIR) |
scripts/checkFixtures.sh |
Re-render the fixtures and pixel-diff them against tests/pdf-baseline/ |
scripts/updateFixtureBaseline.sh |
Refresh that baseline, after an intentional PDF change |
npm run bench |
Time and heap for a whole generation, on a synthetic competition (small/medium/wc) |
npm run record:walkthrough |
Re-record docs/readme/walkthrough.gif after a UI change (needs ffmpeg; -- --shots for one PNG per step) |
CI runs three jobs once per commit, on every PR and on pushes to main: lint, typecheck and
test:coverage; the pixel diff (pdf-fixtures); and the browser journeys (e2e). There is no
staging environment, so those three are the only thing between a branch and production, and the
deploy waits for all of them. The PR run tests the branch merged with main rather than the
branch head, which is what actually lands. A branch with no PR open gets no CI - open the PR.
main is protected: all three must be green and the branch must be up to date with main
before a PR can merge, and force-pushes and deletion are blocked. Admins are not bound by it,
so an emergency fix can still go straight in, though its deploy still waits for all three jobs.
Run npm run test:coverage, not npm test, before pushing: only the former enforces the
coverage thresholds, so it is the one that matches CI.
npm run test:coverage fails if coverage falls below the thresholds committed in
vitest.config.ts, and Vitest's thresholds.autoUpdate rewrites them upward whenever a run
improves on them. So: add tests, commit the raised numbers, and the floor never slips back.
Never lower them by hand. Uncovered files are included on purpose, so the number is the honest
one; only src/assets, src/types, src/main.tsx and the test helpers are excluded.
Vitest runs three projects, picked by file name:
| Project | Files | What it is |
|---|---|---|
unit |
*.test.ts(x) |
Node by default. A test that mounts a component opts into a DOM with a // @vitest-environment jsdom docblock and renders through renderWithProviders (src/test/render.tsx) |
routes |
*.route.test.tsx |
The whole app at a URL: renderApp(path) (src/test/renderApp.tsx) mounts the real routes and AuthProvider. fetch is an in-memory WCA (src/test/fakeWca.ts: seed it with addCompetition, break an endpoint with on()), and the PDF worker runs the real runJobs on the main thread (src/test/fakeWorker.ts) with pdf() stubbed. Downloads land in downloads from src/test/routeSetup.ts |
pdf |
*.pdf.test.ts |
Real @react-pdf rendering, one file at a time |
Run one with npx vitest run --project routes.
Test data:
src/test/fixtures.ts:testSettings()(a completeCompetitionSettings) andsampleWcif(), a one-day competition that fills every bucketparseWCIFproduces.scripts/renderFixtures.tsuses the same settings builder.src/test/wcifGen.ts:randomWcif(seed)builds small, messy competitions. They have random events (FMC and MBLD included), stages, stations, and hard names. They also include the people the parser must leave out: withdrawn, pending, staff-only, and organizer accounts with no registration.scripts/bench.tsuses the same generator through its fixed profiles.tests/fixtures/wcif/*.json: real competitions, loaded withcorpus()fromsrc/test/corpus.ts. Add one withnpx vite-node scripts/fetchWcifFixtures.ts -- <competitionId>. The script only reads the public WCIF, and it still stripsemail,birthdate, comments and admin notes, because the repo is public.src/test/corpus.test.tsfails if any of those, or anything shaped like an email address, shows up in a fixture. Never hand-copy a WCIF from a manager's session into the repo.
Five files cover seams rather than single modules, because the halves either side of each are tested on their own and the joint is what breaks:
src/pages/wizard.route.test.tsxdrives the real routes (AppRoutes) through picker → scope → settings → generate → download, using only the UI. The wizard's steps share no React state, so this is what proves they agree about the same competition.flowState.test.tsstill owns the sessionStorage layer, and each page test still owns its own rendering.src/pdf/render.pdf.test.tsrenders every job kindbuildPdfJobscan emit through the real documents withrenderToBuffer. It asserts only that a valid PDF comes out, never layout: the*-layout.test.tsfiles own the measurements, andscripts/checkFixtures.showns the pixels.src/pdf/pipeline.pdf.test.tsruns the worker'srunJobswith nothing stubbed, on every real competition and a few generated ones: parse, plan, render, split and merge, zip. It unzips the download and checks one file per job and that the page count matches what/generatepromised (estimateTotalPages; exact except for the schedule and checklist, which flow). It also reads a rendered card back withpdftotextand checks the name, round, group, cutoff and limit in English and French. That part needspoppler-utils: it skips locally without it and fails in CI.src/pdf/renderBundle.test.tscovers the orchestration around that - which jobs run, bare PDF versus zip, progress, what happens when one document throws - with the renderer stubbed, so it does not re-render what the file above already renders.server.test.jsstarts the real express app on an ephemeral port and asserts the things that live in headers and status codes: the CSP directives, and/api/eventanswering 204 to everything so a prober learns nothing.
npm run test:e2e builds the app, serves it with the real server.js, and drives it in Chromium
twice: as a desktop and as a Pixel 7. That is the only layer that sees the real CSP, the PDF
worker loading as a module, and a download landing on disk. One spec per journey in e2e/:
sign-in (and its failures, renewal, sign-out), the wizard to a zip, a custom competition with a
CSV, mid-competition defaults, settings (an oversized logo, the French UI), and analytics.
Nothing reaches WCA. e2e/fakeWca.ts answers the browser's WCA calls from page.route, serving
the real competitions in tests/fixtures/wcif/ through src/test/wcaApi.ts, the same API the
route tests' fake serves. The token exchange leaves from server.js, so it goes to
e2e/fakeWcaOrigin.ts through the WCA_ORIGIN env var. That fake checks what WCA checks: the
client, the redirect URI, the PKCE verifier against the challenge, and that a refresh token is
spent once. Access tokens expire on the clock, so renewal is tested for real. Their shared
contract (ports, ids, how codes and tokens are encoded) is e2e/protocol.ts.
Sign-in always runs the real redirect; nothing seeds a session. Every test fails on a console
error, which is also how a CSP violation shows up. A test that provokes one on purpose lists it
in allowConsole. The custom-competition journey reads the PDF back with pdftotext, so
locally it needs poppler-utils.
Run one spec with npx playwright test e2e/login.spec.ts --project desktop. The run builds into
dist-e2e/ with a fake client id, leaving your dist/ alone, and serves it with
DIST_DIR=dist-e2e. Locally, servers already on ports 4173 and 4180 are reused, so stop
npm run preview first or it tests a stale build. CI uploads the HTML report and traces when a
journey fails; open a trace with npx playwright show-trace <trace.zip>.
scripts/checkFixtures.sh re-renders the fixture PDFs and pixel-diffs each page against
tests/pdf-baseline/, catching what the measurement tests cannot see: a changed colour, a
shifted margin, a dropped glyph. It needs poppler-utils and graphicsmagick. Every input it
uses lives in the repo (tests/fixtures/, tests/pdf-baseline/), so a fresh checkout can run
it.
It passes below a max page MAE of 0.002 (override with FIXTURE_MAE_TOLERANCE) rather than
demanding pixel identity, because the CI runner's poppler and font stack are not the ones a
baseline was rendered on. For scale, a 1pt padding change measures about 0.02. The CI job runs
on a pinned runner image, so poppler only changes when someone bumps it.
When it fails, look at the red-highlighted diff images it points at (CI uploads them as the
pdf-fixture-diff artifact). If the change was intended, run scripts/updateFixtureBaseline.sh
and commit the new baseline - never refresh it without opening the diffs first.
npm run bench builds a synthetic WCIF, parses it and renders every document, reporting wall
time, peak heap and output size per PDF. It drives the shipping code (parseWCIF →
filterParsedByScope → buildPdfJobs → jobElement), so a regression it catches is a real one.
npm run bench # wc: ~1800 competitors, the upper bound we support
npm run bench -- small # small / medium / wc
npm run bench -- wc --only=nametags
npm run bench -- wc --a6 # scorecards one per A6 page
npm run bench -- wc --merge # every round in one scorecard PDF
@react-pdf lays out a whole document in one pass and rebuilds the node tree at each stage, so cost scales with node count and the scaling is superlinear once the heap gets large. Three things follow from that, and each has a test guarding it:
- Every scorecard and name tag file is rendered in parts of
RENDER_PART_PAGESpages (jobElements), one at a time, and stitched back into one PDF with pdf-lib (mergePdfs), so the organizer gets one_round1.pdfhowever big the round. The merged file costs heap too: the WC profile peaks around 1.1 GB 4-up and 0.9 GB A6. - When a file passes
MAX_PAGES_PER_SCORECARD_PDF(orMAX_PAGES_PER_NAMETAG_PDF), /generate offers "Split large files" (splitLargePdfs), for a machine that cannot hold the merged file: one PDF per event (competitionId_round1_333.pdf, …), and an event still over the cap is cut into sheet-aligned_part1/_part2files. That peaks around 0.75 GB 4-up. A6 unstacks before splitting, so each part is a contiguous run of the deck. - Name tag QR codes are drawn as a single
Path, not ~200Rects each (src/pdf/qrPath.ts). - First-timer slips are packed into explicit pages by
packSlipPagesrather than flowed into one tallPage, because @react-pdf re-measures the overflow once per page it produces.
The worker otherwise keeps PDFs out of the JS heap: documents stay Blobs and the archive is
streamed through fflate's Zip, so peak memory is one document, not all of them. A merged file
is the exception: pdf-lib holds it in memory while it is assembled.
LoginPage → CompetitionPickerPage → RoundScopePage → SettingsPage → GeneratePage (download)
└→ CustomCompetitionPage ──────────────↑ (custom competitions skip /scope)
- Auth and settings live in
sessionStorageonly, cleared when the tab closes, never sent anywhere. A renewal redirect stays within the tab, so the wizard survives it. - Going back re-opens a step with the choices already made on it (
src/lib/flowState.ts), so nothing is retyped; picking a different competition resets them./settingsgoes back to/scope, or to/customfor a custom competition. - One anonymous event per sign-in, generation, and failure goes to
POST /api/event(see below). - PDF rendering runs in a Web Worker (
src/pdf/scorecardWorker.ts) so the UI stays responsive. /scopepicks which documents and which rounds to generate; regional presets there seed defaults for a province, and everything stays editable afterwards.- Live results exist in two flavours, and name tag QR codes point at whichever the
competition runs on. The codes are labelled "Schedule" and "Results" (translated), not with
domain names, which mean nothing to first-timers.
/settingspreselects it from the competition'sscoretaking_softwarefield (internalmeans ILR, the WCA's integrated live results) and lets the organizer override. WCA Live needs a numeric competition ID and a person-ID map fetched off its GraphQL API; ILR needs neither, since its URLs are built from the WCA competition ID and each competitor'sregistration.wcaRegistrationId, both already in the WCIF. - Scramble double-checking adds a second scrambler-signature column to the scorecards that
need it, picked by any of three rules OR'd together: by round; by ranking (world top 50 by
default, plus an optional national or continental threshold, read from the WCIF personal bests);
and by an uploaded
WCAID,event1,event2CSV. The ranking and CSV rules apply in every round and only to named cards, since a blank card has no competitor to look up. The round rule starts unticked, except Finals when the competition name reads as a championship, because the ranking rules already cover who needs it. The thresholds mirror regulation 11i, which only owes a replacement attempt for a regional record, a personal record in the world top 50, or a championship final scrambled by two scramblers. 11i binds every competition, so there is no switch to turn the feature on: it is on for every WCA competition, its rules live under Advanced at the bottom of/settings, and "off" is unticking both ranking rules with no round and no CSV. Custom (non-WCA) competitions never get it, having no WCIF to rank against. - Scorecards come out in cut-and-stack order: print, cut each sheet into 4, keep the four positions separated, stack them in order, and the deck is already sorted. The download page spells this out per document.
Non-obvious constraints that look arbitrary in the code but break real output if undone:
bufferPolyfill.tsmust be the first import in the worker. react-pdf touchesBuffer,windowanddocumentat module load, and the polyfill also forces PNG decoding synchronous (the async path spawns a nested worker that never reports back, hanging the render).- Never
wrap={false}on the Round Checklist day block. react-pdf squashes an oversized non-breaking block instead of paginating it, collapsing every tick box into a sliver. The Group Overview pins its blocks for the same "never split a group" reason, but only aftergroupBlockFitsPagesays the block fits a page at all. - Horizontal name tags are cut to exactly 90×55 mm (
NAMETAG_H), the card the badge holder takes, and centred on the page, so A4 and LETTER use the same card. Not a per-paper-size number, do not "optimise" it. - Never filter scorecard entries after
finalizeEntries. It sorts, pads to a multiple of 4 and quadrant-reorders; removing entries downstream corrupts the printed pile order. Cover cards are gated at emission time inpushCoverfor exactly this reason. gcp-buildis a deliberate no-op. App Engine's Cloud Build has noVITE_WCA_CLIENT_ID; rebuilding there shipsclient_id=undefinedover the gooddist/.- No test asserts a translated string. Rewording printed copy must never turn CI red, so the
guards are structural: key parity across locales, and width sweeps that check a string still
fits its column. A failing sweep means the text is genuinely too long, shorten it. A page
test that has to find a control by its accessible name looks the name up through
i18n.t(), so it follows a rewording instead of breaking on it.
- UI work follows
DESIGN_GUIDELINES.md(CSS modules, Montserrat at 400/500/700, lucide-react icons only, CSS-variable tokens);src/components/design-system.test.tsenforces the hard rules (no inline style objects, no rawpxfont sizes, 400/500/700 only) andsrc/styles/contrast.test.tsholds every colour pairing to WCAG AA. Shared primitives live insrc/styles/ui.module.css; per-component styling sits in aComponent.module.cssbeside it. - No em dash in on-screen copy, in any locale. Use a comma, a colon, or two sentences.
- One word per concept per locale. A scorecard is a feuille de score / hoja de
puntuación / súmula everywhere, including
src/changelog.ts.src/i18n/terminology.test.tsholds the banned variants and names the offending key. - Adding a region is a JSON file drop in
src/presets/with no code changes, seesrc/presets/README.md. - Adding a language is two entries: a bundle in
LOCALES(src/lib/i18n.ts, printed output) and aLANGUAGESentry plus UI JSON (src/i18n/, interface). - Adding a WCA event touches five files: the
EventIdunion (src/types/wcif.ts),WCA_EVENT_ORDER(src/lib/wcif-parser.ts, which is also the sort order),EVENT_ICONS(src/assets/events.ts), the name tables insrc/lib/i18n.ts(SHORT_NAMETAG_NAMES_BASEplusEVENT_NAMES_EN/FR/ES/PT), andWCA_EVENT_LABELS(src/components/CustomEventEditor.tsx). Only the union fails at compile time;i18n.test.tscatches the rest. The icon is a 165x165 RGBA PNG, glyph#212121on white, traced from cubing/icons. Keep the printed name short: the Round Checklist event column has ~136pt of content width on A4, which is why FTO is named "FTO" and not "Face-Turning Octahedron". - User-visible changes get a bullet in
src/changelog.tsin all four locales, newest first. Returning organizers see it as a "What's new" dialog, so the bar is high: no refactors, bug fixes or small tweaks. - Run
scripts/checkFixtures.shafter touching anything undersrc/pdf/. These PDFs get printed and cut, so layout regressions are expensive.
Merging to main deploys automatically via
.github/workflows/deploy.yml once the test job passes. A pull
request runs the same test job and stops there. One-time
GCP setup lives in .github/workflows/README.md. For an emergency
manual deploy: VITE_WCA_CLIENT_ID=… npm run build && ./deploy.sh.
The app is served at compprint.net. The old scorecards.speedcubingcanada.org and
compprint.speedcubingcanada.org 302-redirect there; www.compprint.net and the comprint.* typo
domains 301 there (see Custom domains).
The tool is used well beyond Canada now, and nothing used to record that. One anonymous JSON
event is posted to POST /api/event on sign-in, on a successful generation, and on a failure.
server.js sanitises it and writes it to stdout; App Engine forwards stdout to Cloud Logging,
a log sink carries component: "analytics" lines into BigQuery, and a Looker Studio report
draws the map and the charts. There is no database and no read API.
Collected: the public WCA competition id, the venue's country and coordinates, how big the competition is (competitors, events, rounds, groups, stages, days), what was produced (PDFs, pages, scorecards, cover cards), and the settings chosen (languages, paper format, name tag options, logo choice, check mode, regional preset, generation scope).
Not collected: WCA user ids, competitor names, anything from the WCIF, and the uploaded
logo. src/lib/analytics.test.ts asserts that none of it can reach the payload.
Opting out: organizers can tick a box under "Your data" in the About dialog ("What is
this?"), which is reachable from the login page too. The choice lives in localStorage under
analytics_opt_out, so it is per browser and survives reloads, and send() checks it in one
place so it covers every event.
Nothing is sent from npm run dev or npm run render:fixtures: send() is a no-op unless
import.meta.env.PROD. analytics.js is the sanitiser both server.js and the tests use, and
it is structural rather than a field whitelist so the payload can grow without drifting.
One-time GCP setup for the sink and the dashboard is in
.github/workflows/README.md.
| Path | |
|---|---|
src/pages/ |
One file per wizard step |
src/components/ |
Shared UI: header, dialogs, tooltip, skeletons, print guide |
src/pdf/ |
react-pdf documents, shared layout constants, the render worker |
src/lib/ |
WCIF parsing, generation scope, PDF job list, printed-output strings |
src/i18n/ |
Interface translations and the language registry |
src/presets/ |
Regional presets, one JSON per region |
src/theme/ |
Light/dark theme context |
scripts/ |
Headless fixture rendering and pixel diffing |
analytics.js |
Server-side sanitiser for the usage events, next to server.js |
For anything deeper, ask DeepWiki or read the file: both track the code, this README does not.


