Skip to content

feat(bracket+pool): venue/time footer + pool-join + manage-page fixes#302

Merged
0800tim merged 12 commits into
mainfrom
feat/match-card-venue-footer
Jun 6, 2026
Merged

feat(bracket+pool): venue/time footer + pool-join + manage-page fixes#302
0800tim merged 12 commits into
mainfrom
feat/match-card-venue-footer

Conversation

@0800tim

@0800tim 0800tim commented Jun 6, 2026

Copy link
Copy Markdown
Owner

Combined PR for today's work. All changes have been live-tested on vtorn-dev.aiva.nz and approved.

Bracket / match overlay

  • Replace the unused 'Add score' toggle and top-right ... link on every match row (group + knockout) with a single neutral charcoal lozenge showing the user's local kickoff date/time and a gold info icon. Tapping anywhere on the lozenge opens the existing MatchOverlay.
  • MatchOverlay gains: stage / matchday chip, full-date 'When' block with primary user-local time + secondary venue-local time, 'Where' block with city + host-country flag + real stadium name + FIFA tournament name + capacity. TZ abbreviation rendered at caption size next to the big time so '08:00' reads as the headline and 'GMT+12 your time' as the supporting detail.
  • SideCard rectangles in the overlay get the blurred full-flag background when the team is known; TBD knockout slots keep the plain '?' card.
  • Small italic caption '(Teams shown once the previous stage closes)' under the title when one or both sides are TBD.
  • Knockout cards' .km-team min-height bumped to 76px so the flag art reads prominently; venue lozenge inside knockout cards tightened so it sits as a quiet caption under the picks. Fixed a :has(.km-odds:empty) override that was dropping the venue row on R32+ cards and stranding the lozenge in an auto-placed bottom-right cell.
  • Selected match-pick rectangles fill solid #fbbf24 instead of the prior 6%-alpha tint; team code + W/D/L percent flip to #15151a for contrast (per Tim's DevTools mock).

Pool join flow

  • JoinSyndicate's OTP signup form now matches ProfileCompletionGate: drops the separate 'handle' input, renames 'Your name' to 'Your @handle (permanent)' with a live slug preview, adds first_name + last_name inputs. Stops the 'my full name became my permanent @handle' trap.
  • Unstuck 'Setting up your profile...' on /s/[slug]/join. Root cause: useUser only listened for Supabase auth events, so inbound logins (WhatsApp / SMS / email OTP) flipped the tnm_session cookie without anyone noticing, the ProfileCompletionGate never showed, and the OnboardingStep's poll waited forever for a display_name that no UI was letting the user set. Fixed with a custom tnm:auth-changed window event dispatched on inbound sign-in and on gate save; useUser listens and re-probes.

Manage pool

  • Manage page gains an editor for allowed_phone_countries (toggle + chip list + add-country select, max 10). Fixes a real backend gap underneath: persistence.updateBranding had been silently dropping the field for ~a year because it wasn't in the stringFields loop. The /owner PATCH Zod schema accepted it (per SEC-POOL-11) but writes never landed.
  • manage-owner GET now returns allowed_phone_countries and PATCH accepts the same shape.

Data fix (already applied to prod DB)

  • One-off SQL UPDATE cleared allowed_phone_countries on the 13b-pupuke syndicates row so the 'New Zealand residents only' banner stops appearing.

Spec

  • Added docs/superpowers/specs/2026-06-06-match-card-venue-footer-design.md.

Tests

  • Added __tests__/host-cities.test.ts (6 tests).
  • Added __tests__/MatchVenueFooter.test.tsx (6 tests).
  • Existing 18 useTranslations-context failures are pre-existing on main (not from this work; confirmed by stash-and-baseline).

🤖 Generated with Claude Code

0800tim and others added 10 commits June 5, 2026 16:45
Tim 2026-06-05 (after the marketing publish):

  > it should fade out to transparent at the bottom so it blends in
  > nicely and we don't get this kind of line where the first
  > paragraph is under the heading in the bottom 20% of the banner
  > image. You just fade that from the 20% point at the bottom down
  > to the very bottom, gradient fade to transparent.

The existing mask had three stops (opaque to 68%, half-opacity at
88%, transparent at 100%) which left a visible threshold band
around the lede. Rewritten as a straight linear ramp from 80%
opaque to 100% transparent so the player-photo edge dissolves
into the page background. Both -webkit- and unprefixed mask-image
forms updated together so iOS Safari + every chromium fork picks
it up.

CSS-only, one rule (.vt-home-hero-bg). Headline-area dark overlay
above the mask is unchanged so legibility on the upper 80% is
unaffected.

Signed-off-by: Tim Thomas <0800tim@gmail.com>
Brainstorm output. Replaces the unused 'Add score' toggle and the
small top-right ellipsis link on each match row with a single neutral
charcoal lozenge showing the user's local kickoff date/time and a
gold info icon, opening the existing MatchOverlay. Extends that
overlay with a stage chip, dual-timezone When block, and a Where
block surfacing city, country, stadium real name, FIFA tournament
name, and capacity.

Architecture: direct prop + small lookup module (Approach A).
Plumbs hostCityId from canonical fixtures through resolveMatch and
MatchPredictionRow. New apps/web/lib/host-cities.ts wraps the
existing data/fifa-wc-2026/host-cities.json.

Refs: sessions/

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tim Thomas <0800tim@gmail.com>
Replace the unused 'Add score' toggle and the small top-right ⋯
link on each match row (group + knockout) with a single neutral
charcoal lozenge containing the user's local kickoff date, time,
and a gold info icon. Whole lozenge is one tap target into the
existing MatchOverlay.

Extend MatchOverlay with:
- Stage / matchday chip ('Group A · Match 1').
- When block: full date, user-local kickoff time (large), venue-
  local kickoff time (small, suppressed when zones match).
- Where block: city, host-country flag, country name (locale-
  resolved), real stadium name, FIFA tournament name, capacity.

Fix a latent bug in the prior formatKickoff helper that
hard-coded en-NZ + UTC while presenting the output as if it were
user-local.

