# Admin Tooling Review — Roster Builders, Schedule Generators, Game-Day Ops

**Date:** July 17, 2026
**Context:** Fall 2026 is the board's first real use of these tools. Five parallel review passes: season roster builder, tournament roster builder, season schedule generator, tournament schedule generator, game-day/display surfaces. Critical claims spot-verified against source.
**Companions:** `INVITATION_FLOW_ANALYSIS.md`, `ORDER_FLOW_EDGE_CASES.md`, `SEASON_TOURNAMENT_DRIFT_AUDIT.md`.

---

## Read this first: three decisions the board needs before fall

**1. The tournament schedule builder cannot run a pool/bracket tournament — RESOLVED: as designed.**
`Tournament` offers formats round_robin / bracket / pool_play, but the generator never reads the field: it always produces one flat, skill-balanced round-robin on a single day (`end_date` is never used). The `Game` entity has no score fields, so results/standings/advancement can't be recorded.

> **Andrew's response (July 2026):** This is "as designed". The tournament schedule builder was purpose built to create the one day SLO Friendly tournament on Saturday of Labor day weekend at Damon Garcia sports complex. The SLO Friendly does not have brackets, we do not keep score, there are no standings. This is simply a fun friendly day of soccer.

**✅ Scope documented in code + UI (July 17):** scope docblocks added to `TournamentScheduleGeneratorService` and `TournamentScheduleBuilderForm`; an info notice now renders at the top of the schedule builder ("single-day round-robin, SLO Friendly format; brackets/pools/multi-day/scores not supported"); the Tournament `format` field's bracket/pool_play options are labeled "(not implemented)" with a field description saying only Round Robin is supported. Residual (fine as-is): if a future year wants brackets, pools, scores, or multi-day, that's new feature work — the code now says so at every entry point.

**2. "Regenerate" is destructive on both schedule builders — RESOLVED: as designed, with operating rules.**
Both generators hard-delete every Game entity and recreate (`clearSchedule()`).

> **Andrew's response (July 2026):** By design. The board meets the week before the season/tournament starts, generates and discards candidate schedules until they like one, then publishes. Schedules rarely change afterward. Snapshots exist to save candidate schedules and restore the preferred one.

That workflow is exactly what the code supports — snapshot/regenerate/restore works reliably **as long as the schedule setup (teams, slots, fields, dates) stays fixed during the meeting**, because restore is a matchup overlay: it repaints existing games at matching positions, never creates or deletes games.

**Operating rules for the board meeting:** finalize teams/slots/fields/dates BEFORE generating candidates; don't use "Clear All Games" mid-meeting (recovery if someone does: regenerate anything, then restore the snapshot over it); don't regenerate after week 1 of a live season (statuses/cancellations/credit flags on played games are destroyed and can re-issue credits if re-cancelled).

**✅ Hardened July 17** (both `restoreSnapshot()` implementations): restoring into an empty grid now returns a clear "generate first, then restore" error instead of a false "0 games updated" success; partial restores now WARN with the count of snapshot entries that had no matching slot (setup changed since the snapshot) instead of silently skipping them. By-design comments added at `clearSchedule()` in both services so future reviews don't re-flag this.

**Still open (relevant even for this workflow):** after a season regenerate the "VISIBLE" indicator stays on while all new games are `published=FALSE` (players see an empty schedule until re-publish — S2); the tournament public view ignores per-game `published`, so regenerating a visible tournament replaces the live schedule instantly (TS2 — keep `schedule_visible` off while iterating; now noted in the clearSchedule docblock).

