Architecture#
CrabbyTab is a Next.js 15 (App Router) + React 19 + TypeScript application with Tailwind CSS, Firebase Authentication and Cloud Firestore, deployed on Vercel. This page is for contributors. Read AGENTS.md for the engineering rules.
Principles#
- Local-first. Draws, allocation, standings, breaks and validation are pure TypeScript functions that run in the organiser's browser. No paid compute service is involved.
- Deterministic and testable. Randomness goes through one seeded source (
src/lib/draw/rng.ts). Domain logic is unit-tested without Firebase. - Least privilege. Browsers talk to Firestore directly only as tournament administrators (plus two narrow direct writes for data-entry staff). Participants, most staff work, the public site and integrations go through server routes that use the Firebase Admin SDK. See Security model.
- Cheap reads. The public site is served from a snapshot built at publish time and cached, never from live collections (Spark tier: 50k reads/day). See Caching, indexing and performance.
How data moves#
Organiser device Firestore Vercel server
------------------- --------- -------------
IndexedDB working copy --upload/download--> tournaments/{id}/... <--Admin SDK-- /api/** routes
TournamentContext <==live listeners==> (ballots, debates, |
(pure libs compute) teams, adjudicators) v
publicViews/{slug} (snapshot)
|
Public pages / portals <---------------- cached JSON from /api/public/** ---------+
1. The working copy (IndexedDB)#
Each tournament's working copy lives on the device in IndexedDB (database crabbytab, store kv), under keys crabbytab_t_{slug}_{suffix} (meta, rounds, teams, debates, ballots, feedback, auditEvents, syncBaseline, ...), each a JSON string.
src/lib/offline/localStore.tsis the only code that touches it.TournamentContextloads one tournament's keys into memory when it opens, reads and writes synchronously, and flushes coalesced writes to IndexedDB in one transaction a moment later. A failed flush (quota, blocked storage) shows an alert and is retried with the next change.- Older working copies in localStorage are migrated to IndexedDB on first use. If IndexedDB is unavailable the same store falls back to localStorage (about 5 MB).
- Theme, language and remembered portal passcodes stay in localStorage.
public/sw.jsis a service worker that caches the app shell and static chunks, so the app reopens offline.src/lib/offline/chunkRecovery.tsreloads once when a deploy has removed a chunk the page asks for.- Draw generation and auto-allocation can run in a Web Worker (
src/features/tournament/compute/), so large draws do not freeze the page and can be cancelled.
A tournament that has never been uploaded is local-only: nothing leaves the device.
2. Cloud sync (upload / download)#
For collections that are not live (settings, rounds, motions, venues, break categories, feedback, institutions, audit events), the organiser syncs explicitly with Upload and Download and compare (src/features/tournament/application/cloudSync.ts, infrastructure/tournamentRepository.ts).
- Every cloud record has a
syncVersion. The device keeps a sync baseline (version and content hash of each record at its last sync) and uploads only records changed since then. - Uploads run in transactions of up to 150 records. A record whose cloud copy is newer, or soft-deleted, is refused and archived in
syncConflicts; the replaced version of an overwritten record is archived too. The Resolve sync conflicts dialog lets the organiser choose. - Audit events are append-only and created in plain batches.
- Automatic per-action cloud writes exist in
TournamentContextbut are switched off (AUTOMATIC_CLOUD_WRITES = false).
Details: docs/storage-and-scale.md and Backups, restore and cloud sync.
3. Live collaboration#
For cloud tournaments on administrator devices, ballots, debates, teams and adjudicators are live: written to Firestore record by record as they change and received from other devices within seconds.
- Writes:
saveLiveRecords→planLiveWrites(application/liveSync.ts) diffs what a command changed and writes field-level patches (setwithmergeFields), one atomicwriteBatchper command, bumpingsyncVersion. - Listeners:
createLiveSession(application/liveSession.ts, adapterinfrastructure/liveRepository.ts) listens to wholeteamsandadjudicatorscollections and todebatesandballotsof the active round only. - Offline: the Firestore SDK's persistent cache (IndexedDB,
src/lib/firebase.ts) queues writes across reloads. Uploads and publishes wait up to 20 s for queued live writes to be acknowledged. - Conflicts: incoming changes use the same baseline rule as a download; a locally edited record that the cloud also changed becomes a sync conflict. Two devices confirming different ballot versions of one debate is detected (
findConfirmationConflicts) and shown, never resolved automatically. - Live updates can be paused per device from the cloud status menu.
Data-entry staff are not live. They read a redacted copy from /api/tournaments/{id}/assistant-data and send changes through /api/tournaments/{id}/data-entry-sync, which whitelists fields and checks permissions per record (src/lib/dataEntrySync.ts).
Participants never touch the working copy. Private portals call /api/private/team (read) and /api/private/sync (queued ballots, feedback, check-ins; src/lib/privateSync.ts). Debates in Firestore carry a cloud-only participantIds index so portals query only their own debates.
Details: docs/live-collaboration.md.
4. Publishing the public site#
The public site is a snapshot built at publish time, not a view of live data.
- After an upload or a release, the browser calls
POST /api/tournaments/{id}/public-view(application/publicView.ts). - The server reads the cloud collections and builds the public projection (
src/lib/publicView/projection.ts): an allowlist of fields, filtered by release settings. Tabs with top-N limits and other aggregates are computed here (derived.ts). snapshot.tssplits it into sections: base sections (tournament,rounds,teams, ...) and oneround-{id}section per public round. Each is content-hashed and chunked (≤700 KB per document) underpublicViews/{slug}.store.tsinvalidates only the changed sections' cache tags (revalidateTag). Reads go throughunstable_cache(Vercel Data Cache) with short CDN caching; a round fetched with its current hash is cached for a day.- Public pages fetch
/api/public/tournaments/{slug}(and/versionto poll for a new publish). Public layouts render metadata server-side (publicView/robots.ts,seo.ts); the pages themselves are client components.
Details: docs/public-pages-and-notifications.md, Public pages and releases.
Server routes#
All server routes live in src/app/api/** and use the Admin SDK from src/lib/firebaseAdmin.ts (credentials from FIREBASE_SERVICE_ACCOUNT_JSON or application default credentials). The Admin SDK bypasses Firestore rules, so every route authorises the caller itself:
| Route group | Purpose | Caller check |
|---|---|---|
/api/auth/access |
Sync the globalAdmin claim |
Firebase ID token |
/api/tournaments, /lookup |
Tournaments for the user | ID token |
/api/tournaments/{id}/access |
Caller's role | ID token |
/api/tournaments/{id}/staff, /staff/groups, /staff/invitations |
Staff, permission groups, invitations | Administrator |
/api/tournaments/{id}/assistant-data, /data-entry-sync, /assistant-ballots |
Data-entry staff read, write, confirm | Data-entry staff with the right permission |
/api/tournaments/{id}/public-view |
Publish the public snapshot | Administrator |
/api/tournaments/{id}/notifications, /email/settings, /api/email/* |
Email and automatic notifications | Administrator (/api/email/webhook: provider) |
/api/tournaments/{id}/api-tokens |
Manage API tokens | Administrator |
/api/tournaments/{id}/audit/release |
Release the audit log | Administrator |
/api/tournaments/{id}/registration/**, /public-forms |
Registration and public-form settings | Administrator |
/api/invitations |
Accept a staff invitation | Invitation token, rate-limited |
/api/private/team, /api/private/sync |
Private portals | Private key + passcode, rate-limited |
/api/public/tournaments/{slug} (+ /version, /checkins, /audit, /schedule, /schedule/ical), /api/public/motions |
Public data | None (cached) |
/api/public/tournaments/{slug}/forms, /registration |
Public ballot/feedback forms, registration | Optional password or link, rate-limited |
/api/v1/** |
Integration API | Bearer API token |
/api/openapi.json |
API description | None |
Administrator routes use authorizeTournamentAdministrator (src/lib/tournamentAdminAuth.ts); staff routes use authorizeDataEntryStaff (src/lib/staffAccessServer.ts). See API and tokens.
Pages (App Router)#
src/app/[tournamentSlug]/— one tournament. The layout wraps pages inTournamentProviderand the tournament chrome (sidebar). Staff pages:draw,allocation,results,standings,break,feedback,checkins,imports,exports,config,schedule,registration,email,private-urls,staff,api,audit,analytics,rehearsal,print,display. Participant and public pages:public/**,private/{team,adjudicator,speaker},team/[teamId], and public sub-routes such asaudit/public,checkins/public,schedule/public.src/app/tournaments/— hub, setup wizard (new), import, demo, motions bank, API reference.src/app/register/[slug],invite,account,login,forgot-password.robots.ts,sitemap.ts— search-engine files built from published snapshots.
Which staff page needs which permission is in routeRequirement (src/lib/tournamentRoutes.ts). Top-level route names are reserved slugs (RESERVED_SLUGS).
Languages#
The interface is translated with a small typed dictionary in src/lib/i18n/, not a library. English (messages/en.ts) is bundled with every page. Each other language is its own code-split chunk loaded on demand (loadCatalogue in messages/index.ts; Bengali as TypeScript, the others as JSON in messages/locales/) and then cached by the service worker. The locale is chosen on the client (viewer's choice, then the tournament's public language, then the browser), so URLs and cached public pages are unchanged. See Languages and translations and docs/internationalization.md.
Where logic lives#
| Layer | Path | Role |
|---|---|---|
| Domain libraries | src/lib/** |
Pure functions, no React. Tournament rules live here. |
| Commands | src/features/tournament/application/ |
One use case each: run state guards (stateGuards.ts), call domain libs, return records to save and audit events. |
| Persistence | src/features/tournament/infrastructure/ |
Firestore repositories, upload transactions, live listeners. |
| Off-thread compute | src/features/tournament/compute/ |
Web Worker wrapper for draws and allocation. |
| State | src/contexts/TournamentContext.tsx, AuthContext.tsx |
Working copy, commands, sync and live session; auth and global-admin claim. |
| UI | src/app/**, src/components/**, src/features/tournament/components/ |
Rendering and interaction. |
Main domain libraries:
| Path | Contents |
|---|---|
lib/draw/ |
Draw generators (random, power-paired, seeded, round robin, manual, elimination and partial elimination; two-team, BP and N-team debates), Hungarian and Tabbycat-style Hungarian, blossom matching (weightedMatching.ts), side allocation, adjudicator allocator, venue allocator, draw validation, seeded RNG |
lib/standings/ |
Standings metrics, precedence, category and as-of-round standings |
lib/breakqual/ |
Break qualification, cutoff ties, overrides, liveness |
lib/scoring/, lib/ballots/ |
Score validation; ballot results, individual ballots, result formats |
lib/adjudicators/ |
Conflicts, testers, allocation positions, diversity |
lib/feedback/, lib/motions/, lib/schedule/, lib/checkins/, lib/venues/, lib/participants/, lib/registration/ |
Their areas |
lib/publicView/ |
Public projection, snapshot, hashing, cached store, SEO |
lib/api/v1/ |
Integration API router, schema, stores, rate limits |
lib/email/, lib/sms/, lib/csv/, lib/qr/, lib/i18n/, lib/offline/ |
Their areas |
lib/*.ts |
Private URLs, passcodes, rate limits, API tokens and scopes, staff permissions, audit chain, data-entry sync, private sync, tournament access, release rules |
Directory map#
firestore.rules Security rules (see Security-Model)
securityHeaders.mjs CSP and other headers, used by next.config.mjs
public/sw.js Service worker (offline app shell)
src/
app/ Routes (pages and /api)
components/ Layout, setup wizard, public pages, i18n provider, shared UI
contexts/ TournamentContext, AuthContext
features/tournament/
application/ Commands, cloudSync, liveSync, liveSession, publicView, ...
infrastructure/ Firestore repositories and live adapter
compute/ Web Worker jobs
components/ Feature components (ballot entry, release panel, ...)
lib/ Domain libraries (above)
test/ Shared test fixtures
types/index.ts Domain types
docs/ Developer docs (more detail than this wiki)
Related pages#
Data model · Security model · Caching, indexing and performance · Languages and translations · Testing