# WCTV Tooling — Project Plan

Tooling for **Windy City Throwdown Five (WCTV)**, a 2-day dance battle event.
Built with **Flutter** (so it can later fold into another Flutter project) and
**Firebase** (Firestore + Storage) for data.

---

## 1. Product overview

The event runs **3 different battles** (competition categories). Each battle
has its own format and configuration. The tooling covers the full lifecycle:

1. **Ingest registrants** from a CSV.
2. **Configure the event** — per-battle format, round counts, round lengths,
   bracket size, prelim grouping.
3. **Run prelims** — randomized order, increment through dancers, judges score
   privately.
4. **Build the bracket** — seed a Top N (configurable, e.g. Top 32) from the
   prelim scores; let judges reorder or re-randomize.
5. **Run battles** — 1v1 or crew NvN, configurable rounds per battle.
6. **Public display** — live prelim progress and live bracket/head-to-head
   views (timers, who's up, who's next, round wins, dancer images).

### Battle formats we must support
- **Solo 1v1** — classic single-dancer battles.
- **Crew NvN** — multiple individual crew battles (3v3, etc.); `crewSize` configurable.
- **Footwork Frenzy** — dancers register *with a style*; prelims are grouped by
  style and each group is randomized independently.

### Configurable parameters (per battle)
- Prelim round length.
- Prelim grouping (none, or by style for Footwork Frenzy).
- Bracket size / Top N.
- Rounds per battle (1, 2, 3, …) and battle round length.
- Crew size (for crew battles).

---

## 2. Tech & architecture

- **Flutter** app, feature-first layout under `lib/src/`.
- **URL routing / deep links** via `go_router` (`lib/src/routing/app_router.dart`).
  Every screen has a stable, shareable path so the browser URL updates as you
  navigate and any view can be linked directly:

  | Location | Screen |
  | --- | --- |
  | `/` | battle list (landing) |
  | `/battles/new` | create-battle form |
  | `/battles/:battleId` | battle overview + roster |
  | `/battles/:battleId/prelims` | prelims operator tools |
  | `/battles/:battleId/bracket` | bracket manager |
  | `/battles/:battleId/display` | public live display |

  Features navigate with `context.go(AppRoutes.xxx(battleId))`; go_router
  synthesizes the parent stack so back/forward and a fresh page-load land on the
  same view. On the web, `usePathUrlStrategy()` (in `main.dart`) keeps the URLs
  clean (no `#`), and the Firebase Hosting SPA rewrite (`** → /index.html` in
  `firebase.json`) lets a direct link / reload resolve any path.

  The three nested feature routes are **gated** (`routerProvider`'s per-route
  redirects): a deep link to a view that isn't ready — prelims below the
  registrant minimum, an un-finalized bracket, a display before prelims start —
  redirects to the battle overview, matching the battle page's button gates.
  The gates are **hydration-aware** (`state/hydration_state.dart`): the roster,
  prelim and bracket providers each flip a flag once their initial load lands,
  and a gate returns "no redirect" until the data it reads is loaded. Combined
  with the `refreshListenable`, a cold-loaded shared link to a *valid* view
  waits for its data and stays put, while a genuinely un-ready link redirects
  once the data confirms it — instead of everything bouncing on the empty first
  frame.
- **Design system** in `lib/src/theme/` (charcoal base + vibrant orange accent)
  and shared glass surfaces in `lib/src/widgets/` (`GlassCard`, `AppBackground`).
  All screens pull colors/spacing from here so the look stays uniform.
- **Riverpod** for state management (clean seams, easy to test, portable).
- **Firebase**: Firestore for event/registration/score/bracket data,
  Firebase Storage for dancer images. Wired behind repository interfaces so
  the app can run on in-memory data during early development.
- **Pure-Dart domain layer** (`models/`, `services/`) with no Flutter imports,
  so business logic (CSV parsing, randomization, seeding) is unit-testable.