New apps/web/lib/host-cities.ts wraps the existing FIFA 2026
host-cities JSON: hostCityById, hostCityByMatchNumber, and
kickoffIsoByMatchNumber. ResolvedMatch gains an optional
hostCityId.

SSR / hydration: footer and overlay render in the venue timezone
on the server (deterministic) and swap to the user's resolved
timezone after mount via Intl.DateTimeFormat. Markup is identical
pre/post hydration.

Tests:
- apps/web/__tests__/host-cities.test.ts (lookup module)
- apps/web/__tests__/MatchVenueFooter.test.tsx (render, hydration,
  overlay-router open, fallback link href)

Refs: docs/superpowers/specs/2026-06-06-match-card-venue-footer-design.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tim Thomas <0800tim@gmail.com>
…zenge, smaller TZ

Round-two polish from Tim's review of the v1 build (2026-06-06):

- MatchOverlay When block now splits the kickoff time from its
  timezone abbreviation via Intl.DateTimeFormat.formatToParts(),
  so the primary line reads '08:00' at heading size + 'GMT+12 your
  time' at caption size. Fixes the over-large 'GMT+12' that was
  reading as if it were part of the headline number.

- MatchOverlay SideCard renders a blurred full-bleed flag behind
  the circular team chip when the team is known (group stage
  popups + resolved knockout slots). The .vt-match-overlay-side-
  tbd variant for unresolved knockout slots keeps the plain card
  with the '?' glyph, matching the bracket's TBD treatment.

- New 'Teams shown once the previous stage closes' italic caption
  appears under the Sheet title when one or both sides are still
  TBD. Singular 'Opponent shown ...' when only one side is TBD.

- Knockout cards (.km-card .mpr-venue-footer) now use a tighter
  lozenge: smaller padding, lower min-height, narrower width
  band. Combined with .km-team min-height bumped 56px -> 76px so
  the flag art gets back the vertical real estate the lozenge
  was consuming. Group rows are unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tim Thomas <0800tim@gmail.com>
The .km-card:has(.km-odds:empty) selector strips the odds row to
avoid a phantom row-gap under the team cells. It was rewriting
grid-template-areas without including the new 'venue' row, so
.mpr-venue-footer (which sets grid-area: venue) lost its named
area and fell into an implicit auto-placed cell at the bottom-
right instead of spanning the full card width under both flags.

Restore the venue row in the empty-odds override so the lozenge
keeps its full-width footer position on R32 and every later
knockout round.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tim Thomas <0800tim@gmail.com>
Bug report (Tim 2026-06-06): registering via the pool join page
captured 'display name' + 'handle' as separate fields, then PATCHed
the typed display name (a human full name like 'Tim Thomas') in as
the auth-sms user's display_name. That display_name IS the @handle
per the 2026-06-05 'one identity per user' rule, and it's immutable
once set, so the user was stuck with their real name as their
permanent @handle. Evidence in the prod game.db included rows like
'John Doe', 'Andy Prentice', and 'Molly (Tournamental Oracle)' in
syndicate_owners_membership.handle.

The JoinSyndicate modal was the only surface in the app still
modelling display_name + handle as two separate fields. The global
ProfileCompletionGate (which the authed-fast-path and the WhatsApp
inbound-login path both flow through) already uses the correct
model: display_name IS the @handle (slug-validated), and the human
name lives in first_name + last_name.

Aligned the modal with the gate:

- Renamed the 'Your name' input to 'Your @handle (permanent)' with
  a slug-preview hint and slug-validation (3..32 chars, reserved
  list, mirrors the gate exactly).
- Replaced the separate 'Handle' input with First name + Last name
  inputs, matching the gate and the /profile page.
- Dropped the auto-derive-handle-from-name effect and the
  handleTouched flag.
- bindAndJoin now PATCHes /v1/auth/me with { display_name: <slug>,
  first_name, last_name } so the user's @handle is the slugified
  value they previewed, not whatever they typed.
- /join body still sends { handle, display_name } both set to the
  slug for log clarity, even though the server ignores both fields.
- PendingJoin localStorage v2 carries displayName + firstName +
  lastName (handle removed); the loader tolerates v1 saves by
  promoting the old  field into displayName so an upgraded
  user with a stale pending row doesn't lose state.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tim Thomas <0800tim@gmail.com>
Tim hit two related issues on the 13B Pupuke pool (2026-06-06):
1. The pool was locked to New Zealand residents only (a leftover
   from creation), but the join page surfaces a country banner
   that puts email-only joiners off.
2. He tried to remove the country lock from the manage page and
   found there was no UI for it.

Root cause beneath the missing UI: the persistence layer's
updateBranding silently dropped allowed_phone_countries because
the field wasn't in the stringFields loop and had no special case.
The /owner PATCH route's Zod schema accepted it (per SEC-POOL-11)
but the writes never landed. The manage-owner PATCH never even
accepted it.

Three changes:

- persistence.updateBranding now honours allowed_phone_countries
  by reusing serialiseAllowedCountries (the same helper
  createSyndicate uses), so empty arrays clear the restriction
  and non-empty arrays serialise to the storage CSV.

- manage-owner GET now returns allowed_phone_countries as a
  string[] of bare dial codes. Manage-owner PATCH accepts the
  same shape (max 10 entries, ^\d{1,3}$ each), mirroring the
  /owner route.

- ManageClient grows a 'Lock entries by country' editor: toggle
  + chip list + add-country select, capped at 10. Toggling off
  sends an empty array, clearing the restriction server-side.
  COUNTRY_CODES grew a flag emoji per row for the chip render.

Tim's 13B Pupuke row was unlocked by hand before this commit
landed via a one-off UPDATE on syndicates.allowed_phone_countries.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tim Thomas <0800tim@gmail.com>
Tim 2026-06-06: signing into /s/<slug>/join via WhatsApp or email
landed on the OnboardingStep's 'Setting up your profile…' spinner
and never advanced. The poll loop sat forever waiting for the
ProfileCompletionGate to write display_name, but the gate never
appeared.

