# Tournament Roster Builder — show women in red, add a per-team women count

**Date:** July 29, 2026
**Status:** **IMPLEMENTED on the working tree — not linted, not tested, not committed.** No git write
commands run. Needs `ddev php -l` + a LOCAL click-through on Andrew's machine (§4) before commit.
**Not blocked after all:** the July 28 in-flight work had already landed on `main` when this was
written — `fix/team_display` merged as PR #119, the two roster-builder controller changes as `b457883`,
the audit docs as PR #120. Working tree was clean of code changes.
**Andrew's scope decisions (July 29):** build §2.1, §2.2 and the `prefers_goalie` half.
**Declined: the per-team women count (§2.3), the `needs-women` warning, and the header total (§2.4).**
Colour alone is the requirement — the director drags a woman out of Unassigned and counts red circles.
**Ask (Andrew):** the season roster builder colour-codes men blue / women red / goalie green. The
tournament roster builder is all blue. SLO Friendly is coed, so the tournament director needs to see
how many women are on each team in order to distribute free agents evenly.
**Scope:** `TournamentRosterBuilderForm.php`, `css/roster-builder.css`,
`css/tournament-roster-builder.css`, `js/tournament-roster-builder.js`. No entity or data changes.

---

## 1. Headline: the colour coding is already built. It is broken by a case-sensitive comparison.

This is **not** a missing-feature request — every piece already exists and is already wired up:

| Piece | Where | State |
|---|---|---|
| `player-female` / `player-male` class on the card | `TournamentRosterBuilderForm::buildPlayerCard()` 549 | ✅ emitted |
| `data-gender` attribute on the card | same, 599 | ✅ emitted |
| `.player-card.player-female .skill-badge { background: var(--color-accent) }` | `css/roster-builder.css` 324-327 | ✅ exists |
| That stylesheet loaded on the tournament page | `ccsoccer.libraries.yml` 233-238 — `tournament-roster-builder` pulls in **both** `roster-builder.css` and `tournament-roster-builder.css` | ✅ loaded |

The one thing that fails is the `is_woman` determination, at
`TournamentRosterBuilderForm.php:366-370`:

```php
$is_woman = FALSE;
if ($player->hasField('field_gender') && !$player->get('field_gender')->isEmpty()) {
  $gender = $player->get('field_gender')->value;
  $is_woman = ($gender === 'female');      // ← strict, lower-case, single literal
}
```

**`field_gender`'s allowed values are capitalised.** From
`config/sync/field.storage.user.field_gender.yml`:

```yaml
allowed_values:
  - { value: Male,   label: Male }
  - { value: Female, label: Female }
  - { value: Other,  label: Other }
  - { value: 'Prefer not to say', label: 'Prefer not to say' }
```

`'Female' === 'female'` is FALSE. **Every woman on the tournament board is classed `player-male` and
renders blue.** The field is a `list_string` with an options widget and is made required at
registration (`ccsoccer.module:1029-1030, 1124-1125, 1245-1247`), so anything entered through the UI
is capitalised — which is to say, all of PROD.

### The season board is correct because it does not use this code

The season builder gets `is_woman` from `TeamBalancerService::isPlayerWoman()` (`:574-580`):

```php
$gender = strtolower($player->get('field_gender')->value);
return in_array($gender, ['female', 'woman', 'f', 'w']);
```

`strtolower` plus a value list. Handles `Female`, `female`, `F`, `woman`. **Two implementations of one
rule, and the newer copy is the broken one** — the same twin-code-path shape that every finding in
`SEASON_TOURNAMENT_DRIFT_AUDIT.md` reduces to. `buildPlayerCard()`'s own comment even says *"Gender-based
styling (same as season roster builder)"*; the styling is the same, the data feeding it is not.

### ⚠ This bug is invisible on LOCAL if you test with seeded data

`CcsoccerCommands.php:1870` — the dev seeder writes **lower-case**:

```php
$user->set('field_gender', $is_woman ? 'female' : 'male');
```