```
lib/
  main.dart              app entry
  app.dart               MaterialApp.router
  src/
    routing/             go_router config + shareable path helpers (AppRoutes)
    models/              pure-Dart domain models
    services/            pure-Dart logic (CSV import, randomizer, seeding)
    state/               Riverpod providers / repositories
    features/
      home/              landing / event dashboard
      csv_import/        CSV ingestion UI
      event_config/      organizer controls
      prelims/           prelim run + progress
      bracket/            bracket build + progress
      public_display/    public-facing live views
test/                    unit tests for services & models
```

---

## 3. Roadmap (in priority order)

### ✅ Phase 0 — Foundation *(this pass)*
- [x] `plan.md`, `CLAUDE.md`, `.gitignore`
- [x] `pubspec.yaml` with dependencies
- [x] Domain models (event, battle, dancer, registration, configs, enums)
- [x] App shell + home screen + navigation
- [x] Firebase scaffolding (options placeholder, guarded init, repo seam)

### 🎯 Phase 1 — CSV ingestion *(first focus)*
- [x] `CsvImportService`: parse CSV → dancers + registrations, with column
      mapping and per-row validation.
- [x] Crew handling: a `crew` column groups dancers into `Crew`s for
      crew-format battles (3v3). `CrewGrouping` builds crews from the roster;
      import warns about dancers with no crew and crews that aren't the
      expected size. Crews are the competing unit for crew prelims & bracket.
- [x] Unit tests for the import service and crew grouping.
- [x] CSV import screen: pick file, preview table + crews + warnings, confirm
      import into a battle.
- [x] Persist imported roster — repository seam (`state/repository.dart`) with
      in-memory + Firestore implementations; write-through on every mutation and
      hydrate on start. Turns on automatically once `flutterfire configure` runs
      (see `FIREBASE_SETUP.md`).

### Phase 2 — Event organizer controls
- [x] Create battles (Stage 1 form): name, format (1v1 / 3v3 / custom NvN),
      prelim round length, default battle round length, bracket size
      (8/16/32/64), and per-stage bracket settings (each Top-N round's length
      and rounds-per-battle, e.g. best-of-3). Bracket stages derive from the
      size (Top 32 → 5 stages).
- [x] Main page is the battle list; each battle opens its own page. Creating a
      battle lives on its own screen ("New battle").
- [x] Battle page: summary of a battle's parameters, with top-right buttons to
      trigger prelims / bracket / public display and a **settings** button that
      opens the battle settings screen (`/battles/:battleId/settings`). Settings
      reuse the create form (`EventForm`) grouped into three glass cards — battle
      info (name, format, judges, display-background upload), prelims (round
      length), and bracket (round lengths, size, per-stage customization, guest
      spots). In settings the sections **lock as the battle progresses**: format
      once registrants are added, prelim length + judges once prelims start, and
      the whole bracket section once the bracket is finalized. Delete is in the
      overflow menu.
- [x] Import registrants per battle; crew battles require a `crew` column.
- [x] Editable registrant table (Stage 3/5): one row per dancer, with a crew
      view toggle for crew battles; add/edit/delete rows manually.
- [x] Generate & randomize prelim order (respecting style grouping). This
      wires the "Start Prelims" button — lands with Phase 3.
- [x] Wire "Start Top X" to seed the bracket — the battle page's bracket icon
      (and the prelims "Finalize Bracket" button) finalize the Top N cut and open
      the bracket manager. Both unlock once at least Top N entrants are scored.

### Phase 3 — Prelims progress
- [x] **Operator tools for prelims.** Reached via the "Start prelims" button on
      the battle page, which is only enabled once at least **8 registrants** are
      registered.
  - **Before "Start Prelims":** the operator can **randomize** the running
    order (repeatedly). The competing unit is the dancer for solo battles and
    the *crew* for crew battles.
    - **Style grouping (Footwork Frenzy):** randomization happens *within* each
      style. Styles are ordered by how many people signed up for them
      (descending); any style with **fewer than 4** representatives is folded
      into a **Misc** group, and Misc always sorts **last**.
  - **After "Start Prelims":** re-randomizing is locked, but the operator can
    still **drag** entrants to adjust the order.
  - **Command console (running):** shows the current battler, a **countdown
    timer** for the prelim round (start + reset), and a **Next** button — enabled
    once the timer runs out — to advance to the next entrant.
  - Prelim order + started/current state persist through the repository seam.
  - **Live across devices.** The prelims page stays in sync for everyone on it —
    order/randomization, who's up, the timer, scores and ranking. The repo
    exposes a `watchPrelimRuns()` snapshot stream (Firestore `.snapshots()`) that
    feeds the notifier, and every mutation is a **field-level merge** so
    concurrent edits by different operators don't clobber each other. The round
    **timer is a shared deadline** (`timerEndsAt`), not a local countdown, so all
    devices count down to the same instant.