Root cause: useUser only subscribes to Supabase auth events. The
inbound-login flow (WhatsApp / SMS / email OTP) sets the
tnm_session cookie but doesn't fire any Supabase event, so
useUser stayed on its pre-auth state. The gate's effect gates on
status==='authenticated' AND a u_-prefixed user id, neither of
which were true even after the tnm_session cookie was live. So
the gate never showed, the user never picked an @handle,
display_name stayed empty, and the OnboardingStep's poll never
saw a value to advance on.

Fix: a custom 'tnm:auth-changed' window event.

- useUser listens for it and re-runs the inbound probe, so the
  inbound session flows through to status='authenticated' without
  a page reload.
- JoinFlowClient.handleSignedIn dispatches it after a successful
  inline verify so the gate fires the moment the user signs in.
- ProfileCompletionGate also dispatches after a successful save
  so AuthChip + any other useUser consumer flips to the new
  display_name / first_name / last_name immediately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tim Thomas <0800tim@gmail.com>
Tim 2026-06-06 DevTools mock: the previous selected-pick treatment
was a 4px gold inset ring on a 6%-alpha gold-tinted background,
leaving the chosen team's rectangle reading as 'ringed but mostly
empty'. The user-facing read of 'this is definitely my pick' was
weak compared with the DRAW pill's full gold fill.

Three changes to .mpr-pick:

- .mpr-pick-home.is-selected / .mpr-pick-away.is-selected now fill
  with #fbbf24 (the same Tailwind amber the bracket's focus-ring
  already uses) instead of the 6%-alpha tint.
- .mpr-pick.is-selected .mpr-pick-code flips to #15151a so the
  team code (MEX, RSA, etc.) reads cleanly against the yellow.
- .mpr-pick.is-selected .mpr-pick-pct flips from #e6edf3 to #15151a
  for the same reason.

Inset gold ring + outer halo retained so the flag chip still sits
cleanly inside the yellow rectangle.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tim Thomas <0800tim@gmail.com>
Per Tim's hard rule (no em-dashes in user-facing copy), the
splitTimeAndZone fallback returns now use '-' instead of '—'.
Internal-only commit comments / CSS comments are left alone.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tim Thomas <0800tim@gmail.com>
@github-actions

github-actions Bot commented Jun 6, 2026

Copy link
Copy Markdown

DRY-RUN — this verdict is informational; CI is not blocked.

Auto-triage: GREEN — auto-triage clear

Risk score: 0/100

Metric Value
Files changed 20
Lines added 1765
Lines removed 324
Apps touched apps/web
New dependencies 0
New 3rd-party hosts 0

No flags raised by the automated scanners. A human reviewer will still take a look.

Labels applied: area:docs, area:overlay, area:web, auto-triage:green

Posted by @vtorn/pr-triage-bot. How this works: docs/security/01-pr-triage-process.md. Disagree with the verdict? Comment /triage override <reason> and a maintainer will re-review.

Both cascade.test.ts and score.test.ts read .home twice across an
if-guard, which TS doesn't narrow through a fresh property access.
Result: TS2339 'Property group does not exist on type SlotSource'
on the .group / .position reads after the narrowing if. Pre-existing
on main and blocking PR #302's Node CI step.

Bind .home to a local const so TS keeps the narrowed
group_position variant alive through both subsequent reads.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tim Thomas <0800tim@gmail.com>
@github-actions

github-actions Bot commented Jun 6, 2026

Copy link
Copy Markdown

DRY-RUN — this verdict is informational; CI is not blocked.

Auto-triage: GREEN — auto-triage clear

Risk score: 0/100

Metric Value
Files changed 22
Lines added 1777
Lines removed 330
Apps touched apps/web, packages/bracket-engine
New dependencies 0
New 3rd-party hosts 0

No flags raised by the automated scanners. A human reviewer will still take a look.

Labels applied: area:docs, area:overlay, area:packages, area:web, auto-triage:green

Posted by @vtorn/pr-triage-bot. How this works: docs/security/01-pr-triage-process.md. Disagree with the verdict? Comment /triage override <reason> and a maintainer will re-review.