So on a seeded LOCAL the `=== 'female'` comparison **works**, and the tournament board shows women in
red. On PROD, where values came from the registration form or the D7 migration
(`MigrateCommands.php:440` passes D7's value straight through), it fails.

**Test with capitalised values or a real migrated user, or this will look fixed when it is not, in both
directions.** Note also that three display sites `ucfirst()` the value before printing
(`SeasonController:568`, `TournamentController:960`, `PlayerAdminController:252`), which implies
someone expected lower case somewhere — so PROD may hold a genuine mix. The `strtolower` + list
approach is correct for a mixed column; a second exact-match literal would not be.

---

## 2. Recommended fix

### 2.1 Fix `is_woman` — one shared implementation, not a third copy

`isPlayerWoman()` is `protected` on `TeamBalancerService`, so no other class can call it — the same
access problem CF4 hit with `pickLiveRegistration()`. Two options:

**Recommended: make it `public` on `TeamBalancerService`** and call it from
`TournamentRosterBuilderForm`. It is a stateless predicate about a user, it already lives in an
injected service, and `TournamentRosterBuilderForm` can take the service in its constructor. Smallest
diff, one implementation, no new file.

```php
// TournamentRosterBuilderForm.php, replacing 366-370
$is_woman = $this->teamBalancer->isPlayerWoman($player);
```

Check the constructor first — if the form does not already inject `ccsoccer.team_balancer`, add it
(`ccsoccer.services.yml`) rather than reaching for `\Drupal::service()`.

**Alternative: a `GenderTrait`** with `isPlayerWoman()`, used by both. Preferable if a third caller
appears, or if pulling the whole balancer service into a form for one predicate feels heavy. Either is
fine; do not write a third inline comparison.

**Also worth doing in the same commit** — `prefers_goalie`. The tournament form reads only
`$reg->get('prefers_goalie')` (373-376), while `TeamBalancerService::getPlayerPrefersGoalie()`
(`:551-560`) falls back to the **user's** `field_prefers_goalie` when the registration field is empty.
So the green `G` badge under-reports on the tournament board for the same structural reason. Same fix
shape: call the shared method. Andrew did not ask for this, so treat it as optional — but it is two
lines and it is the identical bug.

### 2.2 Captain badge — keep it gold, but do not lose the captain's gender

**Agreed: leave the captain gold with `C` regardless of gender.** Captaincy is the more operationally
important fact, and captains are the fixed points the board builds teams around.

**But there is a consequence worth naming.** `buildPlayerCard()` renders the captain badge *instead of*
the skill badge (623-626):

```php
'#markup' => (($is_captain || $is_co_captain) ? $captain_indicator : $skill_badge) . …
```

The skill badge is the **only** thing the gender colour is applied to. So a captain has no coloured
badge, and a woman captain is indistinguishable from a man captain. On the current SLO Friendly board
that is not a corner case — ASS FC's captain is Haley Raymer, and Gallos FC is a captain and nobody
else (1/16). If the eye-count skips captains, the women count is wrong by one on several teams.

**Recommendation: add a card-level gender indicator that is independent of the badge**, so it works for
captains, co-captains and pool players alike. A left border stripe is the least intrusive option and
does not compete with the existing yellow captain background or the group borders:

```css
/* css/tournament-roster-builder.css */
.tournament-roster-builder-content .player-card.player-female,
.ccsoccer-pool-section .player-card.player-female {
  border-left: 4px solid var(--color-female);
}

.tournament-roster-builder-content .player-card.player-male,
.ccsoccer-pool-section .player-card.player-male {
  border-left: 4px solid var(--color-male);
}
```

Scope it to the tournament wrappers so the season board is untouched — Andrew did not ask to change a
screen that already works, and the season cards carry group-membership borders that a stripe could
muddy.

**Two wrappers, not one — found during implementation.** `$form['pool_section']` is a **sibling** of
`$form['content']` in the form array (233 vs 163), not a descendant, so
`.tournament-roster-builder-content .player-card` does **not** reach CCSoccer Pool cards. Both wrappers
have to be named or pool players get no stripe — and pool players get dragged onto teams too.

**Use `--color-female` (`#e74c3c`), not `--color-accent`.** Both are `#e74c3c` today
(`css/ccsoccer-tokens.css` 24, 101), but `--color-accent` is the brand red used site-wide and
`--color-female` exists precisely for this. The season CSS reaching for `--color-accent` at line 325 is
a small pre-existing inconsistency; do not copy it. Leave the season file alone in this commit.

### 2.3 DECLINED — per-team women count, and the `needs-women` warning

**Andrew, July 29: not needed.** Player count and average age are enough in the footer. The colour *is*
the mechanism — drag a woman out of Unassigned and count the red circles in the column. A number would
duplicate what the eye already does and cost footer space.

Also declined: a `needs-women` highlight. The season board flags a team with zero women
(`roster-builder.css:175-178`, set in JS at 949-952) because a coed *season* team has a composition rule
(R3, ≥1 woman). SLO Friendly has no such rule and teams are captain-built, so there is nothing to warn
about.

Kept as a record in case it is ever wanted. If it is, the one non-obvious trap:
`css/roster-builder.css:273-275` hides `.women-count` and `.goalie-count` and **is not scoped to the
season page**, so a new tournament `.women-count` would be invisible until that rule is narrowed. That
would have been the first thing to go wrong.

### 2.4 DECLINED — women total in the header bar

**Andrew, July 29: not needed.** Same reasoning as §2.3.

---

## 3. What was changed

| File | Change |
|---|---|
| `src/Service/TeamBalancerService.php` | `isPlayerWoman()` and `getPlayerPrefersGoalie()` `protected` → `public`, with docblocks recording why and warning against re-inlining. No logic touched. |
| `src/Form/TournamentRosterBuilderForm.php` | `use` + `$teamBalancer` property + constructor/`create()` injection of `ccsoccer.team_balancer`; the two inline checks at 380-391 replaced by calls to the shared predicates |
| `css/tournament-roster-builder.css` | gender stripe on `.player-card.player-female` / `.player-male`, scoped to `.tournament-roster-builder-content` **and** `.ccsoccer-pool-section` |

**`ccsoccer.services.yml` needed no change** — the form is a `FormBase` using `create(ContainerInterface)`,
not a service definition, so injection is entirely inside the class. `ccsoccer.team_balancer` already
exists (`ccsoccer.services.yml:14-16`).

**`js/tournament-roster-builder.js` needed no change** — nothing in the JS reads gender, and the stripe
is CSS on a class the PHP already emitted. Cards created client-side inherit it automatically.

Pre-edit archives written: `archive/TeamBalancerService_2026-07-29.php`,
`archive/TournamentRosterBuilderForm_2026-07-29.php`,
`archive/tournament-roster-builder_2026-07-29.css` (all three filenames confirmed free first).

Code-only. No config export, no update hook, no data change. Deploy is `drush cr`.

**Verification done in the sandbox** (no PHP available, so this is not a substitute for `php -l`):
brace and paren counts balanced in both PHP files, and the deltas versus the archived originals match
the diff exactly (`-2` brace pairs in the form = the two removed `if` blocks). CSS braces balanced,
comments terminated. **`ddev php -l` on both files is still required before commit.**

**No collision with anything in flight.** The working tree was clean when this was written. CF1–CF11
touch `GroupController`, `GroupInvitationsForm`, the two cancel forms, `TournamentTeamManager`,
`TournamentRosterBuilder*Controller*` and `OrderCompleteSubscriber` — none of which this change goes
near. Note `TournamentRosterBuilderController.php` (CF5's file) is a **different file** from
`TournamentRosterBuilderForm.php` (this change); the names are one word apart, so read the path before
staging.

**One shared-code consequence to be aware of:** `TeamBalancerService` is now called from a second
screen, so a future change to either predicate affects the season roster builder and Suggest Rosters as
well. That is the point of de-duplicating, but it means test step 8 below (season board unchanged) is
not optional.

---

## 4. Test on LOCAL

**Set up capitalised data first** — with seeded lower-case values this bug does not reproduce:

```sql
SELECT field_gender_value, COUNT(*) FROM user__field_gender GROUP BY field_gender_value;
```

Expect a mix, or all `Male`/`Female`. If everything is lower case, hand-edit a few users to `Female`
via the profile form (not SQL, so the widget's allowed values apply) and re-test.

1. **Women render red.** A non-captain woman on a tournament team shows a red skill badge. Same player
   on the season board still red. A man is blue in both.
2. **Capitalisation.** One user set to `Female` and one to `female` (if the column really is mixed) both
   render red. That is the regression the old code failed.
3. **`Other` / `Prefer not to say`** render blue, i.e. as men. **Worth a conscious nod from Andrew:**
   the colour means "self-identified woman", so a woman who chose *Prefer not to say* shows blue. The
   season board has always behaved this way, so nothing changes — but it becomes visible on a screen
   that is now being used to balance the gender mix.
4. **Captains.** Gold `C` badge unchanged — no skill badge, as before. A woman captain shows the **red
   left stripe**; a man captain blue. Co-captain `CC` likewise. Captain cards still not draggable, and
   the gold captain-group border is unchanged.
5. **The stripe reaches every container** — `buildPlayerCard()` serves four (workbench 181, pool 243,
   team columns 518, captain groups 775). Check a woman in each: Unassigned, CCSoccer Pool, on a team,
   and inside a captain's group. **Pool is the one at risk** — it is a sibling wrapper, hence the second
   selector.
6. **The goalie half.** A player with `prefers_goalie` empty on the registration but
   `field_prefers_goalie` set on their profile now shows the green `G` on the tournament board, where
   before they did not. Confirm one such player, and confirm someone with neither still has no badge.
7. **Drag still works.** Drag a woman from Unassigned to a team and back; stripe follows the card, no
   layout jump beyond the 3px the wider border adds. Multi-drag a group containing a woman. Shift+drag
   onto a group. The `group-highlight` drag state briefly outranks the stripe on three sides but not the
   left — cosmetic, expected, mention only if it looks wrong.
8. **Season board untouched — not optional**, since `TeamBalancerService` is now shared. Screenshot
   `/admin/ccsoccer/season/48/roster` before and after and diff by eye: men blue, women red, `G` green,
   **no left stripe** (the new CSS is tournament-scoped), group borders unchanged, `needs-women`
   highlight still fires on a team with no women. Then click **Suggest Rosters** and confirm teams still
   balance for gender — it calls the same two predicates.
9. **A user with `field_gender` empty** (possible on migrated rows) → blue, no crash, no log noise.

---

## 5. Noticed while reading, not part of this fix

Recorded so they are not rediscovered. None of these block the change.

- **~15 CSS rules in `tournament-roster-builder.css` are dead.** Every
  `.tournament-player-card…` selector (lines 83, 101, 105, 109, 115, 121, 155, 257, 347, 352, 362, 401,
  426, 445, 460) targets a class **nothing emits** — `buildPlayerCard()` builds
  `['player-card', $gender_class, 'skill-' . $skill]`. The tournament board is styled entirely by
  `roster-builder.css`, which is why the gender rule was already loaded and one PHP line was all that
  stood between Andrew and red badges. **Practical consequence: do not write the new CSS against
  `.tournament-player-card` — it will silently do nothing.** Worth a separate cleanup pass to either
  rename the emitted class or delete the block; leaving it invites exactly this mistake again.
- **`updateTeamStats()` overwrites "Admin Skill" with a computed player average.** PHP renders
  `admin_skill` — a manually set team-level value, or `-` when unset (689) — into `.avg-skill`. The JS
  then writes `(totalSkill / count)` into that same span on the first drag (844-849). So the number
  silently changes meaning mid-session, and a team showing `-` acquires a number that is not its admin
  skill. Pre-existing, unrelated to gender, but it lives four lines from the code this change edits.
  Either give the computed average its own span or stop overwriting.
- **Every tournament player shows skill 3.** `3` is `buildPlayerCard()`'s default when
  `self_score` is empty on the registration (552, and 340-343 in the data build). All-3 across 67
  players suggests tournament checkout does not collect `self_score` the way season checkout does, so
  the skill badge currently carries no information on this board. Worth confirming before relying on
  skill for tournament balancing — and if it is genuinely never collected, the badge may be better
  replaced by something that is.