**3. Game-day cancellations don't reach everyone, and don't work for tournaments at all.**
`GameStatusForm` is season-keyed: for a tournament-only date it previews "0 players will be notified," sends nothing, issues no credits — while reporting success. And for seasons, players with notification preference "none" (or "text" with no phone) silently get no cancellation notice (ORDER_FLOW S1's most dangerous instance). The 3 pm reminder also only fires if a cron tick lands in the 15:00–15:04 window — one delayed run and it never sends that day.

---

## Season Roster Builder

Model note: season rosters live **only** on `Registration.team`; `Team.players` is never written by this flow, and group "consolidation" is display-only.

| # | Sev | Finding |
|---|---|---|
| R1 | ~~HIGH~~ ✅ FIXED July 17 | Admin Teams list (`TeamListBuilder`) counted `Team.players` → showed **"0 players" on every season team** even after a complete build. Fixed: season mode now counts registrations (`team = X`, `status IN paid/active` — cancelled players drop out automatically); tournament mode keeps `Team.players` (its true source). Deliberately did NOT dual-write `Team.players` from the season flow — that pattern caused the tournament ghost-member desyncs (T2/T5/D7). |
| R2 | HIGH | Groups larger than `target_size + 2` can never be placed: "Suggest Rosters" silently drops the whole friend-group to unassigned while the toast reports success. Partial-success errors are never displayed. |
| R3 | HIGH | Coed "≥1 woman per team" is a scoring bonus, not a constraint — teams can be finalized with zero women; only signal is a CSS class. Needs a hard pass + validation summary. |
| R4 | HIGH | `mergeToGroup` doesn't filter registration status: cancelled regs count toward group max ("Group is at maximum size" on a group with room) and a cancelled reg can be picked as group *manager*. Add `status='paid'` to the controller's group queries (the balancer's read path already filters correctly). |
| R5 | MED | Legacy `save` endpoint clears **every** registration's team then rewrites from the posting tab — a stale tab or old bookmark wipes concurrent work. UI no longer uses it; remove or transaction+confirm it. No concurrency guard on `move` either (last-write-wins between two board members). |
| R6 | MED | Group cohesion is visual: a player accepting an invite *after* rosters are built (or an individual team change) can sit on a different real team than their group shows — builder displays them together, printed roster splits them. Persist consolidation or call `syncGroupToRoster()` after changes. |
| R7 | MED | `groups_locked` is enforced everywhere player-facing but ignored by every roster-builder endpoint — merge/create group work after lock. Decide if that's an intended admin override. |
| R8 | MED | Late registrants land silently in the workbench; no badge, no alert. And no player notification exists on roster publish — if the board expects "you're on Team X" emails, that feature doesn't exist. |
| R9 | LOW | Goalie badge reads registration field only, balancer also reads profile field → admin can't see why a team "refuses another goalie." |
| R10 | LOW | `clearTeamsForSeason` deletes Teams without nulling `Registration.team` → dangling refs if ever invoked post-assignment. |

## Tournament Roster Builder

Model note: displays from `Registration.team`, enforces capacity from `Team.players` — the two known-desynced sources fight invisibly.

| # | Sev | Finding |
|---|---|---|
| T1 | CRIT | Capacity unenforced on multi-drags: one `isFull()` pre-check, then every player added with `force=TRUE` — dragging a 5-player group onto a team with 1 slot adds all 5 (roster shows 19/16, no error). |
| T2 | CRIT | Ghost members (cancelled regs never removed from `Team.players`) inflate `isFull()` → admin drags onto a visibly half-empty team and gets "Team is full" with no explanation. Capacity should count what's displayed, or reconcile ghosts on load. |
| T3 | CRIT | Move/merge/create load registrations with **no status filter** + `reset()` — a player with cancelled+active regs can have the cancelled one team-assigned; card snaps back, unreproducible. Match the display query's `status IN (paid,active)`. |
| T4 | HIGH | Pool flag is write-only in this UI: dragging a pool player to Unassigned bounces them back to Pool (`ccsoccer_pool` deliberately never cleared, while the service method it bypasses *does* clear it). |
| T5 | HIGH | Moving a ghost doesn't clean the old team's `Team.players` (old team read from `reg.team`, NULL for ghosts) — desync compounds with every move. Reconcile against all teams in the tournament. |
| T6 | ~~HIGH~~ ✅ FIXED July 17 | One malformed DOB (`new \DateTime($dob)` uncaught in buildForm) **500s the entire builder** for the whole tournament. Fixed: try/catch keeps the existing age-30 default and logs a watchdog warning identifying the player whose DOB needs fixing. Sweep confirmed every other DOB parse in the module (checkUserAge, RegistrationSelectForm, TeamBalancerService) was already guarded — this was the one drifted site. |
| T7 | MED | Admin placement uses `isFull()` not `isFullIncludingPending()` — can fill slots reserved by outstanding captain invitations → overflow when invitees accept. |
| T8 | MED | Builder never touches invitations: placing an invited player leaves their pending invite live (counted as confirmed **and** pending); removing one leaves a dangling invite. |
| T9 | MED | Co-captains unprotected in the service (`removePlayerFromTeam` guards captain only) — a desynced co-captain rendered draggable leaves `Team.co_captain` pointing off-roster. |
| T10 | MED | No team-status guard — players can be dropped onto withdrawn/DQ'd teams. |
| T11 | LOW | Synthetic `captain_<id>` group ids break Shift+drag merge ("Target group not found"); no concurrency guard; no notifications on placement; synthetic skill/age defaults (3 / 30) silently skew the average headers the board balances by. |

## Season Schedule Builder

| # | Sev | Finding |
|---|---|---|
| S1 | CRIT | Regenerate destroys all game history (see decision #2). Only guard is a JS confirm. `fill_only` plumbing exists but is never exposed in the UI — exposing it is most of the fix. |
| S2 | HIGH | Regenerate leaves season "VISIBLE" with all games unpublished — header says visible, players see nothing, no prompt to republish. |
| S3 | ~~HIGH~~ RESOLVED | **Jersey colors — documentation bug, not a code bug.** Original finding: `getTeamSchedule()` says home=Red while the Game entity docblocks said home=white. Investigation with Andrew (July 17): the league has no home/away mentality — the convention is **Red vs White**, and players read it off the grid: **red on top, white on bottom**. All three player-facing surfaces (grid shading, next-game banner, iCal feed) consistently implement home_team=Red/top — only the entity docblocks were backwards. The originally proposed code flip would have *broken* a consistent system. Fixed instead: Game.php docblocks/descriptions corrected (home_team = the Red team, away_team = the White team, with a do-not-"fix" warning comment), and the one player-facing home/away verbiage (`exportPdf` column headers) renamed to "Red Team"/"White Team". |
| S4 | ⏸ ON HOLD — Andrew deciding | Drag-**swap** bypasses validation (`swapTeamsInWeek` has none) — can produce Team X vs Team X or a same-night double-booking, saved silently and publishable. **Discussion July 17:** partly by design — mid-rearrangement passes through broken states, and the workbench was built as the staging area specifically to avoid the hard block that direct moves have (`validateMove`). Blocking is NOT wanted. Proposed (not implemented, awaiting Andrew): `findScheduleConflicts()` on both generators + a persistent non-blocking warning banner in the builders (updated per drag, shown on load) + a single confirm at publish if conflicts exist. **No changes made.** |
| S5 | HIGH | Snapshot restore only updates existing games: useless after "Clear All Games" ("0 games updated"), silently drops rows whose week/slot/field key no longer matches. |
| S6 | MED | Round-robin is greedy, not a true rotation — over a 10-week season some pairs meet 3×, others 1×, with no report. Fine for rec if the board knows; circle-method fix if not. |
| S7 | ~~MED~~ RESOLVED | **Red/White assignment is a coin flip per game — as designed** (Andrew, July 17). No home/away mentality in this league; the flip just decides who wears red (top row) vs white (bottom row) that night, and color repeats across weeks don't matter. No balance tracking needed. |
| S8 | MED | Deleted team → "Unknown" opponents plus a blocking "Team count mismatch" on regenerate; `moveTeam` can leave a published game with a NULL side ("TBD" opponent). Warn on publish for incomplete games. |
| S9 | LOW | No concurrency/double-submit guard on generate; no player notification on publish (by design? players must check the site). |

## Tournament Schedule Builder

| # | Sev | Finding |
|---|---|---|
| TS1 | CRIT | No pool/bracket support, no score fields, single-day only (decision #1 — VERIFIED). |
| TS2 | HIGH | Regenerate hard-deletes and **goes public instantly** (public view checks `tournament.schedule_visible` only, ignores per-game `published`); no draft state, no partial regeneration. |
| TS3 | ⏸ ON HOLD — Andrew deciding | Manual move/swap has zero validation: self-matchups and same-slot double-bookings save silently. Same discussion and proposed warn-don't-block design as S4 (July 17); no changes made pending Andrew's decision. (TS1 bracket concern no longer applies — single-day round-robin is the designed scope.) |
| TS4 | HIGH | No transaction/lock on generate — two admins (or generate + manual edit concurrently) interleave delete/create; grid dedupes by slot so duplicates are invisible while validation warnings go weird. |
| TS5 | MED | Infeasible configs under-schedule silently: 5 teams × 3 games is mathematically impossible, generator floors it, returns success; dropped matchups leave blank shells with only a buried warning. |
| TS6 | MED | Publish/change notifies nobody (captains expected to poll). |
| TS7 | LOW | Slot-distribution scoring hardcodes an 8-slot day; two divergent grid renderers (admin vs public) will drift; snapshots are position-keyed and silently lossy. |

## Game-Day Ops & Player Surfaces

| # | Sev | Finding |
|---|---|---|
| G1 | HIGH | Cancellation notices skipped for preference `none` / `text`-without-phone users (decision #3). Cancellations should bypass preference. |
| G2 | HIGH | Tournament dates: cancel flow finds 0 players, sends nothing, credits nothing, reports success (decision #3). |
| G3 | HIGH | 3 pm reminder: fires only if cron lands in 15:00–15:04; and the config read-modify-write dedupe has a race → possible double-blast from concurrent cron. Gate on "not sent yet AND past 15:00" + a lock. |
| G4 | HIGH | Public per-season iCal (`exportIcal`) emits cancelled games as `STATUS:CONFIRMED` (no status filter); the newer per-user feed does it right — subscribed calendars contradict the site. |
| G5 | HIGH | Printer-friendly PDF shows cancelled games as normal (no status filter, also no `published` filter — unpublished games leak into print). |
| G6 | MED | Legacy Views iCal feed at `schedule-feed/%/ical` is public and maps summary/description/UID to the numeric game **id** — garbage events. Delete or fix the display. |
| G7 | MED | GameStatusForm: only next 4 dates editable; date's cancelled-state derived from the *first* game only (mixed-status dates misrepresented); cancel hits **all leagues** sharing the date with no single-league option; `notification_log` grows unbounded inside config (bloats config sync). |
| G8 | LOW | Up to 50 notifications sent synchronously inside the admin's submit (timeout risk on big seasons); iCal text not RFC-escaped (a comma in a team name corrupts the event). |

---

## What checked out clean (verified, no action)

Season balancer reads only `paid` registrations and builds groups from `Registration.group_id` (immune to the orphaned-invitation data); groups move atomically in suggest and drag; skill/age resolution consistent with sane defaults; all admin endpoints permission-checked defense-in-depth with CSRF on POSTs; destructive UI buttons confirm. Tournament round-robin core (circle method) is mathematically correct including odd-team byes; field assignment collision-free within generation; season generator prevents double-booking *within generation* (the gaps are in manual edit paths). Timezone handling is consistent everywhere (raw stored datetimes, explicit America/Los_Angeles in feeds, DST-safe week math). Per-user iCal feed is solid (HMAC token, timing-safe compare, correct CANCELLED handling). Cancel/uncancel credit logic is idempotent (`credits_applied` guard + audit entity). Team renames are safe everywhere.

## Suggested fix order before fall

1. **Board decisions this week:** tournament format plan (TS1), jersey convention (S3), whether cancellations bypass notification preferences (G1).
2. **Small high-yield code fixes:** ~~S3 jersey flip~~ (resolved — docs corrected, code was right) · T6 DOB try/catch · R1 team-count source · G4/G5 status filters in iCal+PDF · S4/TS3 swap validation · G3 cron window.
3. **Pre-season data hygiene:** reconcile `Team.players` vs `Registration.team` ghosts (T2/T5 root), which also derisks the invitation-PR data repair.
4. **Batch with the invitation PR:** R4/T3 status filters, T7/T8 invitation-aware placement — same root causes as that PR's scope.
5. **Regenerate-safety rework** (S1/S2/TS2): snapshot-before-clear + preserve non-`scheduled` games + republish prompt. Biggest single derisk for mid-season operations.
6. Until #5 lands: **operating rule — snapshot before any regenerate; after teams are final and week 1 starts, edit games individually, never regenerate.**