Tim 2026-06-06: a small brand-yellow (#fbbf24) circle now rides
the top-right of each group-header team chip showing the team's
current predicted standings position (1..4). At-a-glance read of
'who am I currently predicting to advance' without scrolling down
to the predicted-standings panel.

- positionByCode built from computeGroupStandings (index+1), so
  tiebreaker resolution is honoured: when the user supplies a
  manual tiebreaker, the pip order updates immediately.
- Only rendered when predictedCount > 0; before any pick the
  standings are all-zero and a 1/2/3/4 would be stub-ordered
  alphabetically, which is misleading.
- Brand yellow + #15151a digit matches the rest of the freshly
  reworked selection palette (selected-pick fill, selected-DRAW
  pill). aria-hidden because the predicted-standings panel below
  already exposes the same information to screen readers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tim Thomas <0800tim@gmail.com>
@0800tim 0800tim merged commit cfdc81c into main Jun 6, 2026
11 of 12 checks passed
@github-actions

github-actions Bot commented Jun 6, 2026

Copy link
Copy Markdown

DRY-RUN — this verdict is informational; CI is not blocked.

Auto-triage: GREEN — auto-triage clear

Risk score: 0/100

Metric Value
Files changed 22
Lines added 1837
Lines removed 330
Apps touched apps/web, packages/bracket-engine
New dependencies 0
New 3rd-party hosts 0

No flags raised by the automated scanners. A human reviewer will still take a look.

Labels applied: area:docs, area:overlay, area:packages, area:web, auto-triage:green

Posted by @vtorn/pr-triage-bot. How this works: docs/security/01-pr-triage-process.md. Disagree with the verdict? Comment /triage override <reason> and a maintainer will re-review.

0800tim added a commit that referenced this pull request Jun 6, 2026
* style(home): clean 80%-to-bottom linear fade on banner image

Tim 2026-06-05 (after the marketing publish):

  > it should fade out to transparent at the bottom so it blends in
  > nicely and we don't get this kind of line where the first
  > paragraph is under the heading in the bottom 20% of the banner
  > image. You just fade that from the 20% point at the bottom down
  > to the very bottom, gradient fade to transparent.

The existing mask had three stops (opaque to 68%, half-opacity at
88%, transparent at 100%) which left a visible threshold band
around the lede. Rewritten as a straight linear ramp from 80%
opaque to 100% transparent so the player-photo edge dissolves
into the page background. Both -webkit- and unprefixed mask-image
forms updated together so iOS Safari + every chromium fork picks
it up.

CSS-only, one rule (.vt-home-hero-bg). Headline-area dark overlay
above the mask is unchanged so legibility on the upper 80% is
unaffected.

Signed-off-by: Tim Thomas <0800tim@gmail.com>

* docs: add match card venue/time footer + overlay design spec

Brainstorm output. Replaces the unused 'Add score' toggle and the
small top-right ellipsis link on each match row with a single neutral
charcoal lozenge showing the user's local kickoff date/time and a
gold info icon, opening the existing MatchOverlay. Extends that
overlay with a stage chip, dual-timezone When block, and a Where
block surfacing city, country, stadium real name, FIFA tournament
name, and capacity.

Architecture: direct prop + small lookup module (Approach A).
Plumbs hostCityId from canonical fixtures through resolveMatch and
MatchPredictionRow. New apps/web/lib/host-cities.ts wraps the
existing data/fifa-wc-2026/host-cities.json.

Refs: sessions/

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tim Thomas <0800tim@gmail.com>

* feat(bracket): venue/time footer lozenge + extended match overlay

Replace the unused 'Add score' toggle and the small top-right ⋯
link on each match row (group + knockout) with a single neutral
charcoal lozenge containing the user's local kickoff date, time,
and a gold info icon. Whole lozenge is one tap target into the
existing MatchOverlay.

Extend MatchOverlay with:
- Stage / matchday chip ('Group A · Match 1').
- When block: full date, user-local kickoff time (large), venue-
  local kickoff time (small, suppressed when zones match).
- Where block: city, host-country flag, country name (locale-
  resolved), real stadium name, FIFA tournament name, capacity.

Fix a latent bug in the prior formatKickoff helper that
hard-coded en-NZ + UTC while presenting the output as if it were
user-local.

New apps/web/lib/host-cities.ts wraps the existing FIFA 2026
host-cities JSON: hostCityById, hostCityByMatchNumber, and
kickoffIsoByMatchNumber. ResolvedMatch gains an optional
hostCityId.

SSR / hydration: footer and overlay render in the venue timezone
on the server (deterministic) and swap to the user's resolved
timezone after mount via Intl.DateTimeFormat. Markup is identical
pre/post hydration.

Tests:
- apps/web/__tests__/host-cities.test.ts (lookup module)
- apps/web/__tests__/MatchVenueFooter.test.tsx (render, hydration,
  overlay-router open, fallback link href)

Refs: docs/superpowers/specs/2026-06-06-match-card-venue-footer-design.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tim Thomas <0800tim@gmail.com>

* feat(bracket): blurred-flag side cards, TBD note, tighter knockout lozenge, smaller TZ

Round-two polish from Tim's review of the v1 build (2026-06-06):

- MatchOverlay When block now splits the kickoff time from its
  timezone abbreviation via Intl.DateTimeFormat.formatToParts(),
  so the primary line reads '08:00' at heading size + 'GMT+12 your
  time' at caption size. Fixes the over-large 'GMT+12' that was
  reading as if it were part of the headline number.

- MatchOverlay SideCard renders a blurred full-bleed flag behind
  the circular team chip when the team is known (group stage
  popups + resolved knockout slots). The .vt-match-overlay-side-
  tbd variant for unresolved knockout slots keeps the plain card
  with the '?' glyph, matching the bracket's TBD treatment.

- New 'Teams shown once the previous stage closes' italic caption
  appears under the Sheet title when one or both sides are still
  TBD. Singular 'Opponent shown ...' when only one side is TBD.

- Knockout cards (.km-card .mpr-venue-footer) now use a tighter
  lozenge: smaller padding, lower min-height, narrower width
  band. Combined with .km-team min-height bumped 56px -> 76px so
  the flag art gets back the vertical real estate the lozenge
  was consuming. Group rows are unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tim Thomas <0800tim@gmail.com>

* fix(bracket): keep venue row when odds slot is empty on knockout cards

The .km-card:has(.km-odds:empty) selector strips the odds row to
avoid a phantom row-gap under the team cells. It was rewriting
grid-template-areas without including the new 'venue' row, so
.mpr-venue-footer (which sets grid-area: venue) lost its named
area and fell into an implicit auto-placed cell at the bottom-
right instead of spanning the full card width under both flags.

Restore the venue row in the empty-odds override so the lozenge
keeps its full-width footer position on R32 and every later
knockout round.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tim Thomas <0800tim@gmail.com>

* fix(pool-join): JoinSyndicate OTP form now matches ProfileCompletionGate

Bug report (Tim 2026-06-06): registering via the pool join page
captured 'display name' + 'handle' as separate fields, then PATCHed
the typed display name (a human full name like 'Tim Thomas') in as
the auth-sms user's display_name. That display_name IS the @handle
per the 2026-06-05 'one identity per user' rule, and it's immutable
once set, so the user was stuck with their real name as their
permanent @handle. Evidence in the prod game.db included rows like
'John Doe', 'Andy Prentice', and 'Molly (Tournamental Oracle)' in
syndicate_owners_membership.handle.

The JoinSyndicate modal was the only surface in the app still
modelling display_name + handle as two separate fields. The global
ProfileCompletionGate (which the authed-fast-path and the WhatsApp
inbound-login path both flow through) already uses the correct
model: display_name IS the @handle (slug-validated), and the human
name lives in first_name + last_name.

Aligned the modal with the gate:

- Renamed the 'Your name' input to 'Your @handle (permanent)' with
  a slug-preview hint and slug-validation (3..32 chars, reserved
  list, mirrors the gate exactly).
- Replaced the separate 'Handle' input with First name + Last name
  inputs, matching the gate and the /profile page.
- Dropped the auto-derive-handle-from-name effect and the
  handleTouched flag.
- bindAndJoin now PATCHes /v1/auth/me with { display_name: <slug>,
  first_name, last_name } so the user's @handle is the slugified
  value they previewed, not whatever they typed.
- /join body still sends { handle, display_name } both set to the
  slug for log clarity, even though the server ignores both fields.
- PendingJoin localStorage v2 carries displayName + firstName +
  lastName (handle removed); the loader tolerates v1 saves by
  promoting the old  field into displayName so an upgraded
  user with a stale pending row doesn't lose state.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tim Thomas <0800tim@gmail.com>

* feat(manage-pool): edit allowed_phone_countries from the manage page

Tim hit two related issues on the 13B Pupuke pool (2026-06-06):
1. The pool was locked to New Zealand residents only (a leftover
   from creation), but the join page surfaces a country banner
   that puts email-only joiners off.
2. He tried to remove the country lock from the manage page and
   found there was no UI for it.

Root cause beneath the missing UI: the persistence layer's
updateBranding silently dropped allowed_phone_countries because
the field wasn't in the stringFields loop and had no special case.
The /owner PATCH route's Zod schema accepted it (per SEC-POOL-11)
but the writes never landed. The manage-owner PATCH never even
accepted it.

Three changes:

- persistence.updateBranding now honours allowed_phone_countries
  by reusing serialiseAllowedCountries (the same helper
  createSyndicate uses), so empty arrays clear the restriction
  and non-empty arrays serialise to the storage CSV.

- manage-owner GET now returns allowed_phone_countries as a
  string[] of bare dial codes. Manage-owner PATCH accepts the
  same shape (max 10 entries, ^\d{1,3}$ each), mirroring the
  /owner route.

- ManageClient grows a 'Lock entries by country' editor: toggle
  + chip list + add-country select, capped at 10. Toggling off
  sends an empty array, clearing the restriction server-side.
  COUNTRY_CODES grew a flag emoji per row for the chip render.

Tim's 13B Pupuke row was unlocked by hand before this commit
landed via a one-off UPDATE on syndicates.allowed_phone_countries.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tim Thomas <0800tim@gmail.com>

* fix(join-flow): unstick 'Setting up your profile…' after inbound sign-in

Tim 2026-06-06: signing into /s/<slug>/join via WhatsApp or email
landed on the OnboardingStep's 'Setting up your profile…' spinner
and never advanced. The poll loop sat forever waiting for the
ProfileCompletionGate to write display_name, but the gate never
appeared.

Root cause: useUser only subscribes to Supabase auth events. The
inbound-login flow (WhatsApp / SMS / email OTP) sets the
tnm_session cookie but doesn't fire any Supabase event, so
useUser stayed on its pre-auth state. The gate's effect gates on
status==='authenticated' AND a u_-prefixed user id, neither of
which were true even after the tnm_session cookie was live. So
the gate never showed, the user never picked an @handle,
display_name stayed empty, and the OnboardingStep's poll never
saw a value to advance on.

Fix: a custom 'tnm:auth-changed' window event.

- useUser listens for it and re-runs the inbound probe, so the
  inbound session flows through to status='authenticated' without
  a page reload.
- JoinFlowClient.handleSignedIn dispatches it after a successful
  inline verify so the gate fires the moment the user signs in.
- ProfileCompletionGate also dispatches after a successful save
  so AuthChip + any other useUser consumer flips to the new
  display_name / first_name / last_name immediately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tim Thomas <0800tim@gmail.com>

* style(bracket): selected pick gets solid yellow fill + dark text

Tim 2026-06-06 DevTools mock: the previous selected-pick treatment
was a 4px gold inset ring on a 6%-alpha gold-tinted background,
leaving the chosen team's rectangle reading as 'ringed but mostly
empty'. The user-facing read of 'this is definitely my pick' was
weak compared with the DRAW pill's full gold fill.

Three changes to .mpr-pick:

- .mpr-pick-home.is-selected / .mpr-pick-away.is-selected now fill
  with #fbbf24 (the same Tailwind amber the bracket's focus-ring
  already uses) instead of the 6%-alpha tint.