- [x] Increment through the prelim order (now up / on deck / round timer).
- [x] Private judge scoring interface.
- [x] Prelim progress view for organizers.
- [x] **Judge scores → qualification ranking.** Scores collected during prelims
      drive who qualifies for the Top N bracket. Requirements (in priority order):
  1. **Collecting scores.** Scores can come from a future electronic scoring
     page *or* be entered manually. For now:
     - A single **overall** score per entrant, **0–10** by default. The range is
       configurable — `PrelimConfig.scoreMin`/`scoreMax` — so a future page can
       widen it or fold multiple **sub-scores** into an overall on another scale.
       *(Sub-scores → overall is still to build.)*
     - **Manual entry only** for now, **inside the prelims page**: once prelims
       have **started** and an entrant **has gone**, a score field appears on
       their row in the running order and can be typed in. *(Done — inline
       `_ScoreField`, validated against the configured range, write-through.)*
  2. **Ranking + cut + tie highlight.** From the scores we compute a ranking and
     draw the **Top N cut line**. Every entrant **tied across that cut** is
     highlighted **yellow**, so organizers know when a follow-up or a manual
     call is needed. All rows are **draggable** to break ties by hand, and the
     order is **saved dynamically** on every change. *(Done — "Ranking" panel on
     the running view; pure-Dart `PrelimRanking` does the ranking + tie logic;
     order persists via the repo seam.)*
  3. **Finalize Bracket** *(done — Phase 4).* The ranking panel's button locks
     the Top N in as the bracket's top cut, freezes prelim scores/placements,
     and routes to the bracket management page (also reachable from the battle
     page's bracket icon). The first open confirms the move.
- [x] **Print judge sheets**: a "Judge sheets" action on the prelims screen —
      left of "Start Prelims" before starting, and under the running console's
      position indicator once started — that generates a formatted, printable
      PDF once the prelim order has been randomized. The sheet lists the
      competitors in prelim order — battle name (or crew name for crew battles),
      a Style column when styles are present — with blank Score and Notes
      columns per row, a title, and a write-in "Judge" field filled in after
      printing. Pure-Dart `JudgeSheetPdfService` (uses the `pdf` package) builds
      the bytes; `printing` hands them to the OS print/preview dialog.

### Phase 4 — Bracket
- [x] Seed Top N from aggregated judge scores. `BracketSeeding` consumes the
      prelim qualification ranking (top cut, best first) and assigns ranking
      seeds; pure Dart, unit-tested (`bracket_seeding_test.dart`).
- [x] **Finalize Bracket**: locks the Top N cut in (prelim scores/placements
      freeze — score fields + ranking drag disable), unlocks bracket features,
      and routes to the bracket manager. The **first** open prompts to confirm
      the move. Reachable from the prelims ranking button and the battle page's
      bracket icon, both gated on ≥ Top N entrants scored. (`bracket_entry.dart`)
- [x] Judge reorder + re-randomize bracket. Pre-start manager UI toggles
      **Standard** seeding vs a **Random draw** and re-draws on demand; competitors
      keep their ranking seed, only their bracket placement changes.
- [x] Single-elimination bracket generation; advance winners. `Bracket`
      (`models/bracket.dart`) builds every round down to the final, handles byes,
      and propagates winners (best-of-N round wins) up the tree; unit-tested
      (`bracket_model_test.dart`). Persists + stays live across devices through
      the repository seam (`brackets/{battleId}`), like prelims.
- [x] Bracket progress view. The reusable **`BracketView`** widget renders the
      tree over an image/video background — black-tinted glass name cards, thick
      white connector lines, and an orange highlight on the live match — with
      pan/zoom. The bracket manager pairs it with a running console (competitors,
      shared timer, round-win controls, "Next battle"). The same widget will back
      the Phase 5 public display.
- [x] **Guest spots** — competitors who skip prelims and are dropped straight
      into the bracket at a chosen round. A `GuestSpot` (`models/config.dart`,
      on `BracketConfig.guestSpots`) carves out one slot at a stage `S`, which
      removes `topN / S` positions from the prelim field, so
      `BracketConfig.prelimTopN` is the resulting "top X from prelims" (Top 8
      with 2 guests at the Top 8 round → top 6; Top 32 with a guest in the final
      → top 16; etc.). Guests are flagged in the registrant table
      (`Registration.guestSpotId`, whole-crew for crew battles) which keeps them
      out of prelims (`RosterState.prelim*`) and, at seeding time, turns them
      into `GuestEntry`s. `BracketSeeding.build` reserves each guest a disjoint
      aligned subtree at its entry round (buddy-allocation, spread by seed
      priority) and empties the feeder beneath it; the full `topN` tree is still
      built so geometry stays regular, and `BracketView` hides the phantom
      feeder matches (`Bracket.phantomMatchIds`) and badges guests with a star.
      Set-up + derived readout live in `GuestSpotsEditor` on the battle page.
- [x] **Manual seeding adjustment** — an "Arrange" tap-to-swap mode in the
      pre-start bracket manager lets the operator manually exchange two round-0
      competitors' positions, on top of the Standard/Random draw. Only round-0
      occupants are eligible (`BracketSeeding.swap`, unit-tested) — a guest who
      skips round 0 can't be moved into it — and it's a no-op once "Start
      bracket" has been pressed (`BracketNotifier.swapCompetitors`). Byes
      re-resolve automatically if the swap changes who gets one. Seed numbers
      are no longer shown anywhere on `BracketView` (manager or public display)
      — they're an internal ranking detail, not something worth surfacing.

### Phase 6 — Judge scoring UI (digital judge sheets)
Judges enter prelim scores from their own devices (mobile web) instead of paper
sheets. Each judge scores privately; scores are averaged and drive the top cut.

- [x] **Judges on a battle.** A `Judge` (`models/judge.dart`, id + name) lives on
      the `Battle`. The create-battle form has a **Judges** section to add/name
      judges; each gets a distinct, unguessable scoring link so their scores stay
      separate. *(Judges can now be edited after creation on the battle settings
      screen — locked once prelims start.)*
- [x] **Access from the battle page.** A "Judge scoring" action opens a dialog
      listing every judge with their link — copy any link to hand out, or open a
      sheet once prelims have started.
- [x] **Per-judge scoring sheet** (`features/judge_scoring/`, route
      `/battles/:battleId/judge/:judgeId`, gated on prelims started). Mobile-first
      single column. The app-bar header is just the battle name; a plain
      instructions card greets the judge. Below it, the current battler's
      **battle name** with **chevrons** to move to the next/previous battler, then
      one row holding a **score field** (25% width, enforced **1–10**) and a
      **notes field** (75% width). Both **save as you type** — no save button.
      Per-judge scores + notes live on the prelim run (`PrelimRun.judgeScores`,
      `judgeId → entrantId → JudgeScore`) and sync live through the repo seam
      (field-level merge, so judges never clobber each other).
  - **"Currently up" cue.** The judge's cursor does **not** auto-follow the
    operator, but the battler the operator has up is ringed **orange** and tagged
    "(currently up)", so a judge can see who's on at a glance.
  - **Side review panel.** An end-drawer lists every battler in running order
    with this judge's score, to review progress, edit a score inline, or jump to
    a battler.
  - **Finalize scores.** Enabled once every battler is scored; locks the sheet
    (with a reopen-to-edit escape hatch).
- [x] **Collaborative top-cut ranking, adjusted by an operator.** Once every
      judge has finalized, the judge sheet gives way to a **view-only** summary
      of that judge's own scores and notes, ranked by their own score
      highest-first (`_MyFinalScores`, `judge_scoring_screen.dart`) — mobile
      drag-and-drop across judges' phones proved unreliable, so judges no longer
      drag a shared ranking themselves. Instead the operator's prelims "Ranking"
      panel (`_RankingView`, `prelims_screen.dart`) stays draggable through this
      stage, ordered by **average score across judges**
      (`PrelimRun.averageJudgeScores` → `PrelimRanking`) with the Top N cut and
      tie-at-cut highlight, and drags call `reorderJudgeRanking` (`judgeRanking`,
      synced live to every judge's read-only view). An optional **"Hide
      scores"** toggle on that panel masks the numeric scores (position stays
      visible) for events that don't want judges reading each other's numbers
      off the operator's screen. Judges deliberate in person; the operator
      presses **"Finalize Bracket"** to lock in the top cut
      (`openBracketManager`) — there's no per-judge digital lock-in step
      anymore. (The older per-judge `lockInBracket`/`cancelLockIn`/
      `lockedInJudges` consensus path still exists on `PrelimNotifier` and is
      unit-tested, but isn't wired to any UI.)
- [x] Fold per-judge averages into the qualification ranking / bracket seeding.
      `PrelimRun.effectiveScores()` blends the two score surfaces — an operator's
      typed override wins, otherwise the entrant's average across the judges'
      sheets — and that blended score drives the operator's ranking, the
      `bracketReady` gate and the seeded field.
- [x] **Keep judge scores separate from operator scores, side by side.** Judge
      scores stay distinct from the scores an operator types in the prelims
      running order. In the operator view, the (aggregated) judge average sits to
      the **left** of the operator's override field — the operator can still type
      a value there to override the judges (per `effectiveScores`, above).
- [x] **Per-judge score columns in the operator running order.** On the prelims
      running-order view, each judge gets their own column (pinned
      `_JudgeColumnsHeader` naming them); as scores come in, each dancer's row
      populates with **that judge's individual score** in their column — live, as
      the judges enter them on their sheets. Sits alongside the aggregated judge
      average, so the operator sees both the per-judge breakdown and the average.
- [x] **Show the judges' ranking in the operator view once they reach it.** Once
      every judge has finalized and moved into the collaborative ranking stage,
      the operator's prelims "Ranking" panel surfaces that shared, average-based
      ranking + top cut — **draggable**, so the operator sees and can adjust the
      same ranking the judges are agreeing on, not only the operator's own
      manual-score ranking.
- [x] **Show each judge their own score on their read-only summary.** Once every
      judge finalizes, each judge's sheet lists every dancer **they scored**,
      ordered by their own score highest-first, with their score and any notes
      they left — so a judge can recall and discuss their own calls even though
      they can no longer drag the shared ranking themselves.

### Phase 5 — Public display (UI polish)
- [x] **Display shell.** A read-only, full-screen `PublicDisplayScreen`
      (`features/public_display/`) launched from the battle page's "Public
      display" action — unlocked once prelims have started. No app bar, no back
      affordance, pop blocked (`PopScope`), so a page reload is the only exit. It
      derives its phase live from the shared prelim/bracket state and cross-fades
      between the three phases below.
- [x] Prelim public view: the current entrant's battle name front-and-centre on
      black-tinted glass, who's on deck / in the hole bottom-right, and the
      shared round timer. Advancing flows the cards up a slot (on deck → centre,
      old centre exits left, a fresh card rises from the bottom).
- [x] Prelim display customization (`PrelimConfig.upNextCorner`,
      `upNextSizeIncrease`, settings in `EventForm`'s Prelims card): the "Up
      Next" queue can sit bottom-right (header above the stack, soonest entrant
      on top) or top-right (mirrored — header below the stack, soonest entrant
      on the bottom), and its cards/text can scale up to 100% bigger.
- [x] Judging interlude: a "Judging / In Progress" card once every entrant has
      gone, while scores are tallied.
- [x] Bracket public view: the reusable `BracketView` takes over, centred and
      non-interactive (`IgnorePointer`), once the operator starts the bracket.
- [x] Display background image: the battle page's overview card carries an
      "Upload photo" action (three-column layout: parameters · rounds · actions)
      that uploads to Firebase Storage (`events/{eventId}/battle-backgrounds/`,
      via `state/image_storage.dart`) or accepts a public URL. The image is
      painted cover-fit behind the public display (prelims/judging/bracket) and
      the bracket canvas.
- [x] Display fullscreen + orientation. A top-right toggle (`util/fullscreen.dart`,
      web-only conditional import over the browser Fullscreen API) expands the
      display to fill a whole monitor with no browser chrome; it hides itself once
      expanded, leaving Esc as the only exit. On a portrait viewport the whole UI
      rotates a quarter turn so a phone/tablet turned on its side uses its long
      edge for the wide bracket.
- [ ] Bracket public view polish: zoom-in on the active battle, dancer images.

### Phase 7 — Reliability & access (live sync · resilience · auth)
Hardening for running the live event across mixed devices (desktop + mobile
web) on flaky venue wifi. Three streams:

**Sync — keep every device current (esp. mobile web).**
- [x] Auto-detect long-polling transport
      (`Settings.webExperimentalAutoDetectLongPolling`, `main.dart`) so mobile
      browsers/proxies that buffer Firestore's default streaming transport still
      receive live snapshots — the usual cause of "updates show on desktop web
      but not mobile web".
- [x] Reconnect on resume: the root widget is a `WidgetsBindingObserver`
      (`app.dart`) that calls `WctvRepository.resync()` (toggles the Firestore
      network) when the app returns to the foreground, so a backgrounded mobile
      tab whose realtime connection the browser froze catches up immediately.
- [ ] Make the roster/event/config live too — today `loadEvent`/`loadDancers`/
      `loadRegistrations` are one-shot `get()`s (only prelims + brackets stream),
      so config/roster edits don't propagate to other devices without a reload.
- [ ] Visible sync/connection indicator + surfaced write failures (writes
      currently `.catchError(debugPrint)` silently, so a stalled/rejected write
      is invisible to the operator).

**Resilience — work through poor/intermittent internet.**
- [x] Offline persistence on web (`Settings.persistenceEnabled`, off by default
      on web; `main.dart`): local cache serves reads offline, queues writes, and
      replays them on reconnect — including across the `resync()` toggle.
- [x] Bracket concurrent-edit safety: `matches` now serializes as a **map keyed
      by match id** (`bracket.dart`), and in-progress results are written as a
      **per-match merge** on only the keys that changed (`BracketNotifier
      ._writeMatches`, diffed via `BracketMatch`'s new value equality). Two
      operators advancing *different* battles — even both offline — land on
      different keys and both survive on reconnect, instead of one wholesale
      array write clobbering the other. (`start` also stopped rewriting the
      whole tree; it merges just its scalar fields.)
- [ ] Shared-timer clock skew: `timerEndsAt` is computed from the writing
      device's clock (`prelim_state.dart`); other devices count down on their
      own clocks. A prior server-time-offset attempt was reverted (it drifted
      out of sync in practice); the timer stays on the simpler shared-deadline
      approach. Revisit only if real-world clock skew proves to be a problem.

**Auth — only the right people touch the right things.**
Decided (revised): operators/admin → real **Firebase Auth accounts** (Google
popup **or** email + password), not a shared passcode. Judges/public → **no
login**; each judge's **unguessable capability link** (`judgeId` = uuid v4) is
the secret, and everyone is signed in *silently and anonymously* so the locked
rules still admit them. The anonymous session persists in browser storage, so a
judge's link survives refresh and close-and-reopen with zero friction on any
device. View-only for everyone else means: logged-out visitors reach only the
public display and their own judge sheet; all management tools require an
operator sign-in. (Operator-vs-judge *write* separation is enforced in-app, not
at the rules layer — private-event trust model.)
- [x] Lock `firestore.rules` (and `storage.rules`) from open `read, write: if
      true` to `if request.auth != null`.
- [x] Silent anonymous sign-in for judges/public (bootstrapped in `main.dart`
      before the first Firestore read); keep the unguessable per-judge links.
- [x] Operator accounts — Google + email/password (`state/auth_state.dart`,
      `features/auth/sign_in_screen.dart`); operator tools gated by a global
      router redirect, public display + judge sheets left open.

**Per-event access control (approved operators).**
A real operator account is no longer enough on its own — an operator must be
on the event's **approved-operators list** to reach the management tools. This
turns "any signed-in real account" into "the specific people invited to *this*
event", and gives us a record of who has access.
- [x] **Gate events behind an approved-operators list.** Each event carries an
      `operators` list (`models/operator.dart` → `EventOperator`, on `Event`).
      A signed-in real account only reaches the management tools when its email
      is on that list (`state/access_state.dart` → `accessProvider`); the router
      redirect now gates on approved-operator access, not merely on having a
      real account. **Bootstrapping:** if an event has *no* operators yet, the
      first real operator to sign in **claims** it as the owner.
- [x] **Save user ids on login + track who has access.** When an approved (or
      claiming) operator signs in, their Firebase Auth **uid**, display name and
      join time are recorded on their `EventOperator` entry
      (`EventNotifier.recordOperatorAccess`). The operators dialog
      (`features/operators/`) lists everyone with access — owner, joined, and
      still-invited — so an operator can see the full roster at a glance.
- [x] **Invite other operators to an event.** From the home screen's account
      menu, an approved operator can **invite** another operator by email (added
      to the list as "invited"; they gain access on their first sign-in and their
      uid is linked then) and **remove** operators (the owner can't be removed).
      Access to an event grants access to all of its battles.
- [x] **Only approved operators (or a judge link) see event info.** A signed-in
      real account that *isn't* approved for the event is sent to a
      `NoAccessScreen` instead of the tools; judges/public keep their existing
      open access to the public display and their own judge sheet only. (Still
      enforced **in-app** — the private-event trust model — so the rules-layer
      note below stands.)
- [ ] **Judge link should reach *only* the judge sheet.** Today a judge who
      opens their link can press Back to land on the battle overview and from
      there reach other judges' sheets and non-public tools. Lock a judge
      (anonymous, arrived via a judge link) to *only* their own judge sheet and
      genuinely public views (the display); block every other part of the battle
      they're judging. *(Requirement 4 — not yet built.)*
- [x] **Judge/display links should never route through auth.** A cold deep
      link to a judge sheet could briefly bounce through the sign-in screen: the
      `judge`/`display` route redirects checked `run.started` before letting the
      screen render, and on a fresh device Firestore's cache-then-server replay
      can emit an empty first snapshot, so `hydrationProvider.prelims` flipped
      true (and `_judgeReady`/`_displayReady` false) before the real data
      arrived — redirecting to the non-public battle overview, which then
      tripped the top-level auth gate into `/signin`. A second click worked
      because the local cache was warm by then. Fixed by dropping the
      readiness redirect from both routes entirely — `JudgeScoringScreen` and
      `PublicDisplayScreen` already render their own "waiting for prelims to
      start" state for exactly this case, so the routes are now
      unconditionally public (`app_router.dart`). Same cold-load window also
      made `JudgeScoringScreen` flash "Battle not found" while the event doc
      was still loading; it now checks `hydrationProvider.event` and shows a
      spinner instead until that initial load resolves.
- [ ] Follow-ups: optional custom-claim operator
      role for a hard write split at the rules layer (would also let the
      approved-operators gate be enforced at the database layer, not just
      in-app); storage/authorized-domain review before a public launch.

---

## 4. Open questions / decisions to confirm
- Exact CSV column headers from the registration platform (import service is
  built with configurable column mapping until we lock these in).
- Scoring model: started as a single overall 0–10 score per entrant (manual
  entry). Still open: multiple sub-scores → overall, and per-judge scores from
  the future electronic scoring page (today's manual score is a single value,
  not attributed to a judge).
- Bracket seeding: standard 1-vs-N seeding, or randomized within score tiers?
- Auth: do judges/organizers need accounts, or is this run on trusted devices?

---

## 5. Local setup
```bash
flutter pub get
flutter test          # runs pure-Dart unit tests
flutter run           # runs the app (in-memory data by default)
```
Firebase is optional during early development — see `CLAUDE.md` for wiring it up.