- .mpr-pick.is-selected .mpr-pick-code flips to #15151a so the
  team code (MEX, RSA, etc.) reads cleanly against the yellow.
- .mpr-pick.is-selected .mpr-pick-pct flips from #e6edf3 to #15151a
  for the same reason.

Inset gold ring + outer halo retained so the flag chip still sits
cleanly inside the yellow rectangle.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tim Thomas <0800tim@gmail.com>

* style: replace em-dash with hyphen in MatchOverlay time fallback

Per Tim's hard rule (no em-dashes in user-facing copy), the
splitTimeAndZone fallback returns now use '-' instead of '—'.
Internal-only commit comments / CSS comments are left alone.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tim Thomas <0800tim@gmail.com>

* fix(bracket-engine): narrow r32_01.home via local const in two tests

Both cascade.test.ts and score.test.ts read .home twice across an
if-guard, which TS doesn't narrow through a fresh property access.
Result: TS2339 'Property group does not exist on type SlotSource'
on the .group / .position reads after the narrowing if. Pre-existing
on main and blocking PR #302's Node CI step.

Bind .home to a local const so TS keeps the narrowed
group_position variant alive through both subsequent reads.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tim Thomas <0800tim@gmail.com>

* feat(bracket): position-indicator pips on group-header team chips

Tim 2026-06-06: a small brand-yellow (#fbbf24) circle now rides
the top-right of each group-header team chip showing the team's
current predicted standings position (1..4). At-a-glance read of
'who am I currently predicting to advance' without scrolling down
to the predicted-standings panel.

- positionByCode built from computeGroupStandings (index+1), so
  tiebreaker resolution is honoured: when the user supplies a
  manual tiebreaker, the pip order updates immediately.
- Only rendered when predictedCount > 0; before any pick the
  standings are all-zero and a 1/2/3/4 would be stub-ordered
  alphabetically, which is misleading.
- Brand yellow + #15151a digit matches the rest of the freshly
  reworked selection palette (selected-pick fill, selected-DRAW
  pill). aria-hidden because the predicted-standings panel below
  already exposes the same information to screen readers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tim Thomas <0800tim@gmail.com>

* fix(join-flow): hard-reload after OTP verify to unstick auto-redirect

Urgent prod hotfix (Tim 2026-06-06): users hitting /s/<slug>/join,
signing in via WhatsApp or email OTP, were landing on the
OnboardingStep's 'Setting up your profile…' spinner and never
advancing. Manual refresh fixed it every time.

Earlier fix attempted to bridge the inbound-session flip into
useUser by dispatching a 'tnm:auth-changed' event from
handleSignedIn. It worked on dev but raced with the browser's
cookie-commit on production for some clients, leaving the
ProfileCompletionGate unmounted and the OnboardingStep stranded.

Switch to the deterministic path: window.location.reload() after a
successful inline verify. The page comes up with the tnm_session
cookie already in the jar, useUser's init() picks it up on first
paint, and the gate either fires the @handle picker (new user) or
the OnboardingStep auto-joins (returning user).

Two belt-and-braces additions stay in place:

- OnboardingStep's 1s poll loop re-broadcasts the
  'tnm:auth-changed' event each tick when the inbound probe returns
  a user without a display_name. If anything ever skips the reload
  path the gate still fires within 1s.
- The 'Setting up your profile…' message gains an explicit
  '(Refresh this page if you're not auto-redirected.)' caption.
  Cheap, visible self-recovery for any edge case the reload misses.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tim Thomas <0800tim@gmail.com>

---------

Signed-off-by: Tim Thomas <0800tim@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
0800tim added a commit that referenced this pull request Jun 6, 2026
…y X/Y (#304)

* style(home): clean 80%-to-bottom linear fade on banner image

Tim 2026-06-05 (after the marketing publish):

  > it should fade out to transparent at the bottom so it blends in
  > nicely and we don't get this kind of line where the first
  > paragraph is under the heading in the bottom 20% of the banner
  > image. You just fade that from the 20% point at the bottom down
  > to the very bottom, gradient fade to transparent.

The existing mask had three stops (opaque to 68%, half-opacity at
88%, transparent at 100%) which left a visible threshold band
around the lede. Rewritten as a straight linear ramp from 80%
opaque to 100% transparent so the player-photo edge dissolves
into the page background. Both -webkit- and unprefixed mask-image
forms updated together so iOS Safari + every chromium fork picks
it up.

CSS-only, one rule (.vt-home-hero-bg). Headline-area dark overlay
above the mask is unchanged so legibility on the upper 80% is
unaffected.

Signed-off-by: Tim Thomas <0800tim@gmail.com>

* docs: add match card venue/time footer + overlay design spec

Brainstorm output. Replaces the unused 'Add score' toggle and the
small top-right ellipsis link on each match row with a single neutral
charcoal lozenge showing the user's local kickoff date/time and a
gold info icon, opening the existing MatchOverlay. Extends that
overlay with a stage chip, dual-timezone When block, and a Where
block surfacing city, country, stadium real name, FIFA tournament
name, and capacity.

Architecture: direct prop + small lookup module (Approach A).
Plumbs hostCityId from canonical fixtures through resolveMatch and
MatchPredictionRow. New apps/web/lib/host-cities.ts wraps the
existing data/fifa-wc-2026/host-cities.json.

Refs: sessions/

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tim Thomas <0800tim@gmail.com>

* feat(bracket): venue/time footer lozenge + extended match overlay

Replace the unused 'Add score' toggle and the small top-right ⋯
link on each match row (group + knockout) with a single neutral
charcoal lozenge containing the user's local kickoff date, time,
and a gold info icon. Whole lozenge is one tap target into the
existing MatchOverlay.

Extend MatchOverlay with:
- Stage / matchday chip ('Group A · Match 1').
- When block: full date, user-local kickoff time (large), venue-
  local kickoff time (small, suppressed when zones match).
- Where block: city, host-country flag, country name (locale-
  resolved), real stadium name, FIFA tournament name, capacity.

Fix a latent bug in the prior formatKickoff helper that
hard-coded en-NZ + UTC while presenting the output as if it were
user-local.

New apps/web/lib/host-cities.ts wraps the existing FIFA 2026
host-cities JSON: hostCityById, hostCityByMatchNumber, and
kickoffIsoByMatchNumber. ResolvedMatch gains an optional
hostCityId.

SSR / hydration: footer and overlay render in the venue timezone
on the server (deterministic) and swap to the user's resolved
timezone after mount via Intl.DateTimeFormat. Markup is identical
pre/post hydration.

Tests:
- apps/web/__tests__/host-cities.test.ts (lookup module)
- apps/web/__tests__/MatchVenueFooter.test.tsx (render, hydration,
  overlay-router open, fallback link href)

Refs: docs/superpowers/specs/2026-06-06-match-card-venue-footer-design.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tim Thomas <0800tim@gmail.com>

* feat(bracket): blurred-flag side cards, TBD note, tighter knockout lozenge, smaller TZ

Round-two polish from Tim's review of the v1 build (2026-06-06):

- MatchOverlay When block now splits the kickoff time from its
  timezone abbreviation via Intl.DateTimeFormat.formatToParts(),
  so the primary line reads '08:00' at heading size + 'GMT+12 your
  time' at caption size. Fixes the over-large 'GMT+12' that was
  reading as if it were part of the headline number.

- MatchOverlay SideCard renders a blurred full-bleed flag behind
  the circular team chip when the team is known (group stage
  popups + resolved knockout slots). The .vt-match-overlay-side-
  tbd variant for unresolved knockout slots keeps the plain card
  with the '?' glyph, matching the bracket's TBD treatment.

- New 'Teams shown once the previous stage closes' italic caption
  appears under the Sheet title when one or both sides are still
  TBD. Singular 'Opponent shown ...' when only one side is TBD.

- Knockout cards (.km-card .mpr-venue-footer) now use a tighter
  lozenge: smaller padding, lower min-height, narrower width
  band. Combined with .km-team min-height bumped 56px -> 76px so
  the flag art gets back the vertical real estate the lozenge
  was consuming. Group rows are unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tim Thomas <0800tim@gmail.com>

* fix(bracket): keep venue row when odds slot is empty on knockout cards

The .km-card:has(.km-odds:empty) selector strips the odds row to
avoid a phantom row-gap under the team cells. It was rewriting
grid-template-areas without including the new 'venue' row, so
.mpr-venue-footer (which sets grid-area: venue) lost its named
area and fell into an implicit auto-placed cell at the bottom-
right instead of spanning the full card width under both flags.

Restore the venue row in the empty-odds override so the lozenge
keeps its full-width footer position on R32 and every later
knockout round.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tim Thomas <0800tim@gmail.com>

* fix(pool-join): JoinSyndicate OTP form now matches ProfileCompletionGate

Bug report (Tim 2026-06-06): registering via the pool join page
captured 'display name' + 'handle' as separate fields, then PATCHed
the typed display name (a human full name like 'Tim Thomas') in as
the auth-sms user's display_name. That display_name IS the @handle
per the 2026-06-05 'one identity per user' rule, and it's immutable
once set, so the user was stuck with their real name as their
permanent @handle. Evidence in the prod game.db included rows like
'John Doe', 'Andy Prentice', and 'Molly (Tournamental Oracle)' in
syndicate_owners_membership.handle.

The JoinSyndicate modal was the only surface in the app still
modelling display_name + handle as two separate fields. The global
ProfileCompletionGate (which the authed-fast-path and the WhatsApp
inbound-login path both flow through) already uses the correct
model: display_name IS the @handle (slug-validated), and the human
name lives in first_name + last_name.

Aligned the modal with the gate:

- Renamed the 'Your name' input to 'Your @handle (permanent)' with
  a slug-preview hint and slug-validation (3..32 chars, reserved
  list, mirrors the gate exactly).
- Replaced the separate 'Handle' input with First name + Last name
  inputs, matching the gate and the /profile page.
- Dropped the auto-derive-handle-from-name effect and the
  handleTouched flag.
- bindAndJoin now PATCHes /v1/auth/me with { display_name: <slug>,
  first_name, last_name } so the user's @handle is the slugified
  value they previewed, not whatever they typed.
- /join body still sends { handle, display_name } both set to the
  slug for log clarity, even though the server ignores both fields.
- PendingJoin localStorage v2 carries displayName + firstName +
  lastName (handle removed); the loader tolerates v1 saves by
  promoting the old  field into displayName so an upgraded
  user with a stale pending row doesn't lose state.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tim Thomas <0800tim@gmail.com>

* feat(manage-pool): edit allowed_phone_countries from the manage page

Tim hit two related issues on the 13B Pupuke pool (2026-06-06):
1. The pool was locked to New Zealand residents only (a leftover
   from creation), but the join page surfaces a country banner
   that puts email-only joiners off.
2. He tried to remove the country lock from the manage page and
   found there was no UI for it.

Root cause beneath the missing UI: the persistence layer's
updateBranding silently dropped allowed_phone_countries because
the field wasn't in the stringFields loop and had no special case.
The /owner PATCH route's Zod schema accepted it (per SEC-POOL-11)
but the writes never landed. The manage-owner PATCH never even
accepted it.

Three changes:

- persistence.updateBranding now honours allowed_phone_countries
  by reusing serialiseAllowedCountries (the same helper
  createSyndicate uses), so empty arrays clear the restriction
  and non-empty arrays serialise to the storage CSV.

- manage-owner GET now returns allowed_phone_countries as a
  string[] of bare dial codes. Manage-owner PATCH accepts the
  same shape (max 10 entries, ^\d{1,3}$ each), mirroring the
  /owner route.

- ManageClient grows a 'Lock entries by country' editor: toggle
  + chip list + add-country select, capped at 10. Toggling off
  sends an empty array, clearing the restriction server-side.
  COUNTRY_CODES grew a flag emoji per row for the chip render.

Tim's 13B Pupuke row was unlocked by hand before this commit
landed via a one-off UPDATE on syndicates.allowed_phone_countries.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tim Thomas <0800tim@gmail.com>

* fix(join-flow): unstick 'Setting up your profile…' after inbound sign-in

Tim 2026-06-06: signing into /s/<slug>/join via WhatsApp or email
landed on the OnboardingStep's 'Setting up your profile…' spinner
and never advanced. The poll loop sat forever waiting for the
ProfileCompletionGate to write display_name, but the gate never
appeared.

Root cause: useUser only subscribes to Supabase auth events. The
inbound-login flow (WhatsApp / SMS / email OTP) sets the
tnm_session cookie but doesn't fire any Supabase event, so
useUser stayed on its pre-auth state. The gate's effect gates on
status==='authenticated' AND a u_-prefixed user id, neither of
which were true even after the tnm_session cookie was live. So
the gate never showed, the user never picked an @handle,
display_name stayed empty, and the OnboardingStep's poll never
saw a value to advance on.

Fix: a custom 'tnm:auth-changed' window event.

- useUser listens for it and re-runs the inbound probe, so the
  inbound session flows through to status='authenticated' without
  a page reload.
- JoinFlowClient.handleSignedIn dispatches it after a successful
  inline verify so the gate fires the moment the user signs in.
- ProfileCompletionGate also dispatches after a successful save
  so AuthChip + any other useUser consumer flips to the new
  display_name / first_name / last_name immediately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tim Thomas <0800tim@gmail.com>

* style(bracket): selected pick gets solid yellow fill + dark text

Tim 2026-06-06 DevTools mock: the previous selected-pick treatment
was a 4px gold inset ring on a 6%-alpha gold-tinted background,
leaving the chosen team's rectangle reading as 'ringed but mostly
empty'. The user-facing read of 'this is definitely my pick' was
weak compared with the DRAW pill's full gold fill.

Three changes to .mpr-pick:

- .mpr-pick-home.is-selected / .mpr-pick-away.is-selected now fill
  with #fbbf24 (the same Tailwind amber the bracket's focus-ring
  already uses) instead of the 6%-alpha tint.
- .mpr-pick.is-selected .mpr-pick-code flips to #15151a so the
  team code (MEX, RSA, etc.) reads cleanly against the yellow.
- .mpr-pick.is-selected .mpr-pick-pct flips from #e6edf3 to #15151a
  for the same reason.

Inset gold ring + outer halo retained so the flag chip still sits
cleanly inside the yellow rectangle.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tim Thomas <0800tim@gmail.com>

* style: replace em-dash with hyphen in MatchOverlay time fallback

Per Tim's hard rule (no em-dashes in user-facing copy), the
splitTimeAndZone fallback returns now use '-' instead of '—'.
Internal-only commit comments / CSS comments are left alone.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tim Thomas <0800tim@gmail.com>

* fix(bracket-engine): narrow r32_01.home via local const in two tests

Both cascade.test.ts and score.test.ts read .home twice across an
if-guard, which TS doesn't narrow through a fresh property access.
Result: TS2339 'Property group does not exist on type SlotSource'
on the .group / .position reads after the narrowing if. Pre-existing
on main and blocking PR #302's Node CI step.

Bind .home to a local const so TS keeps the narrowed
group_position variant alive through both subsequent reads.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tim Thomas <0800tim@gmail.com>

* feat(bracket): position-indicator pips on group-header team chips

Tim 2026-06-06: a small brand-yellow (#fbbf24) circle now rides
the top-right of each group-header team chip showing the team's
current predicted standings position (1..4). At-a-glance read of
'who am I currently predicting to advance' without scrolling down
to the predicted-standings panel.

- positionByCode built from computeGroupStandings (index+1), so
  tiebreaker resolution is honoured: when the user supplies a
  manual tiebreaker, the pip order updates immediately.
- Only rendered when predictedCount > 0; before any pick the
  standings are all-zero and a 1/2/3/4 would be stub-ordered
  alphabetically, which is misleading.
- Brand yellow + #15151a digit matches the rest of the freshly
  reworked selection palette (selected-pick fill, selected-DRAW
  pill). aria-hidden because the predicted-standings panel below
  already exposes the same information to screen readers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tim Thomas <0800tim@gmail.com>

* fix(join-flow): hard-reload after OTP verify to unstick auto-redirect

Urgent prod hotfix (Tim 2026-06-06): users hitting /s/<slug>/join,
signing in via WhatsApp or email OTP, were landing on the
OnboardingStep's 'Setting up your profile…' spinner and never
advancing. Manual refresh fixed it every time.

Earlier fix attempted to bridge the inbound-session flip into
useUser by dispatching a 'tnm:auth-changed' event from
handleSignedIn. It worked on dev but raced with the browser's
cookie-commit on production for some clients, leaving the
ProfileCompletionGate unmounted and the OnboardingStep stranded.

Switch to the deterministic path: window.location.reload() after a
successful inline verify. The page comes up with the tnm_session
cookie already in the jar, useUser's init() picks it up on first
paint, and the gate either fires the @handle picker (new user) or
the OnboardingStep auto-joins (returning user).

Two belt-and-braces additions stay in place:

- OnboardingStep's 1s poll loop re-broadcasts the
  'tnm:auth-changed' event each tick when the inbound probe returns
  a user without a display_name. If anything ever skips the reload
  path the gate still fires within 1s.
- The 'Setting up your profile…' message gains an explicit
  '(Refresh this page if you're not auto-redirected.)' caption.
  Cheap, visible self-recovery for any edge case the reload misses.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tim Thomas <0800tim@gmail.com>

* feat(leaderboard): wire syndicate leaderboard to real API + late-entry X/Y

Day 1 of the FIFA WC 2026 leaderboard wire-up. The leaderboard UI
is no longer mock-data on the syndicate route (/s/<slug>); it now
fetches from the game service.

Game service:

- LeaderboardRow gains matches_available_to_user — count of fixtures
  whose kickoff_utc has elapsed and landed strictly after the user's
  registered_at timestamp. Renders 'X / Y' as
  score_total / matches_available_to_user.
  For syndicate scope, registered_at = syndicate_owners_membership.
  joined_at (per-pool). For global scope, registered_at falls back to
  brackets.locked_at (first time the user committed predictions). A
  follow-up will land an immutable brackets.created_at column so the
  global denominator can't drift on re-saves.

- Per-tournament kickoff catalogue loaded once at process start
  (currently fifa-wc-2026 only); matches_available_to_user is a
  walk over the sorted catalogue at request time.

- Two bugs fixed underneath:
  1. The syndicate leaderboard query INNER JOINed syndicate_members
     (game-service table from 0001_init.sql, never populated by the
     web's /join flow — 0 rows on prod despite 51 brackets and 37
     active pool memberships). Switched the JOIN to
     syndicate_owners_membership which the web layer actually writes
     to. Migration 0003 already created the table; this is the
     consolidation its comment ('Post-launch we'll consolidate')
     anticipated.
  2. addSyndicateMember now dual-writes to both
     syndicate_owners_membership AND the legacy syndicate_members
     table, so the bot / direct game-service callers still land on
     the new leaderboard view.

Web:

- New apps/web/lib/leaderboard/fetch.ts wraps the two endpoints.

- SyndicateLeaderboardSection now fetches /v1/leaderboard/<tid>/
  syndicate/<slug> on mount and renders the rows. Mock global /
  friends tabs unchanged; DraftPreviewBanner drops away on the live
  pool tab so users see a hard live list.

- Leaderboard component's X/Y display prefers a per-row
  matchesAvailable when present, falls back to the global
  matchesPlayed prop for mock data. Renders '0 / 0' before kickoff
  rather than collapsing to a bare points number, so the late-entry
  contract is visible from day zero.

All 110 game-service tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tim Thomas <0800tim@gmail.com>

---------

Signed-off-by: Tim Thomas <0800tim@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant