Compare commits

...
Sign in to create a new pull request.

54 commits

Author SHA1 Message Date
Chris Parsons
b85f387c79 Rank the third place game winner instead of leaving them in contention
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m8s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m18s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🧪 Test (push) Successful in 3m28s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m22s
🚀 Deploy / 🐳 Build (push) Successful in 1m22s
🚀 Deploy / 🚀 Deploy (push) Successful in 12s
The Third Place Game winner kept showing under "In Contention" on the FIFA
World Cup bracket even after the game was scored.

The scoring side was already correct — TEMPLATE_ROUND_CONFIG.fifa_48 finalizes
the 3PG winner at finalPosition 3 with isPartialScore false, so they got their
50 points. The bug was in PlayoffBracket, which re-derives rankings from match
records rather than participant results and only ever ranks losers. The 3PG
winner lost the semifinal but was correctly excluded there by the double-chance
guard, then won the 3PG — so they were never a loser anywhere, never ranked,
and fell through into the active list.

Fixing that also fixes the rank labels, which double-counted positions 3-4:
the 3PG loser was labelled T3 rather than 4, and quarterfinal losers T6 rather
than T5.

Rankings are now built by computeRankedEntries, which resolves a consolation
round at its feeder round: the winner takes the top of the tier the feeder's
losers would otherwise share and the loser the next position, with the
consolation round itself consuming none. Positions are derived rather than
hardcoded to 3/4, so a consolation round hanging off a different feeder lands
correctly too. Only matches that can be placed exactly are withheld from the
loser-driven walk, so an incomplete or partially hydrated consolation match
falls back to the old path instead of being dropped.

loserFeedsInto is set on exactly one round in the templates (fifa_48
Semifinals), so every other bracket keeps its existing behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 19:25:10 -07:00
Chris Parsons
932f36ca07 Show non-eliminated loser's manager tag in Scored Matches notifications
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m7s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m24s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🧪 Test (push) Successful in 3m5s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m22s
🚀 Deploy / 🐳 Build (push) Successful in 1m12s
🚀 Deploy / 🚀 Deploy (push) Successful in 12s
In playoff "Scored Matches" Discord notifications, the winner's manager tag
was shown unconditionally but the loser's was gated behind isLoserNotifiable,
which is only true when the loser scored or was eliminated. A World Cup
semifinal loser (drops to the 3rd-place playoff) or an AFL Qualifying-Final
loser (drops to a Semi Final) is neither, so their tag was dropped:

  • Argentina (philosohraptors) def. England

Decouple the loser's display name from the ping gate, mirroring the winner:
loserUsername is now shown whenever the loser's team is drafted, while the
@-ping (loserDiscordUserId) stays gated by showLoser. A still-alive loser is
named for context but not pinged:

  • Argentina (philosohraptors) def. England (elementsoul)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 21:36:23 -07:00
7e578c714c Merge pull request 'Merge QP scoreboard into a single Drafted Participants list with a Points Bubble divider' (#135) from claude/qp-points-bubble into main
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 2m49s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m19s
🚀 Deploy / 🐳 Build (push) Successful in 1m11s
🚀 Deploy / 🚀 Deploy (push) Successful in 12s
2026-07-10 15:16:50 +00:00
Chris Parsons
687fb6f040 Merge QP scoreboard into a single Drafted Participants list with a Points Bubble divider
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m4s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m19s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Collapse the "Drafted Participants in Top 8" and "Non-scoring Participants"
sections of the qualifying-points Discord announcement into one "Drafted
Participants" standings section. It lists every drafted participant who has
scored (qpTotal > 0) as ranked lines, with a "═══ Points Bubble ═══" divider
marking the points cutoff between rank 8 and rank 9. Participants with 0 QP are
no longer shown.

The divider is only emitted once at least one above-the-cutoff row exists:
globalRank is a season-wide rank while the scoreboard is scoped to one league's
drafts, so a league can have drafted nobody in the global top 8 — guarding on
that avoids a leading divider with nothing above it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 23:22:24 -07:00
Chris Parsons
5d0363a309 Fix qualifying-points notifications: full-field scoreboard + manual-set announcements
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m58s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m19s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🧪 Test (push) Successful in 2m53s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m23s
🚀 Deploy / 🐳 Build (push) Successful in 1m8s
🚀 Deploy / 🚀 Deploy (push) Successful in 12s
The QP Discord embed's two "scoreboard" sections now reflect the whole drafted
field instead of only participants whose QP changed this sync:

- Non-scoring / Top 8 draw from a new per-league `scoreboard` (all drafted
  participants), scoped to the sports season being announced so a golf pick can't
  leak into a tennis event. Points Awarded / Knocked Out stay scoped to the sync's
  changes and remain the only pinged sections.
- Non-scoring is now a single compact "Name (points, manager)" line below Top 8,
  covering everyone not in the top 8 (the exact complement of the Top 8 filter).
- Top 8 requires qpTotal > 0 as well as rank <= 8, so early-season winless players
  tied into a low rank band no longer flood the section with "T5. Name — 0 QP".

Manually setting a bracket match result (e.g. a Wimbledon semifinal) now announces
the QP update. The set-winner / set-round-winners / complete-round paths route
through processQualifyingEvent (which snapshots, diffs, and notifies) instead of
processQualifyingBracketEvent (which scored silently). The tournament fan-out still
skips the primary window via skipEventId, so mirror windows aren't double-posted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 10:04:22 -07:00
b05bad6554 Merge pull request 'fix qualifying notification' (#133) from claude/qualifying-points-format-cs9qrv into main
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m10s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m20s
🚀 Deploy / 🐳 Build (push) Successful in 1m9s
🚀 Deploy / 🚀 Deploy (push) Successful in 11s
2026-07-09 15:08:39 +00:00
Claude
cab161424e
Trim trailing zeros on qualifying-points values
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m6s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m21s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Display fractional QP with up to 2 decimals and trailing zeros trimmed
(1.50→1.5, 0.50→0.5) in both the Discord embed (formatQPValue) and the web
UI (formatQP) so the two surfaces stay consistent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011U7mTxDVK2cH8E2BWjCiUv
2026-07-09 06:34:25 +00:00
Claude
3f16d5e1d3
Restructure qualifying-points Discord embed
- Drop the "+" prefix on Points Awarded values (they are totals awarded,
  not increments).
- Rename "No Points" to "Non-scoring Participants" and format each line as
  "Name (seasonTotal, manager)" using the plain manager name.
- Rename "QP Standings" to "Drafted Participants in Top 8" and limit it to
  participants in the top 8 of the full field (plus ties), with plain
  manager names.
- Ping only Points Awarded and Knocked Out managers; non-scoring managers
  are no longer pinged.
- Link the embed title to the league's sport-season standings page and
  remove the plain "brackt.com" footer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011U7mTxDVK2cH8E2BWjCiUv
2026-07-09 05:12:40 +00:00
036411e9d8 Paginate and sport-tailor the simulator setup participant table (#132)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m46s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m28s
🚀 Deploy / 🐳 Build (push) Successful in 1m12s
🚀 Deploy / 🚀 Deploy (push) Successful in 11s
## What

The participant preview on the simulator setup page (`admin/sports-seasons/:id/simulator`) was capped at the first 20 rows by a hard-coded `.slice(0, 20)`, so most of the field was invisible for seasons with up to ~300 participants (golf, tennis). All rows were already loaded — this was purely a UI limit.

## Changes

- **Search + pagination** — name search box (accent-insensitive via `normalizeName`) plus Prev/Next paging at 50/page, over the already-loaded rows. Page resets on filter change.
- **Sport-aware columns** — columns are derived from each simulator's `requiredInputs` + `optionalInputs` (from the manifest), labeled, with required columns marked. So F1 shows odds, NBA shows Elo, NCAA shows rating, etc.
- **Missing-input filter** — "Only show participants missing a required input" toggle (shown only when the simulator has required inputs) plus a per-row amber marker so gaps are visible at a glance.
- **Tennis/golf link-out** — for sports whose inputs live on a dedicated page (surface Elo, golf skills), a note links there instead of implying they're edited on this page.

## Notes

Column resolution is the only manifest **runtime** call, and it runs in the **loader** — this keeps the simulator manifest/registry out of the client bundle. Importing it into the client component pulled in every simulator and their transitive server-only modules (`scoring-calculator.ts → qualifying-points-discord.server`, `cs2-major-stage.ts`), which broke the Champions League setup page with a "server-only module referenced by client" error.

## Verification

`npm run typecheck`, `oxlint`, and `npm run build` (client + server) all pass with no server-only-leak error.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #132
2026-07-07 06:00:15 +00:00
Chris Parsons
07f1356cd3 Sync IndyCar standings from OC Blacktop, fall back to ESPN
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m32s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m23s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🧪 Test (push) Successful in 3m29s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m30s
🚀 Deploy / 🐳 Build (push) Successful in 1m17s
🚀 Deploy / 🚀 Deploy (push) Successful in 11s
ESPN's IndyCar standings aggregate lags hours behind a race finish (Palou
stuck at 374 an evening after Mid-Ohio, vs. his true 409/P1). Add an
OcBlacktopIndyCarStandingsAdapter that reads the fresher OC Blacktop feed and
delegates to the existing ESPN IndyCarStandingsAdapter on any failure (missing
key, network error, empty payload, or schema drift) so a flaky third party
never breaks the sync.

Per-row validation throws on missing id/name or unparseable position/points to
route to the fallback rather than overwriting real standings with zeros, while
still keeping legitimate 0-point backmarkers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 21:20:47 -07:00
Chris Parsons
3f6af2665e Don't ping owners on rank-only standings changes in Discord
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m24s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m23s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🧪 Test (push) Successful in 3m17s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m28s
🚀 Deploy / 🐳 Build (push) Successful in 1m11s
🚀 Deploy / 🚀 Deploy (push) Successful in 11s
The first score of a season displaces every other team's rank (e.g.
everyone becomes T2), which fired a mass @-ping of the whole league even
though only one owner actually scored. The Standings Changes section
pinged every team whose rank *or* points changed.

Now the section pings an owner only when that team's points genuinely
changed this event; rank-only shufflers are still displayed with their
rank delta, but by name and without a mention. Mirrors the existing
"display but don't ping" behaviour in the Scored Matches section and
preserves pings for pure season-standings sports (F1, IndyCar), where a
real scorer's points always change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 22:02:06 -07:00
91227b2cb3 Merge pull request 'mirrored elimination' (#129) from claude/mirrored-tournament-eliminations-50nosy into main
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 2m55s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m18s
🚀 Deploy / 🐳 Build (push) Successful in 1m8s
🚀 Deploy / 🚀 Deploy (push) Successful in 13s
2026-07-04 19:43:21 +00:00
Claude
b9a9eb4c4b
Announce mirror knockouts from the admin bracket-scoring path too
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m1s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m17s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
The live tennis-draw sync now fans eliminations out to mirror windows, but the
admin bracket UI is a second live scoring path: when an admin enters match
results for a shared qualifying major, mirror windows already receive
"Qualifying Points Update" posts via the fan-out, yet the "Knocked Out" section
was still dropped there.

Thread the newly-decided losers (losers of matches reaching completion for the
first time this action) through the admin route's fanOutMajorIfPrimary calls in
the set-winner and set-round-winners intents, reusing the same per-window
elimination translation. Re-scores, complete-round, reprocess, and finalize
decide no new losers, so they pass nothing and never re-announce an exit —
mirroring populateBracketFromDraw's first-completion rule.

Extract the first-completion decision into a pure newlyDecidedLosers() helper
shared by both intents and unit-tested directly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WZGsG5R1q3kyKCaXuysiWJ
2026-07-04 18:19:52 +00:00
Claude
7ca89aafc4
Fix mirrored tournaments dropping QP knockout announcements
Mirror tournament windows (leagues drafting the same real-world major)
announced Qualifying Points awards but silently omitted the "Knocked Out"
section. Knockouts are derived only on the primary window's bracket
(playoff_matches → newlyDecidedLoserIds); mirrors receive placement/rawScore
only, so a player eliminated in a non-scoring round (0 QP) becomes a
null-placement filler indistinguishable from "not yet played" — the mirror
has no local signal to detect the knockout, and its notification was also
gated on QP having changed.

Thread the primary's newly-eliminated participants down the fan-out
(syncTennisDraw → fanOutMajorIfPrimary → syncMajorFromPrimaryEvent →
syncTournamentResults → processQualifyingEvent), translating identity across
window boundaries (primary season_participant → canonical participant → each
mirror's season_participant), and relax the mirror notification guard to fire
on eliminations even when no QP changed — matching the primary path.

Reuses the same notifyQualifyingPointsUpdate the primary already calls, so
mirrors now produce the same combined "Points Awarded" + "Knocked Out" embed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WZGsG5R1q3kyKCaXuysiWJ
2026-07-04 16:24:36 +00:00
8416b54052 Merge pull request 'claude/reprocess-bracket-scoring-mbkfa4' (#128) from claude/reprocess-bracket-scoring-mbkfa4 into main
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m5s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m20s
🚀 Deploy / 🐳 Build (push) Successful in 1m8s
🚀 Deploy / 🚀 Deploy (push) Successful in 11s
Reviewed-on: #128
2026-07-04 04:09:09 +00:00
Chris Parsons
698c6a6509 Fix lint errors in PlayoffBracket and sync-tournament-results test
All checks were successful
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m25s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m8s
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 21:02:06 -07:00
Claude
12c165a481
Surface partial mirror fan-out failures as a warning, not a green success
Some checks failed
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m30s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Failing after 49s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
When some mirror windows fail to sync during reprocess, return an error
result so it renders as a warning banner (with the synced/failed counts and
reasons) instead of a green success the admin might skim past while those
windows are left stale.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UkZVYgLmquWV2xDn349T2
2026-07-04 02:06:14 +00:00
Claude
0baef5a948
Fix reprocess bracket to score mirror windows like the primary
Reprocessing a tennis (qualifying) major recomputed the primary window's
QP correctly (R16 players = 1.5, the average of the 9th–16th values) but
left mirror/sibling windows showing 2 QP. Two defects:

1. The mirror fan-out ran through fanOutMajorIfPrimary, which swallows
   every error and returns void, so reprocess reported a green "success"
   even when mirrors were never re-scored. The reprocess qualifying path
   now calls syncMajorFromPrimaryEvent directly and folds the SyncReport
   (windows synced / failed) into the response, surfacing failures instead
   of hiding them.

2. Mirror windows split QP by counting canonical tournament_results rows
   at each placement, which only equals the round's structural tie span
   when placements are final. Mid-tournament, players floored at a tier
   make the row-count diverge from the tier size, so mirrors split
   differently than the primary. syncMajorFromPrimaryEvent now derives the
   structural span (R16 = 8, QF = 4, SF = 2, Final = 1) from the primary
   bracket via deriveBracketQualifyingStates and merges it over the
   canonical counts, so every window splits identically to the primary at
   every stage. Golf and CS2 Swiss-exit placements keep their canonical
   count via the merge.

Adds a fan-out test covering an R16-in-progress bracket where only 4 rows
sit at placement 9: the mirror is scored with the structural span (8), not
the live count (4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UkZVYgLmquWV2xDn349T2
2026-07-04 01:45:53 +00:00
58e656f15e claude/wimbledon-qp-scoring-bug-xevhlf (#127)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m14s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m20s
🚀 Deploy / 🐳 Build (push) Successful in 1m8s
🚀 Deploy / 🚀 Deploy (push) Successful in 12s
Co-authored-by: Claude <noreply@anthropic.com>
Reviewed-on: #127
2026-07-03 21:40:11 +00:00
8a948e1388 Merge pull request 'fix notifications' (#126) from claude/wimbledon-bracket-announcement-bug-tg1wzl into main
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m49s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m22s
🚀 Deploy / 🐳 Build (push) Successful in 1m15s
🚀 Deploy / 🚀 Deploy (push) Successful in 13s
Reviewed-on: #126
2026-07-03 15:47:55 +00:00
Claude
5be8ac3f2f
Short-circuit QP notifier when nothing drafted is involved
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m47s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m21s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Widening the tennis-sync gate to fire on any elimination means
notifyQualifyingPointsUpdate now runs on essentially every early-round
sync, since most eliminated players are undrafted. Fetch the draft picks
first and bail before the QP-total, season, participant, user, and Discord
lookups when no drafted participant earned QP or was knocked out this sync —
avoiding a full query battery just to send nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LkPWxSCunhPFknNUTXm4aZ
2026-07-03 15:10:07 +00:00
Claude
8900bf79dc
Announce drafted tennis players eliminated in non-scoring rounds
A player drafted in a tennis major (e.g. Jakob Mensik, out in the Round of
64) got no Discord announcement when the bracket was scored by sync. The
first three Grand Slam rounds are non-scoring, so an early-round loser earns
0 QP, gets no event_results row, and is dropped from the
"Qualifying Points Update" notification — the only announcement the tennis
sync emits mid-tournament.

Detect players knocked out on each sync and surface them:

- populateBracketFromDraw now returns newlyDecidedLoserIds: losers of
  matches that transition to complete on this run. Idempotent across
  re-syncs since playoff_matches persist, so a knockout is announced once.
- syncTennisDraw threads that set into notifyQualifyingPointsUpdate and
  fires the notification even when no QP changed.
- notifyQualifyingPointsUpdate builds an eliminated list scoped to players
  drafted in the league, deduped against QP earners (so a Round-of-16 loser
  who scores isn't listed twice), tagging the drafting manager.
- sendQualifyingPointsUpdateNotification renders a "Knocked Out" section and
  pings those managers; the QP Standings block is skipped when a sync only
  reports knockouts.

Tests cover the new detection, dedup, manager tagging, knockout-only
notifications, and rendering.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LkPWxSCunhPFknNUTXm4aZ
2026-07-03 14:42:06 +00:00
0c100aa834 Merge pull request 'claude/shared-tournaments-events-ux-6z5jkt' (#125) from claude/shared-tournaments-events-ux-6z5jkt into main
All checks were successful
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m19s
🚀 Deploy / 🧪 Test (push) Successful in 2m53s
🚀 Deploy / 🐳 Build (push) Successful in 1m11s
🚀 Deploy / 🚀 Deploy (push) Successful in 13s
Reviewed-on: #125
2026-07-02 18:30:18 +00:00
Claude
ec89e3eb89
Address review: don't auto-promote primary for golf; lighten window count
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m26s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m22s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
- Auto-heal now only promotes a new primary when the deleted event was
  itself the primary window. Golf-style shared majors intentionally have
  no primary (scored on the canonical tournament page), so deleting one
  of their windows no longer flips a sibling into a primary and changes
  its scoring/guard behavior. Adds a regression test for that case.
- Replace the relation-heavy getSportsSeasonsByTournament + double
  Array.find in the events loader with a new countWindowsByTournament
  helper (count(distinct sports_season_id)) and a single cached lookup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBzjCLVkVaQMj1MF3K54t2
2026-07-02 05:27:14 +00:00
Claude
7f7ea4e29d
Improve shared-tournament/event management UX
Deleting an event from a season only affects that season's scoring
event; the shared canonical tournament and the other linked seasons
survive. The old flow never communicated this and offered no way to
manage the relationship, so it felt like deleting an event might wipe
the whole shared tournament.

- deleteScoringEvent is now shared-tournament-aware: it auto-heals the
  primary window (promotes the earliest remaining window when the
  primary is removed) and optionally deletes the canonical tournament
  when the last linked window is removed. Returns a result describing
  what happened.
- Add deleteTournament model helper.
- Events page: delete confirmation now explains exactly what a delete
  does (removed from this season only vs. last window), with an opt-in
  checkbox to also remove the orphaned shared tournament.
- Tournament page: add a per-window "Remove" control on the Linked
  Sports Seasons card to unlink a season, reusing the auto-heal path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBzjCLVkVaQMj1MF3K54t2
2026-07-02 05:17:37 +00:00
7cbcc6fb14 Merge pull request 'add gantt chart' (#124) from claude/draft-schedule-gantt-9in9u9 into main
All checks were successful
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m26s
🚀 Deploy / 🧪 Test (push) Successful in 3m16s
🚀 Deploy / 🐳 Build (push) Successful in 1m12s
🚀 Deploy / 🚀 Deploy (push) Successful in 11s
Reviewed-on: #124
2026-07-02 03:44:48 +00:00
Claude
77963a13f4
Address code-review findings on draft schedule Gantt
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m1s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m18s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
- Use make_interval(months => $n) instead of parameterized interval
  multiplication so Postgres can infer the bound param's type (the prior
  form risked a "could not determine data type of parameter" runtime error)
- Fix subject/verb grammar in the coverage-gap warning for a single sport
  ("1 sport has" vs "N sports have")
- Stack overlapping draft windows for the same sport into separate lanes
  via greedy interval scheduling so bars no longer render on top of one
  another; row height grows with the number of concurrent windows
- Add a test covering overlapping windows rendering as separate bars

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd51U9DPMNz4KzBeucR6CK
2026-07-02 02:33:15 +00:00
Claude
7f021aa534
Add admin draft schedule Gantt chart
Adds an admin page at /admin/draft-schedule that visualizes sport-season
draft windows (draftOn -> draftOff) as a Gantt-style timeline: Y axis is
the sport, X axis is a 6- or 12-month horizon. A warning panel highlights
sports with no open or upcoming draft window so gaps in coverage are
obvious at a glance.

- New findDraftScheduleForHorizon model query (overlap test against the
  horizon, admin sport-seasons only)
- Presentational DraftScheduleGantt component with month gridlines, a
  "today" marker, and status-colored bars linking to each sport-season
- Route registered in routes.ts and linked from the admin nav
- Unit tests for the model query and component

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd51U9DPMNz4KzBeucR6CK
2026-07-02 00:44:23 +00:00
f527fc8f7f Merge pull request 'Fix Docker build failure caused by server-only import reaching client bundle' (#123) from claude/docker-build-output-vjywdf into main
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m1s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m18s
🚀 Deploy / 🐳 Build (push) Successful in 1m7s
🚀 Deploy / 🚀 Deploy (push) Successful in 14s
Reviewed-on: #123
2026-07-01 22:54:32 +00:00
Claude
ef7a71485c
Fix Docker build failure caused by server-only import reaching client bundle
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m3s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m19s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
scoring-event.ts exported getEventTypeLabel, a pure display helper used
directly in route components, from the same module that imports
scoring-calculator.ts (which now pulls in the Discord notification
service, a .server-only module). Because that whole chain was
value-imported into the client bundle, Vite's server-only-module guard
failed the build. Move getEventTypeLabel into a new client-safe
scoring-event-types.ts module with no server dependencies, and import
it directly where it's rendered.
2026-07-01 22:36:25 +00:00
6b0e32c3d5 claude/discord-qp-notifications-75wa7k (#122)
Some checks failed
🚀 Deploy / 🧪 Test (push) Successful in 3m6s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m20s
🚀 Deploy / 🐳 Build (push) Failing after 40s
🚀 Deploy / 🚀 Deploy (push) Has been skipped
Co-authored-by: Claude <noreply@anthropic.com>
Reviewed-on: #122
2026-07-01 22:14:51 +00:00
b0c25beb90 claude/discord-qualifying-points-t55s3x (#121)
Some checks failed
🚀 Deploy / 🐳 Build (push) Failing after 43s
🚀 Deploy / 🚀 Deploy (push) Has been skipped
🚀 Deploy / 🧪 Test (push) Successful in 2m53s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m18s
Co-authored-by: Claude <noreply@anthropic.com>
Reviewed-on: #121
2026-07-01 17:29:45 +00:00
64e5cb23ef claude/discord-ping-notification-filter-6m1ymt (#120)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m2s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m18s
🚀 Deploy / 🐳 Build (push) Successful in 1m8s
🚀 Deploy / 🚀 Deploy (push) Successful in 10s
Co-authored-by: Claude <noreply@anthropic.com>
Reviewed-on: #120
2026-06-30 23:48:11 +00:00
3e50619629 claude/probability-config-review-1tdolw (#119)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m1s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m17s
🚀 Deploy / 🐳 Build (push) Successful in 1m9s
🚀 Deploy / 🚀 Deploy (push) Successful in 11s
Co-authored-by: Claude <noreply@anthropic.com>
Reviewed-on: #119
2026-06-30 23:24:48 +00:00
8efa4aab9e Centralize futures odds onto the simulator page and fix bulk import (#117)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 2m51s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m16s
🚀 Deploy / 🐳 Build (push) Successful in 1m8s
🚀 Deploy / 🚀 Deploy (push) Successful in 12s
Futures odds entry was split across two unrelated places: a standalone
/futures-odds page (which auto-ran the simulation on save and surfaced a
"Simulator is not ready" run failure as if the save itself had failed) and
the Bulk Simulator Inputs CSV importer on the simulator setup page.

Consolidate everything onto the Bulk Simulator Inputs card:

- batchUpsertParticipantSimulatorInputs now COALESCE-wraps every conflict
  update column, so a partial paste (e.g. odds-only) updates just the columns
  it provides instead of nulling out previously stored Elo/rating/etc.
- The bulk importer accepts sportsbook-style paste (one team per line ending
  in American odds) when no CSV header is present, reusing the existing fuzzy
  matcher, and auto-runs the simulation on save. A not-ready run is reported
  as a successful save plus the readiness gap, never as a failed save.
- Retire the standalone /futures-odds page (now redirects to the simulator
  setup page) and drop the redundant Futures links.
- Remove the now-dead futures-only model paths (batchSaveSourceOdds,
  clearSourceOddsForParticipants, batchSaveFuturesOddsForSimulator,
  batchSaveParticipantSimulatorSourceOdds); the EV-table bridge is preserved
  by batchUpsertParticipantSimulatorInputs.
- Repoint the test to the consolidated path and assert the non-destructive
  COALESCE behaviour.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHnoQ7myY3PzNdTnd7iGNE

Co-authored-by: Claude <noreply@anthropic.com>
Reviewed-on: #117
2026-06-30 17:05:52 +00:00
3273cf1bcb claude/settings-menu-urls-kbp6x8 (#116)
All checks were successful
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m17s
🚀 Deploy / 🐳 Build (push) Successful in 1m9s
🚀 Deploy / 🧪 Test (push) Successful in 3m17s
🚀 Deploy / 🚀 Deploy (push) Successful in 14s
Co-authored-by: Claude <noreply@anthropic.com>
Reviewed-on: #116
2026-06-30 16:01:36 +00:00
070b825077 scoring notifications (#115)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 2m48s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m17s
🚀 Deploy / 🐳 Build (push) Successful in 1m11s
🚀 Deploy / 🚀 Deploy (push) Successful in 13s
2026-06-30 01:58:20 +00:00
e3e8485e26 elimination announcements (#114)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 2m53s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m17s
🚀 Deploy / 🐳 Build (push) Successful in 1m9s
🚀 Deploy / 🚀 Deploy (push) Successful in 12s
2026-06-28 19:36:33 +00:00
b8c21d227b Show bracket on tennis Grand Slam major event pages (#113)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m19s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m23s
🚀 Deploy / 🐳 Build (push) Successful in 1m11s
🚀 Deploy / 🚀 Deploy (push) Successful in 10s
## What

Tennis Grand Slam majors already store the full 128-player draw in `playoff_matches` (synced from Wikipedia), and the `PlayoffBracket` component + `tennis_128` template already exist — but users couldn't see any of it. The public league event page only rendered a bracket for `eventType === "playoff_game"`, while tennis majors are `major_tournament` events, so they fell through to the QP-only results table.

## Change

- New `kind: "tennis"` branch in the event loader, gated on `isBracketMajor(simulatorType) && eventType === "major_tournament"`. Loads the bracket (primary-keyed for shared majors, via the existing read-only-sibling ownership remap) plus this window's local QP results.
- The event page renders the full draw via the existing `PlayoffBracket`, followed by the QP results table.
- Extracted the duplicated results-table markup into a shared `QpResultsTable` used by both the `tennis` and `results` branches.
- "In Contention" table now sorts manager-drafted players first, then alphabetically by name.

CS2 majors (handled earlier) and golf (`isBracketMajor` false) are unaffected.

## Verification

- `npm run typecheck` — clean
- `npm run test:run` — 2533 passed
- `oxlint` — clean

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #113
2026-06-28 04:35:40 +00:00
dfbb25771d Merge pull request 'Auto-populate & auto-score tennis Grand Slam brackets from Wikipedia' (#112) from feat/tennis-draw-sync into main
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m26s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m22s
🚀 Deploy / 🐳 Build (push) Successful in 1m13s
🚀 Deploy / 🚀 Deploy (push) Successful in 12s
Reviewed-on: #112
2026-06-27 04:55:29 +00:00
Chris Parsons
81d063faa1 Auto-populate & auto-score tennis Grand Slam brackets from Wikipedia
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m13s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m23s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Add a per-event "Sync Draw" that pulls a tennis major's full 128-player
draw from its Wikipedia article, auto-creates/links participants (and
propagates them to every linked sibling season), builds the bracket, and
runs the qualifying-points scorer. Re-running advances the bracket and
scoring as matches complete.

Core
- match-sync: WikipediaTennisAdapter + wikitext bracket parser, DrawSync
  DTOs, syncTennisDraw orchestrator, pure draw->rows mapping
- playoff-match: populateBracketFromDraw (idempotent upsert on externalMatchId)
- scoring_events.externalSourceKey column (Wikipedia article; migration 0123)
- admin bracket "Sync Draw" card (accepts URL or title) + cron pass

Scoring fix
- deriveBracketQualifyingStates only floors players who have reached the
  scoring stage; for deep brackets (tennis_128) early-round losers earn 0 QP
  instead of a phantom 9th-place floor. CS2/simple_8 behavior preserved
  (gated on rounds[0].isScoring). Re-sync reconciles stale QP rows in a
  transaction.

Matching & parsing
- accent-folding in normalizeTeamName; strip Wikipedia "(tennis)"
  disambiguators; treat Bye/TBD/Qualifier as TBD; extract wikilink before
  template-stripping so {{nowrap}}-wrapped players parse

Admin UX
- dry-run preview (matched / will-create / possible duplicates / unfilled
  slots) with inline "rename existing" / "create as new" resolution via
  fetcher (no full reload)

Tests: parser vs real 2025 Wimbledon fixture, draw mapping, tennis_128
scoring, accent/disambiguator/nowrap parsing, URL parsing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:48:00 -07:00
120056b0bd claude/fix-lint-errors-n48594 (#111)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 2m57s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m21s
🚀 Deploy / 🐳 Build (push) Successful in 1m10s
🚀 Deploy / 🚀 Deploy (push) Successful in 10s
Co-authored-by: Claude <noreply@anthropic.com>
Reviewed-on: #111
2026-06-26 07:26:55 +00:00
ee099c64cd claude/clever-archimedes-dvye42 (#110)
Some checks failed
🚀 Deploy / 🧪 Test (push) Successful in 3m7s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Failing after 50s
🚀 Deploy / 🐳 Build (push) Has been skipped
🚀 Deploy / 🚀 Deploy (push) Has been skipped
Co-authored-by: Claude <noreply@anthropic.com>
Reviewed-on: #110
2026-06-26 05:16:54 +00:00
ab74b19420 Merge pull request 'Fix futures odds being ignored when stale Elo exists' (#109) from claude/determined-einstein-dzzjtk into main
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m1s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m21s
🚀 Deploy / 🐳 Build (push) Successful in 1m11s
🚀 Deploy / 🚀 Deploy (push) Successful in 13s
Reviewed-on: #109
2026-06-25 18:04:46 +00:00
Claude
88248e349c
Fix futures odds being ignored when stale Elo exists
All checks were successful
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m58s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m23s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
When an admin entered futures (preseason) odds for a season that already
had Elo ratings stored, the simulator kept using the old Elo and silently
ignored the new odds. This affected any Elo-based simulator (e.g. NHL).

Root cause: resolveSourceElos() ranks a direct sourceElo above the
sourceOdds -> convertFuturesToElo branch, but batchSaveFuturesOddsForSimulator()
only cleared the bracket-seeding `rating`/`ratingMethod` — never the stale
`sourceElo`/`sourceEloMethod`. A manually entered Elo (method "direct") is not
treated as generated, so it survived and short-circuited the resolver.

Fix:
- batchSaveFuturesOddsForSimulator now also nulls sourceElo and strips
  sourceEloMethod (both the pre-update and upsert-conflict paths), so the
  existing futures -> Elo conversion drives the run.
- resolveSourceElos' sourceOdds branch now guards for >= 2 participants
  (mirroring resolveRatings), so a lone-odds season falls through to the
  configured missing-Elo strategy instead of getting a flat ~1500.
- batchSaveSourceOdds clears the legacy EV sourceElo and marks source as
  futures_odds so the elo-ratings page won't resurrect a stale rating.

Adds unit coverage for odds-derived Elo, the single-participant guard, the
post-clear regression, generated-vs-direct sourceElo suppression, and the
new clearing behavior in batchSaveFuturesOddsForSimulator.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YNfUEd9RzD3zm84oLHBHUH
2026-06-25 17:43:31 +00:00
Chris Parsons
6772079e86 Reflect group stage in World Cup sim; seed R32 from real 2026 bracket
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m53s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m19s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🧪 Test (push) Successful in 3m6s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m22s
🚀 Deploy / 🐳 Build (push) Successful in 1m13s
🚀 Deploy / 🚀 Deploy (push) Successful in 13s
The World Cup Monte Carlo simulator ignored the admin-populated knockout
draw and re-paired its own simulated group output sequentially, so bracket
paths from the Round of 32 onward did not match the real tournament. It
also silently invented 12 synthetic groups when none were configured,
discarding real group-stage results.

Rework the simulator around three regimes:
- draw set     → simulate the real R32 matchups, advancing through the
  bracket tree via the canonical ceil(matchNumber/2) rule (matching
  advanceWinnerTemplate) so sim paths match the admin tooling.
- groups only  → simulate remaining group matches, then seed the R32 with
  the official 2026 group-position template + FIFA's exact Annex C
  best-third-place allocation (495-row published table).
- neither      → futures-seeded bracket fallback, flagged via result source.

Completed group and knockout matches still lock in real results at every
round.

New app/lib/fifa-2026-bracket.ts encodes the R32 group-position template
(official FIFA matches 73-88 mapped onto DB matchNumbers 1-16 so the tree
reproduces the real halves) and assignThirdPlaceSlots(). The Annex C table
is generated deterministically by scripts/gen-fifa-third-place.mjs into
app/lib/fifa-2026-third-place-allocation.ts.

Tests assert exact Annex C values, cross-check the template's eligible
groups against all 495 combinations, and cover each regime including the
partial-draw dedup and fully-drawn fast path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 12:15:45 -07:00
Chris Parsons
9480501932 Unify majors: score once, fan out across windows + tennis bracket EV
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m12s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m24s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🧪 Test (push) Successful in 3m27s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m25s
🚀 Deploy / 🐳 Build (push) Successful in 1m14s
🚀 Deploy / 🚀 Deploy (push) Successful in 14s
Make a "major" (golf/tennis/CS2) scored once on its canonical tournament
and fan out to every linked sports_season window and league.

Fan-out & completion (app/services/sync-tournament-results.ts):
- syncTournamentResults now marks each synced window's event complete
  (gated by markComplete), recalculates affected leagues, and counts
  recalc failures so a stale league can't hide behind a "completed" badge
- syncMajorFromPrimaryEvent promotes a primary window's derived results to
  canonical tournament_results (deleting rows for dropped placements) and
  fans out to siblings; fanOutMajorIfPrimary guards on the primary
- placement removals now propagate (stale rows reset to filler)

Primary-event model (scoring_events.is_primary, migration 0122):
- getPrimaryEventForTournament / isReadOnlySibling / ensurePrimaryEvent /
  setPrimaryEvent; event creation auto-seeds a primary for bracket majors;
  "Make primary" button on the tournament page
- per-window event/bracket/cs2 pages are read-only for non-primary linked
  events (not-participating stays editable)

Tennis Grand Slam bracket (tennis_128 template + TEMPLATE_ROUND_CONFIG):
- bracket-scored qualifying major via the existing bracket pipeline
- simulator conditions in-progress EV on the real bracket (honoring
  completed matches, walkover for withdrawals), QP derived from config,
  round structure read from the template; CS2 + tennis share resolveStructureSource

Backfill (scripts/backfill-major-linking.ts): one-time idempotent reconcile
of existing majors (link orphans, designate primary, promote canonical, sync).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:32:22 -07:00
Chris Parsons
b960d39be3 Add projections to full standings; clean up change indicators
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m23s
🚀 Deploy / 🧪 Test (push) Successful in 3m9s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m20s
🚀 Deploy / 🐳 Build (push) Successful in 1m9s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m23s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (push) Successful in 12s
Surface projected final points on the full standings page and restructure
the rank/point change indicators so columns align cleanly on desktop and
mobile.

- StatHelpers: stacked label/value/delta columns with a per-row reservable
  delta line; merge rank/point indicators into a single DeltaBadge;
  parameterize StatDivider height.
- StandingsPreview: opt-in `showProjected` column (projected points only),
  gated on participants remaining; reserve the delta line only when a row
  actually moved (no stray em-dashes).
- Full standings page: pass projected data + showProjected (hidden when the
  season is complete).
- League home: fetch via getSevenDayStandingsChange so the preview shows the
  same 7-day rank/point changes as the full standings page.
- LeagueRow: top-align stats and restore h-8 dividers so the shared-component
  changes don't alter the league list rows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 11:56:15 -07:00
4a5ea6fdc5 Merge pull request 'Add complete CS2 event reset to clear orphaned results/QP' (#102) from fix/cs2-complete-event-reset into main
All checks were successful
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m43s
🚀 Deploy / 🐳 Build (push) Successful in 1m25s
🚀 Deploy / 🚀 Deploy (push) Successful in 11s
🚀 Deploy / 🧪 Test (push) Successful in 3m15s
Reviewed-on: #102
2026-06-20 05:33:16 +00:00
Chris Parsons
dfbcb7d953 Add complete CS2 event reset to clear orphaned results/QP
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m14s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m41s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
The CS2 Major EV simulation silently discarded the Champions Stage
bracket for IEM Cologne 2026: the bracket seeded "Team Spirit" in QF1
while the stage assignments listed the (different) "Spirit Academy", so
the realBracket gate found an out-of-field participant and rejected the
whole bracket. The phantom "Spirit Academy" also left a stray
event_results row (33 rows for 32 teams).

The existing "Reset all" button only cleared cs2_major_stage_results, so
re-entering couldn't remove the phantom result or its cached QP total.

Add resetCs2Event, which also deletes the event's event_results and
recomputes the affected participants' cached QP totals — all inside a
single transaction so a mid-operation failure can't leave the event in a
torn state. It deliberately preserves the Champions Stage bracket so
re-entry realigns the stage data to it. The reset button now confirms
and states that results/QP are cleared too.

Tests: resetCs2Event clear+recompute behavior, and a simulator
regression pinning that one out-of-field QF participant disables the
whole bracket.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 22:22:11 -07:00
472875f151 claude/exciting-ride-q1jq8k (#101)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 2m55s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m42s
🚀 Deploy / 🐳 Build (push) Successful in 1m10s
🚀 Deploy / 🚀 Deploy (push) Successful in 15s
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #101
2026-06-20 03:56:28 +00:00
dedd2f3d86 claude/sweet-carson-46j5k6 (#100)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m23s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m19s
🚀 Deploy / 🐳 Build (push) Successful in 1m13s
🚀 Deploy / 🚀 Deploy (push) Successful in 12s
Co-authored-by: Claude <noreply@anthropic.com>
Reviewed-on: #100
2026-06-19 06:15:32 +00:00
f078eab492 claude/bracket-wins-minimum-qp-el8z14 (#99)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 2m35s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m24s
🚀 Deploy / 🐳 Build (push) Successful in 1m11s
🚀 Deploy / 🚀 Deploy (push) Successful in 11s
Co-authored-by: Claude <noreply@anthropic.com>
Reviewed-on: #99
2026-06-19 01:25:17 +00:00
d31c23d63b claude/practical-newton-4v041g (#98)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m32s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m21s
🚀 Deploy / 🐳 Build (push) Successful in 1m13s
🚀 Deploy / 🚀 Deploy (push) Successful in 10s
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #98
2026-06-18 02:33:05 +00:00
142 changed files with 30073 additions and 2541 deletions

View file

@ -9,17 +9,14 @@
"command": ".claude/hooks/lint-on-edit.sh",
"timeout": 30,
"statusMessage": "Linting..."
}
]
}
],
"Stop": [
{
"hooks": [
},
{
"type": "command",
"command": "output=$(npm run typecheck 2>&1); rc=$?; if [ $rc -ne 0 ]; then printf '{\"systemMessage\":\"TypeCheck failed:\\n%s\"}' \"$(echo \"$output\" | tail -30 | sed 's/\"/\\\\\"/g; s/$/\\\\n/' | tr -d '\\n')\"; fi",
"timeout": 60
"if": "Write(*.ts)|Write(*.tsx)|Edit(*.ts)|Edit(*.tsx)|MultiEdit(*.ts)|MultiEdit(*.tsx)",
"command": "output=$(npm run typecheck 2>&1); rc=$?; if [ $rc -ne 0 ]; then printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PostToolUse\",\"additionalContext\":\"TypeCheck failed:\\n%s\"}}' \"$(echo \"$output\" | tail -30 | sed 's/\"/\\\\\"/g; s/$/\\\\n/' | tr -d '\\n')\"; fi",
"timeout": 60,
"statusMessage": "Type-checking...",
"async": true
}
]
}

View file

@ -31,3 +31,8 @@ CLOUDINARY_API_SECRET=""
# Must match the CRON_SECRET repo secret in Forgejo.
# Generate with: openssl rand -hex 32
CRON_SECRET=""
# OC Blacktop motorsport API — used to sync IndyCar championship standings
# (fresher than ESPN's aggregate). Free tier at https://ocblacktop.com/api.
# If unset, IndyCar standings fall back to ESPN.
OCBLACKTOP_API_KEY=""

View file

@ -14,7 +14,7 @@ const scoringRules = {
};
describe("QualifyingPointsStandings", () => {
it("renders fractional QP to hundredths", () => {
it("renders fractional QP to at most hundredths with trailing zeros trimmed", () => {
render(
<QualifyingPointsStandings
standings={[
@ -52,7 +52,8 @@ describe("QualifyingPointsStandings", () => {
);
expect(screen.getByText("14.67 QP")).toBeInTheDocument();
expect(screen.getByText("0.50 QP")).toBeInTheDocument();
// 0.50 trims its trailing zero to 0.5; 14.67 and 0.43 are unaffected.
expect(screen.getByText("0.5 QP")).toBeInTheDocument();
expect(screen.getByText("0.43 QP")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,236 @@
import { Link } from "react-router";
import {
addMonths,
differenceInCalendarDays,
eachMonthOfInterval,
format,
parseISO,
} from "date-fns";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import type { DraftScheduleWindow } from "~/models/sports-season";
export interface GanttSport {
id: string;
name: string;
slug: string;
iconUrl: string | null;
windows: DraftScheduleWindow[];
}
interface DraftScheduleGanttProps {
sports: GanttSport[];
/** Horizon start (today) as a YYYY-MM-DD string. */
today: string;
/** Number of months the timeline spans. */
months: number;
}
// Bar color by sport-season status, using the theme chart tokens from app.css.
const STATUS_COLORS: Record<DraftScheduleWindow["status"], string> = {
active: "var(--chart-1)",
upcoming: "var(--chart-2)",
completed: "var(--muted-foreground)",
};
const STATUS_LABELS: Record<DraftScheduleWindow["status"], string> = {
active: "Active",
upcoming: "Upcoming",
completed: "Completed",
};
const clampPct = (n: number) => Math.max(0, Math.min(100, n));
// Row layout (px). A single-lane row is LANE_HEIGHT + ROW_V_PAD tall; extra
// concurrent windows add one lane each so overlapping bars never stack on top
// of one another.
const LANE_HEIGHT = 30;
const LANE_GAP = 8;
const ROW_V_PAD = 14;
const rowHeight = (laneCount: number) => Math.max(laneCount, 1) * LANE_HEIGHT + ROW_V_PAD;
/**
* Greedy interval-scheduling: assign each window to the first lane whose last
* bar ends before this one starts, otherwise open a new lane. Windows arrive
* sorted by draftOn (see findDraftScheduleForHorizon).
*/
function assignLanes(
windows: DraftScheduleWindow[],
start: Date
): { placed: Array<{ window: DraftScheduleWindow; lane: number }>; laneCount: number } {
const laneEnds: number[] = [];
const placed = windows.map((window) => {
const startDay = differenceInCalendarDays(parseISO(window.draftOn), start);
const endDay = differenceInCalendarDays(parseISO(window.draftOff), start);
let lane = laneEnds.findIndex((laneEnd) => laneEnd < startDay);
if (lane === -1) {
lane = laneEnds.length;
laneEnds.push(endDay);
} else {
laneEnds[lane] = endDay;
}
return { window, lane };
});
return { placed, laneCount: Math.max(laneEnds.length, 1) };
}
export function DraftScheduleGantt({ sports, today, months }: DraftScheduleGanttProps) {
const start = parseISO(today);
const end = addMonths(start, months);
const totalDays = Math.max(differenceInCalendarDays(end, start), 1);
const pct = (date: Date) => (differenceInCalendarDays(date, start) / totalDays) * 100;
// Month-boundary gridlines that fall inside the horizon.
const monthLines = eachMonthOfInterval({ start, end })
.map((date) => ({ date, left: pct(date) }))
.filter((m) => m.left >= 0 && m.left <= 100);
// Pre-compute lane assignment + height for each sport row.
const rows = sports.map((sport) => {
const { placed, laneCount } = assignLanes(sport.windows, start);
return { sport, placed, height: rowHeight(laneCount) };
});
if (sports.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Draft Schedule</CardTitle>
<CardDescription>Draft windows across the next {months} months</CardDescription>
</CardHeader>
<CardContent>
<div className="text-center py-12 text-muted-foreground">
<p>No sports found.</p>
<p className="text-sm mt-2">Create a sport to start scheduling draft windows.</p>
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle>Draft Schedule</CardTitle>
<CardDescription>
Draft windows across the next {months} months (each bar spans a
sport-season&rsquo;s draft-on &rarr; draft-off window)
</CardDescription>
</div>
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
{(Object.keys(STATUS_COLORS) as DraftScheduleWindow["status"][]).map((status) => (
<div key={status} className="flex items-center gap-1.5">
<span
className="h-3 w-3 rounded-sm"
style={{ backgroundColor: STATUS_COLORS[status] }}
aria-hidden="true"
/>
<span>{STATUS_LABELS[status]}</span>
</div>
))}
</div>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<div className="flex" style={{ minWidth: 160 + months * 80 }}>
{/* Sport labels column */}
<div className="w-40 shrink-0">
<div className="h-8" aria-hidden="true" />
{rows.map(({ sport, height }) => (
<div
key={sport.id}
style={{ height }}
className="flex items-center border-b border-border/50 pr-2"
>
<span className="truncate text-sm font-medium" title={sport.name}>
{sport.name}
</span>
</div>
))}
</div>
{/* Timeline column */}
<div className="relative flex-1">
{/* Month gridlines (full height) */}
{monthLines.map((m) => (
<div
key={`line-${m.date.toISOString()}`}
className="pointer-events-none absolute bottom-0 top-0 w-px bg-border/60"
style={{ left: `${m.left}%` }}
aria-hidden="true"
/>
))}
{/* Today marker (full height) */}
<div
className="pointer-events-none absolute bottom-0 top-0 w-0.5 bg-primary/80"
style={{ left: 0 }}
aria-hidden="true"
/>
{/* Month labels header */}
<div className="relative h-8">
{monthLines.map((m) => (
<span
key={`label-${m.date.toISOString()}`}
className="absolute top-1 text-xs text-muted-foreground"
style={{ left: `calc(${m.left}% + 4px)` }}
>
{format(m.date, "MMM yyyy")}
</span>
))}
</div>
{/* Rows */}
{rows.map(({ sport, placed, height }) => (
<div
key={sport.id}
style={{ height }}
className="relative border-b border-border/50"
>
{placed.length === 0 ? (
<span className="absolute left-2 top-1/2 -translate-y-1/2 text-xs italic text-muted-foreground/70">
No draft window
</span>
) : (
placed.map(({ window: w, lane }) => {
const left = clampPct(pct(parseISO(w.draftOn)));
const right = clampPct(pct(parseISO(w.draftOff)));
const width = Math.max(right - left, 0.75);
return (
<Link
key={w.id}
to={`/admin/sports-seasons/${w.id}`}
className="absolute flex items-center overflow-hidden rounded px-1.5 text-xs font-medium text-background transition-opacity hover:opacity-80"
style={{
left: `${left}%`,
width: `${width}%`,
top: ROW_V_PAD / 2 + lane * LANE_HEIGHT,
height: LANE_HEIGHT - LANE_GAP,
backgroundColor: STATUS_COLORS[w.status],
}}
title={`${w.name} (${w.year}) · ${w.draftOn}${w.draftOff}`}
>
<span className="truncate">{w.name}</span>
</Link>
);
})
)}
</div>
))}
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,97 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { DraftScheduleGantt, type GanttSport } from "../DraftScheduleGantt";
const nbaWindow = {
id: "ss-1",
name: "2026 NBA Playoffs",
year: 2026,
status: "upcoming" as const,
draftOn: "2026-08-01",
draftOff: "2026-09-15",
sport: { id: "nba", name: "NBA", slug: "nba", iconUrl: null },
};
function renderGantt(sports: GanttSport[], months = 6) {
return render(
<MemoryRouter>
<DraftScheduleGantt sports={sports} today="2026-07-02" months={months} />
</MemoryRouter>
);
}
describe("DraftScheduleGantt", () => {
it("renders empty state when there are no sports", () => {
renderGantt([]);
expect(screen.getByText("No sports found.")).toBeInTheDocument();
});
it("renders a bar linking to the sport-season for each draft window", () => {
renderGantt([
{ id: "nba", name: "NBA", slug: "nba", iconUrl: null, windows: [nbaWindow] },
]);
const bar = screen.getByRole("link", { name: /2026 NBA Playoffs/ });
expect(bar).toHaveAttribute("href", "/admin/sports-seasons/ss-1");
});
it("renders overlapping windows as separate, clickable bars", () => {
const overlappingWindow = {
id: "ss-2",
name: "2027 NBA Playoffs",
year: 2027,
status: "active" as const,
draftOn: "2026-08-15",
draftOff: "2026-10-01",
sport: { id: "nba", name: "NBA", slug: "nba", iconUrl: null },
};
renderGantt([
{
id: "nba",
name: "NBA",
slug: "nba",
iconUrl: null,
windows: [nbaWindow, overlappingWindow],
},
]);
expect(
screen.getByRole("link", { name: /2026 NBA Playoffs/ })
).toHaveAttribute("href", "/admin/sports-seasons/ss-1");
expect(
screen.getByRole("link", { name: /2027 NBA Playoffs/ })
).toHaveAttribute("href", "/admin/sports-seasons/ss-2");
});
it("shows a 'No draft window' hint for a sport with no windows", () => {
renderGantt([
{ id: "golf", name: "Golf", slug: "golf", iconUrl: null, windows: [] },
]);
expect(screen.getByText("Golf")).toBeInTheDocument();
expect(screen.getByText("No draft window")).toBeInTheDocument();
});
it("renders the status legend", () => {
renderGantt([
{ id: "nba", name: "NBA", slug: "nba", iconUrl: null, windows: [nbaWindow] },
]);
expect(screen.getByText("Active")).toBeInTheDocument();
expect(screen.getByText("Upcoming")).toBeInTheDocument();
expect(screen.getByText("Completed")).toBeInTheDocument();
});
it("reflects the horizon length in the description", () => {
renderGantt(
[{ id: "nba", name: "NBA", slug: "nba", iconUrl: null, windows: [nbaWindow] }],
12
);
expect(
screen.getByText(/Draft windows across the next 12 months/)
).toBeInTheDocument();
});
});

View file

@ -118,20 +118,23 @@ function ActiveRow({
</div>
{/* Stats — second row on mobile, right side on desktop */}
{showStats && (
<div className="flex items-center gap-4 border-t border-border/50 pt-2 sm:border-0 sm:pt-0 sm:shrink-0">
<div className="flex items-start gap-4 border-t border-border/50 pt-2 sm:border-0 sm:pt-0 sm:shrink-0">
{displayRank !== undefined && (
<RankingDisplay
displayRank={displayRank}
rankChange={previousRank !== undefined && currentRank !== undefined && previousRank !== currentRank ? previousRank - currentRank : undefined}
/>
)}
{currentRank !== undefined && totalPoints !== undefined && <StatDivider />}
{currentRank !== undefined && totalPoints !== undefined && <StatDivider className="h-8" />}
{totalPoints !== undefined && (
<StatColumn label="Points">
<span className="text-2xl font-bold leading-none text-electric">
{Math.round(totalPoints).toLocaleString("en-US")}
</span>
</StatColumn>
<StatColumn
label="Points"
value={
<span className="text-2xl font-bold leading-none text-electric">
{Math.round(totalPoints).toLocaleString("en-US")}
</span>
}
/>
)}
</div>
)}
@ -169,17 +172,21 @@ function PreDraftRow({
</div>
{/* Stats — second row on mobile, right side on desktop */}
<div className="flex items-center gap-4 border-t border-border/50 pt-2 sm:border-0 sm:pt-0 sm:shrink-0">
<StatColumn label="Draft">
<span className="text-2xl font-bold leading-none">{draftTimeValue}</span>
</StatColumn>
<StatColumn
label="Draft"
value={<span className="text-2xl font-bold leading-none">{draftTimeValue}</span>}
/>
{draftPosition !== undefined && (
<>
<StatDivider />
<StatColumn label="Position">
<span className="text-2xl font-bold leading-none">
{ordinal(draftPosition)}
</span>
</StatColumn>
<StatDivider className="h-8" />
<StatColumn
label="Position"
value={
<span className="text-2xl font-bold leading-none">
{ordinal(draftPosition)}
</span>
}
/>
</>
)}
</div>

View file

@ -137,6 +137,52 @@ export const FullLeague: Story = {
},
};
export const WithProjections: Story = {
args: {
showProjected: true,
entries: [
{
teamId: "t1",
teamName: "Lightning Wolves",
ownerName: "alice",
displayRank: 1,
currentRank: 1,
points: 2810,
rankChange: 2,
pointChange: 87.5,
projectedPoints: 3120,
participantsRemaining: 3,
href: "/leagues/1/standings/1/teams/t1",
},
{
teamId: "t2",
teamName: "Shadow Hawks",
ownerName: "bob",
displayRank: 2,
currentRank: 2,
points: 2654.5,
rankChange: -1,
pointChange: 42.0,
projectedPoints: 2980,
participantsRemaining: 2,
href: "/leagues/1/standings/1/teams/t2",
},
{
teamId: "t3",
teamName: "Iron Eagles",
ownerName: "carol",
displayRank: 3,
currentRank: 3,
points: 2493,
// No 7-day change and no live participants — placeholders keep alignment.
projectedPoints: 2493,
participantsRemaining: 0,
href: "/leagues/1/standings/1/teams/t3",
},
],
},
};
export const WithTies: Story = {
args: {
entries: [

View file

@ -5,7 +5,17 @@ import { Card, CardContent, CardHeader } from "~/components/ui/card";
import { GradientIcon } from "~/components/ui/GradientIcon";
import { TeamAvatar } from "~/components/TeamAvatar";
import type { AvatarData, RawFlagConfig } from "~/lib/flag-types";
import { StatColumn, StatDivider, PointChangeIndicator, RankingDisplay } from "./StatHelpers";
import { StatColumn, StatDivider, DeltaBadge, RankingDisplay } from "./StatHelpers";
/** Mirrors PointsDisplay gating: only project while the team still has participants live. */
function hasProjection(entry: StandingsPreviewEntry): boolean {
return (
entry.projectedPoints !== null &&
entry.projectedPoints !== undefined &&
entry.participantsRemaining !== undefined &&
entry.participantsRemaining > 0
);
}
// ─── Row styles ───────────────────────────────────────────────────────────────
@ -23,7 +33,20 @@ function rowClasses(currentRank: number | undefined, hasHref: boolean): string {
// ─── Row content ──────────────────────────────────────────────────────────────
function RowContent({ entry }: { entry: StandingsPreviewEntry }) {
function RowContent({
entry,
showProjected,
}: {
entry: StandingsPreviewEntry;
showProjected: boolean;
}) {
// Only reserve the delta line when this row actually changed. A row with no rank
// or point movement drops the line entirely (no "—" placeholders); rows that moved
// keep aligned columns by showing "—" for whichever stat didn't change.
const rowHasChange =
(entry.rankChange !== undefined && entry.rankChange !== 0) ||
(entry.pointChange !== undefined && entry.pointChange !== 0);
return (
<>
{/* Left: avatar + name */}
@ -46,16 +69,44 @@ function RowContent({ entry }: { entry: StandingsPreviewEntry }) {
{/* Right: stats — second row on mobile */}
<div className="flex items-center gap-4 w-full border-t border-border/30 pt-2 mt-1 sm:w-auto sm:border-0 sm:pt-0 sm:mt-0 sm:shrink-0">
<RankingDisplay displayRank={entry.displayRank} rankChange={entry.rankChange} />
<RankingDisplay
displayRank={entry.displayRank}
rankChange={entry.rankChange}
reserveDelta={rowHasChange}
/>
{showProjected && <StatDivider />}
{showProjected && (
<StatColumn
label="Projected"
reserveDelta={rowHasChange}
value={
hasProjection(entry) ? (
<span className="text-2xl font-normal leading-none text-muted-foreground">
{Math.round(entry.projectedPoints as number).toLocaleString("en-US")}
</span>
) : (
<span className="text-2xl font-normal leading-none text-muted-foreground/40">
</span>
)
}
/>
)}
<StatDivider />
<StatColumn label="Points">
<span className="text-2xl font-bold leading-none text-electric">
{Math.round(entry.points).toLocaleString("en-US")}
</span>
{entry.pointChange !== undefined && entry.pointChange !== 0 && (
<PointChangeIndicator delta={entry.pointChange} />
)}
</StatColumn>
<StatColumn
label="Points"
reserveDelta={rowHasChange}
value={
<span className="text-2xl font-bold leading-none text-electric">
{Math.round(entry.points).toLocaleString("en-US")}
</span>
}
delta={
entry.pointChange !== undefined && entry.pointChange !== 0 ? (
<DeltaBadge delta={entry.pointChange} kind="points" />
) : undefined
}
/>
</div>
</>
);
@ -81,15 +132,26 @@ export interface StandingsPreviewEntry {
rankChange?: number;
/** 7-day point delta. From TeamStanding.sevenDayPointChange. */
pointChange?: number;
/** Projected final points. From TeamStanding.projectedPoints. */
projectedPoints?: number | null;
/** Live participants left; projection only shown while > 0. */
participantsRemaining?: number;
}
export interface StandingsPreviewProps {
entries: StandingsPreviewEntry[];
description?: string;
fullStandingsHref?: string;
/** When true, adds a "Projected" column (projected final points). Default false. */
showProjected?: boolean;
}
export function StandingsPreview({ entries, description, fullStandingsHref }: StandingsPreviewProps) {
export function StandingsPreview({
entries,
description,
fullStandingsHref,
showProjected = false,
}: StandingsPreviewProps) {
return (
<Card className="gap-2">
<CardHeader className="px-3 sm:px-6 pb-2">
@ -119,11 +181,11 @@ export function StandingsPreview({ entries, description, fullStandingsHref }: St
to={entry.href}
className={rowClasses(entry.currentRank, true)}
>
<RowContent entry={entry} />
<RowContent entry={entry} showProjected={showProjected} />
</Link>
) : (
<div key={entry.teamId} className={rowClasses(entry.currentRank, false)}>
<RowContent entry={entry} />
<RowContent entry={entry} showProjected={showProjected} />
</div>
)
)}

View file

@ -1,30 +1,71 @@
/**
* Stacked stat column: label / value / delta. The delta slot is always rendered
* (a muted "—" placeholder when there is no change) so that every row reserves the
* same vertical space and the columns line up across the standings table.
*/
export function StatColumn({
label,
children,
value,
delta,
reserveDelta = false,
}: {
label: string;
children: React.ReactNode;
value: React.ReactNode;
/** Optional change indicator rendered on its own line below the value. */
delta?: React.ReactNode;
/**
* When true, the delta line is always rendered (a muted placeholder when there is
* no delta) so columns stay vertically aligned across rows. When false and no delta
* is provided, the line is omitted entirely.
*/
reserveDelta?: boolean;
}) {
const showDeltaLine = delta !== undefined || reserveDelta;
return (
<div className="text-right shrink-0 flex-1 sm:flex-none">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{label}
</p>
<div className="flex items-baseline justify-end gap-1">{children}</div>
<div className="flex items-baseline justify-end">{value}</div>
{showDeltaLine && (
<div className="flex items-baseline justify-end leading-none mt-0.5">
{delta ?? <span className="text-xs text-muted-foreground/60"></span>}
</div>
)}
</div>
);
}
export function StatDivider() {
return <div className="h-8 w-px bg-border shrink-0 self-center" />;
export function StatDivider({ className = "h-12" }: { className?: string } = {}) {
return <div className={`${className} w-px bg-border shrink-0 self-center`} />;
}
export function RankChangeIndicator({ delta }: { delta: number }) {
if (delta === 0) return null;
if (delta > 0) {
/**
* 7-day change indicator. `rank` deltas are unitless places; `points` deltas are
* rounded point totals. Positive = up (green ), negative = down (coral ).
*/
export function DeltaBadge({
delta,
kind,
}: {
delta: number;
kind: "rank" | "points";
}) {
const magnitude = kind === "rank" ? Math.abs(delta) : Math.abs(Math.round(delta));
const up = delta > 0;
const label =
kind === "rank"
? up
? `up ${magnitude}`
: `down ${magnitude}`
: up
? `+${magnitude} points`
: `-${magnitude} points`;
if (up) {
return (
<span className="text-xs font-semibold text-primary" aria-label={`up ${delta}`}>
{delta}
<span className="text-xs font-semibold text-primary" aria-label={label}>
{magnitude}
</span>
);
}
@ -32,9 +73,9 @@ export function RankChangeIndicator({ delta }: { delta: number }) {
<span
className="text-xs font-semibold"
style={{ color: "var(--coral-accent, #ef4444)" }}
aria-label={`down ${Math.abs(delta)}`}
aria-label={label}
>
{Math.abs(delta)}
{magnitude}
</span>
);
}
@ -42,35 +83,22 @@ export function RankChangeIndicator({ delta }: { delta: number }) {
export function RankingDisplay({
displayRank,
rankChange,
reserveDelta = false,
}: {
displayRank: string | number;
rankChange?: number;
reserveDelta?: boolean;
}) {
return (
<StatColumn label="Ranking">
<span className="text-2xl font-bold leading-none">{displayRank}</span>
{rankChange !== undefined && rankChange !== 0 && (
<RankChangeIndicator delta={rankChange} />
)}
</StatColumn>
);
}
export function PointChangeIndicator({ delta }: { delta: number }) {
if (delta >= 0) {
return (
<span className="text-xs font-semibold text-primary" aria-label={`+${Math.round(delta)} points`}>
{Math.round(delta)}
</span>
);
}
return (
<span
className="text-xs font-semibold"
style={{ color: "var(--coral-accent, #ef4444)" }}
aria-label={`${Math.round(delta)} points`}
>
{Math.abs(Math.round(delta))}
</span>
<StatColumn
label="Ranking"
reserveDelta={reserveDelta}
value={<span className="text-2xl font-bold leading-none">{displayRank}</span>}
delta={
rankChange !== undefined && rankChange !== 0 ? (
<DeltaBadge delta={rankChange} kind="rank" />
) : undefined
}
/>
);
}

View file

@ -103,4 +103,54 @@ describe("StandingsPreview", () => {
expect(link).toHaveAttribute("href", "/leagues/1/standings/1");
});
});
describe("Projections", () => {
it("does not render a Projected column by default", () => {
const entry: StandingsPreviewEntry = {
...baseEntry,
projectedPoints: 3000,
participantsRemaining: 3,
};
renderWithRouter(<StandingsPreview entries={[entry]} />);
expect(screen.queryByText("Projected")).not.toBeInTheDocument();
expect(screen.queryByText("3,000")).not.toBeInTheDocument();
});
it("renders the projected value when showProjected and participants remain", () => {
const entry: StandingsPreviewEntry = {
...baseEntry,
projectedPoints: 3000,
participantsRemaining: 3,
};
renderWithRouter(<StandingsPreview entries={[entry]} showProjected />);
expect(screen.getByText("Projected")).toBeInTheDocument();
expect(screen.getByText("3,000")).toBeInTheDocument();
});
it("shows a placeholder instead of a value when no participants remain", () => {
const entry: StandingsPreviewEntry = {
...baseEntry,
projectedPoints: 9999,
participantsRemaining: 0,
};
renderWithRouter(<StandingsPreview entries={[entry]} showProjected />);
// Column header still present, but the projected total is not shown.
expect(screen.getByText("Projected")).toBeInTheDocument();
expect(screen.queryByText("9,999")).not.toBeInTheDocument();
});
it("shows a placeholder when projectedPoints is missing", () => {
const entry: StandingsPreviewEntry = {
...baseEntry,
projectedPoints: null,
participantsRemaining: 3,
};
renderWithRouter(<StandingsPreview entries={[entry]} showProjected />);
expect(screen.getByText("Projected")).toBeInTheDocument();
});
});
});

View file

@ -1,6 +1,7 @@
import type { ComponentType } from "react";
import type { LucideProps } from "lucide-react";
import { ArrowLeft } from "lucide-react";
import { Link } from "react-router";
import { cn } from "~/lib/utils";
type SettingsNavSection = {
@ -17,10 +18,37 @@ export type SettingsGridSection = SettingsNavSection & {
export function SettingsMobileGridNav({
sections,
onSectionChange,
buildHref,
}: {
sections: readonly SettingsGridSection[];
onSectionChange: (sectionId: string) => void;
onSectionChange?: (sectionId: string) => void;
buildHref?: (sectionId: string) => string;
}) {
const cardClassName = (section: SettingsGridSection) =>
cn(
"flex cursor-pointer flex-col gap-3 rounded-xl border bg-card p-4 text-left transition-colors hover:border-primary/40 active:scale-[0.98]",
section.isDanger && "border-destructive/40"
);
const cardInner = (section: SettingsGridSection) => (
<>
<div
className={cn(
"flex h-10 w-10 items-center justify-center rounded-lg",
section.isDanger
? "bg-destructive/15 text-destructive"
: "bg-primary/20 text-primary"
)}
>
<section.icon className="h-5 w-5" />
</div>
<div>
<p className="text-sm font-semibold leading-tight">{section.label}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{section.subtitle}</p>
</div>
</>
);
return (
<div className="mb-5 lg:hidden">
<div className="mb-3">
@ -29,32 +57,22 @@ export function SettingsMobileGridNav({
</p>
</div>
<div className="grid grid-cols-2 gap-3">
{sections.map((section) => (
<button
key={section.id}
type="button"
onClick={() => onSectionChange(section.id)}
className={cn(
"flex cursor-pointer flex-col gap-3 rounded-xl border bg-card p-4 text-left transition-colors hover:border-primary/40 active:scale-[0.98]",
section.isDanger && "border-destructive/40"
)}
>
<div
className={cn(
"flex h-10 w-10 items-center justify-center rounded-lg",
section.isDanger
? "bg-destructive/15 text-destructive"
: "bg-primary/20 text-primary"
)}
{sections.map((section) =>
buildHref ? (
<Link key={section.id} to={buildHref(section.id)} className={cardClassName(section)}>
{cardInner(section)}
</Link>
) : (
<button
key={section.id}
type="button"
onClick={() => onSectionChange?.(section.id)}
className={cardClassName(section)}
>
<section.icon className="h-5 w-5" />
</div>
<div>
<p className="text-sm font-semibold leading-tight">{section.label}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{section.subtitle}</p>
</div>
</button>
))}
{cardInner(section)}
</button>
)
)}
</div>
</div>
);
@ -62,19 +80,30 @@ export function SettingsMobileGridNav({
export function SettingsMobileSectionPill({
onShowGrid,
backHref,
}: {
onShowGrid: () => void;
onShowGrid?: () => void;
backHref?: string;
}) {
const className =
"flex cursor-pointer items-center gap-1.5 rounded-full border bg-card px-3 py-1.5 text-sm font-medium hover:bg-muted";
const content = (
<>
<ArrowLeft className="h-3.5 w-3.5" />
Back to all settings
</>
);
return (
<div className="mb-5 lg:hidden">
<button
type="button"
onClick={onShowGrid}
className="flex cursor-pointer items-center gap-1.5 rounded-full border bg-card px-3 py-1.5 text-sm font-medium hover:bg-muted"
>
<ArrowLeft className="h-3.5 w-3.5" />
Back to all settings
</button>
{backHref ? (
<Link to={backHref} className={className}>
{content}
</Link>
) : (
<button type="button" onClick={onShowGrid} className={className}>
{content}
</button>
)}
</div>
);
}
@ -83,33 +112,57 @@ export function SettingsDesktopNav({
sections,
activeSection,
onSectionChange,
buildHref,
navLabel = "League settings",
}: {
sections: readonly SettingsGridSection[];
activeSection: string;
onSectionChange: (sectionId: string) => void;
onSectionChange?: (sectionId: string) => void;
buildHref?: (sectionId: string) => string;
navLabel?: string;
}) {
const itemClassName = (section: SettingsGridSection) =>
cn(
"flex w-full cursor-pointer items-center gap-2.5 rounded-md px-3 py-2 text-left text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground",
activeSection === section.id && "bg-muted text-foreground"
);
const itemInner = (section: SettingsGridSection) => (
<>
<section.icon className={cn("h-4 w-4 shrink-0", section.isDanger && "text-destructive")} aria-hidden="true" />
{section.label}
</>
);
return (
<aside className="hidden lg:block">
<div className="sticky top-6 rounded-xl border bg-card p-3">
<p className="px-3 pb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Manage
</p>
<nav aria-label="League settings" className="space-y-1">
{sections.map((section) => (
<button
key={section.id}
type="button"
onClick={() => onSectionChange(section.id)}
aria-current={activeSection === section.id ? "page" : undefined}
className={cn(
"flex w-full cursor-pointer items-center gap-2.5 rounded-md px-3 py-2 text-left text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground",
activeSection === section.id && "bg-muted text-foreground"
)}
>
<section.icon className={cn("h-4 w-4 shrink-0", section.isDanger && "text-destructive")} aria-hidden="true" />
{section.label}
</button>
))}
<nav aria-label={navLabel} className="space-y-1">
{sections.map((section) =>
buildHref ? (
<Link
key={section.id}
to={buildHref(section.id)}
aria-current={activeSection === section.id ? "page" : undefined}
className={itemClassName(section)}
>
{itemInner(section)}
</Link>
) : (
<button
key={section.id}
type="button"
onClick={() => onSectionChange?.(section.id)}
aria-current={activeSection === section.id ? "page" : undefined}
className={itemClassName(section)}
>
{itemInner(section)}
</button>
)
)}
</nav>
</div>
</aside>

View file

@ -0,0 +1,67 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { Bell, User } from "lucide-react";
import {
SettingsDesktopNav,
SettingsMobileGridNav,
SettingsMobileSectionPill,
type SettingsGridSection,
} from "../SettingsNavigation";
const sections: readonly SettingsGridSection[] = [
{ id: "profile", label: "Profile", icon: User, subtitle: "Name, avatar" },
{ id: "notifications", label: "Notifications", icon: Bell, subtitle: "Email & Discord" },
];
describe("SettingsNavigation link mode", () => {
it("renders desktop nav entries as anchors built from buildHref", () => {
render(
<MemoryRouter>
<SettingsDesktopNav
sections={sections}
activeSection="profile"
buildHref={(id) => `/settings/${id}`}
/>
</MemoryRouter>
);
expect(screen.getByRole("link", { name: "Profile" })).toHaveAttribute("href", "/settings/profile");
expect(screen.getByRole("link", { name: "Notifications" })).toHaveAttribute(
"href",
"/settings/notifications"
);
expect(screen.getByRole("link", { name: "Profile" })).toHaveAttribute("aria-current", "page");
});
it("renders the mobile grid as anchors and the back pill as an anchor", () => {
render(
<MemoryRouter>
<SettingsMobileGridNav sections={sections} buildHref={(id) => `/settings/${id}`} />
<SettingsMobileSectionPill backHref="/settings" />
</MemoryRouter>
);
expect(screen.getByRole("link", { name: /Profile/i })).toHaveAttribute("href", "/settings/profile");
expect(screen.getByRole("link", { name: /back to all settings/i })).toHaveAttribute(
"href",
"/settings"
);
});
});
describe("SettingsNavigation button mode (league settings)", () => {
it("still fires onSectionChange when no buildHref is provided", () => {
const onSectionChange = vi.fn();
render(
<SettingsDesktopNav
sections={sections}
activeSection="profile"
onSectionChange={onSectionChange}
/>
);
fireEvent.click(screen.getByRole("button", { name: "Notifications" }));
expect(onSectionChange).toHaveBeenCalledWith("notifications");
});
});

View file

@ -13,7 +13,7 @@ import { GradientIcon } from "~/components/ui/GradientIcon";
import { RankingsRow } from "./RankingsRow";
import { BracketTreeView, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
import { BracketTreePaginated } from "./BracketTreePaginated";
import { getBracketTemplate } from "~/lib/bracket-templates";
import { getBracketTemplate, type BracketTemplate } from "~/lib/bracket-templates";
import { NbaBracketLayout } from "./NbaBracketLayout";
import { TabbedBracketLayout } from "./TabbedBracketLayout";
@ -174,6 +174,138 @@ export function computeEliminatedByRound(
return result;
}
/** The score recorded for one participant in a match, or null if they didn't play in it. */
function participantScore(match: Match, participantId: string | null): string | null {
if (!participantId) return null;
if (participantId === match.participant1Id) return match.participant1Score;
if (participantId === match.participant2Id) return match.participant2Score;
return null;
}
/**
* A consolation final: a round contested by the losers of an earlier round, which
* splits the positions those losers would otherwise share. FIFA's "Third Place Game"
* (fed by the Semifinals) is the only one in the templates today.
*/
export interface ConsolationRound {
/** The consolation round itself, e.g. "Third Place Game". */
round: string;
/** The round whose losers contest it, e.g. "Semifinals". */
feederRound: string;
}
/**
* Find the template's consolation round, if it has one.
* Exported for unit testing.
*/
export function findConsolationRound(
template: BracketTemplate | undefined
): ConsolationRound | undefined {
const feeder = template?.rounds.find((r) => r.loserFeedsInto);
if (!feeder?.loserFeedsInto) return undefined;
return { round: feeder.loserFeedsInto, feederRound: feeder.name };
}
/**
* Build the ordered final-rankings list from completed matches.
*
* Ranks are derived by walking rounds latest-first: the final's loser is 2nd, the
* previous round's losers share the next tier, and so on each round consuming as
* many positions as it has matches.
*
* A consolation round needs different handling, because its winner never loses a
* match and so the loser-driven walk above would leave them unranked and "in
* contention" forever. Its two places are exactly the top of the tier its feeder
* round's losers would otherwise share, so it is resolved *at the feeder round*
* the winner takes that tier's first position and the loser the second and the
* consolation round itself consumes no positions. Positions are derived rather than
* hardcoded, so a consolation round hanging off a different feeder still lands right.
*
* Exported for unit testing.
*/
export function computeRankedEntries(
matches: Match[],
rounds: string[],
matchesByRound: Map<string, Match[]>,
consolation: ConsolationRound | undefined,
ownershipMap: Map<string, TeamOwnership>
): EliminatedEntry[] {
const eliminatedByRound = computeEliminatedByRound(matches, rounds);
// Only take the consolation path when both rounds actually have matches; otherwise
// fall through to the loser-driven walk so nothing is dropped.
const consolationActive =
consolation &&
rounds.includes(consolation.round) &&
rounds.includes(consolation.feederRound);
// Consolation matches we can place exactly. Anything else in that round (still in
// progress, or missing its hydrated winner/loser) deliberately stays eligible for
// the loser-driven walk rather than being silently dropped.
const consolationMatches =
consolationActive && consolation
? (matchesByRound.get(consolation.round) ?? []).filter(
(m): m is Match & { winner: Participant; loser: Participant } =>
m.isComplete && !!m.winner && !!m.loser
)
: [];
const exactlyPlacedMatchIds = new Set(consolationMatches.map((m) => m.id));
const entryFor = (
match: Match,
participant: Participant,
participantId: string | null
): Omit<EliminatedEntry, "rankLabel"> => ({
participant,
score: participantScore(match, participantId),
ownership: ownershipMap.get(participant.id) || null,
});
const losersByRound = new Map<string, Omit<EliminatedEntry, "rankLabel">[]>();
for (const match of matches) {
if (!match.isComplete || !match.loser) continue;
if (exactlyPlacedMatchIds.has(match.id)) continue;
if (!eliminatedByRound.get(match.round)?.includes(match.loser.id)) continue;
if (!losersByRound.has(match.round)) losersByRound.set(match.round, []);
losersByRound.get(match.round)?.push(entryFor(match, match.loser, match.loserId));
}
const rankedEntries: EliminatedEntry[] = [];
let nextRank = 2;
for (let ri = rounds.length - 1; ri >= 0; ri--) {
const roundName = rounds[ri];
// The consolation match splits the top of its feeder round's tier, so it is
// placed first and the round's remaining losers share what's left below it.
let tierRank = nextRank;
if (consolationActive && roundName === consolation?.feederRound) {
for (const match of consolationMatches) {
rankedEntries.push({
...entryFor(match, match.winner, match.winnerId),
rankLabel: `${tierRank}`,
});
rankedEntries.push({
...entryFor(match, match.loser, match.loserId),
rankLabel: `${tierRank + 1}`,
});
tierRank += 2;
}
}
for (const loser of losersByRound.get(roundName) ?? []) {
rankedEntries.push({ ...loser, rankLabel: `T${tierRank}` });
}
// The consolation round's places belong to its feeder round's tier, so it
// consumes none of its own.
if (consolationActive && roundName === consolation?.round) continue;
nextRank += matchesByRound.get(roundName)?.length ?? 0;
}
return rankedEntries;
}
/** Find the index of the first round that has scoring matches. */
function firstScoringRoundIdx(matchesByRound: Map<string, Match[]>, rounds: string[]): number {
for (let i = 0; i < rounds.length; i++) {
@ -216,12 +348,10 @@ export function PlayoffBracket({
const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds);
const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined;
const thirdPlaceRound = template?.rounds
.find((r) => template.rounds.some((other) => other.loserFeedsInto === r.name))
?.name;
const consolation = findConsolationRound(template);
const thirdPlaceRound = consolation?.round;
// Build elimination rankings
const losersByRound = new Map<string, Array<{ participant: Participant; score: string | null; ownership: TeamOwnership | null }>>();
let bracketWinner: Participant | null = null;
const lastRound = rounds[rounds.length - 1];
@ -230,43 +360,19 @@ export function PlayoffBracket({
: null;
if (finalMatch?.winner) bracketWinner = finalMatch.winner;
const eliminatedByRound = computeEliminatedByRound(matches, rounds);
for (const match of matches) {
if (!match.isComplete || !match.loser) continue;
const eliminatedInRound = eliminatedByRound.get(match.round);
if (!eliminatedInRound?.includes(match.loser.id)) continue;
const loserScore =
match.loserId === match.participant1Id
? match.participant1Score
: match.participant2Score;
if (!losersByRound.has(match.round)) losersByRound.set(match.round, []);
losersByRound.get(match.round)?.push({
participant: match.loser,
score: loserScore,
ownership: ownershipMap.get(match.loser.id) || null,
});
}
const allBracketParticipantIds = new Set<string>();
for (const match of matches) {
if (match.participant1Id) allBracketParticipantIds.add(match.participant1Id);
if (match.participant2Id) allBracketParticipantIds.add(match.participant2Id);
}
const rankedEntries: EliminatedEntry[] = [];
let nextRank = 2;
for (let ri = rounds.length - 1; ri >= 0; ri--) {
const roundName = rounds[ri];
const roundLosers = losersByRound.get(roundName) || [];
const totalMatchesInRound = matchesByRound.get(roundName)?.length ?? 0;
if (roundLosers.length > 0) {
const rankLabel = `T${nextRank}`;
for (const loser of roundLosers) {
rankedEntries.push({ ...loser, rankLabel });
}
}
nextRank += totalMatchesInRound;
}
const rankedEntries = computeRankedEntries(
matches,
rounds,
matchesByRound,
consolation,
ownershipMap
);
const rankedParticipantIds = new Set(rankedEntries.map((e) => e.participant.id));
if (bracketWinner) rankedParticipantIds.add(bracketWinner.id);
@ -284,7 +390,14 @@ export function PlayoffBracket({
const activeParticipants = [...allBracketParticipantIds]
.filter((id) => !rankedParticipantIds.has(id))
.map((id) => participantMap.get(id))
.filter((p): p is Participant => p !== undefined);
.filter((p): p is Participant => p !== undefined)
// Owned-by-a-manager players first, then alphabetical by name.
.toSorted((a, b) => {
const aOwned = ownershipMap.has(a.id);
const bOwned = ownershipMap.has(b.id);
if (aOwned !== bOwned) return aOwned ? -1 : 1;
return a.name.localeCompare(b.name);
});
const isDraftedOrScoring = (participantId: string) =>
ownershipMap.has(participantId) || (pointsMap.get(participantId) ?? 0) > 0;

View file

@ -55,7 +55,9 @@ interface QualifyingPointsStandingsProps {
function formatQP(raw: string): string {
const n = parseFloat(raw);
if (isNaN(n)) return "—";
return n % 1 === 0 ? n.toString() : n.toFixed(2);
// At most 2 decimals, trailing zeros trimmed (1.50→1.5). Mirrors the Discord
// embed's formatQPValue (app/services/discord.ts) so the site and Discord agree.
return parseFloat(n.toFixed(2)).toString();
}
export function QualifyingPointsStandings({

View file

@ -1,5 +1,15 @@
import { describe, it, expect } from "vitest";
import { buildFeederMap, groupMatchesByRound, computeEliminatedByRound } from "../PlayoffBracket";
import { render, screen, within } from "@testing-library/react";
import {
PlayoffBracket,
buildFeederMap,
groupMatchesByRound,
computeEliminatedByRound,
computeRankedEntries,
findConsolationRound,
type Match,
} from "../PlayoffBracket";
import { getBracketTemplate } from "~/lib/bracket-templates";
// ---------------------------------------------------------------------------
// Helpers
@ -291,3 +301,392 @@ describe("computeEliminatedByRound", () => {
});
});
});
// ---------------------------------------------------------------------------
// computeRankedEntries — consolation ("third place") round handling
// ---------------------------------------------------------------------------
// fifa_48 round order. The Third Place Game sits between the Semifinals (whose
// losers feed it) and the Finals.
const FIFA_ROUNDS = ["Quarterfinals", "Semifinals", "Third Place Game", "Finals"];
const FIFA_CONSOLATION = {
round: "Third Place Game",
feederRound: "Semifinals",
};
type MatchOpts = {
/** Which slot the winner occupies. Defaults to 1. */
winnerSlot?: 1 | 2;
winnerScore?: string;
loserScore?: string;
/**
* Drop the hydrated `winner` relation, keeping `loser` and both ids the shape a
* hand-built match object can arrive in. The loser is still placeable this way.
*/
missingWinnerRelation?: boolean;
};
/** A completed match. */
function makeRankedMatch(
round: string,
matchNumber: number,
winnerId: string,
loserId: string,
opts: MatchOpts = {}
): Match {
const winnerIsP1 = (opts.winnerSlot ?? 1) === 1;
const p1 = winnerIsP1 ? winnerId : loserId;
const p2 = winnerIsP1 ? loserId : winnerId;
return {
id: `${round}-${matchNumber}`,
round,
matchNumber,
participant1Id: p1,
participant2Id: p2,
winnerId,
loserId,
isComplete: true,
participant1Score: (winnerIsP1 ? opts.winnerScore : opts.loserScore) ?? null,
participant2Score: (winnerIsP1 ? opts.loserScore : opts.winnerScore) ?? null,
participant1: { id: p1, name: p1 },
participant2: { id: p2, name: p2 },
winner: opts.missingWinnerRelation ? null : { id: winnerId, name: winnerId },
loser: { id: loserId, name: loserId },
};
}
/**
* A scheduled-but-unplayed match. Bracket rows are pre-generated with their slots
* filled as earlier rounds resolve, so an unplayed 3PG still lists both SF losers.
*/
function makePendingMatch(
round: string,
matchNumber: number,
participant1Id: string | null,
participant2Id: string | null
): Match {
return {
id: `${round}-${matchNumber}`,
round,
matchNumber,
participant1Id,
participant2Id,
winnerId: null,
loserId: null,
isComplete: false,
participant1Score: null,
participant2Score: null,
participant1: participant1Id ? { id: participant1Id, name: participant1Id } : null,
participant2: participant2Id ? { id: participant2Id, name: participant2Id } : null,
winner: null,
loser: null,
};
}
/** A full fifa_48-shaped knockout tail: 4 QF, 2 SF, the 3PG, and the Final. */
function fifaMatches(
overrides: { played?: boolean; thirdPlace?: Match } = {}
): Match[] {
const played = overrides.played ?? true;
return [
makeRankedMatch("Quarterfinals", 1, "sfA", "qf1"),
makeRankedMatch("Quarterfinals", 2, "sfB", "qf2"),
makeRankedMatch("Quarterfinals", 3, "sfC", "qf3"),
makeRankedMatch("Quarterfinals", 4, "sfD", "qf4"),
makeRankedMatch("Semifinals", 1, "sfA", "sfB"),
makeRankedMatch("Semifinals", 2, "sfC", "sfD"),
overrides.thirdPlace ??
(played
? makeRankedMatch("Third Place Game", 1, "sfB", "sfD")
: makePendingMatch("Third Place Game", 1, "sfB", "sfD")),
played
? makeRankedMatch("Finals", 1, "sfA", "sfC")
: makePendingMatch("Finals", 1, "sfA", "sfC"),
];
}
/** Rank the fifa fixture, defaulting to the fifa_48 consolation config. */
function rankFifa(
matches: Match[] = fifaMatches(),
ownership: Map<string, { participantId: string; teamName: string; teamId: string }> = new Map(),
consolation: typeof FIFA_CONSOLATION | undefined = FIFA_CONSOLATION
) {
return computeRankedEntries(
matches,
FIFA_ROUNDS,
groupMatchesByRound(matches),
consolation,
ownership
);
}
function rankOf(entries: ReturnType<typeof computeRankedEntries>, id: string) {
return entries.find((e) => e.participant.id === id)?.rankLabel;
}
describe("findConsolationRound", () => {
it("identifies the fifa_48 third place game and the round that feeds it", () => {
expect(findConsolationRound(getBracketTemplate("fifa_48"))).toEqual({
round: "Third Place Game",
feederRound: "Semifinals",
});
});
it("returns undefined for a template with no consolation round", () => {
expect(findConsolationRound(getBracketTemplate("ncaa_64"))).toBeUndefined();
});
it("returns undefined when there is no template", () => {
expect(findConsolationRound(undefined)).toBeUndefined();
});
});
describe("computeRankedEntries", () => {
describe("fifa_48 third place game", () => {
it("ranks the third place game winner 3rd — they never lose a match after the SF", () => {
// sfB lost the semifinal, then won the 3PG.
expect(rankOf(rankFifa(), "sfB")).toBe("3");
});
it("ranks the third place game loser 4th, not 3rd", () => {
expect(rankOf(rankFifa(), "sfD")).toBe("4");
});
it("gives quarterfinal losers T5 — the 3PG consumes no positions of its own", () => {
const entries = rankFifa();
expect(rankOf(entries, "qf1")).toBe("T5");
expect(rankOf(entries, "qf2")).toBe("T5");
expect(rankOf(entries, "qf3")).toBe("T5");
expect(rankOf(entries, "qf4")).toBe("T5");
});
it("ranks the finals loser 2nd and leaves the champion out of the list", () => {
const entries = rankFifa();
expect(rankOf(entries, "sfC")).toBe("T2");
expect(rankOf(entries, "sfA")).toBeUndefined();
});
it("orders the list by rank: 2nd, 3rd, 4th, then the T5 tier", () => {
expect(rankFifa().map((e) => e.rankLabel)).toEqual([
"T2",
"3",
"4",
"T5",
"T5",
"T5",
"T5",
]);
});
it("leaves semifinal losers unranked until the third place game is played", () => {
const entries = rankFifa(fifaMatches({ played: false }));
// Both SF losers are still alive for the 3PG.
expect(rankOf(entries, "sfB")).toBeUndefined();
expect(rankOf(entries, "sfD")).toBeUndefined();
// QF losers are still T5 — the later rounds still consume their positions.
expect(rankOf(entries, "qf1")).toBe("T5");
});
it("carries ownership through onto the third place entries", () => {
const ownership = new Map([
["sfB", { participantId: "sfB", teamName: "Team Nine", teamId: "t9" }],
]);
const entries = rankFifa(fifaMatches(), ownership);
expect(entries.find((e) => e.participant.id === "sfB")?.ownership?.teamName).toBe(
"Team Nine"
);
expect(entries.find((e) => e.participant.id === "sfD")?.ownership).toBeNull();
});
});
describe("scores", () => {
it("reads each participant's own score regardless of which slot they occupied", () => {
const matches = fifaMatches({
// Winner sits in slot 2 this time, so a slot-blind lookup would swap the scores.
thirdPlace: makeRankedMatch("Third Place Game", 1, "sfB", "sfD", {
winnerSlot: 2,
winnerScore: "3",
loserScore: "1",
}),
});
const entries = rankFifa(matches);
expect(entries.find((e) => e.participant.id === "sfB")?.score).toBe("3");
expect(entries.find((e) => e.participant.id === "sfD")?.score).toBe("1");
});
it("reads a loser's score from the slot they actually played in", () => {
const matches: Match[] = [
makeRankedMatch("Semifinals", 1, "sfA", "sfB", {
winnerSlot: 2,
winnerScore: "4",
loserScore: "2",
}),
];
const entries = computeRankedEntries(
matches,
["Semifinals"],
groupMatchesByRound(matches),
undefined,
new Map()
);
expect(entries.find((e) => e.participant.id === "sfB")?.score).toBe("2");
});
it("reports no score for a participant who occupies neither slot", () => {
// A stale row after a bracket edit: loserId no longer matches either slot.
// Attributing the other team's score here would look entirely plausible.
const stale: Match = {
...makeRankedMatch("Semifinals", 1, "sfA", "sfB", {
winnerScore: "4",
loserScore: "2",
}),
loserId: "ghost",
loser: { id: "ghost", name: "ghost" },
};
const entries = computeRankedEntries(
[stale],
["Semifinals"],
groupMatchesByRound([stale]),
undefined,
new Map()
);
expect(entries.find((e) => e.participant.id === "ghost")?.score).toBeNull();
});
});
describe("a consolation round somewhere other than 3rd/4th", () => {
// No template ships this today, but the positions must come from the feeder
// round rather than being hardcoded to 3 and 4.
const ROUNDS = ["Quarterfinals", "Semifinals", "Fifth Place Game", "Finals"];
const CONSOLATION = { round: "Fifth Place Game", feederRound: "Quarterfinals" };
const matches: Match[] = [
makeRankedMatch("Quarterfinals", 1, "sfA", "qf1"),
makeRankedMatch("Quarterfinals", 2, "sfB", "qf2"),
makeRankedMatch("Quarterfinals", 3, "sfC", "qf3"),
makeRankedMatch("Quarterfinals", 4, "sfD", "qf4"),
makeRankedMatch("Semifinals", 1, "sfA", "sfB"),
makeRankedMatch("Semifinals", 2, "sfC", "sfD"),
makeRankedMatch("Fifth Place Game", 1, "qf1", "qf2"),
makeRankedMatch("Finals", 1, "sfA", "sfC"),
];
it("places the consolation pair at its feeder round's tier, not at 3rd and 4th", () => {
const entries = computeRankedEntries(
matches,
ROUNDS,
groupMatchesByRound(matches),
CONSOLATION,
new Map()
);
expect(rankOf(entries, "qf1")).toBe("5");
expect(rankOf(entries, "qf2")).toBe("6");
// The feeder round's other losers start below the pair, not alongside them.
expect(rankOf(entries, "qf3")).toBe("T7");
expect(rankOf(entries, "qf4")).toBe("T7");
// The rounds above it are unaffected.
expect(rankOf(entries, "sfC")).toBe("T2");
expect(rankOf(entries, "sfB")).toBe("T3");
});
});
describe("consolation matches that cannot be placed exactly", () => {
it("still ranks the loser when the winner relation is missing", () => {
const matches = fifaMatches({
thirdPlace: makeRankedMatch("Third Place Game", 1, "sfB", "sfD", {
missingWinnerRelation: true,
}),
});
const entries = rankFifa(matches);
// The winner cannot be placed without a participant object, but the loser must
// not silently vanish the way it would if the round were skipped wholesale.
expect(rankOf(entries, "sfD")).toBeDefined();
});
it("falls back to the loser-driven walk when the feeder round has no matches", () => {
const matches: Match[] = [
makeRankedMatch("Third Place Game", 1, "sfB", "sfD"),
makeRankedMatch("Finals", 1, "sfA", "sfC"),
];
const entries = computeRankedEntries(
matches,
["Third Place Game", "Finals"],
groupMatchesByRound(matches),
FIFA_CONSOLATION,
new Map()
);
expect(rankOf(entries, "sfC")).toBe("T2");
expect(rankOf(entries, "sfD")).toBeDefined();
});
});
describe("rendered output", () => {
/** The card the 3PG winner was incorrectly appearing in. */
function inContentionNames() {
const card = screen.queryByText("In Contention")?.closest('[data-slot="card"]');
if (!card) return [];
return within(card as HTMLElement)
.getAllByRole("row")
.map((r) => r.textContent ?? "");
}
it("does not list the third place game winner as in contention", () => {
render(
<PlayoffBracket matches={fifaMatches()} rounds={FIFA_ROUNDS} bracketTemplateId="fifa_48" />
);
// sfB won the third place game — they are finished, not still playing.
expect(inContentionNames().some((t) => t.includes("sfB"))).toBe(false);
});
it("still lists semifinalists as in contention before the third place game", () => {
render(
<PlayoffBracket
matches={fifaMatches({ played: false })}
rounds={FIFA_ROUNDS}
bracketTemplateId="fifa_48"
/>
);
expect(inContentionNames().some((t) => t.includes("sfB"))).toBe(true);
});
});
describe("brackets without a consolation round", () => {
const ROUNDS = ["Quarterfinals", "Semifinals", "Finals"];
it("ranks losers by round with tie labels, unchanged", () => {
const matches: Match[] = [
makeRankedMatch("Quarterfinals", 1, "sfA", "qf1"),
makeRankedMatch("Quarterfinals", 2, "sfB", "qf2"),
makeRankedMatch("Quarterfinals", 3, "sfC", "qf3"),
makeRankedMatch("Quarterfinals", 4, "sfD", "qf4"),
makeRankedMatch("Semifinals", 1, "sfA", "sfB"),
makeRankedMatch("Semifinals", 2, "sfC", "sfD"),
makeRankedMatch("Finals", 1, "sfA", "sfC"),
];
const entries = computeRankedEntries(
matches,
ROUNDS,
groupMatchesByRound(matches),
undefined,
new Map()
);
expect(rankOf(entries, "sfC")).toBe("T2");
expect(rankOf(entries, "sfB")).toBe("T3");
expect(rankOf(entries, "sfD")).toBe("T3");
expect(rankOf(entries, "qf1")).toBe("T5");
expect(rankOf(entries, "sfA")).toBeUndefined();
});
});
});

View file

@ -1,5 +1,6 @@
import { useState, useEffect } from "react";
import type { GroupStandingsRow } from "~/models/group-stage-match";
import { utcIsoToLocalDateTime } from "~/lib/date-utils";
import { TeamOwnerBadge } from "~/components/ui/team-owner-badge";
interface GroupMatch {
@ -100,6 +101,12 @@ export function GroupStageStandings({
ownershipMap,
showEmpty = false,
}: GroupStageStandingsProps) {
// SSR (and first client paint) groups/labels by UTC date for deterministic markup;
// after hydration we switch to the viewer's local date so evening matches don't roll
// into the wrong day header.
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const visibleGroups = showEmpty
? groups
: groups.filter((g) => g.matches.some((m) => m.isComplete) || g.standings.length > 0);
@ -186,25 +193,35 @@ export function GroupStageStandings({
if (!b.scheduledAt) return -1;
return new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime();
});
// Use the UTC date portion as a stable key so server and client always
// produce the same Map structure regardless of the user's timezone.
// Key by UTC date on SSR/first paint (deterministic); after mount, key by
// the viewer's local date so the day headers match local wall-clock.
const byDay = new Map<string, GroupMatch[]>();
for (const m of sorted) {
const key = m.scheduledAt ? m.scheduledAt.slice(0, 10) : "TBD";
const key = m.scheduledAt
? (mounted
? utcIsoToLocalDateTime(m.scheduledAt).slice(0, 10)
: m.scheduledAt.slice(0, 10))
: "TBD";
if (!byDay.has(key)) byDay.set(key, []);
byDay.get(key)?.push(m);
}
return (
<div className="px-4 pb-3 space-y-0.5 border-t pt-2">
{[...byDay.entries()].map(([key, matches]) => {
// Format from the UTC date string so label is identical on server and client.
// Before mount the key is a UTC date (format in UTC); after mount it is
// a local date (format in local time) so the label matches the grouping.
const dayLabel = key === "TBD"
? "TBD"
: new Date(key + "T12:00:00Z").toLocaleDateString("en-US", {
month: "short",
day: "numeric",
timeZone: "UTC",
});
: mounted
? new Date(key + "T12:00:00").toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})
: new Date(key + "T12:00:00Z").toLocaleDateString("en-US", {
month: "short",
day: "numeric",
timeZone: "UTC",
});
return (
<div key={key} className="space-y-0.5">
<p className="text-[10px] text-muted-foreground/70 uppercase tracking-wide pt-1">

View file

@ -1,7 +1,15 @@
import { useState } from "react";
import { Link } from "react-router";
import { ArrowUp, ArrowDown, ArrowUpDown } from "lucide-react";
import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "~/components/ui/table";
import { Badge } from "~/components/ui/badge";
@ -97,6 +105,54 @@ function sortPicks(
});
}
type Pick = TeamScoreBreakdownProps["breakdown"]["picks"][number];
const SORT_OPTIONS: Array<{ value: SortColumn; label: string }> = [
{ value: "pick", label: "Pick #" },
{ value: "sport", label: "Sport" },
{ value: "participant", label: "Participant" },
{ value: "points", label: "Points" },
];
function pickLabel(pick: Pick, numTeams: number): string {
return numTeams > 0
? `${pick.round}.${String(pick.pickNumber - (pick.round - 1) * numTeams).padStart(2, "0")}`
: `#${pick.pickNumber}`;
}
/** Position badge shared by the desktop table and the mobile cards. */
function PositionCell({ pick }: { pick: Pick }) {
if (pick.isComplete && !pick.isPartialScore) {
return (pick.finalPosition ?? 0) === 0 ? (
<Badge variant="secondary">Did Not Score</Badge>
) : (
<PlacementBadge position={pick.finalPosition ?? 0} />
);
}
return <Badge variant="outline">Pending</Badge>;
}
/** Points value (actual, with projected underneath when incomplete). */
function PointsValue({ pick }: { pick: Pick }) {
if (pick.isComplete && !pick.isPartialScore) {
return (
<span className="font-semibold">
{pick.points > 0 ? pick.points.toFixed(2) : "0.00"}
</span>
);
}
return (
<div className="flex flex-col items-end">
<span className="font-semibold">{pick.points.toFixed(2)}</span>
{pick.projectedPoints !== null && (
<span className="text-xs text-muted-foreground">
{pick.projectedPoints.toFixed(2)}
</span>
)}
</div>
);
}
/**
* Display detailed team score breakdown with all drafted participants
* Phase 4.3: Team breakdown pages
@ -130,17 +186,23 @@ export function TeamScoreBreakdown({
}
}
// Mobile sort control: picking a column applies its default direction.
function handleSortColumn(column: SortColumn) {
setSortColumn(column);
setSortDirection(column === "points" ? "desc" : "asc");
}
const allPicks = sortPicks(breakdown.picks, sortColumn, sortDirection);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-3xl font-bold">{breakdown.team.name}</h1>
<h1 className="text-2xl sm:text-3xl font-bold">{breakdown.team.name}</h1>
<p className="text-muted-foreground mt-1">Team Score Breakdown</p>
</div>
<div className="text-right">
<div className="text-left sm:text-right">
<div className="text-4xl font-bold text-primary">
{breakdown.actualPoints.toFixed(2)}
</div>
@ -164,8 +226,8 @@ export function TeamScoreBreakdown({
</div>
</div>
{/* All picks — single flat table */}
<Card>
{/* All picks — desktop table */}
<Card className="hidden md:block">
<CardContent className="pt-4">
<Table>
<TableHeader>
@ -198,9 +260,7 @@ export function TeamScoreBreakdown({
{allPicks.map((pick) => (
<TableRow key={pick.pickNumber}>
<TableCell className="text-muted-foreground">
{numTeams > 0
? `${pick.round}.${String(pick.pickNumber - (pick.round - 1) * numTeams).padStart(2, "0")}`
: `#${pick.pickNumber}`}
{pickLabel(pick, numTeams)}
<span className="text-xs ml-1">(#{pick.pickNumber})</span>
</TableCell>
<TableCell>
@ -215,31 +275,10 @@ export function TeamScoreBreakdown({
{pick.participant.name}
</TableCell>
<TableCell className="text-center">
{pick.isComplete && !pick.isPartialScore ? (
(pick.finalPosition ?? 0) === 0 ? (
<Badge variant="secondary">Did Not Score</Badge>
) : (
<PlacementBadge position={pick.finalPosition ?? 0} />
)
) : (
<Badge variant="outline">Pending</Badge>
)}
<PositionCell pick={pick} />
</TableCell>
<TableCell className="text-right">
{pick.isComplete && !pick.isPartialScore ? (
<span className="font-semibold">
{pick.points > 0 ? pick.points.toFixed(2) : "0.00"}
</span>
) : (
<div className="flex flex-col items-end">
<span className="font-semibold">{pick.points.toFixed(2)}</span>
{pick.projectedPoints !== null && (
<span className="text-xs text-muted-foreground">
{pick.projectedPoints.toFixed(2)}
</span>
)}
</div>
)}
<PointsValue pick={pick} />
</TableCell>
</TableRow>
))}
@ -248,6 +287,70 @@ export function TeamScoreBreakdown({
</CardContent>
</Card>
{/* All picks — mobile cards (no horizontal scroll, all fields visible) */}
<div className="md:hidden space-y-3" data-testid="picks-mobile">
{/* Sort control */}
<div className="flex items-center gap-2">
<span id="picks-sort-label" className="text-sm text-muted-foreground shrink-0">
Sort by
</span>
<Select
value={sortColumn}
onValueChange={(v) => handleSortColumn(v as SortColumn)}
>
<SelectTrigger className="flex-1" aria-labelledby="picks-sort-label">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"
onClick={() => setSortDirection(sortDirection === "asc" ? "desc" : "asc")}
aria-label={`Sort ${sortDirection === "asc" ? "descending" : "ascending"}`}
>
{sortDirection === "asc" ? (
<ArrowUp className="h-4 w-4" />
) : (
<ArrowDown className="h-4 w-4" />
)}
</Button>
</div>
{allPicks.map((pick) => (
<Card key={pick.pickNumber} className="py-0" data-testid="pick-card">
<CardContent className="p-3 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="text-sm text-muted-foreground">
{pickLabel(pick, numTeams)}
<span className="text-xs ml-1">(#{pick.pickNumber})</span>
</div>
<PositionCell pick={pick} />
</div>
<div className="min-w-0">
<div className="font-medium break-words">{pick.participant.name}</div>
<Link
to={`/leagues/${leagueId}/sports-seasons/${pick.participant.sportsSeasonId}`}
className="text-sm font-medium hover:underline text-primary"
>
{pick.participant.sport}
</Link>
</div>
<div className="flex items-baseline justify-between border-t border-border/30 pt-2">
<span className="text-sm text-muted-foreground">Points</span>
<PointsValue pick={pick} />
</div>
</CardContent>
</Card>
))}
</div>
{/* Navigation */}
<div className="flex justify-between pt-4">
<Link

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { BrowserRouter } from "react-router";
import { TeamScoreBreakdown } from "../TeamScoreBreakdown";
@ -160,9 +160,10 @@ describe("TeamScoreBreakdown", () => {
/>
);
expect(screen.getByText("1.01")).toBeInTheDocument();
expect(screen.getByText("1.02")).toBeInTheDocument();
expect(screen.getByText("2.01")).toBeInTheDocument();
// Pick labels render in both the desktop table and mobile cards.
expect(screen.getAllByText("1.01").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("1.02").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("2.01").length).toBeGreaterThanOrEqual(1);
});
it("should show overall pick number in parentheses", () => {
@ -176,9 +177,9 @@ describe("TeamScoreBreakdown", () => {
/>
);
expect(screen.getByText("(#1)")).toBeInTheDocument();
expect(screen.getByText("(#2)")).toBeInTheDocument();
expect(screen.getByText("(#3)")).toBeInTheDocument();
expect(screen.getAllByText("(#1)").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("(#2)").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("(#3)").length).toBeGreaterThanOrEqual(1);
});
it("should fall back to #pickNumber when numTeams is 0", () => {
@ -192,9 +193,9 @@ describe("TeamScoreBreakdown", () => {
/>
);
expect(screen.getByText("#1")).toBeInTheDocument();
expect(screen.getByText("#2")).toBeInTheDocument();
expect(screen.getByText("#3")).toBeInTheDocument();
expect(screen.getAllByText("#1").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("#2").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("#3").length).toBeGreaterThanOrEqual(1);
});
});
@ -210,9 +211,10 @@ describe("TeamScoreBreakdown", () => {
/>
);
expect(screen.getByText("Team A")).toBeInTheDocument();
expect(screen.getByText("Driver B")).toBeInTheDocument();
expect(screen.getByText("Team C")).toBeInTheDocument();
const table = within(screen.getByRole("table"));
expect(table.getByText("Team A")).toBeInTheDocument();
expect(table.getByText("Driver B")).toBeInTheDocument();
expect(table.getByText("Team C")).toBeInTheDocument();
});
it("should show sport names as links to sport season pages", () => {
@ -226,14 +228,15 @@ describe("TeamScoreBreakdown", () => {
/>
);
const nflLinks = screen.getAllByRole("link", { name: "NFL" });
const table = within(screen.getByRole("table"));
const nflLinks = table.getAllByRole("link", { name: "NFL" });
expect(nflLinks.length).toBeGreaterThanOrEqual(1);
expect(nflLinks[0]).toHaveAttribute(
"href",
`/leagues/${mockLeagueId}/sports-seasons/ss-nfl`
);
const f1Link = screen.getByRole("link", { name: "F1" });
const f1Link = table.getByRole("link", { name: "F1" });
expect(f1Link).toHaveAttribute(
"href",
`/leagues/${mockLeagueId}/sports-seasons/ss-f1`
@ -251,8 +254,9 @@ describe("TeamScoreBreakdown", () => {
/>
);
expect(screen.getByText("1st")).toBeInTheDocument();
expect(screen.getByText("3rd")).toBeInTheDocument();
const table = within(screen.getByRole("table"));
expect(table.getByText("1st")).toBeInTheDocument();
expect(table.getByText("3rd")).toBeInTheDocument();
});
it("should show Pending badge for incomplete participants", () => {
@ -266,7 +270,8 @@ describe("TeamScoreBreakdown", () => {
/>
);
expect(screen.getByText("Pending")).toBeInTheDocument();
const table = within(screen.getByRole("table"));
expect(table.getByText("Pending")).toBeInTheDocument();
});
it("should show Did Not Score badge when finalPosition is 0", () => {
@ -291,7 +296,8 @@ describe("TeamScoreBreakdown", () => {
/>
);
expect(screen.getByText("Did Not Score")).toBeInTheDocument();
const table = within(screen.getByRole("table"));
expect(table.getByText("Did Not Score")).toBeInTheDocument();
});
it("should show Did Not Score badge when isComplete but finalPosition is null", () => {
@ -318,7 +324,8 @@ describe("TeamScoreBreakdown", () => {
/>
);
expect(screen.getByText("Did Not Score")).toBeInTheDocument();
const table = within(screen.getByRole("table"));
expect(table.getByText("Did Not Score")).toBeInTheDocument();
});
it("should show projected EV for incomplete participants", () => {
@ -333,8 +340,9 @@ describe("TeamScoreBreakdown", () => {
);
// Incomplete participant shows 0.00 (actual) and 25.00 (EV) in the row
expect(screen.getByText("0.00")).toBeInTheDocument();
expect(screen.getByText("25.00")).toBeInTheDocument();
const table = within(screen.getByRole("table"));
expect(table.getByText("0.00")).toBeInTheDocument();
expect(table.getByText("25.00")).toBeInTheDocument();
});
it("should display 0.00 for completed picks with zero points", () => {
@ -353,7 +361,8 @@ describe("TeamScoreBreakdown", () => {
/>
);
expect(screen.getByText("0.00")).toBeInTheDocument();
const table = within(screen.getByRole("table"));
expect(table.getByText("0.00")).toBeInTheDocument();
});
});
@ -501,6 +510,96 @@ describe("TeamScoreBreakdown", () => {
});
});
describe("Mobile view", () => {
function getCardOrder() {
const mobile = screen.getByTestId("picks-mobile");
const cards = within(mobile).getAllByTestId("pick-card");
return cards.map((card) =>
["Team A", "Driver B", "Team C"].find((n) => card.textContent?.includes(n))
);
}
async function selectSortColumn(user: ReturnType<typeof userEvent.setup>, label: string) {
await user.click(screen.getByRole("combobox", { name: /sort by/i }));
await user.click(screen.getByRole("option", { name: label }));
}
it("renders a card per pick with all fields visible", () => {
renderWithRouter(
<TeamScoreBreakdown
leagueId={mockLeagueId}
seasonId={mockSeasonId}
numTeams={numTeams}
breakdown={mockBreakdown}
standing={mockStanding}
/>
);
const mobile = within(screen.getByTestId("picks-mobile"));
expect(mobile.getAllByTestId("pick-card")).toHaveLength(3);
// Participant names, sports, positions all visible without hiding data
expect(mobile.getByText("Team A")).toBeInTheDocument();
expect(mobile.getByText("Driver B")).toBeInTheDocument();
expect(mobile.getByText("Team C")).toBeInTheDocument();
expect(mobile.getByText("1st")).toBeInTheDocument();
expect(mobile.getByText("Pending")).toBeInTheDocument();
expect(mobile.getByRole("link", { name: "F1" })).toHaveAttribute(
"href",
`/leagues/${mockLeagueId}/sports-seasons/ss-f1`
);
});
it("defaults to pick order", () => {
renderWithRouter(
<TeamScoreBreakdown
leagueId={mockLeagueId}
seasonId={mockSeasonId}
numTeams={numTeams}
breakdown={mockBreakdown}
standing={mockStanding}
/>
);
expect(getCardOrder()).toEqual(["Team A", "Driver B", "Team C"]);
});
it("reorders cards when sorting by points (desc) via the select", async () => {
const user = userEvent.setup();
renderWithRouter(
<TeamScoreBreakdown
leagueId={mockLeagueId}
seasonId={mockSeasonId}
numTeams={numTeams}
breakdown={mockBreakdown}
standing={mockStanding}
/>
);
// picks: Team A=100, Driver B=50, Team C=0; points defaults to descending
await selectSortColumn(user, "Points");
expect(getCardOrder()).toEqual(["Team A", "Driver B", "Team C"]);
});
it("toggles sort direction with the direction button", async () => {
const user = userEvent.setup();
renderWithRouter(
<TeamScoreBreakdown
leagueId={mockLeagueId}
seasonId={mockSeasonId}
numTeams={numTeams}
breakdown={mockBreakdown}
standing={mockStanding}
/>
);
await selectSortColumn(user, "Points"); // desc
// While descending, the toggle's action is to sort ascending.
await user.click(screen.getByRole("button", { name: /sort ascending/i }));
// now ascending: Team C (0), Driver B (50), Team A (100)
expect(getCardOrder()).toEqual(["Team C", "Driver B", "Team A"]);
});
});
describe("Without Standing Data", () => {
it("should render without standing prop", () => {
renderWithRouter(

View file

@ -31,7 +31,7 @@ export function AccountSection({ email, linkedAccounts }: Props) {
setLinkingDiscord(true);
setLinkError(null);
try {
await authClient.linkSocial({ provider: "discord", callbackURL: "/settings?section=account" });
await authClient.linkSocial({ provider: "discord", callbackURL: "/settings/account" });
} catch {
setLinkError("Failed to connect Discord. Please try again.");
setLinkingDiscord(false);

View file

@ -1,4 +1,4 @@
import { useFetcher } from "react-router";
import { Link, useFetcher } from "react-router";
import { Switch } from "~/components/ui/switch";
import { Label } from "~/components/ui/label";
@ -14,10 +14,9 @@ type Props = {
discordPingEnabled: boolean;
hasDiscordLinked: boolean;
draftEmailNotificationsEnabled: boolean;
onNavigateToAccount: () => void;
};
export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, draftEmailNotificationsEnabled, onNavigateToAccount }: Props) {
export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, draftEmailNotificationsEnabled }: Props) {
const discordFetcher = useFetcher();
const emailFetcher = useFetcher();
@ -87,13 +86,12 @@ export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, dra
{!hasDiscordLinked ? (
<p className="text-sm text-muted-foreground">
Link your Discord account in{" "}
<button
type="button"
onClick={onNavigateToAccount}
<Link
to="/settings/account"
className="text-primary underline-offset-4 hover:underline"
>
Account settings
</button>{" "}
</Link>{" "}
to enable Discord pings for league notifications.
</p>
) : (

View file

@ -55,7 +55,7 @@ describe("AccountSection", () => {
await waitFor(() => {
expect(mockLinkSocial).toHaveBeenCalledWith({
provider: "discord",
callbackURL: "/settings?section=account",
callbackURL: "/settings/account",
});
});
});

View file

@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest";
import { format, parseISO } from "date-fns";
import { localDateTimeToUtcIso, toEventSortKey } from "../date-utils";
import { localDateTimeToUtcIso, toEventSortKey, utcIsoToLocalDateTime } from "../date-utils";
describe("localDateTimeToUtcIso", () => {
it("returns null for empty string", () => {
@ -118,6 +118,46 @@ describe("event date display — UTC midnight rollover bug", () => {
});
});
describe("utcIsoToLocalDateTime", () => {
it("returns empty string for empty string", () => {
expect(utcIsoToLocalDateTime("")).toBe("");
});
it("returns empty string for null", () => {
expect(utcIsoToLocalDateTime(null)).toBe("");
});
it("returns empty string for undefined", () => {
expect(utcIsoToLocalDateTime(undefined)).toBe("");
});
it("returns empty string for an invalid date string", () => {
expect(utcIsoToLocalDateTime("not-a-date")).toBe("");
});
it("produces a datetime-local formatted string", () => {
expect(utcIsoToLocalDateTime("2026-06-17T19:00:00.000Z")).toMatch(
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/
);
});
it("accepts a Date instance", () => {
const result = utcIsoToLocalDateTime(new Date("2026-06-17T19:00:00.000Z"));
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
});
it("round-trips with localDateTimeToUtcIso to the same instant", () => {
// utcIsoToLocalDateTime renders a UTC instant in local time; feeding that
// back through localDateTimeToUtcIso (which interprets local time) must
// yield the original instant, regardless of the runtime timezone.
const utc = "2026-06-17T19:00:00.000Z";
const local = utcIsoToLocalDateTime(utc);
const backToUtc = localDateTimeToUtcIso(local);
expect(backToUtc).not.toBeNull();
expect(new Date(backToUtc ?? "").getTime()).toBe(new Date(utc).getTime());
});
});
describe("toEventSortKey", () => {
it("prefers earliestGameTime over eventDate", () => {
const key = toEventSortKey({ eventDate: "2026-03-01", earliestGameTime: "2026-03-17T10:45:00.000Z" });

View file

@ -0,0 +1,133 @@
import { describe, it, expect } from "vitest";
import {
FIFA_2026_R32_TEMPLATE,
THIRD_PLACE_SLOTS,
assignThirdPlaceSlots,
type R32Slot,
} from "../fifa-2026-bracket";
const GROUPS = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L"];
describe("FIFA_2026_R32_TEMPLATE", () => {
it("has 16 matches with unique DB match numbers 116", () => {
expect(FIFA_2026_R32_TEMPLATE).toHaveLength(16);
const dbNums = FIFA_2026_R32_TEMPLATE.map((m) => m.dbMatchNumber).toSorted((a, b) => a - b);
expect(dbNums).toEqual(Array.from({ length: 16 }, (_, i) => i + 1));
});
it("maps onto the official FIFA match numbers 7388", () => {
const fifaNums = FIFA_2026_R32_TEMPLATE.map((m) => m.fifaMatchNumber).toSorted((a, b) => a - b);
expect(fifaNums).toEqual(Array.from({ length: 16 }, (_, i) => i + 73));
});
it("uses each group winner and runner-up exactly once across the 32 slots", () => {
const winners: string[] = [];
const runnersUp: string[] = [];
for (const m of FIFA_2026_R32_TEMPLATE) {
for (const slot of [m.slot1, m.slot2] as R32Slot[]) {
if (slot.kind === "winner") winners.push(slot.group);
if (slot.kind === "runnerUp") runnersUp.push(slot.group);
}
}
expect(winners.toSorted()).toEqual(GROUPS);
expect(runnersUp.toSorted()).toEqual(GROUPS);
});
it("has exactly 8 third-place slots", () => {
expect(THIRD_PLACE_SLOTS).toHaveLength(8);
});
});
describe("assignThirdPlaceSlots", () => {
it("assigns all 8 qualifying thirds to distinct, eligible slots", () => {
// Best-8 thirds come from groups AH (a plausible qualifying set).
const qualifying = ["A", "B", "C", "D", "E", "F", "G", "H"];
const assignment = assignThirdPlaceSlots(qualifying);
expect(assignment.size).toBe(8);
// Every slot filled, each by an eligible & qualifying group, no group reused.
const usedGroups = new Set<string>();
for (const slot of THIRD_PLACE_SLOTS) {
const group = assignment.get(slot.thirdSlotId) ?? "";
expect(group).not.toBe("");
expect(slot.eligibleGroups).toContain(group);
expect(qualifying).toContain(group);
expect(usedGroups.has(group)).toBe(false);
usedGroups.add(group);
}
expect(usedGroups.size).toBe(8);
});
it("produces a valid matching for every 8-of-12 qualifying combination", () => {
// Exhaustively check all C(12,8) = 495 combinations resolve to a complete,
// eligibility-respecting, conflict-free assignment.
let combinations = 0;
const combos = (start: number, chosen: string[]) => {
if (chosen.length === 8) {
combinations++;
const assignment = assignThirdPlaceSlots(chosen);
expect(assignment.size).toBe(8);
const used = new Set(assignment.values());
expect(used.size).toBe(8);
for (const slot of THIRD_PLACE_SLOTS) {
const group = assignment.get(slot.thirdSlotId);
expect(slot.eligibleGroups).toContain(group);
expect(chosen).toContain(group);
}
return;
}
for (let i = start; i < GROUPS.length; i++) {
combos(i + 1, [...chosen, GROUPS[i]]);
}
};
combos(0, []);
expect(combinations).toBe(495);
});
it("is deterministic for a given combination", () => {
const qualifying = ["B", "D", "E", "F", "H", "I", "J", "L"];
const a = assignThirdPlaceSlots(qualifying);
const b = assignThirdPlaceSlots(qualifying.toReversed());
expect([...a.entries()].toSorted()).toEqual([...b.entries()].toSorted());
});
it("matches FIFA's exact Annex C allocation for option 1 (groups EL qualify)", () => {
// Published row 1: 1A vs 3E, 1B vs 3J, 1D vs 3I, 1E vs 3F,
// 1G vs 3H, 1I vs 3G, 1K vs 3L, 1L vs 3K.
// Mapped to DB third-slot ids (the match each winner plays in):
const assignment = assignThirdPlaceSlots(["E", "F", "G", "H", "I", "J", "K", "L"]);
expect(assignment.get(11)).toBe("E"); // winner A's match
expect(assignment.get(15)).toBe("J"); // winner B's match
expect(assignment.get(7)).toBe("I"); // winner D's match
expect(assignment.get(1)).toBe("F"); // winner E's match
expect(assignment.get(8)).toBe("H"); // winner G's match
expect(assignment.get(2)).toBe("G"); // winner I's match
expect(assignment.get(16)).toBe("L"); // winner K's match
expect(assignment.get(12)).toBe("K"); // winner L's match
});
it("template eligible-group lists exactly match the Annex C table", () => {
// For every slot, collect the set of groups the published table ever assigns
// to it across all 495 combinations, and assert it equals the hand-written
// eligibleGroups list in the template.
const observed = new Map<number, Set<string>>(
THIRD_PLACE_SLOTS.map((s) => [s.thirdSlotId, new Set<string>()])
);
const combos = (start: number, chosen: string[]) => {
if (chosen.length === 8) {
const assignment = assignThirdPlaceSlots(chosen);
for (const [slotId, group] of assignment) observed.get(slotId)?.add(group);
return;
}
for (let i = start; i < GROUPS.length; i++) combos(i + 1, [...chosen, GROUPS[i]]);
};
combos(0, []);
for (const slot of THIRD_PLACE_SLOTS) {
expect([...(observed.get(slot.thirdSlotId) ?? [])].toSorted()).toEqual(
[...slot.eligibleGroups].toSorted()
);
}
});
});

View file

@ -14,6 +14,11 @@ describe("normalizeTeamName", () => {
const name = "oklahoma city thunder";
expect(normalizeTeamName(name)).toBe(name);
});
it("folds accents so diacritics don't block a match", () => {
expect(normalizeTeamName("Stéfanos Tsitsipás")).toBe("stefanos tsitsipas");
expect(normalizeTeamName("Félix Auger-Aliassime")).toBe("felix auger-aliassime");
});
});
describe("findMatchingTeamName", () => {
@ -38,6 +43,12 @@ describe("findMatchingTeamName", () => {
expect(findMatchingTeamName("Oklahoma City", participants)).toBe("Oklahoma City Thunder");
});
it("matches across accents (API has them, our list doesn't)", () => {
expect(findMatchingTeamName("Stéfanos Tsitsipás", ["Stefanos Tsitsipas"])).toBe(
"Stefanos Tsitsipas",
);
});
it("returns null for unmatched name", () => {
expect(findMatchingTeamName("Phoenix Suns", participants)).toBeNull();
});

View file

@ -455,6 +455,31 @@ export const DARTS_128: BracketTemplate = {
],
};
/**
* Tennis Grand Slam (128-player single-elimination draw)
* R128 R64 R32 Round of 16 Quarterfinals Semifinals Final
*
* Qualifying-points sport: QP is derived from how far each player advances
* (see TEMPLATE_ROUND_CONFIG["tennis_128"] in scoring-calculator.ts). Scoring
* begins at the Round of 16 R16 losers share placements 916; earlier rounds
* award nothing. Seeding: top 32 fixed, remaining 96 randomly drawn.
*/
export const TENNIS_128: BracketTemplate = {
id: "tennis_128",
name: "Tennis Grand Slam (128 Players)",
totalTeams: 128,
scoringStartsAtRound: "Round of 16",
rounds: [
{ name: "Round of 128", matchCount: 64, feedsInto: "Round of 64", isScoring: false },
{ name: "Round of 64", matchCount: 32, feedsInto: "Round of 32", isScoring: false },
{ name: "Round of 32", matchCount: 16, feedsInto: "Round of 16", isScoring: false },
{ name: "Round of 16", matchCount: 8, feedsInto: "Quarterfinals", isScoring: true }, // losers share 9th16th
{ name: "Quarterfinals", matchCount: 4, feedsInto: "Semifinals", isScoring: true }, // losers share 5th8th
{ name: "Semifinals", matchCount: 2, feedsInto: "Final", isScoring: true }, // losers share 3rd4th
{ name: "Final", matchCount: 1, feedsInto: null, isScoring: true }, // winner 1st, loser 2nd
],
};
/**
* NCAA March Madness (68 teams)
* First Four (play-in) Round of 64 Round of 32 Sweet Sixteen Elite Eight Final Four Championship
@ -923,6 +948,7 @@ export const BRACKET_TEMPLATES: Record<string, BracketTemplate> = {
afl_10: AFL_10,
fifa_48: FIFA_48,
darts_128: DARTS_128,
tennis_128: TENNIS_128,
cfp_12: CFP_12,
nba_20: NBA_20,
};

View file

@ -18,6 +18,8 @@
*/
import { format } from "date-fns";
const pad = (n: number) => String(n).padStart(2, "0");
/**
* Format a YYYY-MM-DD date string for display (e.g. "Apr 9").
* Returns null when the value is missing or unparseable callers decide how to handle that case.
@ -57,3 +59,28 @@ export function localDateTimeToUtcIso(value: string | null | undefined): string
if (isNaN(date.getTime())) return null;
return date.toISOString();
}
/**
* Converts a stored UTC value (ISO string or Date) to the "YYYY-MM-DDTHH:MM"
* string a `datetime-local` input expects, expressed in the **browser's local**
* timezone. This is the inverse of `localDateTimeToUtcIso`.
*
* Built from local date getters (not `toISOString`, which is UTC) so the input
* shows the viewer's wall-clock time. Because it depends on the runtime
* timezone, call it on the client (e.g. inside `useEffect`) to avoid SSR
* hydration mismatches.
*
* Returns "" for nullish or unparseable input so it can be assigned directly to
* an input value.
*/
export function utcIsoToLocalDateTime(value: string | Date | null | undefined): string {
if (!value) return "";
const date = value instanceof Date ? value : new Date(value);
if (isNaN(date.getTime())) return "";
const year = date.getFullYear();
const month = pad(date.getMonth() + 1);
const day = pad(date.getDate());
const hours = pad(date.getHours());
const minutes = pad(date.getMinutes());
return `${year}-${month}-${day}T${hours}:${minutes}`;
}

View file

@ -0,0 +1,162 @@
/**
* Official 2026 FIFA World Cup knockout bracket structure (48-team format).
*
* Encodes how the 32 knockout qualifiers are seeded into the Round of 32 from
* their group-stage finishing positions, so the simulator can reproduce the
* real tournament draw *before* the admin has manually populated the bracket
* (i.e. while the group stage is still being played).
*
* Two pieces:
* (a) FIFA_2026_R32_TEMPLATE the fixed group-position slot mapping.
* (b) assignThirdPlaceSlots() which group's 3rd-place team fills each of the
* eight "best third-place" slots, given which 8 of the 12 groups produced
* a qualifying third.
*
* Bracket-tree note: the official FIFA match numbers (7388 for the R32) do NOT
* pair adjacently into the Round of 16 (e.g. R16 match 89 = winner of FIFA 74 vs
* winner of FIFA 77). The codebase advances brackets with the canonical
* `nextMatchNumber = ceil(matchNumber / 2)` rule (see `advanceWinnerTemplate` in
* app/models/playoff-match.ts). To make that rule reproduce FIFA's real bracket
* halves all the way to the final, the official matches are assigned to DB
* matchNumbers 116 in the deliberate order below verified through
* R16 QF SF so both halves and the semifinal pairings match the real draw.
*
* Source: Wikipedia "2026 FIFA World Cup knockout stage" and
* "Template:2026 FIFA World Cup third-place table" (FIFA regulations Annex C).
*/
import {
ANNEX_C_THIRD_PLACE_ALLOCATION,
THIRD_PLACE_WINNER_COLUMNS,
} from "./fifa-2026-third-place-allocation";
export type R32Slot =
| { kind: "winner"; group: string }
| { kind: "runnerUp"; group: string }
/** thirdSlotId is the DB matchNumber this slot belongs to (each is unique). */
| { kind: "third"; thirdSlotId: number; eligibleGroups: string[] };
export interface R32MatchSpec {
/** 116; drives the codebase's ceil(n/2) bracket tree. */
dbMatchNumber: number;
/** Official FIFA match number (7388); for traceability only. */
fifaMatchNumber: number;
slot1: R32Slot;
slot2: R32Slot;
}
const winner = (group: string): R32Slot => ({ kind: "winner", group });
const runnerUp = (group: string): R32Slot => ({ kind: "runnerUp", group });
const third = (dbMatchNumber: number, eligibleGroups: string[]): R32Slot => ({
kind: "third",
thirdSlotId: dbMatchNumber,
eligibleGroups,
});
export const FIFA_2026_R32_TEMPLATE: R32MatchSpec[] = [
{ dbMatchNumber: 1, fifaMatchNumber: 74, slot1: winner("E"), slot2: third(1, ["A", "B", "C", "D", "F"]) },
{ dbMatchNumber: 2, fifaMatchNumber: 77, slot1: winner("I"), slot2: third(2, ["C", "D", "F", "G", "H"]) },
{ dbMatchNumber: 3, fifaMatchNumber: 73, slot1: runnerUp("A"), slot2: runnerUp("B") },
{ dbMatchNumber: 4, fifaMatchNumber: 75, slot1: winner("F"), slot2: runnerUp("C") },
{ dbMatchNumber: 5, fifaMatchNumber: 83, slot1: runnerUp("K"), slot2: runnerUp("L") },
{ dbMatchNumber: 6, fifaMatchNumber: 84, slot1: winner("H"), slot2: runnerUp("J") },
{ dbMatchNumber: 7, fifaMatchNumber: 81, slot1: winner("D"), slot2: third(7, ["B", "E", "F", "I", "J"]) },
{ dbMatchNumber: 8, fifaMatchNumber: 82, slot1: winner("G"), slot2: third(8, ["A", "E", "H", "I", "J"]) },
{ dbMatchNumber: 9, fifaMatchNumber: 76, slot1: winner("C"), slot2: runnerUp("F") },
{ dbMatchNumber: 10, fifaMatchNumber: 78, slot1: runnerUp("E"), slot2: runnerUp("I") },
{ dbMatchNumber: 11, fifaMatchNumber: 79, slot1: winner("A"), slot2: third(11, ["C", "E", "F", "H", "I"]) },
{ dbMatchNumber: 12, fifaMatchNumber: 80, slot1: winner("L"), slot2: third(12, ["E", "H", "I", "J", "K"]) },
{ dbMatchNumber: 13, fifaMatchNumber: 86, slot1: winner("J"), slot2: runnerUp("H") },
{ dbMatchNumber: 14, fifaMatchNumber: 88, slot1: runnerUp("D"), slot2: runnerUp("G") },
{ dbMatchNumber: 15, fifaMatchNumber: 85, slot1: winner("B"), slot2: third(15, ["E", "F", "G", "I", "J"]) },
{ dbMatchNumber: 16, fifaMatchNumber: 87, slot1: winner("K"), slot2: third(16, ["D", "E", "I", "J", "L"]) },
];
/** The eight third-place slots (DB matchNumber → eligible source groups). */
export const THIRD_PLACE_SLOTS: Array<{ thirdSlotId: number; eligibleGroups: string[] }> =
FIFA_2026_R32_TEMPLATE.flatMap((m) =>
[m.slot1, m.slot2].filter((s): s is Extract<R32Slot, { kind: "third" }> => s.kind === "third")
).map((s) => ({ thirdSlotId: s.thirdSlotId, eligibleGroups: s.eligibleGroups }));
/**
* For each third-place slot, the group winner it faces derived from the
* template (a third slot always shares its match with a winner slot). This is
* how the Annex C columns (keyed by group winner) map onto our DB slots.
*/
const WINNER_BY_THIRD_SLOT = new Map<number, string>(
FIFA_2026_R32_TEMPLATE.flatMap((m) => {
const slots = [m.slot1, m.slot2];
const thirdSlot = slots.find((s) => s.kind === "third");
const winnerSlot = slots.find((s) => s.kind === "winner");
return thirdSlot && winnerSlot
? [[thirdSlot.thirdSlotId, winnerSlot.group] as [number, string]]
: [];
})
);
/**
* Assign each qualifying third-place group to its third-place slot, using FIFA's
* exact published Annex C allocation (495-row lookup). The lookup value lists the
* third-place group facing each group winner in the column order
* [1A, 1B, 1D, 1E, 1G, 1I, 1K, 1L]; we map each onto the slot that faces that
* winner.
*
* Returns a map of thirdSlotId (DB matchNumber) group letter. Falls back to a
* constrained matching only for non-standard inputs (e.g. fewer than 8 thirds in
* degraded data) where no Annex C row exists.
*/
export function assignThirdPlaceSlots(qualifyingGroups: string[]): Map<number, string> {
const groups = qualifyingGroups.map((g) => g.toUpperCase());
const key = groups.toSorted().join("");
const allocation = ANNEX_C_THIRD_PLACE_ALLOCATION[key];
const assignment = new Map<number, string>();
if (allocation && allocation.length === THIRD_PLACE_WINNER_COLUMNS.length) {
THIRD_PLACE_WINNER_COLUMNS.forEach((winnerGroup, i) => {
const thirdGroup = allocation[i];
const slotId = [...WINNER_BY_THIRD_SLOT].find(([, w]) => w === winnerGroup)?.[0];
if (slotId !== undefined) assignment.set(slotId, thirdGroup);
});
return assignment;
}
return assignThirdPlaceSlotsByMatching(groups);
}
/**
* Constrained-matching fallback used only when no Annex C row applies (non-8
* qualifying groups). A perfect, eligibility-respecting matching is found via
* backtracking so the simulation still completes deterministically.
*/
function assignThirdPlaceSlotsByMatching(groups: string[]): Map<number, string> {
const slots = THIRD_PLACE_SLOTS.map((s) => ({
thirdSlotId: s.thirdSlotId,
options: groups.filter((g) => s.eligibleGroups.includes(g)),
})).toSorted((a, b) => a.options.length - b.options.length || a.thirdSlotId - b.thirdSlotId);
const assignment = new Map<number, string>();
const used = new Set<string>();
const backtrack = (i: number): boolean => {
if (i === slots.length) return true;
for (const group of slots[i].options) {
if (used.has(group)) continue;
assignment.set(slots[i].thirdSlotId, group);
used.add(group);
if (backtrack(i + 1)) return true;
used.delete(group);
assignment.delete(slots[i].thirdSlotId);
}
return false;
};
if (backtrack(0)) return assignment;
const leftover = groups.filter((g) => !used.has(g));
for (const s of THIRD_PLACE_SLOTS) {
if (assignment.has(s.thirdSlotId)) continue;
const g = leftover.shift();
if (g) assignment.set(s.thirdSlotId, g);
}
return assignment;
}

View file

@ -0,0 +1,506 @@
// AUTO-GENERATED from FIFA 2026 World Cup Annex C third-place allocation.
// Source: Wikipedia "Template:2026 FIFA World Cup third-place table".
// Do not edit by hand — regenerate with scripts/gen-fifa-third-place.mjs.
//
// Key: the eight qualifying third-place group letters, sorted (e.g. "EFGHIJKL").
// Value: the qualifying third-place group facing each group winner, in the
// column order [1A, 1B, 1D, 1E, 1G, 1I, 1K, 1L].
export const THIRD_PLACE_WINNER_COLUMNS = ["A", "B", "D", "E", "G", "I", "K", "L"] as const;
export const ANNEX_C_THIRD_PLACE_ALLOCATION: Record<string, string> = {
ABCDEFGH: "HGBCAFDE",
ABCDEFGI: "CGBDAFEI",
ABCDEFGJ: "CGBDAFEJ",
ABCDEFGK: "CGBDAFEK",
ABCDEFGL: "CGBDAFLE",
ABCDEFHI: "HEBCAFDI",
ABCDEFHJ: "HJBCAFDE",
ABCDEFHK: "HEBCAFDK",
ABCDEFHL: "HFBCADLE",
ABCDEFIJ: "CJBDAFEI",
ABCDEFIK: "CEBDAFIK",
ABCDEFIL: "CEBDAFLI",
ABCDEFJK: "CJBDAFEK",
ABCDEFJL: "CJBDAFLE",
ABCDEFKL: "CEBDAFLK",
ABCDEGHI: "HGBCADEI",
ABCDEGHJ: "HGBCADEJ",
ABCDEGHK: "HGBCADEK",
ABCDEGHL: "HGBCADLE",
ABCDEGIJ: "EGBCADIJ",
ABCDEGIK: "EGBCADIK",
ABCDEGIL: "EGBCADLI",
ABCDEGJK: "EGBCADJK",
ABCDEGJL: "EGBCADLJ",
ABCDEGKL: "EGBCADLK",
ABCDEHIJ: "HJBCADEI",
ABCDEHIK: "HEBCADIK",
ABCDEHIL: "HEBCADLI",
ABCDEHJK: "HJBCADEK",
ABCDEHJL: "HJBCADLE",
ABCDEHKL: "HEBCADLK",
ABCDEIJK: "EJBCADIK",
ABCDEIJL: "EJBCADLI",
ABCDEIKL: "EIBCADLK",
ABCDEJKL: "EJBCADLK",
ABCDFGHI: "HGBCAFDI",
ABCDFGHJ: "HGBCAFDJ",
ABCDFGHK: "HGBCAFDK",
ABCDFGHL: "CGBDAFLH",
ABCDFGIJ: "CGBDAFIJ",
ABCDFGIK: "CGBDAFIK",
ABCDFGIL: "CGBDAFLI",
ABCDFGJK: "CGBDAFJK",
ABCDFGJL: "CGBDAFLJ",
ABCDFGKL: "CGBDAFLK",
ABCDFHIJ: "HJBCAFDI",
ABCDFHIK: "HFBCADIK",
ABCDFHIL: "HFBCADLI",
ABCDFHJK: "HJBCAFDK",
ABCDFHJL: "CJBDAFLH",
ABCDFHKL: "HFBCADLK",
ABCDFIJK: "CJBDAFIK",
ABCDFIJL: "CJBDAFLI",
ABCDFIKL: "CIBDAFLK",
ABCDFJKL: "CJBDAFLK",
ABCDGHIJ: "HGBCADIJ",
ABCDGHIK: "HGBCADIK",
ABCDGHIL: "HGBCADLI",
ABCDGHJK: "HGBCADJK",
ABCDGHJL: "HGBCADLJ",
ABCDGHKL: "HGBCADLK",
ABCDGIJK: "CJBDAGIK",
ABCDGIJL: "CJBDAGLI",
ABCDGIKL: "IGBCADLK",
ABCDGJKL: "CJBDAGLK",
ABCDHIJK: "HJBCADIK",
ABCDHIJL: "HJBCADLI",
ABCDHIKL: "HIBCADLK",
ABCDHJKL: "HJBCADLK",
ABCDIJKL: "IJBCADLK",
ABCEFGHI: "HGBCAFEI",
ABCEFGHJ: "HGBCAFEJ",
ABCEFGHK: "HGBCAFEK",
ABCEFGHL: "HGBCAFLE",
ABCEFGIJ: "EGBCAFIJ",
ABCEFGIK: "EGBCAFIK",
ABCEFGIL: "EGBCAFLI",
ABCEFGJK: "EGBCAFJK",
ABCEFGJL: "EGBCAFLJ",
ABCEFGKL: "EGBCAFLK",
ABCEFHIJ: "HJBCAFEI",
ABCEFHIK: "HEBCAFIK",
ABCEFHIL: "HEBCAFLI",
ABCEFHJK: "HJBCAFEK",
ABCEFHJL: "HJBCAFLE",
ABCEFHKL: "HEBCAFLK",
ABCEFIJK: "EJBCAFIK",
ABCEFIJL: "EJBCAFLI",
ABCEFIKL: "EIBCAFLK",
ABCEFJKL: "EJBCAFLK",
ABCEGHIJ: "HJBCAGEI",
ABCEGHIK: "EGBCAHIK",
ABCEGHIL: "EGBCAHLI",
ABCEGHJK: "HJBCAGEK",
ABCEGHJL: "HJBCAGLE",
ABCEGHKL: "EGBCAHLK",
ABCEGIJK: "EJBCAGIK",
ABCEGIJL: "EJBCAGLI",
ABCEGIKL: "EGBAICLK",
ABCEGJKL: "EJBCAGLK",
ABCEHIJK: "EJBCAHIK",
ABCEHIJL: "EJBCAHLI",
ABCEHIKL: "EIBCAHLK",
ABCEHJKL: "EJBCAHLK",
ABCEIJKL: "EJBAICLK",
ABCFGHIJ: "HGBCAFIJ",
ABCFGHIK: "HGBCAFIK",
ABCFGHIL: "HGBCAFLI",
ABCFGHJK: "HGBCAFJK",
ABCFGHJL: "HGBCAFLJ",
ABCFGHKL: "HGBCAFLK",
ABCFGIJK: "CJBFAGIK",
ABCFGIJL: "CJBFAGLI",
ABCFGIKL: "IGBCAFLK",
ABCFGJKL: "CJBFAGLK",
ABCFHIJK: "HJBCAFIK",
ABCFHIJL: "HJBCAFLI",
ABCFHIKL: "HIBCAFLK",
ABCFHJKL: "HJBCAFLK",
ABCFIJKL: "IJBCAFLK",
ABCGHIJK: "HJBCAGIK",
ABCGHIJL: "HJBCAGLI",
ABCGHIKL: "IGBCAHLK",
ABCGHJKL: "HJBCAGLK",
ABCGIJKL: "IJBCAGLK",
ABCHIJKL: "IJBCAHLK",
ABDEFGHI: "HGBDAFEI",
ABDEFGHJ: "HGBDAFEJ",
ABDEFGHK: "HGBDAFEK",
ABDEFGHL: "HGBDAFLE",
ABDEFGIJ: "EGBDAFIJ",
ABDEFGIK: "EGBDAFIK",
ABDEFGIL: "EGBDAFLI",
ABDEFGJK: "EGBDAFJK",
ABDEFGJL: "EGBDAFLJ",
ABDEFGKL: "EGBDAFLK",
ABDEFHIJ: "HJBDAFEI",
ABDEFHIK: "HEBDAFIK",
ABDEFHIL: "HEBDAFLI",
ABDEFHJK: "HJBDAFEK",
ABDEFHJL: "HJBDAFLE",
ABDEFHKL: "HEBDAFLK",
ABDEFIJK: "EJBDAFIK",
ABDEFIJL: "EJBDAFLI",
ABDEFIKL: "EIBDAFLK",
ABDEFJKL: "EJBDAFLK",
ABDEGHIJ: "HJBDAGEI",
ABDEGHIK: "EGBDAHIK",
ABDEGHIL: "EGBDAHLI",
ABDEGHJK: "HJBDAGEK",
ABDEGHJL: "HJBDAGLE",
ABDEGHKL: "EGBDAHLK",
ABDEGIJK: "EJBDAGIK",
ABDEGIJL: "EJBDAGLI",
ABDEGIKL: "EGBAIDLK",
ABDEGJKL: "EJBDAGLK",
ABDEHIJK: "EJBDAHIK",
ABDEHIJL: "EJBDAHLI",
ABDEHIKL: "EIBDAHLK",
ABDEHJKL: "EJBDAHLK",
ABDEIJKL: "EJBAIDLK",
ABDFGHIJ: "HGBDAFIJ",
ABDFGHIK: "HGBDAFIK",
ABDFGHIL: "HGBDAFLI",
ABDFGHJK: "HGBDAFJK",
ABDFGHJL: "HGBDAFLJ",
ABDFGHKL: "HGBDAFLK",
ABDFGIJK: "FJBDAGIK",
ABDFGIJL: "FJBDAGLI",
ABDFGIKL: "IGBDAFLK",
ABDFGJKL: "FJBDAGLK",
ABDFHIJK: "HJBDAFIK",
ABDFHIJL: "HJBDAFLI",
ABDFHIKL: "HIBDAFLK",
ABDFHJKL: "HJBDAFLK",
ABDFIJKL: "IJBDAFLK",
ABDGHIJK: "HJBDAGIK",
ABDGHIJL: "HJBDAGLI",
ABDGHIKL: "IGBDAHLK",
ABDGHJKL: "HJBDAGLK",
ABDGIJKL: "IJBDAGLK",
ABDHIJKL: "IJBDAHLK",
ABEFGHIJ: "HJBFAGEI",
ABEFGHIK: "EGBFAHIK",
ABEFGHIL: "EGBFAHLI",
ABEFGHJK: "HJBFAGEK",
ABEFGHJL: "HJBFAGLE",
ABEFGHKL: "EGBFAHLK",
ABEFGIJK: "EJBFAGIK",
ABEFGIJL: "EJBFAGLI",
ABEFGIKL: "EGBAIFLK",
ABEFGJKL: "EJBFAGLK",
ABEFHIJK: "EJBFAHIK",
ABEFHIJL: "EJBFAHLI",
ABEFHIKL: "EIBFAHLK",
ABEFHJKL: "EJBFAHLK",
ABEFIJKL: "EJBAIFLK",
ABEGHIJK: "EJBAHGIK",
ABEGHIJL: "EJBAHGLI",
ABEGHIKL: "EGBAIHLK",
ABEGHJKL: "EJBAHGLK",
ABEGIJKL: "EJBAIGLK",
ABEHIJKL: "EJBAIHLK",
ABFGHIJK: "HJBFAGIK",
ABFGHIJL: "HJBFAGLI",
ABFGHIKL: "HGBAIFLK",
ABFGHJKL: "HJBFAGLK",
ABFGIJKL: "IJBFAGLK",
ABFHIJKL: "HJBAIFLK",
ABGHIJKL: "HJBAIGLK",
ACDEFGHI: "HGECAFDI",
ACDEFGHJ: "HGJCAFDE",
ACDEFGHK: "HGECAFDK",
ACDEFGHL: "HGFCADLE",
ACDEFGIJ: "CGJDAFEI",
ACDEFGIK: "CGEDAFIK",
ACDEFGIL: "CGEDAFLI",
ACDEFGJK: "CGJDAFEK",
ACDEFGJL: "CGJDAFLE",
ACDEFGKL: "CGEDAFLK",
ACDEFHIJ: "HJECAFDI",
ACDEFHIK: "HEFCADIK",
ACDEFHIL: "HEFCADLI",
ACDEFHJK: "HJECAFDK",
ACDEFHJL: "HJFCADLE",
ACDEFHKL: "HEFCADLK",
ACDEFIJK: "CJEDAFIK",
ACDEFIJL: "CJEDAFLI",
ACDEFIKL: "CEIDAFLK",
ACDEFJKL: "CJEDAFLK",
ACDEGHIJ: "HGJCADEI",
ACDEGHIK: "HGECADIK",
ACDEGHIL: "HGECADLI",
ACDEGHJK: "HGJCADEK",
ACDEGHJL: "HGJCADLE",
ACDEGHKL: "HGECADLK",
ACDEGIJK: "EGJCADIK",
ACDEGIJL: "EGJCADLI",
ACDEGIKL: "EGICADLK",
ACDEGJKL: "EGJCADLK",
ACDEHIJK: "HJECADIK",
ACDEHIJL: "HJECADLI",
ACDEHIKL: "HEICADLK",
ACDEHJKL: "HJECADLK",
ACDEIJKL: "EJICADLK",
ACDFGHIJ: "HGJCAFDI",
ACDFGHIK: "HGFCADIK",
ACDFGHIL: "HGFCADLI",
ACDFGHJK: "HGJCAFDK",
ACDFGHJL: "CGJDAFLH",
ACDFGHKL: "HGFCADLK",
ACDFGIJK: "CGJDAFIK",
ACDFGIJL: "CGJDAFLI",
ACDFGIKL: "CGIDAFLK",
ACDFGJKL: "CGJDAFLK",
ACDFHIJK: "HJFCADIK",
ACDFHIJL: "HJFCADLI",
ACDFHIKL: "HFICADLK",
ACDFHJKL: "HJFCADLK",
ACDFIJKL: "CJIDAFLK",
ACDGHIJK: "HGJCADIK",
ACDGHIJL: "HGJCADLI",
ACDGHIKL: "HGICADLK",
ACDGHJKL: "HGJCADLK",
ACDGIJKL: "IGJCADLK",
ACDHIJKL: "HJICADLK",
ACEFGHIJ: "HGJCAFEI",
ACEFGHIK: "HGECAFIK",
ACEFGHIL: "HGECAFLI",
ACEFGHJK: "HGJCAFEK",
ACEFGHJL: "HGJCAFLE",
ACEFGHKL: "HGECAFLK",
ACEFGIJK: "EGJCAFIK",
ACEFGIJL: "EGJCAFLI",
ACEFGIKL: "EGICAFLK",
ACEFGJKL: "EGJCAFLK",
ACEFHIJK: "HJECAFIK",
ACEFHIJL: "HJECAFLI",
ACEFHIKL: "HEICAFLK",
ACEFHJKL: "HJECAFLK",
ACEFIJKL: "EJICAFLK",
ACEGHIJK: "EGJCAHIK",
ACEGHIJL: "EGJCAHLI",
ACEGHIKL: "EGICAHLK",
ACEGHJKL: "EGJCAHLK",
ACEGIJKL: "EJICAGLK",
ACEHIJKL: "EJICAHLK",
ACFGHIJK: "HGJCAFIK",
ACFGHIJL: "HGJCAFLI",
ACFGHIKL: "HGICAFLK",
ACFGHJKL: "HGJCAFLK",
ACFGIJKL: "IGJCAFLK",
ACFHIJKL: "HJICAFLK",
ACGHIJKL: "HJICAGLK",
ADEFGHIJ: "HGJDAFEI",
ADEFGHIK: "HGEDAFIK",
ADEFGHIL: "HGEDAFLI",
ADEFGHJK: "HGJDAFEK",
ADEFGHJL: "HGJDAFLE",
ADEFGHKL: "HGEDAFLK",
ADEFGIJK: "EGJDAFIK",
ADEFGIJL: "EGJDAFLI",
ADEFGIKL: "EGIDAFLK",
ADEFGJKL: "EGJDAFLK",
ADEFHIJK: "HJEDAFIK",
ADEFHIJL: "HJEDAFLI",
ADEFHIKL: "HEIDAFLK",
ADEFHJKL: "HJEDAFLK",
ADEFIJKL: "EJIDAFLK",
ADEGHIJK: "EGJDAHIK",
ADEGHIJL: "EGJDAHLI",
ADEGHIKL: "EGIDAHLK",
ADEGHJKL: "EGJDAHLK",
ADEGIJKL: "EJIDAGLK",
ADEHIJKL: "EJIDAHLK",
ADFGHIJK: "HGJDAFIK",
ADFGHIJL: "HGJDAFLI",
ADFGHIKL: "HGIDAFLK",
ADFGHJKL: "HGJDAFLK",
ADFGIJKL: "IGJDAFLK",
ADFHIJKL: "HJIDAFLK",
ADGHIJKL: "HJIDAGLK",
AEFGHIJK: "EGJFAHIK",
AEFGHIJL: "EGJFAHLI",
AEFGHIKL: "EGIFAHLK",
AEFGHJKL: "EGJFAHLK",
AEFGIJKL: "EJIFAGLK",
AEFHIJKL: "EJIFAHLK",
AEGHIJKL: "EJIAHGLK",
AFGHIJKL: "HJIFAGLK",
BCDEFGHI: "CGBDHFEI",
BCDEFGHJ: "HGBCJFDE",
BCDEFGHK: "CGBDHFEK",
BCDEFGHL: "CGBDHFLE",
BCDEFGIJ: "CGBDJFEI",
BCDEFGIK: "CGBDEFIK",
BCDEFGIL: "CGBDEFLI",
BCDEFGJK: "CGBDJFEK",
BCDEFGJL: "CGBDJFLE",
BCDEFGKL: "CGBDEFLK",
BCDEFHIJ: "CJBDHFEI",
BCDEFHIK: "CEBDHFIK",
BCDEFHIL: "CEBDHFLI",
BCDEFHJK: "CJBDHFEK",
BCDEFHJL: "CJBDHFLE",
BCDEFHKL: "CEBDHFLK",
BCDEFIJK: "CJBDEFIK",
BCDEFIJL: "CJBDEFLI",
BCDEFIKL: "CEBDIFLK",
BCDEFJKL: "CJBDEFLK",
BCDEGHIJ: "HGBCJDEI",
BCDEGHIK: "EGBCHDIK",
BCDEGHIL: "EGBCHDLI",
BCDEGHJK: "HGBCJDEK",
BCDEGHJL: "HGBCJDLE",
BCDEGHKL: "EGBCHDLK",
BCDEGIJK: "EGBCJDIK",
BCDEGIJL: "EGBCJDLI",
BCDEGIKL: "EGBCIDLK",
BCDEGJKL: "EGBCJDLK",
BCDEHIJK: "EJBCHDIK",
BCDEHIJL: "EJBCHDLI",
BCDEHIKL: "EIBCHDLK",
BCDEHJKL: "EJBCHDLK",
BCDEIJKL: "EJBCIDLK",
BCDFGHIJ: "HGBCJFDI",
BCDFGHIK: "CGBDHFIK",
BCDFGHIL: "CGBDHFLI",
BCDFGHJK: "HGBCJFDK",
BCDFGHJL: "CGBDHFLJ",
BCDFGHKL: "CGBDHFLK",
BCDFGIJK: "CGBDJFIK",
BCDFGIJL: "CGBDJFLI",
BCDFGIKL: "CGBDIFLK",
BCDFGJKL: "CGBDJFLK",
BCDFHIJK: "CJBDHFIK",
BCDFHIJL: "CJBDHFLI",
BCDFHIKL: "CIBDHFLK",
BCDFHJKL: "CJBDHFLK",
BCDFIJKL: "CJBDIFLK",
BCDGHIJK: "HGBCJDIK",
BCDGHIJL: "HGBCJDLI",
BCDGHIKL: "HGBCIDLK",
BCDGHJKL: "HGBCJDLK",
BCDGIJKL: "IGBCJDLK",
BCDHIJKL: "HJBCIDLK",
BCEFGHIJ: "HGBCJFEI",
BCEFGHIK: "EGBCHFIK",
BCEFGHIL: "EGBCHFLI",
BCEFGHJK: "HGBCJFEK",
BCEFGHJL: "HGBCJFLE",
BCEFGHKL: "EGBCHFLK",
BCEFGIJK: "EGBCJFIK",
BCEFGIJL: "EGBCJFLI",
BCEFGIKL: "EGBCIFLK",
BCEFGJKL: "EGBCJFLK",
BCEFHIJK: "EJBCHFIK",
BCEFHIJL: "EJBCHFLI",
BCEFHIKL: "EIBCHFLK",
BCEFHJKL: "EJBCHFLK",
BCEFIJKL: "EJBCIFLK",
BCEGHIJK: "EJBCHGIK",
BCEGHIJL: "EJBCHGLI",
BCEGHIKL: "EGBCIHLK",
BCEGHJKL: "EJBCHGLK",
BCEGIJKL: "EJBCIGLK",
BCEHIJKL: "EJBCIHLK",
BCFGHIJK: "HGBCJFIK",
BCFGHIJL: "HGBCJFLI",
BCFGHIKL: "HGBCIFLK",
BCFGHJKL: "HGBCJFLK",
BCFGIJKL: "IGBCJFLK",
BCFHIJKL: "HJBCIFLK",
BCGHIJKL: "HJBCIGLK",
BDEFGHIJ: "HGBDJFEI",
BDEFGHIK: "EGBDHFIK",
BDEFGHIL: "EGBDHFLI",
BDEFGHJK: "HGBDJFEK",
BDEFGHJL: "HGBDJFLE",
BDEFGHKL: "EGBDHFLK",
BDEFGIJK: "EGBDJFIK",
BDEFGIJL: "EGBDJFLI",
BDEFGIKL: "EGBDIFLK",
BDEFGJKL: "EGBDJFLK",
BDEFHIJK: "EJBDHFIK",
BDEFHIJL: "EJBDHFLI",
BDEFHIKL: "EIBDHFLK",
BDEFHJKL: "EJBDHFLK",
BDEFIJKL: "EJBDIFLK",
BDEGHIJK: "EJBDHGIK",
BDEGHIJL: "EJBDHGLI",
BDEGHIKL: "EGBDIHLK",
BDEGHJKL: "EJBDHGLK",
BDEGIJKL: "EJBDIGLK",
BDEHIJKL: "EJBDIHLK",
BDFGHIJK: "HGBDJFIK",
BDFGHIJL: "HGBDJFLI",
BDFGHIKL: "HGBDIFLK",
BDFGHJKL: "HGBDJFLK",
BDFGIJKL: "IGBDJFLK",
BDFHIJKL: "HJBDIFLK",
BDGHIJKL: "HJBDIGLK",
BEFGHIJK: "EJBFHGIK",
BEFGHIJL: "EJBFHGLI",
BEFGHIKL: "EGBFIHLK",
BEFGHJKL: "EJBFHGLK",
BEFGIJKL: "EJBFIGLK",
BEFHIJKL: "EJBFIHLK",
BEGHIJKL: "EJIBHGLK",
BFGHIJKL: "HJBFIGLK",
CDEFGHIJ: "CGJDHFEI",
CDEFGHIK: "CGEDHFIK",
CDEFGHIL: "CGEDHFLI",
CDEFGHJK: "CGJDHFEK",
CDEFGHJL: "CGJDHFLE",
CDEFGHKL: "CGEDHFLK",
CDEFGIJK: "CGEDJFIK",
CDEFGIJL: "CGEDJFLI",
CDEFGIKL: "CGEDIFLK",
CDEFGJKL: "CGEDJFLK",
CDEFHIJK: "CJEDHFIK",
CDEFHIJL: "CJEDHFLI",
CDEFHIKL: "CEIDHFLK",
CDEFHJKL: "CJEDHFLK",
CDEFIJKL: "CJEDIFLK",
CDEGHIJK: "EGJCHDIK",
CDEGHIJL: "EGJCHDLI",
CDEGHIKL: "EGICHDLK",
CDEGHJKL: "EGJCHDLK",
CDEGIJKL: "EGICJDLK",
CDEHIJKL: "EJICHDLK",
CDFGHIJK: "CGJDHFIK",
CDFGHIJL: "CGJDHFLI",
CDFGHIKL: "CGIDHFLK",
CDFGHJKL: "CGJDHFLK",
CDFGIJKL: "CGIDJFLK",
CDFHIJKL: "CJIDHFLK",
CDGHIJKL: "HGICJDLK",
CEFGHIJK: "EGJCHFIK",
CEFGHIJL: "EGJCHFLI",
CEFGHIKL: "EGICHFLK",
CEFGHJKL: "EGJCHFLK",
CEFGIJKL: "EGICJFLK",
CEFHIJKL: "EJICHFLK",
CEGHIJKL: "EJICHGLK",
CFGHIJKL: "HGICJFLK",
DEFGHIJK: "EGJDHFIK",
DEFGHIJL: "EGJDHFLI",
DEFGHIKL: "EGIDHFLK",
DEFGHJKL: "EGJDHFLK",
DEFGIJKL: "EGIDJFLK",
DEFHIJKL: "EJIDHFLK",
DEGHIJKL: "EJIDHGLK",
DFGHIJKL: "HGIDJFLK",
EFGHIJKL: "EJIFHGLK",
};

View file

@ -1,9 +1,15 @@
/**
* Normalize a team name for fuzzy matching across data sources.
* Lowercases, trims, and collapses whitespace.
* Normalize a team/participant name for fuzzy matching across data sources.
* Folds accents (so "Stéfanos Tsitsipás" matches a plain "Stefanos Tsitsipas"),
* lowercases, trims, and collapses whitespace.
*/
export function normalizeTeamName(name: string): string {
return name.toLowerCase().trim().replace(/\s+/g, " ");
return name
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "") // strip combining diacritical marks
.toLowerCase()
.trim()
.replace(/\s+/g, " ");
}
/**

View file

@ -1,6 +1,17 @@
import { describe, it, expect } from "vitest";
import { computeStage3ExitQP } from "../cs2-major-stage";
import { calculateSplitQualifyingPoints } from "../qualifying-points";
import { describe, it, expect, vi } from "vitest";
// Stub only the cached-total recompute so resetCs2Event can be exercised without
// a real DB; the rest of qualifying-points stays real for the pure-function tests.
vi.mock("../qualifying-points", async (importOriginal) => {
const actual = await importOriginal();
return {
...(actual as Record<string, unknown>),
recalculateParticipantQP: vi.fn(),
};
});
import { computeStage3ExitQP, resetCs2Event } from "../cs2-major-stage";
import { calculateSplitQualifyingPoints, recalculateParticipantQP } from "../qualifying-points";
function makeStage3QPConfig(): Map<number, number> {
const config = new Map<number, number>();
@ -257,3 +268,57 @@ describe("Champions Stage floor QP calculation", () => {
expect(floorQP).toBeCloseTo((10 + 8 + 6 + 4) / 4); // = 7
});
});
describe("resetCs2Event", () => {
/**
* Build a mock db whose `transaction` runs its callback with the same handle
* (so the deletes + recalcs run inline), capturing which tables were deleted.
* The eventResults SELECT returns `resultRows`.
*/
function makeMockDb(resultRows: Array<{ participantId: string }>) {
const deletedTables: unknown[] = [];
const db: Record<string, unknown> = {
select: vi.fn(() => ({
from: vi.fn(() => ({ where: vi.fn().mockResolvedValue(resultRows) })),
})),
delete: vi.fn((table: unknown) => {
deletedTables.push(table);
return { where: vi.fn().mockResolvedValue(undefined) };
}),
};
db.transaction = vi.fn(async (cb: (tx: unknown) => unknown) => cb(db));
return { db, deletedTables };
}
it("clears assignments + results and recomputes each distinct participant's QP total", async () => {
vi.mocked(recalculateParticipantQP).mockClear();
// Two participants, one duplicated across rows → recompute exactly twice.
const { db, deletedTables } = makeMockDb([
{ participantId: "p1" },
{ participantId: "p1" },
{ participantId: "p2" },
]);
await resetCs2Event("event-1", "season-1", db as never);
// Both the stage-results table and the event-results table were deleted,
// inside the transaction.
expect((db as Record<string, ReturnType<typeof vi.fn>>).transaction).toHaveBeenCalledTimes(1);
expect(deletedTables).toHaveLength(2);
const recalcCalls = vi.mocked(recalculateParticipantQP).mock.calls;
expect(recalcCalls).toHaveLength(2);
expect(recalcCalls.map((c) => c[0]).toSorted()).toEqual(["p1", "p2"]);
for (const call of recalcCalls) expect(call[1]).toBe("season-1");
});
it("still clears the event when there are no recorded results", async () => {
vi.mocked(recalculateParticipantQP).mockClear();
const { db, deletedTables } = makeMockDb([]);
await resetCs2Event("event-1", "season-1", db as never);
expect(deletedTables).toHaveLength(2);
expect(recalculateParticipantQP).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,87 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock the database context before importing any model
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
import { database } from "~/database/context";
import { batchUpsertParticipantSimulatorInputs } from "../simulator";
type SetPayload = Record<string, unknown>;
const conflictSetCalls: SetPayload[] = [];
const onConflictDoUpdate = vi.fn((arg: { set: SetPayload }) => {
conflictSetCalls.push(arg.set);
return Promise.resolve(undefined);
});
const tx = {
insert: vi.fn(() => ({
values: vi.fn(() => ({ onConflictDoUpdate })),
})),
};
const mockDb = {
transaction: vi.fn((cb: (t: typeof tx) => Promise<unknown>) => cb(tx)),
};
beforeEach(() => {
vi.clearAllMocks();
conflictSetCalls.length = 0;
(database as ReturnType<typeof vi.fn>).mockReturnValue(mockDb);
});
// Recursively flatten a Drizzle `sql` template into its static text so we can
// assert how a conflict-update column is built (e.g. wrapped in COALESCE).
function sqlToText(value: unknown): string {
if (value === null || value === undefined) return "";
const chunks = (value as { queryChunks?: unknown[] }).queryChunks;
if (Array.isArray(chunks)) return chunks.map(sqlToText).join("");
const stringValue = (value as { value?: unknown }).value;
if (Array.isArray(stringValue)) return stringValue.join("");
if (typeof stringValue === "string") return stringValue;
return "";
}
describe("batchUpsertParticipantSimulatorInputs", () => {
it("updates only the columns it is given, preserving the rest (non-destructive)", async () => {
await batchUpsertParticipantSimulatorInputs([
// Odds-only import (e.g. from the bulk futures paste): no Elo/rating supplied.
{ participantId: "team-1", sportsSeasonId: "season-1", sourceOdds: 550 },
]);
// Runs in a transaction, writing the simulator-inputs row first then the
// legacy EV bridge row.
expect(mockDb.transaction).toHaveBeenCalledTimes(1);
expect(conflictSetCalls.length).toBeGreaterThanOrEqual(1);
const simulatorSet = conflictSetCalls[0];
// Every input column participates in the conflict update so partial pastes
// can target any field...
for (const column of ["sourceOdds", "sourceElo", "worldRanking", "rating", "seed", "region"]) {
expect(simulatorSet).toHaveProperty(column);
}
// ...but each is COALESCE-wrapped, so a null incoming value keeps the stored
// one instead of clobbering it. A bare `excluded.*` here would be the bug.
const sourceEloSql = sqlToText(simulatorSet.sourceElo).toLowerCase();
expect(sourceEloSql).toContain("coalesce");
const ratingSql = sqlToText(simulatorSet.rating).toLowerCase();
expect(ratingSql).toContain("coalesce");
// Metadata must drop the per-column method flag whenever that column gets a
// fresh direct value, so a stale "generated" flag can't hide a newly entered
// Elo/rating. Blindly preserving metadata (e.g. COALESCE) would be the bug.
const metadataSql = sqlToText(simulatorSet.metadata).toLowerCase();
expect(metadataSql).toContain("sourceelomethod");
expect(metadataSql).toContain("ratingmethod");
expect(metadataSql).toContain("excluded.source_elo");
expect(metadataSql).toContain("excluded.rating");
});
it("is a no-op when given no inputs", async () => {
await batchUpsertParticipantSimulatorInputs([]);
expect(mockDb.transaction).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// populateBracketFromDraw upserts a full draw into playoff_matches and reports
// which losers' matches *transitioned to complete on this run* — the signal used
// to announce knockouts once, idempotently across re-syncs.
const existingRows: Array<Record<string, unknown>> = [];
const mockDb = {
query: {
playoffMatches: {
findMany: vi.fn(async () => existingRows),
},
},
update: vi.fn(() => ({
set: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })),
})),
insert: vi.fn(() => ({
values: vi.fn(() => ({ returning: vi.fn().mockResolvedValue([]) })),
})),
};
vi.mock("~/database/context", () => ({ database: () => mockDb }));
import { populateBracketFromDraw, type ResolvedDrawMatch } from "../playoff-match";
const EVENT_ID = "ev-1";
function match(overrides: Partial<ResolvedDrawMatch> = {}): ResolvedDrawMatch {
return {
externalMatchId: "m-1",
round: "Round of 64",
matchNumber: 1,
participant1Id: "winner",
participant2Id: "loser",
winnerId: "winner",
loserId: "loser",
isScoring: false,
...overrides,
};
}
beforeEach(() => {
vi.clearAllMocks();
existingRows.length = 0;
});
describe("populateBracketFromDraw newlyDecidedLoserIds", () => {
it("reports the loser of a brand-new completed match", async () => {
const { newlyDecidedLoserIds } = await populateBracketFromDraw(EVENT_ID, [
match({ externalMatchId: "m-1", loserId: "mensik" }),
]);
expect(newlyDecidedLoserIds).toEqual(["mensik"]);
});
it("reports the loser of an existing match that just reached completion", async () => {
existingRows.push({
id: "row-1",
externalMatchId: "m-1",
isComplete: false,
loserId: null,
winnerId: null,
});
const { newlyDecidedLoserIds } = await populateBracketFromDraw(EVENT_ID, [
match({ externalMatchId: "m-1", loserId: "mensik" }),
]);
expect(newlyDecidedLoserIds).toEqual(["mensik"]);
});
it("reports nothing on an idempotent re-sync of an already-complete match", async () => {
existingRows.push({
id: "row-1",
externalMatchId: "m-1",
isComplete: true,
loserId: "mensik",
winnerId: "winner",
});
const { newlyDecidedLoserIds } = await populateBracketFromDraw(EVENT_ID, [
match({ externalMatchId: "m-1", loserId: "mensik" }),
]);
expect(newlyDecidedLoserIds).toEqual([]);
});
it("ignores incomplete matches (no winner/loser yet)", async () => {
const { newlyDecidedLoserIds, completed } = await populateBracketFromDraw(EVENT_ID, [
match({ externalMatchId: "m-2", winnerId: null, loserId: null }),
]);
expect(newlyDecidedLoserIds).toEqual([]);
expect(completed).toBe(0);
});
it("collects only the newly-decided losers in a mixed batch", async () => {
existingRows.push(
{ id: "row-1", externalMatchId: "m-1", isComplete: true, loserId: "old", winnerId: "w1" },
{ id: "row-2", externalMatchId: "m-2", isComplete: false, loserId: null, winnerId: null },
);
const { newlyDecidedLoserIds } = await populateBracketFromDraw(EVENT_ID, [
match({ externalMatchId: "m-1", loserId: "old" }), // already complete → skip
match({ externalMatchId: "m-2", loserId: "freshly-out" }), // transitioned → include
match({ externalMatchId: "m-3", loserId: "brand-new-out" }), // new complete → include
match({ externalMatchId: "m-4", winnerId: null, loserId: null }), // incomplete → skip
]);
expect(newlyDecidedLoserIds.toSorted()).toEqual(["brand-new-out", "freshly-out"]);
});
});

View file

@ -399,6 +399,7 @@ describe("isLoserNotifiable", () => {
});
});
describe("processPlayoffEvent - NBA Play-In Round 1 loserAdvances fix", () => {
function makePlayInDb(matches: object[]) {
const insertedRows: Array<{ participantId: string; finalPosition: number; isPartialScore: boolean }> = [];

View file

@ -0,0 +1,251 @@
import { describe, it, expect } from "vitest";
import {
deriveBracketQualifyingStates,
getRoundConfig,
getGuaranteedMinimumPosition,
type BracketMatchInput,
} from "../scoring-calculator";
import { BRACKET_TEMPLATES } from "../../lib/bracket-templates";
import { calculateSplitQualifyingPoints, DEFAULT_QP_VALUES } from "../qualifying-points";
// ── Test fixtures ─────────────────────────────────────────────────────────────
const SIMPLE_8 = BRACKET_TEMPLATES["simple_8"];
const getConfig = (round: string) => getRoundConfig(round, "simple_8");
const states = (matches: BracketMatchInput[]) =>
deriveBracketQualifyingStates(matches, SIMPLE_8.rounds, getConfig);
const QP_MAP = new Map<number, number>(
DEFAULT_QP_VALUES.map((v) => [v.placement, v.points])
);
const qpFor = (placement: number, tieCount: number) =>
calculateSplitQualifyingPoints(placement, tieCount, QP_MAP);
/** Helper to build a played match. */
const played = (
round: string,
winnerId: string,
loserId: string
): BracketMatchInput => ({
round,
winnerId,
loserId,
participant1Id: winnerId,
participant2Id: loserId,
});
/** Helper to build an as-yet-unplayed match (participants seeded, no result). */
const pending = (
round: string,
p1: string,
p2: string
): BracketMatchInput => ({
round,
winnerId: null,
loserId: null,
participant1Id: p1,
participant2Id: p2,
});
// Full simple_8 quarterfinal field: winners t1,t2,t3,t4 / losers t5,t6,t7,t8.
const QF_ALL = [
played("Quarterfinals", "t1", "t8"),
played("Quarterfinals", "t2", "t7"),
played("Quarterfinals", "t3", "t6"),
played("Quarterfinals", "t4", "t5"),
];
// Semifinals: t1 & t3 advance.
const SF_ALL = [
played("Semifinals", "t1", "t2"),
played("Semifinals", "t3", "t4"),
];
// Final: t1 champion.
const FINAL = [played("Finals", "t1", "t3")];
// ── deriveBracketQualifyingStates ─────────────────────────────────────────────
describe("deriveBracketQualifyingStates (simple_8)", () => {
it("returns an empty map for no matches", () => {
expect(states([]).size).toBe(0);
});
it("QF entered: losers lock T58, winners floor at T34", () => {
const s = states(QF_ALL);
// Losers — final placement 5, tie span 4 (T58).
for (const id of ["t5", "t6", "t7", "t8"]) {
expect(s.get(id)).toEqual({ placement: 5, tieCount: 4 });
}
// Winners — guaranteed at least T34: floor placement 3, tie span 2.
for (const id of ["t1", "t2", "t3", "t4"]) {
expect(s.get(id)).toEqual({ placement: 3, tieCount: 2 });
}
});
it("QF+SF entered: SF losers lock T34, SF winners floor at 2nd", () => {
const s = states([...QF_ALL, ...SF_ALL]);
// SF losers — final placement 3, tie span 2.
expect(s.get("t2")).toEqual({ placement: 3, tieCount: 2 });
expect(s.get("t4")).toEqual({ placement: 3, tieCount: 2 });
// SF winners (finalists-in-waiting) — floor placement 2, tie span 1.
expect(s.get("t1")).toEqual({ placement: 2, tieCount: 1 });
expect(s.get("t3")).toEqual({ placement: 2, tieCount: 1 });
// QF losers unchanged.
expect(s.get("t8")).toEqual({ placement: 5, tieCount: 4 });
});
it("full bracket: champion 1st, finalist 2nd, SF losers T34, QF losers T58", () => {
const s = states([...QF_ALL, ...SF_ALL, ...FINAL]);
expect(s.get("t1")).toEqual({ placement: 1, tieCount: 1 }); // champion
expect(s.get("t3")).toEqual({ placement: 2, tieCount: 1 }); // finalist
expect(s.get("t2")).toEqual({ placement: 3, tieCount: 2 }); // SF loser
expect(s.get("t4")).toEqual({ placement: 3, tieCount: 2 }); // SF loser
expect(s.get("t5")).toEqual({ placement: 5, tieCount: 4 }); // QF loser
});
it("partial QF (2 of 4 played): unplayed teams sit at the T58 entry floor", () => {
const matches = [
played("Quarterfinals", "t1", "t8"),
played("Quarterfinals", "t4", "t5"),
pending("Quarterfinals", "t3", "t6"),
pending("Quarterfinals", "t2", "t7"),
];
const s = states(matches);
// Played winners floor at T34.
expect(s.get("t1")).toEqual({ placement: 3, tieCount: 2 });
expect(s.get("t4")).toEqual({ placement: 3, tieCount: 2 });
// Played losers lock T58.
expect(s.get("t8")).toEqual({ placement: 5, tieCount: 4 });
expect(s.get("t5")).toEqual({ placement: 5, tieCount: 4 });
// Unplayed teams: entry floor T58 (no crash on incomplete round).
for (const id of ["t2", "t3", "t6", "t7"]) {
expect(s.get(id)).toEqual({ placement: 5, tieCount: 4 });
}
});
it("un-setting the Finals winner reverts both finalists to the 2nd-place floor", () => {
const matches = [
...QF_ALL,
...SF_ALL,
pending("Finals", "t1", "t3"), // winner cleared
];
const s = states(matches);
expect(s.get("t1")).toEqual({ placement: 2, tieCount: 1 });
expect(s.get("t3")).toEqual({ placement: 2, tieCount: 1 });
});
it("a team eliminated in the QF never gains a later-round floor", () => {
const s = states([...QF_ALL, ...SF_ALL, ...FINAL]);
// t8 lost in the QF and stays T58 through the rest of the bracket.
expect(s.get("t8")).toEqual({ placement: 5, tieCount: 4 });
});
it("is idempotent — same matches yield the same states", () => {
const a = states([...QF_ALL, ...SF_ALL]);
const b = states([...QF_ALL, ...SF_ALL]);
expect([...a.entries()].toSorted()).toEqual([...b.entries()].toSorted());
});
});
// ── tennis_128: scoring starts deep (Round of 16), not at round 1 ─────────────
describe("deriveBracketQualifyingStates (tennis_128 — deep scoring start)", () => {
const TENNIS = BRACKET_TEMPLATES["tennis_128"];
const tStates = (matches: BracketMatchInput[]) =>
deriveBracketQualifyingStates(matches, TENNIS.rounds, (round) =>
getRoundConfig(round, "tennis_128"),
);
it("awards NO QP to a player who lost before the Round of 16", () => {
const s = tStates([
played("Round of 128", "winner", "earlyLoser"),
]);
expect(s.has("earlyLoser")).toBe(false); // 0 QP — didn't reach scoring
});
it("awards NO QP just for winning a pre-scoring round (still short of R16)", () => {
// Won R128, next match (R64) not played → not yet in the Round of 16.
const s = tStates([played("Round of 128", "p", "x")]);
expect(s.has("p")).toBe(false);
});
it("gives NO entry floor to undrawn/unplayed players (the 9th-place bug)", () => {
const s = tStates([
pending("Round of 128", "a", "b"),
pending("Round of 128", "c", "d"),
]);
expect(s.size).toBe(0); // nobody has earned anything yet
});
it("floors a player who reached the Round of 16 (won R32) at T916", () => {
// a wins R128 → R64 → R32, so a is now IN the Round of 16 (not yet played).
const s = tStates([
played("Round of 128", "a", "x1"),
played("Round of 64", "a", "x2"),
played("Round of 32", "a", "x3"),
]);
expect(s.get("a")).toEqual({ placement: 9, tieCount: 8 });
// The players a beat in non-scoring rounds earn nothing.
expect(s.has("x1")).toBe(false);
});
it("locks an actual Round of 16 loser at T916 and floors the winner at T58", () => {
const s = tStates([
played("Round of 16", "r16winner", "r16loser"),
]);
expect(s.get("r16loser")).toEqual({ placement: 9, tieCount: 8 });
expect(s.get("r16winner")).toEqual({ placement: 5, tieCount: 4 }); // QF floor
});
});
// ── QP values (the worked example) ────────────────────────────────────────────
describe("guaranteed-minimum QP per outcome (default config)", () => {
it("matches the worked example: QF win → 9, SF win → 14, Final win → 20", () => {
expect(qpFor(3, 2)).toBeCloseTo(9); // QF winner, guaranteed T34
expect(qpFor(2, 1)).toBeCloseTo(14); // SF winner, guaranteed 2nd
expect(qpFor(1, 1)).toBeCloseTo(20); // champion
});
it("losers: QF loss → 4, SF loss → 9, Final loss → 14", () => {
expect(qpFor(5, 4)).toBeCloseTo(4); // (5+5+3+3)/4
expect(qpFor(3, 2)).toBeCloseTo(9); // (10+8)/2
expect(qpFor(2, 1)).toBeCloseTo(14);
});
it("end-to-end: a QF win immediately yields a 9 QP floor", () => {
const s = states(QF_ALL);
expect(s.get("t1")).toEqual({ placement: 3, tieCount: 2 });
expect(qpFor(3, 2)).toBeCloseTo(9);
expect(s.get("t8")).toEqual({ placement: 5, tieCount: 4 });
expect(qpFor(5, 4)).toBeCloseTo(4);
});
});
// ── Regression guard: structural tieCount, not live count (the #1 fix) ─────────
describe("structural tie span vs. live-count regrouping", () => {
it("4 QF winners share placement 3 but each keeps the 2-slot (9 QP) floor", () => {
const s = states(QF_ALL);
// All four QF winners sit at placement 3, but with the STRUCTURAL tie span of 2.
for (const id of ["t1", "t2", "t3", "t4"]) {
expect(s.get(id)).toEqual({ placement: 3, tieCount: 2 });
}
expect(qpFor(3, 2)).toBeCloseTo(9);
// A naive regroup by LIVE count (4 rows at placement 3) would average over
// slots 36 and wrongly yield 7 QP — the bug processQualifyingEvent must avoid
// by delegating bracket events to the structural-tieCount path.
expect(qpFor(3, 4)).toBeCloseTo(7);
expect(qpFor(3, 4)).not.toBeCloseTo(9);
});
});
// ── Regression guard: reused ROUND_CONFIG floors are intact ────────────────────
describe("ROUND_CONFIG floors (regression guard)", () => {
it("simple_8 Quarterfinals still floors winners at 3rd", () => {
expect(getGuaranteedMinimumPosition("Quarterfinals", "simple_8", true)).toBe(3);
expect(getRoundConfig("Quarterfinals", "simple_8")?.loserPosition).toBe(5);
expect(getRoundConfig("Semifinals", "simple_8")?.winnerFloor).toBe(2);
expect(getRoundConfig("Finals", "simple_8")?.winnerFloor).toBeNull();
});
});

View file

@ -2,9 +2,47 @@ import { describe, it, expect } from "vitest";
import {
calculateSplitQualifyingPoints,
DEFAULT_QP_VALUES,
hasProcessedQualifyingPlacement,
diffChangedQualifyingPoints,
} from "../qualifying-points";
describe("diffChangedQualifyingPoints", () => {
it("flags a changed value, a first-time score (null → value), and ignores unchanged", () => {
const before = [
{ id: "A", qp: "10.00" },
{ id: "B", qp: "5.00" },
{ id: "C", qp: null },
];
const after = [
{ id: "A", qp: "10.00" }, // unchanged
{ id: "B", qp: "8.00" }, // changed
{ id: "C", qp: "3.00" }, // first-time score
];
expect([...diffChangedQualifyingPoints(before, after)].toSorted()).toEqual(["B", "C"]);
});
it("normalizes decimal formatting so 10 and 10.00 are equal", () => {
const changed = diffChangedQualifyingPoints([{ id: "A", qp: "10" }], [{ id: "A", qp: "10.00" }]);
expect(changed.size).toBe(0);
});
it("does not report participants absent from the after set", () => {
const changed = diffChangedQualifyingPoints(
[
{ id: "A", qp: "10.00" },
{ id: "D", qp: "2.00" },
],
[{ id: "A", qp: "10.00" }],
);
expect(changed.size).toBe(0);
});
it("treats a value → zero drop as a change", () => {
const changed = diffChangedQualifyingPoints([{ id: "A", qp: "10.00" }], [{ id: "A", qp: "0.00" }]);
expect([...changed]).toEqual(["A"]);
});
});
describe("Qualifying Points Configuration", () => {
describe("DEFAULT_QP_VALUES", () => {
it("should have 16 placement values", () => {
@ -271,52 +309,6 @@ describe("Qualifying Points Configuration", () => {
expect(totalQP2).toBe(20); // Should have 20 QP (1st place)
});
it("should not increment majorsCompleted when reprocessing", () => {
// First processing
let majorsCompleted = 0;
const wasAlreadyProcessed = false;
if (!wasAlreadyProcessed) {
majorsCompleted += 1;
}
expect(majorsCompleted).toBe(1);
// Reprocessing (wasAlreadyProcessed = true)
const reprocessing = true;
if (!reprocessing) {
majorsCompleted += 1;
}
expect(majorsCompleted).toBe(1); // Should still be 1, not 2
});
});
describe("Processed event detection", () => {
it("treats a placed zero-QP result as already processed", () => {
expect(
hasProcessedQualifyingPlacement([
{ placement: 20, qualifyingPointsAwarded: "0.00" },
])
).toBe(true);
});
it("does not treat filler zero-QP rows without placements as processed", () => {
expect(
hasProcessedQualifyingPlacement([
{ placement: null, qualifyingPointsAwarded: "0" },
])
).toBe(false);
});
it("does not treat unprocessed placed rows as processed", () => {
expect(
hasProcessedQualifyingPlacement([
{ placement: 15, qualifyingPointsAwarded: null },
])
).toBe(false);
});
});
describe("Scoring Workflow", () => {

View file

@ -0,0 +1,143 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type * as QualifyingPointsModule from "~/models/qualifying-points";
// Spy on the QP Discord notification, and no-op the per-participant total recalc
// (its own eventResults query would otherwise interfere with the before/after
// snapshot counter below). Everything else in the scorer runs for real.
vi.mock("~/services/qualifying-points-discord.server", () => ({
notifyQualifyingPointsUpdate: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("~/models/qualifying-points", async (importActual) => {
const actual = await importActual<typeof QualifyingPointsModule>();
return {
...actual,
recalculateParticipantQP: vi.fn().mockResolvedValue({ totalQP: 0, eventsScored: 0 }),
};
});
import { processQualifyingEvent } from "../scoring-calculator";
import { DEFAULT_QP_VALUES } from "../qualifying-points";
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
const EVENT_ID = "event-1";
const SPORTS_SEASON_ID = "sports-season-1";
type ResultRow = {
id: string;
seasonParticipantId: string;
placement: number | null;
qualifyingPointsAwarded: string | null;
scoringEvent: { id: string; sportsSeasonId: string };
};
/**
* Mock db for the non-bracket `processQualifyingEvent` path. `eventResults.findMany`
* returns `before` on its first call (the initial getEventResults snapshot) and
* `after` on its second (the post-reprocess re-query that drives change detection).
* Awarding updates go to a no-op update chain `after` stands in for the DB state
* once the scorer has written its QP.
*/
function makeDb(before: ResultRow[], after: ResultRow[]) {
const results = [before, after];
let findManyCall = 0;
return {
query: {
scoringEvents: {
findFirst: async () => ({
id: EVENT_ID,
sportsSeasonId: SPORTS_SEASON_ID,
isQualifyingEvent: true,
bracketTemplateId: null,
sportsSeason: { majorsCompleted: 1 },
}),
},
eventResults: {
findMany: async () => results[Math.min(findManyCall++, 1)],
},
qualifyingPointConfig: {
findMany: async () =>
DEFAULT_QP_VALUES.map((qp) => ({ placement: qp.placement, points: qp.points.toString() })),
},
},
update: () => ({ set: () => ({ where: async () => [] }) }),
} as never;
}
const scoringEvent = { id: EVENT_ID, sportsSeasonId: SPORTS_SEASON_ID };
beforeEach(() => {
vi.clearAllMocks();
});
describe("processQualifyingEvent — QP change notification", () => {
it("notifies only participants whose QP changed (first-time score: null → value)", async () => {
const db = makeDb(
[
{ id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: null, scoringEvent },
{ id: "r2", seasonParticipantId: "p2", placement: 20, qualifyingPointsAwarded: "0.00", scoringEvent },
],
[
{ id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: "100.00", scoringEvent },
{ id: "r2", seasonParticipantId: "p2", placement: 20, qualifyingPointsAwarded: "0.00", scoringEvent },
],
);
await processQualifyingEvent(EVENT_ID, db);
expect(notifyQualifyingPointsUpdate).toHaveBeenCalledTimes(1);
const [ssId, evId, , filter] = vi.mocked(notifyQualifyingPointsUpdate).mock.calls[0];
expect(ssId).toBe(SPORTS_SEASON_ID);
expect(evId).toBe(EVENT_ID);
// p1 went null → 100 (changed); p2 stayed at 0 (unchanged).
expect([...(filter as Set<string>)]).toEqual(["p1"]);
});
it("does not notify when a re-sync produces identical QP", async () => {
const rows: ResultRow[] = [
{ id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: "100.00", scoringEvent },
{ id: "r2", seasonParticipantId: "p2", placement: 20, qualifyingPointsAwarded: "0.00", scoringEvent },
];
const db = makeDb(rows, rows.map((r) => ({ ...r })));
await processQualifyingEvent(EVENT_ID, db);
expect(notifyQualifyingPointsUpdate).not.toHaveBeenCalled();
});
it("announces newly-eliminated players even when no QP changed (mirror non-scoring-round exit)", async () => {
// A mirror window re-scored on fan-out: nobody's QP changed, but the primary
// bracket reports p2 knocked out in a non-scoring round. The notification must
// still fire, passing the eliminated id through as the 5th arg so the "Knocked
// Out" section isn't dropped on the mirror.
const rows: ResultRow[] = [
{ id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: "100.00", scoringEvent },
{ id: "r2", seasonParticipantId: "p2", placement: null, qualifyingPointsAwarded: "0.00", scoringEvent },
];
const db = makeDb(rows, rows.map((r) => ({ ...r })));
await processQualifyingEvent(EVENT_ID, db, {
newlyEliminatedParticipantIds: new Set(["p2"]),
});
expect(notifyQualifyingPointsUpdate).toHaveBeenCalledTimes(1);
const [, , , changed, eliminated] = vi.mocked(notifyQualifyingPointsUpdate).mock.calls[0];
// No QP change this sync.
expect([...(changed as Set<string>)]).toEqual([]);
// The knocked-out player is forwarded to the notifier.
expect([...(eliminated as Set<string>)]).toEqual(["p2"]);
});
it("does not notify on a re-sync with no QP change and no eliminations", async () => {
const rows: ResultRow[] = [
{ id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: "100.00", scoringEvent },
];
const db = makeDb(rows, rows.map((r) => ({ ...r })));
await processQualifyingEvent(EVENT_ID, db, {
newlyEliminatedParticipantIds: new Set(),
});
expect(notifyQualifyingPointsUpdate).not.toHaveBeenCalled();
});
});

View file

@ -200,7 +200,21 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
});
describe("processQualifyingEvent", () => {
function makeProcessQPMockDb(eventResults: Array<Record<string, unknown>>) {
function makeProcessQPMockDb(
eventResults: Array<Record<string, unknown>>,
options: {
tournamentId?: string | null;
canonicalPlacements?: number[];
bracketTemplateId?: string | null;
playoffMatchIds?: string[];
} = {}
) {
const {
tournamentId = null,
canonicalPlacements = [],
bracketTemplateId = null,
playoffMatchIds = [],
} = options;
const setCalls: unknown[] = [];
const updateChain = {
set: (values: unknown) => {
@ -217,6 +231,8 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
id: "event-1",
sportsSeasonId: "sports-season-1",
isQualifyingEvent: true,
tournamentId,
bracketTemplateId,
sportsSeason: { majorsCompleted: 1 },
}),
},
@ -238,6 +254,23 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
}),
},
},
// Two select shapes flow through the non-bracket path:
// • playoff-match existence check (has an `id` projection, uses .limit(1))
// • canonical tournament_results tie span (has a `placement` projection).
// Discriminate by the projection keys and return a thenable that also
// exposes .limit so both call shapes resolve.
select: (fields: Record<string, unknown>) => {
const isPlayoff = fields && "id" in fields;
const rows = isPlayoff
? playoffMatchIds.map((id) => ({ id }))
: canonicalPlacements.map((placement) => ({ placement }));
return {
from: () => ({
where: () =>
Object.assign(Promise.resolve(rows), { limit: async () => rows }),
}),
};
},
update: () => updateChain,
} as any;
@ -273,6 +306,123 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
);
});
it("splits a tied placement by the FULL canonical field, not the window's roster subset", async () => {
// Regression for the reported 2-vs-1.5 QP bug. Tennis Round-of-16 losers all
// land at placement 9 with a structural tie span of 8 (positions 916 →
// (2+2+2+2+1+1+1+1)/8 = 1.5). A sibling/mirror window only holds the drafted
// subset — here just 2 of the 8 tied players — but the split must still use 8,
// not the 2 present locally (which would wrongly give (2+2)/2 = 2.00).
const windowRows = [
{
id: "result-1",
seasonParticipantId: "participant-1",
placement: 9,
qualifyingPointsAwarded: null,
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
},
{
id: "result-2",
seasonParticipantId: "participant-2",
placement: 9,
qualifyingPointsAwarded: null,
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
},
];
const { db, setCalls } = makeProcessQPMockDb(windowRows, {
tournamentId: "tournament-1",
canonicalPlacements: Array.from({ length: 8 }, () => 9), // full field: 8 at 9th
});
await processQualifyingEvent("event-1", db, { skipNotifications: true });
// Both present players earn the correct split of 1.50, not 2.00.
expect(setCalls).toEqual(
expect.arrayContaining([
expect.objectContaining({ qualifyingPointsAwarded: "1.50" }),
])
);
expect(setCalls).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ qualifyingPointsAwarded: "2.00" }),
])
);
});
it("scores a cloned window (bracketTemplateId but no playoff matches) via the canonical path", async () => {
// cloneSportsSeason copies bracketTemplateId to league windows but not the
// playoff matches. Such a window must NOT take the bracket branch (which would
// derive zero states and write no QP) — it has to fall through to the
// placement/canonical path and still split R16 losers to 1.5.
const windowRows = [
{
id: "result-1",
seasonParticipantId: "participant-1",
placement: 9,
qualifyingPointsAwarded: null,
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
},
{
id: "result-2",
seasonParticipantId: "participant-2",
placement: 9,
qualifyingPointsAwarded: null,
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
},
];
const { db, setCalls } = makeProcessQPMockDb(windowRows, {
tournamentId: "tournament-1",
bracketTemplateId: "tennis_128", // copied by clone…
playoffMatchIds: [], // …but no matches exist on this window
canonicalPlacements: Array.from({ length: 8 }, () => 9),
});
await processQualifyingEvent("event-1", db, { skipNotifications: true });
expect(setCalls).toEqual(
expect.arrayContaining([
expect.objectContaining({ qualifyingPointsAwarded: "1.50" }),
])
);
expect(setCalls).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ qualifyingPointsAwarded: "2.00" }),
])
);
});
it("falls back to the live roster count for standalone events with no tournament", async () => {
// No canonical field exists for a manual/standalone qualifying event, so the
// tie span is the players actually present: 2 players tied at 9th →
// (2+2)/2 = 2.00. This preserves existing behavior where there is no full field.
const windowRows = [
{
id: "result-1",
seasonParticipantId: "participant-1",
placement: 9,
qualifyingPointsAwarded: null,
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
},
{
id: "result-2",
seasonParticipantId: "participant-2",
placement: 9,
qualifyingPointsAwarded: null,
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
},
];
const { db, setCalls } = makeProcessQPMockDb(windowRows, {
tournamentId: null,
});
await processQualifyingEvent("event-1", db, { skipNotifications: true });
expect(setCalls).toEqual(
expect.arrayContaining([
expect.objectContaining({ qualifyingPointsAwarded: "2.00" }),
])
);
});
it("does not increment majorsCompleted when reprocessing a placed zero-QP result", async () => {
const { db, setCalls } = makeProcessQPMockDb([
{

View file

@ -15,7 +15,10 @@ vi.mock("../qualifying-points", async (importOriginal) => {
import { deleteScoringEvent } from "../scoring-event";
describe("deleteScoringEvent", () => {
it("decrements majorsCompleted for a processed zero-QP qualifying event", async () => {
it("does not write majorsCompleted on delete (it is derived on read)", async () => {
// majorsCompleted is no longer a stored counter — it is computed via
// getMajorsCompleted from completed qualifying events. Deleting a qualifying
// event must therefore never issue a sportsSeasons.majorsCompleted update.
const setCalls: unknown[] = [];
const deleteChain = { where: vi.fn().mockResolvedValue(undefined) };
const updateChain = {
@ -43,12 +46,6 @@ describe("deleteScoringEvent", () => {
},
]),
},
sportsSeasons: {
findFirst: vi.fn().mockResolvedValue({
id: "sports-season-1",
majorsCompleted: 1,
}),
},
},
transaction: vi.fn(async (callback) => callback(db)),
delete: vi.fn(() => deleteChain),
@ -57,10 +54,125 @@ describe("deleteScoringEvent", () => {
await deleteScoringEvent("event-1", db);
expect(setCalls).toEqual(
expect(setCalls).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ majorsCompleted: 0 }),
expect.objectContaining({ majorsCompleted: expect.anything() }),
])
);
});
describe("shared tournament handling", () => {
// Builds a mock db for a non-qualifying schedule_event linked to a tournament,
// so the delete skips the QP/league branches and exercises only the
// shared-tournament bookkeeping.
function makeSharedDb({
deletedEvent,
remaining,
siblingTournamentId = "tournament-1",
}: {
deletedEvent: { tournamentId: string | null; isPrimary: boolean };
remaining: Array<{ id: string; isPrimary: boolean }>;
siblingTournamentId?: string;
}) {
const findFirst = vi
.fn()
// 1st call: the event being deleted
.mockResolvedValueOnce({
id: "event-1",
sportsSeasonId: "sports-season-1",
eventType: "schedule_event",
isQualifyingEvent: false,
...deletedEvent,
})
// subsequent calls: setPrimaryEvent looking up the promoted event
.mockResolvedValue({ id: "promoted", tournamentId: siblingTournamentId });
const deleteWhere = vi.fn().mockResolvedValue(undefined);
const updateWhere = vi.fn().mockResolvedValue(undefined);
const db = {
query: {
scoringEvents: {
findFirst,
findMany: vi.fn().mockResolvedValue(remaining),
},
},
transaction: vi.fn(async (callback) => callback(db)),
delete: vi.fn(() => ({ where: deleteWhere })),
update: vi.fn(() => ({ set: vi.fn(() => ({ where: updateWhere })) })),
} as any;
return { db, deleteWhere };
}
it("keeps the tournament and promotes nothing when a non-primary window is deleted", async () => {
const { db } = makeSharedDb({
deletedEvent: { tournamentId: "tournament-1", isPrimary: false },
remaining: [{ id: "event-2", isPrimary: true }],
});
const result = await deleteScoringEvent("event-1", db);
expect(result.remainingWindows).toBe(1);
expect(result.promotedPrimaryId).toBeNull();
expect(result.deletedTournament).toBe(false);
});
it("does not promote a primary when a golf-style tournament (no primary) loses a window", async () => {
// Golf-style shared majors intentionally have no primary window — every
// linked event is isPrimary=false. Deleting one must not flip a sibling
// into a primary, which would change its scoring/guard behavior.
const { db } = makeSharedDb({
deletedEvent: { tournamentId: "tournament-1", isPrimary: false },
remaining: [
{ id: "event-2", isPrimary: false },
{ id: "event-3", isPrimary: false },
],
});
const result = await deleteScoringEvent("event-1", db);
expect(result.promotedPrimaryId).toBeNull();
expect(result.remainingWindows).toBe(2);
});
it("promotes the earliest remaining window when the primary window is deleted", async () => {
const { db } = makeSharedDb({
deletedEvent: { tournamentId: "tournament-1", isPrimary: true },
// findMany is ordered asc(createdAt); first is the earliest.
remaining: [
{ id: "event-2", isPrimary: false },
{ id: "event-3", isPrimary: false },
],
});
const result = await deleteScoringEvent("event-1", db);
expect(result.promotedPrimaryId).toBe("event-2");
expect(result.remainingWindows).toBe(2);
});
it("leaves the orphaned tournament intact when the last window is deleted without the flag", async () => {
const { db } = makeSharedDb({
deletedEvent: { tournamentId: "tournament-1", isPrimary: true },
remaining: [],
});
const result = await deleteScoringEvent("event-1", db);
expect(result.remainingWindows).toBe(0);
expect(result.deletedTournament).toBe(false);
});
it("deletes the orphaned tournament when the last window is deleted with the flag", async () => {
const { db } = makeSharedDb({
deletedEvent: { tournamentId: "tournament-1", isPrimary: true },
remaining: [],
});
const result = await deleteScoringEvent("event-1", db, {
deleteOrphanTournament: true,
});
expect(result.deletedTournament).toBe(true);
});
});
});

View file

@ -1,11 +1,23 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const mockFindMany = vi.fn();
const mockFindFirst = vi.fn();
// Records each db.update(...).set(patch).where(pred) call for assertions.
const updateCalls: Array<{ patch: unknown; where: unknown }> = [];
const mockUpdate = vi.fn(() => ({
set: (patch: unknown) => ({
where: (where: unknown) => {
updateCalls.push({ patch, where });
return Promise.resolve();
},
}),
}));
const mockDb = {
query: {
scoringEvents: { findMany: mockFindMany },
scoringEvents: { findMany: mockFindMany, findFirst: mockFindFirst },
},
update: mockUpdate,
};
vi.mock("~/database/context", () => ({
@ -14,8 +26,10 @@ vi.mock("~/database/context", () => ({
vi.mock("~/database/schema", () => ({
scoringEvents: {
id: "se.id",
sportsSeasonId: "se.sports_season_id",
tournamentId: "se.tournament_id",
isPrimary: "se.is_primary",
},
}));
@ -23,11 +37,14 @@ vi.mock("drizzle-orm", () => ({
eq: (col: unknown, val: unknown) => ({ type: "eq", col, val }),
and: (...args: unknown[]) => ({ type: "and", args }),
isNotNull: (col: unknown) => ({ type: "isNotNull", col }),
asc: (col: unknown) => ({ type: "asc", col }),
}));
import {
getTournamentsBySportsSeason,
getSportsSeasonsByTournament,
ensurePrimaryEvent,
setPrimaryEvent,
} from "../scoring-event";
const SEASON_ID = "ss-1";
@ -39,6 +56,7 @@ const mockTournamentB = { id: TOURNAMENT_ID_B, name: "US Open", year: 2026 };
beforeEach(() => {
vi.clearAllMocks();
updateCalls.length = 0;
});
describe("getTournamentsBySportsSeason", () => {
@ -116,3 +134,37 @@ describe("getSportsSeasonsByTournament", () => {
);
});
});
describe("ensurePrimaryEvent", () => {
it("sets the event primary when the tournament has no primary yet", async () => {
mockFindFirst.mockResolvedValue(undefined); // no existing primary
const result = await ensurePrimaryEvent(TOURNAMENT_ID_A, "ev-1");
expect(result).toBe("ev-1");
expect(updateCalls).toHaveLength(1);
expect(updateCalls[0].patch).toMatchObject({ isPrimary: true });
});
it("is idempotent: keeps the existing primary and writes nothing", async () => {
mockFindFirst.mockResolvedValue({ id: "ev-existing" });
const result = await ensurePrimaryEvent(TOURNAMENT_ID_A, "ev-2");
expect(result).toBe("ev-existing");
expect(updateCalls).toHaveLength(0);
});
});
describe("setPrimaryEvent", () => {
it("clears siblings then sets the chosen event primary", async () => {
mockFindFirst.mockResolvedValue({ id: "ev-3", tournamentId: TOURNAMENT_ID_A });
await setPrimaryEvent("ev-3");
// Two updates: clear-all (isPrimary:false) then set-one (isPrimary:true).
expect(updateCalls).toHaveLength(2);
expect(updateCalls[0].patch).toMatchObject({ isPrimary: false });
expect(updateCalls[1].patch).toMatchObject({ isPrimary: true });
});
it("throws when the event is not linked to a tournament", async () => {
mockFindFirst.mockResolvedValue({ id: "ev-4", tournamentId: null });
await expect(setPrimaryEvent("ev-4")).rejects.toThrow(/not linked to a tournament/);
expect(updateCalls).toHaveLength(0);
});
});

View file

@ -71,4 +71,44 @@ describe("simulator input model", () => {
expect(byParticipant.get("generated-rating")?.rating).toBeNull();
expect(byParticipant.get("legacy-direct-rating")?.rating).toBe(27.5);
});
it("keeps direct sourceElo but suppresses generated sourceElo", async () => {
mockDb.query.seasonParticipants.findMany.mockResolvedValue([
{ id: "direct-elo" },
{ id: "generated-elo" },
]);
mockDb.query.seasonParticipantSimulatorInputs.findMany.mockResolvedValue([
{
participantId: "direct-elo",
sourceOdds: null,
sourceElo: 1600,
worldRanking: null,
rating: null,
projectedWins: null,
projectedTablePoints: null,
seed: null,
region: null,
metadata: { sourceEloMethod: "direct" },
},
{
participantId: "generated-elo",
sourceOdds: 750,
sourceElo: 1480,
worldRanking: null,
rating: null,
projectedWins: null,
projectedTablePoints: null,
seed: null,
region: null,
metadata: { sourceEloMethod: "sourceOdds" },
},
]);
mockDb.query.seasonParticipantExpectedValues.findMany.mockResolvedValue([]);
const inputs = await getParticipantSimulatorInputs("season-1");
const byParticipant = new Map(inputs.map((input) => [input.participantId, input]));
expect(byParticipant.get("direct-elo")?.sourceElo).toBe(1600);
expect(byParticipant.get("generated-elo")?.sourceElo).toBeNull();
});
});

View file

@ -27,7 +27,11 @@ vi.mock("drizzle-orm", () => ({
asc: (col: unknown) => ({ type: "asc", col }),
}));
import { findAllSportsSeasons, findDraftableSportsSeasons } from "../sports-season";
import {
findAllSportsSeasons,
findDraftableSportsSeasons,
findDraftScheduleForHorizon,
} from "../sports-season";
import { database } from "~/database/context";
const today = new Date().toISOString().slice(0, 10);
@ -125,3 +129,58 @@ describe("findDraftableSportsSeasons", () => {
);
});
});
describe("findDraftScheduleForHorizon", () => {
const horizonWindows = [
{
id: "w1",
name: "2026 NBA Playoffs",
year: 2026,
status: "upcoming",
draftOn: today,
draftOff: future,
sport: { id: "nba", name: "NBA", slug: "nba", iconUrl: null },
},
{
id: "w2",
name: "2026 F1 Season",
year: 2026,
status: "active",
draftOn: today,
draftOff: future,
sport: { id: "f1", name: "F1", slug: "f1", iconUrl: null },
},
];
it("maps rows to windows carrying their sport info", async () => {
vi.mocked(database).mockReturnValue(makeMockDb(horizonWindows) as never);
const result = await findDraftScheduleForHorizon(6);
expect(result).toHaveLength(2);
expect(result[0]).toMatchObject({
id: "w1",
draftOn: today,
draftOff: future,
sport: { id: "nba", name: "NBA" },
});
});
it("returns an empty array when no windows overlap the horizon", async () => {
vi.mocked(database).mockReturnValue(makeMockDb([]) as never);
const result = await findDraftScheduleForHorizon(12);
expect(result).toHaveLength(0);
});
it("queries with a where clause, ordering, and the sport relation", async () => {
const mockDb = makeMockDb([]);
vi.mocked(database).mockReturnValue(mockDb as never);
await findDraftScheduleForHorizon(6);
expect(mockDb.query.sportsSeasons.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.anything(),
orderBy: expect.anything(),
with: expect.anything(),
})
);
});
});

View file

@ -11,6 +11,7 @@ import {
findTournamentBySportNameYear,
upsertTournament,
updateTournamentStatus,
deleteTournament,
} from "../tournament";
import { database } from "~/database/context";
@ -172,3 +173,26 @@ describe("updateTournamentStatus", () => {
expect(result.status).toBe("in_progress");
});
});
describe("deleteTournament", () => {
it("deletes the tournament row by id", async () => {
const where = vi.fn().mockResolvedValue(undefined);
const db = { delete: vi.fn().mockReturnValue({ where }) };
vi.mocked(database).mockReturnValue(db as never);
await deleteTournament(TOURNAMENT_ID);
expect(db.delete).toHaveBeenCalledTimes(1);
expect(where).toHaveBeenCalledTimes(1);
});
it("uses a provided db when passed (transaction)", async () => {
const where = vi.fn().mockResolvedValue(undefined);
const providedDb = { delete: vi.fn().mockReturnValue({ where }) };
await deleteTournament(TOURNAMENT_ID, providedDb as never);
expect(providedDb.delete).toHaveBeenCalledTimes(1);
expect(vi.mocked(database)).not.toHaveBeenCalled();
});
});

View file

@ -15,7 +15,10 @@
import { database } from "~/database/context";
import { cs2MajorStageResults, seasonParticipants, eventResults } from "~/database/schema";
import { eq, and, sql } from "drizzle-orm";
import { getQPConfig, recalculateParticipantQP, calculateSplitQualifyingPoints } from "~/models/qualifying-points";
import { getQPConfig, calculateSplitQualifyingPoints, writeEventResultsQP, recalculateParticipantQP } from "~/models/qualifying-points";
import { deleteEventResults } from "~/models/event-result";
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
import { logger } from "~/lib/logger";
export interface Cs2StageResult {
id: string;
@ -126,14 +129,57 @@ export async function upsertCs2StageAssignments(
* Called when the admin resets the event setup.
*/
export async function clearCs2StageAssignments(
scoringEventId: string
scoringEventId: string,
providedDb?: ReturnType<typeof database>
): Promise<void> {
const db = database();
const db = providedDb || database();
await db
.delete(cs2MajorStageResults)
.where(eq(cs2MajorStageResults.scoringEventId, scoringEventId));
}
/**
* Fully reset a CS2 Major event so it can be set up again from scratch.
*
* Clears more than stage assignments alone: it also removes every recorded
* result for the event and recomputes the cached QP totals of the affected
* participants. This matters because a stale stage assignment can leave a
* phantom participant (e.g. an academy team entered in place of the main
* roster) with provisional QP in event_results and a cached total that would
* otherwise survive a stage-only reset and keep polluting the standings and
* the EV simulation field.
*
* Deliberately does NOT touch the Champions-Stage bracket (playoffMatches)
* re-entry realigns the stage data to the existing bracket.
*/
export async function resetCs2Event(
scoringEventId: string,
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
): Promise<void> {
const db = providedDb || database();
// Capture participants with results first so we can recompute their cached
// QP totals after the rows are deleted (mirrors deleteScoringEvent).
const existing = await db
.select({ participantId: eventResults.seasonParticipantId })
.from(eventResults)
.where(eq(eventResults.scoringEventId, scoringEventId));
const affectedParticipantIds = [...new Set(existing.map((r) => r.participantId))];
// Delete + recompute atomically so a mid-operation failure can't leave the
// event torn (e.g. assignments gone but stale QP totals surviving in the
// standings) — the exact phantom-QP state this reset exists to clear.
await db.transaction(async (tx) => {
await clearCs2StageAssignments(scoringEventId, tx);
await deleteEventResults(scoringEventId, tx);
for (const participantId of affectedParticipantIds) {
await recalculateParticipantQP(participantId, sportsSeasonId, tx);
}
});
}
/**
* Mark teams as eliminated from a specific stage.
* Each elimination entry specifies which stage the team was eliminated at
@ -394,34 +440,30 @@ export async function assignCs2EliminationQP(
}
}
const now = new Date();
await Promise.all(
[...resultsByParticipant.entries()].map(([seasonParticipantId, { qp, placement }]) =>
db
.insert(eventResults)
.values({
scoringEventId,
seasonParticipantId,
qualifyingPointsAwarded: qp.toString(),
placement,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: [eventResults.scoringEventId, eventResults.seasonParticipantId],
set: {
qualifyingPointsAwarded: qp.toString(),
placement,
updatedAt: now,
},
})
)
// Snapshot existing QP before writing so the notification only covers new/changed participants,
// preventing earlier stage exits from being re-announced on each subsequent stage call.
const existingRows = await db.query.eventResults.findMany({
where: eq(eventResults.scoringEventId, scoringEventId),
});
const existingQPBySPId = new Map<string, number>(
existingRows
.filter((r) => r.qualifyingPointsAwarded !== null)
.map((r) => [r.seasonParticipantId, parseFloat(r.qualifyingPointsAwarded as string)])
);
await Promise.all(
[...resultsByParticipant.keys()].map((seasonParticipantId) =>
recalculateParticipantQP(seasonParticipantId, sportsSeasonId)
)
await writeEventResultsQP(scoringEventId, sportsSeasonId, resultsByParticipant, db);
const changedIds = new Set<string>(
[...resultsByParticipant.entries()]
.filter(([id, { qp }]) => existingQPBySPId.get(id) !== qp)
.map(([id]) => id)
);
if (changedIds.size > 0) {
try {
await notifyQualifyingPointsUpdate(sportsSeasonId, scoringEventId, db, changedIds);
} catch (error) {
logger.error(`[CS2MajorStage] QP Discord notification failed for event ${scoringEventId}:`, error);
}
}
}

View file

@ -6,11 +6,11 @@
*/
import { database } from "~/database/context";
import { seasonParticipantExpectedValues, seasonParticipants, seasonParticipantSimulatorInputs } from "~/database/schema";
import { eq, and, count, inArray, sql } from "drizzle-orm";
import { seasonParticipantExpectedValues, seasonParticipants } from "~/database/schema";
import { eq, and, count, sql } from "drizzle-orm";
import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator";
import { calculateEV, normalizeProbabilities, calculateReplacementLevel, calculateVORP } from "~/services/ev-calculator";
import { batchSaveParticipantSimulatorSourceOdds, batchUpsertParticipantSimulatorInputs } from "~/models/simulator";
import { batchUpsertParticipantSimulatorInputs } from "~/models/simulator";
export type ProbabilitySource = "manual" | "futures_odds" | "elo_simulation" | "performance_model";
@ -366,95 +366,6 @@ export async function batchUpsertParticipantEVs(
return results;
}
/**
* Save American odds for a batch of seasonParticipants without touching probabilities or EV.
* Used by the futures-odds admin page to persist odds before running the full simulation.
*/
export async function batchSaveSourceOdds(
inputs: Array<{ participantId: string; sportsSeasonId: string; sourceOdds: number }>
): Promise<void> {
const db = database();
await db.transaction(async (tx) => {
const now = new Date();
for (const { participantId, sportsSeasonId, sourceOdds } of inputs) {
const existing = await tx
.select({ id: seasonParticipantExpectedValues.id })
.from(seasonParticipantExpectedValues)
.where(
and(
eq(seasonParticipantExpectedValues.participantId, participantId),
eq(seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)
)
)
.limit(1);
if (existing.length > 0) {
await tx
.update(seasonParticipantExpectedValues)
.set({ sourceOdds, updatedAt: now })
.where(eq(seasonParticipantExpectedValues.id, existing[0].id));
} else {
// Insert a stub record — probabilities/EV will be filled in by the simulator
await tx.insert(seasonParticipantExpectedValues).values({
participantId,
sportsSeasonId,
probFirst: "0",
probSecond: "0",
probThird: "0",
probFourth: "0",
probFifth: "0",
probSixth: "0",
probSeventh: "0",
probEighth: "0",
expectedValue: "0",
source: "futures_odds",
sourceOdds,
calculatedAt: now,
updatedAt: now,
});
}
}
});
await batchSaveParticipantSimulatorSourceOdds(inputs);
}
export async function clearSourceOddsForParticipants(
sportsSeasonId: string,
participantIds: string[]
): Promise<void> {
if (participantIds.length === 0) return;
const db = database();
const now = new Date();
await db.transaction(async (tx) => {
await tx
.update(seasonParticipantExpectedValues)
.set({ sourceOdds: null, updatedAt: now })
.where(
and(
eq(seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId),
inArray(seasonParticipantExpectedValues.participantId, participantIds)
)
);
await tx
.update(seasonParticipantSimulatorInputs)
.set({
sourceOdds: null,
rating: null,
metadata: sql`coalesce(${seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod'`,
updatedAt: now,
})
.where(
and(
eq(seasonParticipantSimulatorInputs.sportsSeasonId, sportsSeasonId),
inArray(seasonParticipantSimulatorInputs.participantId, participantIds)
)
);
});
}
/**
* Persist raw Elo ratings (and optional world rankings) for a batch of seasonParticipants.
* Used by Elo-based simulators like snooker_bracket and darts_bracket.

View file

@ -95,9 +95,10 @@ export async function deleteParticipantResult(id: string): Promise<void> {
}
export async function deleteParticipantResultsBySportsSeasonId(
sportsSeasonId: string
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
): Promise<void> {
const db = database();
const db = providedDb || database();
await db
.delete(schema.seasonParticipantResults)
.where(eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId));

View file

@ -142,6 +142,108 @@ export async function setMatchWinner(
return match;
}
/** A draw match with participants/winner already resolved to ids. */
export interface ResolvedDrawMatch {
externalMatchId: string;
round: string;
matchNumber: number;
participant1Id: string | null;
participant2Id: string | null;
winnerId: string | null;
loserId: string | null;
isScoring: boolean;
}
/**
* Populate (or advance) a bracket from a fully-resolved external draw, keyed on
* `externalMatchId` so repeated syncs update in place rather than duplicating.
*
* Unlike `generateBracketFromTemplate` (which seeds only round 1 and leaves later
* rounds empty for `advanceWinnerTemplate`), this writes every round's actual
* matchups and known winners directly from the source appropriate for a feed
* (e.g. Wikipedia) that reports the full draw including completed rounds. A row
* is marked complete when both its winner and loser are known.
*
* @returns counts of rows written (inserted or updated), those carrying a result,
* and the participant ids of losers whose match *transitioned to complete on this
* run* (a row that was absent or not-yet-complete before and is complete now).
* That set is the newly-decided eliminations used to announce knockouts once,
* idempotently across re-syncs, since playoff_matches persist between syncs.
*/
export async function populateBracketFromDraw(
eventId: string,
matches: ResolvedDrawMatch[]
): Promise<{ written: number; completed: number; newlyDecidedLoserIds: string[] }> {
const db = database();
const existing = await db.query.playoffMatches.findMany({
where: eq(schema.playoffMatches.scoringEventId, eventId),
});
const byExternalId = new Map(
existing
.filter((m) => m.externalMatchId)
.map((m) => [m.externalMatchId as string, m])
);
const toInsert: NewPlayoffMatch[] = [];
let written = 0;
let completed = 0;
const newlyDecidedLoserIds: string[] = [];
for (const m of matches) {
const isComplete = m.winnerId !== null && m.loserId !== null;
if (isComplete) completed++;
const existingRow = byExternalId.get(m.externalMatchId);
// Loser is "newly decided" when this match reaches completion for the first
// time: either a brand-new complete row, or an existing row that was not
// complete before. Re-syncing an already-complete match yields nothing.
if (isComplete && m.loserId && !existingRow?.isComplete) {
newlyDecidedLoserIds.push(m.loserId);
}
if (existingRow) {
await db
.update(schema.playoffMatches)
.set({
round: m.round,
matchNumber: m.matchNumber,
participant1Id: m.participant1Id,
participant2Id: m.participant2Id,
winnerId: m.winnerId,
loserId: m.loserId,
isComplete,
isScoring: m.isScoring,
templateRound: m.round,
updatedAt: new Date(),
})
.where(eq(schema.playoffMatches.id, existingRow.id));
written++;
} else {
toInsert.push({
scoringEventId: eventId,
round: m.round,
matchNumber: m.matchNumber,
participant1Id: m.participant1Id,
participant2Id: m.participant2Id,
winnerId: m.winnerId,
loserId: m.loserId,
isComplete,
isScoring: m.isScoring,
templateRound: m.round,
externalMatchId: m.externalMatchId,
});
}
}
if (toInsert.length > 0) {
await createManyPlayoffMatches(toInsert);
written += toInsert.length;
}
return { written, completed, newlyDecidedLoserIds };
}
/**
* Generate a standard single elimination bracket structure
*/

View file

@ -33,6 +33,33 @@ export function roundQualifyingPoints(points: number): number {
return Math.round((points + Number.EPSILON) * 100) / 100;
}
/**
* Given a participant's awarded QP before and after a re-score, return the ids
* whose QP changed. A row's `qp` is the raw decimal string (or null when no QP
* was awarded). A participant counts as changed when their new value differs from
* the old, including a first-time score (null value). Participants absent from
* `after` are not reported (their removal is handled by the caller's recalc).
*
* Shared by the two QP re-score paths processQualifyingEvent (sibling windows,
* CS2, manual admin) and rescoreTennisBracketAndDetectChanges (tennis primary)
* so the "only announce real changes" semantics live in one place.
*/
export function diffChangedQualifyingPoints(
before: Iterable<{ id: string; qp: string | null }>,
after: Iterable<{ id: string; qp: string | null }>
): Set<string> {
const beforeQP = new Map<string, number>();
for (const r of before) {
if (r.qp !== null) beforeQP.set(r.id, parseFloat(r.qp));
}
const changed = new Set<string>();
for (const r of after) {
if (r.qp === null) continue;
if (beforeQP.get(r.id) !== parseFloat(r.qp)) changed.add(r.id);
}
return changed;
}
export function calculateSplitQualifyingPoints(
placement: number,
tieCount: number,
@ -48,21 +75,6 @@ export function calculateSplitQualifyingPoints(
return roundQualifyingPoints(totalQP / tieCount);
}
export function hasProcessedQualifyingPlacement(
results: Array<{
placement?: number | null;
qualifyingPointsAwarded?: string | null;
}>
): boolean {
return results.some(
(result) =>
result.placement !== null &&
result.placement !== undefined &&
result.qualifyingPointsAwarded !== null &&
result.qualifyingPointsAwarded !== undefined
);
}
/**
* Initialize default qualifying point configuration for a sports season
*/
@ -283,6 +295,57 @@ export async function recalculateParticipantQP(
return { totalQP, eventsScored };
}
/**
* Upsert qualifying points + placement into event_results for a set of participants,
* then recalculate each participant's QP total.
*
* Shared by the QP writers (assignCs2EliminationQP for Swiss-stage exits,
* processQualifyingBracketEvent for Champions-Stage / knockout brackets) so the
* upsert + recalc loop lives in one place. Upserts on
* (scoringEventId, seasonParticipantId), so repeated calls overwrite cleanly.
*
* @param entries Map from seasonParticipantId { qp, placement } to write.
*/
export async function writeEventResultsQP(
scoringEventId: string,
sportsSeasonId: string,
entries: Map<string, { qp: number; placement: number }>,
providedDb?: ReturnType<typeof database>
): Promise<void> {
if (entries.size === 0) return;
const db = providedDb || database();
const now = new Date();
await Promise.all(
[...entries.entries()].map(([seasonParticipantId, { qp, placement }]) =>
db
.insert(schema.eventResults)
.values({
scoringEventId,
seasonParticipantId,
qualifyingPointsAwarded: qp.toFixed(2),
placement,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: [schema.eventResults.scoringEventId, schema.eventResults.seasonParticipantId],
set: {
qualifyingPointsAwarded: qp.toFixed(2),
placement,
updatedAt: now,
},
})
)
);
await Promise.all(
[...entries.keys()].map((seasonParticipantId) =>
recalculateParticipantQP(seasonParticipantId, sportsSeasonId, db)
)
);
}
/**
* Recalculate eventsScored for a participant by counting unique events with QP awarded
* @deprecated Use recalculateParticipantQP instead for more accurate recalculation

View file

@ -9,8 +9,9 @@ import {
} from "./scoring-rules";
import { getSeasonResults } from "./participant-season-result";
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
import { sendStandingsUpdateNotification, type ScoredMatch } from "~/services/discord";
import { BRACKET_TEMPLATES } from "~/lib/bracket-templates";
import { sendStandingsUpdateNotification, type ScoredMatch, type EliminatedTeam } from "~/services/discord";
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
import { BRACKET_TEMPLATES, type BracketRound } from "~/lib/bracket-templates";
import { doesLoserAdvance, findPlayoffMatchesByEventId } from "~/models/playoff-match";
import { getUserDisplayName } from "~/models/user";
import { findDiscordIdsByUserIds } from "~/models/account";
@ -20,9 +21,10 @@ import { logger } from "~/lib/logger";
import { getEventResults } from "./event-result";
import {
calculateSplitQualifyingPoints,
diffChangedQualifyingPoints,
getQPConfig,
hasProcessedQualifyingPlacement,
recalculateParticipantQP,
writeEventResultsQP,
getQPStandings,
updateFinalRankings,
} from "./qualifying-points";
@ -111,6 +113,16 @@ const TEMPLATE_ROUND_CONFIG: Record<string, Record<string, RoundScoringConfig>>
// 3rd place game finalizes both positions distinctly.
"Third Place Game": { loserPosition: 4, loserIsPartial: false, winnerFloor: null, winnerPosition: 3 },
},
tennis_128: {
// R16 losers share 9th16th; winner advances to QF (floor 5th8th).
"Round of 16": { loserPosition: 9, loserIsPartial: false, winnerFloor: 5 },
// QF losers share 5th8th; winner advances to SF (floor 3rd4th).
Quarterfinals: { loserPosition: 5, loserIsPartial: false, winnerFloor: 3 },
// SF losers share 3rd4th; winner advances to Final (floor 2nd).
Semifinals: { loserPosition: 3, loserIsPartial: false, winnerFloor: 2 },
// Final finalizes both: winner 1st, loser 2nd.
Final: { loserPosition: 2, loserIsPartial: false, winnerFloor: null },
},
};
/**
@ -143,7 +155,7 @@ function doesNonScoringRoundFeedIntoScoringRound(
* overrides before falling back to the standard ROUND_CONFIG.
* Returns null for unrecognized rounds.
*/
function getRoundConfig(
export function getRoundConfig(
round: string,
bracketTemplateId?: string | null
): RoundScoringConfig | null {
@ -612,6 +624,7 @@ export function isLoserNotifiable(
return scoreChanged || finalizedLoserIds.has(loserId);
}
export function getGuaranteedMinimumPosition(
round: string,
bracketTemplateId: string | null | undefined,
@ -623,13 +636,260 @@ export function getGuaranteedMinimumPosition(
return config.winnerFloor; // null for Finals; actual floor for all other rounds
}
// ── Qualifying-points bracket scoring ────────────────────────────────────────
/** Minimal shape of a playoff match needed to derive qualifying-bracket states. */
export interface BracketMatchInput {
round: string;
winnerId: string | null;
loserId: string | null;
participant1Id: string | null;
participant2Id: string | null;
}
/** The guaranteed-minimum placement (and its structural tie span) for one team. */
export interface BracketQualifyingState {
/** Placement tier the team has locked in (1, 2, 3, 5, …). */
placement: number;
/**
* Number of bracket slots this placement tier spans (e.g. 4 for T58, 2 for
* T34, 1 for 1st/2nd). Used to tie-split QP across the tier. This is the
* *structural* span from the template not the live count of teams currently
* sitting at this placement so the floor value is stable and idempotent and
* agrees with processQualifyingEvent's finalization re-grouping.
*/
tieCount: number;
}
/**
* Derive each bracket team's guaranteed-minimum (placement, tieCount) from the
* current set of playoff matches, reusing the standard ROUND_CONFIG floors.
*
* Rules per team (single-elimination a team loses at most once):
* - Lost in round R final placement = loserPosition(R), tier span = matchCount(R).
* (QF loss 5/T58, SF loss 3/T34, Finals loss 2/finalist)
* - Still alive, highest round won = R:
* · R is the finalization round (winnerFloor null) champion: winnerPosition ?? 1.
* · otherwise floor = winnerFloor(R), tier span = matchCount(R.feedsInto).
* (QF win 3/T34 = 9 QP, SF win 2/finalist = 14 QP)
* - Alive but no match resolved yet entry floor of the first scoring round
* (QF 5/T58 = 4 QP), mirroring assignCs2EliminationQP's provisional floor.
*
* Pure and exported for unit testing. Teams eliminated in a non-scoring round
* (getConfig returns null) are omitted they earn no QP from the bracket.
*/
export function deriveBracketQualifyingStates(
matches: BracketMatchInput[],
rounds: BracketRound[],
getConfig: (round: string) => RoundScoringConfig | null
): Map<string, BracketQualifyingState> {
const roundIndex = new Map(rounds.map((r, i) => [r.name, i]));
const matchCountByRound = new Map(rounds.map((r) => [r.name, r.matchCount]));
const feedsIntoByRound = new Map(rounds.map((r) => [r.name, r.feedsInto]));
const firstScoringRound = rounds.find((r) => r.isScoring) ?? rounds[0];
const participants = new Set<string>();
const wonRounds = new Map<string, Set<string>>();
const lostRound = new Map<string, string>();
for (const m of matches) {
if (m.participant1Id) participants.add(m.participant1Id);
if (m.participant2Id) participants.add(m.participant2Id);
if (m.winnerId) {
participants.add(m.winnerId);
if (!wonRounds.has(m.winnerId)) wonRounds.set(m.winnerId, new Set());
wonRounds.get(m.winnerId)?.add(m.round);
}
if (m.loserId) {
participants.add(m.loserId);
lostRound.set(m.loserId, m.round);
}
}
const result = new Map<string, BracketQualifyingState>();
for (const id of participants) {
const lost = lostRound.get(id);
if (lost) {
const cfg = getConfig(lost);
if (!cfg) continue; // non-scoring elimination: no bracket QP
result.set(id, { placement: cfg.loserPosition, tieCount: matchCountByRound.get(lost) ?? 1 });
continue;
}
// Still alive: floor from the highest round they have won.
const won = wonRounds.get(id);
let highest: string | null = null;
if (won) {
for (const r of won) {
if (highest === null || (roundIndex.get(r) ?? -1) > (roundIndex.get(highest) ?? -1)) {
highest = r;
}
}
}
if (!highest) {
// In the bracket but no match resolved yet → sitting in the first round.
// Only grant an entry floor if that first round is itself a scoring round
// (e.g. simple_8 / CS2 Champions Stage, where every entrant is already in
// the scoring stage). For deep brackets whose scoring starts later
// (tennis_128: Round of 128 → … → Round of 16), merely being drawn earns
// nothing — no QP until the player reaches the first scoring round.
if (!rounds[0]?.isScoring || !firstScoringRound) continue;
const cfg = getConfig(firstScoringRound.name);
if (!cfg) continue;
result.set(id, { placement: cfg.loserPosition, tieCount: firstScoringRound.matchCount });
continue;
}
const cfg = getConfig(highest);
if (!cfg) {
// Won a non-scoring round. If that win advanced them INTO a scoring round,
// they've guaranteed that round's loser floor (e.g. a tennis R32 win → in
// the Round of 16 → guaranteed T916). Otherwise (won an earlier
// non-scoring round) they've earned no QP yet.
const feedsInto = feedsIntoByRound.get(highest) ?? null;
const nextRound = feedsInto ? rounds.find((r) => r.name === feedsInto) : null;
if (nextRound?.isScoring) {
const floorCfg = getConfig(nextRound.name);
if (floorCfg) {
result.set(id, {
placement: floorCfg.loserPosition,
tieCount: nextRound.matchCount,
});
}
}
continue;
}
if (cfg.winnerFloor === null) {
// Won the finalization round → champion (or 3rd-place-game winner).
result.set(id, { placement: cfg.winnerPosition ?? 1, tieCount: 1 });
} else {
const next = feedsIntoByRound.get(highest) ?? null;
const tieCount = next ? (matchCountByRound.get(next) ?? 1) : 1;
result.set(id, { placement: cfg.winnerFloor, tieCount });
}
}
return result;
}
/**
* Score a qualifying event whose Champions-Stage / knockout bracket lives in
* playoffMatches (e.g. a CS2 Major). Derives each bracket team's guaranteed
* minimum QP from the matches and upserts it into event_results, then refreshes
* each participant's QP total.
*
* Unlike processPlayoffEvent (which writes the fantasy-points table
* seasonParticipantResults), this writes ONLY the QP path. For a
* qualifying_points sport the per-major fantasy placement is meaningless final
* fantasy placements come solely from finalizeQualifyingPoints across all majors.
*
* QP is written directly with the placement tier's STRUCTURAL tie span (QF tier
* spans 4 slots, SF tier 2, finalist/champion 1) not the live count of teams
* currently at a placement. This is what makes a QF-winner floor 9 QP (avg of
* 3rd+4th) even while all four QF winners transiently share placement 3.
* processQualifyingEvent delegates to this function for bracket events precisely
* so it does NOT re-group those rows by live count (which would average four
* placement-3 rows down to 7).
*
* Idempotent: re-running on the same match state recomputes identical values.
*/
export async function processQualifyingBracketEvent(
eventId: string,
providedDb?: ReturnType<typeof database>
): Promise<void> {
const db = providedDb || database();
const event = await db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.id, eventId),
});
if (!event) throw new Error(`Event ${eventId} not found`);
if (!event.isQualifyingEvent) {
throw new Error(`Event ${eventId} is not a qualifying event`);
}
const template = event.bracketTemplateId ? BRACKET_TEMPLATES[event.bracketTemplateId] : undefined;
if (!template) {
throw new Error(`Event ${eventId} has no bracket template; cannot derive qualifying bracket QP`);
}
const matches = await db.query.playoffMatches.findMany({
where: eq(schema.playoffMatches.scoringEventId, eventId),
});
const states = deriveBracketQualifyingStates(
matches.map((m) => ({
round: m.round,
winnerId: m.winnerId,
loserId: m.loserId,
participant1Id: m.participant1Id,
participant2Id: m.participant2Id,
})),
template.rounds,
(round) => getRoundConfig(round, event.bracketTemplateId)
);
if (states.size === 0) return;
const qpConfigArray = await getQPConfig(event.sportsSeasonId, db);
const qpMap = new Map<number, number>(
qpConfigArray.map((c) => [c.placement, parseFloat(c.points)])
);
const entries = new Map<string, { qp: number; placement: number }>(
[...states.entries()].map(([id, { placement, tieCount }]) => [
id,
{ qp: calculateSplitQualifyingPoints(placement, tieCount, qpMap), placement },
])
);
await writeEventResultsQP(eventId, event.sportsSeasonId, entries, db);
}
/**
* Count how many results share each placement the structural tie span used to
* split QP across a tied group. Callers pass the FULL canonical field
* (tournament_results) so the span reflects the whole tournament, not one window's
* roster subset. Null placements (filler / not-participating) are ignored.
*/
export function buildTieCountByPlacement(
results: Array<{ placement: number | null }>
): Map<number, number> {
const map = new Map<number, number>();
for (const r of results) {
if (r.placement === null) continue;
map.set(r.placement, (map.get(r.placement) ?? 0) + 1);
}
return map;
}
/**
* Process a qualifying event completion and update QP totals.
* Ties in QP are handled by sharing placements (averaged points).
*/
export async function processQualifyingEvent(
eventId: string,
providedDb?: ReturnType<typeof database>
providedDb?: ReturnType<typeof database>,
options: {
skipNotifications?: boolean;
/**
* Pre-computed full-field tie span (placement count) from the canonical
* tournament_results. When the fan-out already loaded the canonical results it
* passes this in so we don't re-query per window. Omitted for direct callers,
* which fall back to querying it here.
*/
canonicalTieCountByPlacement?: Map<number, number>;
/**
* This window's season_participant ids that were knocked out this sync in a
* non-scoring round. They earn no QP (so they never surface via changed QP),
* but a manager who drafted them should still be told. Threaded down from the
* primary bracket by the fan-out (syncTournamentResults), already translated
* to THIS window's season_participant ids. See the primary path in
* app/services/match-sync/index.ts (newlyEliminatedIds).
*/
newlyEliminatedParticipantIds?: Set<string>;
} = {}
): Promise<void> {
const db = providedDb || database();
@ -652,55 +912,109 @@ export async function processQualifyingEvent(
// Get all event results for this qualifying event
const results = await getEventResults(eventId, db);
// Check if this was already processed (for majorsCompleted counter)
const wasAlreadyProcessed = hasProcessedQualifyingPlacement(results);
// Snapshot awarded QP before reprocessing so the Discord notification below can
// announce only the participants whose QP actually changed. Without this, a
// sibling window re-scored on every fan-out sync (syncTournamentResults) would
// re-ping the full QP standings each run even when nothing changed.
const beforeQP = results.map((r) => ({
id: r.seasonParticipantId,
qp: r.qualifyingPointsAwarded,
}));
// Clear stale QP before reprocessing so removed/null placements do not keep old awards.
for (const result of results) {
await db
.update(schema.eventResults)
.set({
qualifyingPointsAwarded: "0",
updatedAt: new Date(),
})
.where(eq(schema.eventResults.id, result.id));
// Route to the bracket writer only when this window actually OWNS a bracket (has
// playoff matches). A window can carry a bracketTemplateId with no matches — e.g. a
// league window cloned from a bracket season copies the template id but not the
// matches (cloneSportsSeason) — and processQualifyingBracketEvent would derive zero
// states and write NO QP. Those windows must be scored via the placement/canonical
// path below, exactly like a no-template sibling.
let hasBracketMatches = false;
if (event.bracketTemplateId) {
const existing = await db
.select({ id: schema.playoffMatches.id })
.from(schema.playoffMatches)
.where(eq(schema.playoffMatches.scoringEventId, eventId))
.limit(1);
hasBracketMatches = existing.length > 0;
}
const qpConfig = await getQPConfig(event.sportsSeasonId, db);
const pointsByPlacement = new Map(
qpConfig.map((config) => [config.placement, parseFloat(config.points)])
);
// Group results by placement to handle ties
const placementGroups = new Map<number, typeof results>();
for (const result of results) {
if (result.placement) {
const group = placementGroups.get(result.placement) || [];
group.push(result);
placementGroups.set(result.placement, group);
}
}
// Process each placement group and update event_results with QP awarded
for (const [placement, group] of placementGroups) {
const tieCount = group.length;
const qpPerParticipant = calculateSplitQualifyingPoints(
placement,
tieCount,
pointsByPlacement
);
// Update the event result with the QP awarded
for (const result of group) {
if (hasBracketMatches) {
// Bracket-based qualifying event (e.g. CS2 Champions Stage): QP is owned by the
// bracket/stage writers, which assign each placement its STRUCTURAL tie span.
// Re-derive via processQualifyingBracketEvent and leave the Swiss-exit rows
// (written by assignCs2EliminationQP) untouched. This deliberately skips the
// placement-regroup below — re-grouping provisional floors by their LIVE count
// would average four placement-3 QF winners down to 7 QP instead of their 9 floor.
await processQualifyingBracketEvent(eventId, db);
} else {
// Clear stale QP before reprocessing so removed/null placements do not keep old awards.
for (const result of results) {
await db
.update(schema.eventResults)
.set({
qualifyingPointsAwarded: qpPerParticipant.toFixed(2),
qualifyingPointsAwarded: "0",
updatedAt: new Date(),
})
.where(eq(schema.eventResults.id, result.id));
}
const qpConfig = await getQPConfig(event.sportsSeasonId, db);
const pointsByPlacement = new Map(
qpConfig.map((config) => [config.placement, parseFloat(config.points)])
);
// Full-field tie span. The number of players tied at a placement is a property
// of the whole tournament field (canonical tournament_results), NOT of who
// happens to be on THIS window's roster. Sibling/mirror windows only hold the
// draftable subset of the field, so counting the placements present locally
// (group.length) under-counts a tied group and over-awards it: tennis R16 losers
// all sit at placement 9 with a structural span of 8 → (2+2+2+2+1+1+1+1)/8 = 1.5
// QP; a window holding only 4 of them would wrongly split 4 ways → 2 QP. Deriving
// the span from canonical results keeps every window/league identical. The fan-out
// passes this map in (already loaded once per tournament); direct callers with a
// tournament link query it here. Standalone events (no tournamentId, no map) have
// no canonical field, so fall back to the live count.
const canonicalTieCountByPlacement: Map<number, number> | null =
options.canonicalTieCountByPlacement ??
(event.tournamentId
? buildTieCountByPlacement(
await db
.select({ placement: schema.tournamentResults.placement })
.from(schema.tournamentResults)
.where(eq(schema.tournamentResults.tournamentId, event.tournamentId))
)
: null);
// Group results by placement to handle ties
const placementGroups = new Map<number, typeof results>();
for (const result of results) {
if (result.placement) {
const group = placementGroups.get(result.placement) || [];
group.push(result);
placementGroups.set(result.placement, group);
}
}
// Process each placement group and update event_results with QP awarded
for (const [placement, group] of placementGroups) {
const tieCount = canonicalTieCountByPlacement?.get(placement) ?? group.length;
const qpPerParticipant = calculateSplitQualifyingPoints(
placement,
tieCount,
pointsByPlacement
);
// Update the event result with the QP awarded
for (const result of group) {
await db
.update(schema.eventResults)
.set({
qualifyingPointsAwarded: qpPerParticipant.toFixed(2),
updatedAt: new Date(),
})
.where(eq(schema.eventResults.id, result.id));
}
}
}
// Recalculate totals for all participants from scratch
@ -710,21 +1024,46 @@ export async function processQualifyingEvent(
await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
}
// Increment majorsCompleted counter (only if this is the first time processing)
if (!wasAlreadyProcessed) {
const sportsSeason = event.sportsSeason;
await db
.update(schema.sportsSeasons)
.set({
majorsCompleted: (sportsSeason.majorsCompleted || 0) + 1,
updatedAt: new Date(),
})
.where(eq(schema.sportsSeasons.id, event.sportsSeasonId));
}
// NOTE: majorsCompleted is NOT a stored counter. It is derived on read via
// getMajorsCompleted() (count of completed qualifying events). Incrementing here
// per fan-out sync over-counted it past totalMajors ("11 of 4"), so the write was
// removed. See app/models/scoring-event.ts:getMajorsCompleted.
logger.log(
`[ScoringCalculator] Processed qualifying event ${eventId}: awarded QP to ${results.length} participants`
);
// Announce only participants whose awarded QP changed vs. the pre-reprocess
// snapshot (a differing value, or a first-time score: null → value). This keeps
// repeated fan-out syncs of an unchanged window from re-pinging the standings.
const afterRows = await db.query.eventResults.findMany({
where: eq(schema.eventResults.scoringEventId, eventId),
});
const changedParticipantIds = diffChangedQualifyingPoints(
beforeQP,
afterRows.map((r) => ({ id: r.seasonParticipantId, qp: r.qualifyingPointsAwarded }))
);
// Players knocked out this sync in a non-scoring round earn no QP, so they never
// appear in changedParticipantIds. Announce them too (mirroring the primary path
// in syncTennisDraw), so a mirror window's "Knocked Out" section isn't dropped.
const eliminatedIds = options.newlyEliminatedParticipantIds ?? new Set<string>();
if (
(changedParticipantIds.size > 0 || eliminatedIds.size > 0) &&
!options.skipNotifications
) {
try {
await notifyQualifyingPointsUpdate(
event.sportsSeasonId,
eventId,
db,
changedParticipantIds,
eliminatedIds,
);
} catch (error) {
logger.error(`[ScoringCalculator] QP Discord notification failed for event ${eventId}:`, error);
}
}
}
/**
@ -1425,7 +1764,7 @@ export async function recalculateStandings(
export async function recalculateAffectedLeagues(
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>,
options?: { eventName?: string; eventId?: string; matchIds?: string[]; skipDiscord?: boolean }
options?: { eventName?: string; eventId?: string; matchIds?: string[]; skipDiscord?: boolean; eliminatedParticipantIds?: string[] }
): Promise<void> {
const db = providedDb || database();
@ -1494,6 +1833,18 @@ export async function recalculateAffectedLeagues(
for (const r of loserResults) finalizedLoserIds.add(r.participantId);
}
// Look up names for participants eliminated by this action (e.g. bracket/knockout
// generation). These produce no score delta, so they're surfaced via a dedicated
// "Eliminated" section rather than the match/standings-change sections.
const eliminatedIds = options?.eliminatedParticipantIds ?? [];
const eliminatedNameById = new Map<string, string>();
if (eliminatedIds.length > 0) {
const eliminatedParticipants = await db.query.seasonParticipants.findMany({
where: inArray(schema.seasonParticipants.id, eliminatedIds),
});
for (const p of eliminatedParticipants) eliminatedNameById.set(p.id, p.name);
}
// Recalculate each affected season
for (const seasonId of seasonIds) {
// Capture standings before recalculation for delta calculation
@ -1568,20 +1919,33 @@ export async function recalculateAffectedLeagues(
afterStandings.map((s) => [s.teamId, s.team.ownerId])
);
// Shared draft-pick lookup for both the scored-match and elimination sections,
// loaded once per season only when at least one section needs it.
const needDraftPicks = allCompletedMatches.length > 0 || eliminatedIds.length > 0;
const draftPicks = needDraftPicks
? await db.query.draftPicks.findMany({
where: eq(schema.draftPicks.seasonId, seasonId),
})
: [];
const draftedIds = new Set(draftPicks.map((p) => p.participantId));
const teamIdByParticipantId = new Map(
draftPicks.map((p) => [p.participantId, p.teamId])
);
// Build scored matches for the notification.
// Winners only appear if their team's score changed (they earned points this round).
// Losers appear if their team's score changed OR they were definitively eliminated
// (finalPosition set, non-partial) — the latter catches 0-pt eliminations that
// produce no score delta. Losers who advance to another match (loserAdvances=true,
// e.g. NBA 7v8 → PIR2) have no finalized result and no score change, so they're
// correctly suppressed.
// A match is worth announcing when:
// • the winner is owned by a manager AND earned points this round, OR
// • the loser is owned by a manager (eliminated or scored).
// A winning team that merely advanced through a non-scoring round (e.g. R32 → R16
// in the World Cup) does not qualify on its own — their owner hasn't earned anything yet.
// When a match qualifies because the loser is owned, the winner's manager tag is still
// shown for context (who beat them), but the winner is not Discord-pinged.
// Both managers' tags are always shown for context when their teams are drafted; the
// showLoser flag (isLoserNotifiable) only gates whether the loser is @-pinged — a loser
// who advanced rather than being eliminated (loserAdvances=true, e.g. NBA 7v8 → PIR2, or
// a World Cup semifinal loser) is named but not pinged.
let scoredMatches: ScoredMatch[] | undefined;
if (allCompletedMatches.length > 0) {
const draftPicks = await db.query.draftPicks.findMany({
where: eq(schema.draftPicks.seasonId, seasonId),
});
const draftedIds = new Set(draftPicks.map((p) => p.participantId));
const relevant = allCompletedMatches.filter(
(m) =>
(m.winnerId && draftedIds.has(m.winnerId)) ||
@ -1589,10 +1953,6 @@ export async function recalculateAffectedLeagues(
);
if (relevant.length > 0) {
const teamIdByParticipantId = new Map(
draftPicks.map((p) => [p.participantId, p.teamId])
);
const lookupOwner = (participantId: string | null | undefined) => {
if (!participantId) return undefined;
const teamId = teamIdByParticipantId.get(participantId);
@ -1609,23 +1969,52 @@ export async function recalculateAffectedLeagues(
const winnerOwnerId = lookupOwner(m.winnerId);
const loserOwnerId = lookupOwner(m.loserId);
const showLoser = isLoserNotifiable(m.loserId, loserTeamId, changedTeamIds, finalizedLoserIds);
return {
winnerName: m.winnerName ?? "",
loserName: m.loserName ?? "",
winnerUsername: winnerScoreChanged && winnerOwnerId ? usernameByUserId.get(winnerOwnerId) : undefined,
loserUsername: showLoser && loserOwnerId ? usernameByUserId.get(loserOwnerId) : undefined,
winnerDiscordUserId: winnerScoreChanged && winnerOwnerId ? discordIdByUserId.get(winnerOwnerId) : undefined,
loserDiscordUserId: showLoser && loserOwnerId ? discordIdByUserId.get(loserOwnerId) : undefined,
};
});
return { m, winnerScoreChanged, showLoser, winnerOwnerId, loserOwnerId };
})
.filter((x) => (x.winnerScoreChanged && !!x.winnerOwnerId) || (x.showLoser && !!x.loserOwnerId))
.map((x) => ({
winnerName: x.m.winnerName ?? "",
loserName: x.m.loserName ?? "",
winnerUsername: x.winnerOwnerId ? usernameByUserId.get(x.winnerOwnerId) : undefined,
// Show the loser's manager tag whenever their team is drafted, mirroring the
// winner above — even when the loser advances rather than being eliminated
// (World Cup semifinal → 3rd-place playoff, AFL Qualifying Final → Semi Final).
// The @-ping stays gated by showLoser (loserDiscordUserId below): a still-alive
// loser who neither scored nor was eliminated is named for context but not pinged.
loserUsername: x.loserOwnerId ? usernameByUserId.get(x.loserOwnerId) : undefined,
winnerDiscordUserId: x.winnerScoreChanged && x.winnerOwnerId ? discordIdByUserId.get(x.winnerOwnerId) : undefined,
loserDiscordUserId: x.showLoser && x.loserOwnerId ? discordIdByUserId.get(x.loserOwnerId) : undefined,
}))
.filter((m) => m.winnerUsername !== undefined || m.loserUsername !== undefined);
}
}
// Skip notification if scores didn't change AND no match has a displayable username.
// Build the eliminated-teams section: drafted participants in this league that
// were knocked out by this action (bracket/knockout generation). These have a
// 0-pt result and no match, so they only surface here.
let eliminatedTeams: EliminatedTeam[] | undefined;
if (eliminatedIds.length > 0) {
const teams = eliminatedIds
.filter((id) => teamIdByParticipantId.has(id))
.map((id) => {
const teamId = teamIdByParticipantId.get(id);
const ownerId = teamId ? ownerIdByTeamId.get(teamId) : undefined;
return {
participantName: eliminatedNameById.get(id) ?? "Unknown",
username: ownerId ? usernameByUserId.get(ownerId) : undefined,
discordUserId: ownerId ? discordIdByUserId.get(ownerId) : undefined,
};
});
if (teams.length > 0) eliminatedTeams = teams;
}
// Skip notification if scores didn't change AND no match has a displayable
// username AND there's nothing eliminated to announce.
const hasScoredMatchesToShow = scoredMatches?.some(
(m) => m.winnerUsername !== undefined || m.loserUsername !== undefined
);
if (!hasChanges && !hasScoredMatchesToShow) continue;
const hasEliminatedToShow = (eliminatedTeams?.length ?? 0) > 0;
if (!hasChanges && !hasScoredMatchesToShow && !hasEliminatedToShow) continue;
const standings = afterStandings.map((s) => ({
teamId: s.teamId,
@ -1646,6 +2035,7 @@ export async function recalculateAffectedLeagues(
sportName,
eventName: options?.eventName,
scoredMatches,
eliminatedTeams,
});
} catch (err) {
// Log but don't fail the scoring pipeline if Discord is unreachable

View file

@ -0,0 +1,11 @@
export type EventType = "playoff_game" | "major_tournament" | "final_standings" | "schedule_event";
export function getEventTypeLabel(eventType: string): string {
switch (eventType) {
case "playoff_game": return "Bracket";
case "major_tournament": return "Major Tournament";
case "final_standings": return "Final Standings";
case "schedule_event": return "Non-Scoring";
default: return eventType;
}
}

View file

@ -3,20 +3,11 @@ import * as schema from "~/database/schema";
import { eq, and, desc, asc, gte, lte, or, inArray, isNotNull, sql } from "drizzle-orm";
import type { BracketRegion } from "~/lib/bracket-templates";
import { recalculateAffectedLeagues } from "./scoring-calculator";
import { hasProcessedQualifyingPlacement, recalculateParticipantQP } from "./qualifying-points";
import { recalculateParticipantQP } from "./qualifying-points";
import { findParticipantNamesByIds } from "./season-participant";
export type EventType = "playoff_game" | "major_tournament" | "final_standings" | "schedule_event";
export function getEventTypeLabel(eventType: string): string {
switch (eventType) {
case "playoff_game": return "Bracket";
case "major_tournament": return "Major Tournament";
case "final_standings": return "Final Standings";
case "schedule_event": return "Non-Scoring";
default: return eventType;
}
}
import { deleteTournament } from "./tournament";
import type { EventType } from "./scoring-event-types";
export { type EventType, getEventTypeLabel } from "./scoring-event-types";
export interface CreateScoringEventData {
sportsSeasonId: string;
@ -42,6 +33,8 @@ export interface UpdateScoringEventData {
scoringStartsAtRound?: string;
/** Per-event region config for NCAA-style brackets (overrides template defaults) */
bracketRegionConfig?: BracketRegion[] | null;
/** External data-source locator (e.g. Wikipedia article title for tennis draws) */
externalSourceKey?: string | null;
}
/**
@ -151,6 +144,33 @@ export async function getQualifyingEvents(
});
}
/**
* Number of "majors completed" for a sports season, derived on read as the count of
* qualifying events that have been marked complete. Replaces the old stored
* sportsSeasons.majorsCompleted counter, which was incremented per fan-out sync and
* over-counted past totalMajors ("11 of 4"). Computing it makes the value
* self-correcting and immune to double-counting.
*/
export async function getMajorsCompleted(
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
): Promise<number> {
const db = providedDb || database();
const rows = await db
.select({ count: sql<number>`count(*)::int` })
.from(schema.scoringEvents)
.where(
and(
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
eq(schema.scoringEvents.isQualifyingEvent, true),
eq(schema.scoringEvents.isComplete, true)
)
);
return rows[0]?.count ?? 0;
}
/**
* Update a scoring event
*/
@ -173,6 +193,7 @@ export async function updateScoringEvent(
if (data.bracketTemplateId !== undefined) updateData.bracketTemplateId = data.bracketTemplateId;
if (data.scoringStartsAtRound !== undefined) updateData.scoringStartsAtRound = data.scoringStartsAtRound;
if (data.bracketRegionConfig !== undefined) updateData.bracketRegionConfig = data.bracketRegionConfig;
if (data.externalSourceKey !== undefined) updateData.externalSourceKey = data.externalSourceKey;
const [updated] = await db
.update(schema.scoringEvents)
@ -208,28 +229,55 @@ export async function completeScoringEvent(
* sports season (placements are stored there with no FK back to the event) and
* recalculates league standings.
*/
export interface DeleteScoringEventOptions {
/**
* When this was the last window linked to its shared tournament, also delete
* the now-orphaned canonical tournament (and its results). No-op when other
* windows remain. Off by default so deleting one season's event never touches
* the shared tournament unless explicitly requested.
*/
deleteOrphanTournament?: boolean;
}
export interface DeleteScoringEventResult {
/** The shared tournament this event was linked to, if any. */
tournamentId: string | null;
/** How many other windows still link to that tournament after the delete. */
remainingWindows: number;
/** Event promoted to primary because the deleted event was the primary window. */
promotedPrimaryId: string | null;
/** Whether the orphaned canonical tournament was deleted. */
deletedTournament: boolean;
}
export async function deleteScoringEvent(
eventId: string,
providedDb?: ReturnType<typeof database>
) {
providedDb?: ReturnType<typeof database>,
opts?: DeleteScoringEventOptions
): Promise<DeleteScoringEventResult> {
const db = providedDb || database();
const event = await db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.id, eventId),
});
if (!event) return;
if (!event) {
return {
tournamentId: null,
remainingWindows: 0,
promotedPrimaryId: null,
deletedTournament: false,
};
}
// For qualifying events: capture affected participant IDs before the cascade deletes
// eventResults (which is how we know who had QP awarded from this event).
let affectedParticipantIds: string[] = [];
let wasQPProcessed = false;
if (event.isQualifyingEvent) {
const results = await db.query.eventResults.findMany({
where: eq(schema.eventResults.scoringEventId, eventId),
});
affectedParticipantIds = results.map((r) => r.seasonParticipantId);
wasQPProcessed = hasProcessedQualifyingPlacement(results);
}
await db.transaction(async (tx) => {
@ -252,21 +300,49 @@ export async function deleteScoringEvent(
await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
}
// Decrement majorsCompleted if this event had already been processed
if (wasQPProcessed) {
const sportsSeason = await db.query.sportsSeasons.findFirst({
where: eq(schema.sportsSeasons.id, event.sportsSeasonId),
});
if (sportsSeason && (sportsSeason.majorsCompleted ?? 0) > 0) {
await db
.update(schema.sportsSeasons)
.set({ majorsCompleted: (sportsSeason.majorsCompleted ?? 1) - 1, updatedAt: new Date() })
.where(eq(schema.sportsSeasons.id, event.sportsSeasonId));
}
}
// majorsCompleted is derived on read (getMajorsCompleted), so deleting the event
// — which removes it from the completed-qualifying-event count — self-corrects the
// number. No stored counter to decrement.
await recalculateAffectedLeagues(event.sportsSeasonId, db);
}
// Shared-tournament bookkeeping: keep the primary window valid and optionally
// clean up a canonical tournament left with no windows.
let remainingWindows = 0;
let promotedPrimaryId: string | null = null;
let deletedTournament = false;
if (event.tournamentId) {
const remaining = await db.query.scoringEvents.findMany({
where: eq(schema.scoringEvents.tournamentId, event.tournamentId),
orderBy: asc(schema.scoringEvents.createdAt),
});
remainingWindows = remaining.length;
if (remaining.length > 0) {
// Auto-heal: if the deleted event was the primary window, promote the
// earliest remaining window so the shared major stays scorable and
// fan-out stays deterministic. Only heal when we actually removed the
// primary — golf-style majors intentionally have no primary (scored on the
// canonical tournament page), and event.isPrimary is false for them, so
// they are left untouched.
if (event.isPrimary) {
await setPrimaryEvent(remaining[0].id, db);
promotedPrimaryId = remaining[0].id;
}
} else if (opts?.deleteOrphanTournament) {
await deleteTournament(event.tournamentId, db);
deletedTournament = true;
}
}
return {
tournamentId: event.tournamentId,
remainingWindows,
promotedPrimaryId,
deletedTournament,
};
}
/**
@ -1067,3 +1143,112 @@ export async function getSportsSeasonsByTournament(tournamentId: string) {
return true;
});
}
/**
* Count the distinct sports-season windows linked to a tournament. Cheaper than
* getSportsSeasonsByTournament when only the count is needed (no relations).
*/
export async function countWindowsByTournament(
tournamentId: string,
providedDb?: ReturnType<typeof database>
): Promise<number> {
const db = providedDb || database();
const [row] = await db
.select({
count: sql<number>`count(distinct ${schema.scoringEvents.sportsSeasonId})::int`,
})
.from(schema.scoringEvents)
.where(eq(schema.scoringEvents.tournamentId, tournamentId));
return row?.count ?? 0;
}
/**
* The primary scoring event for a tournament the single window where the admin
* builds the bracket/stages and scoring happens; its results fan out to siblings.
* Returns the explicitly-flagged primary, falling back to the earliest-created
* linked event so callers (league/sim loaders) always have a structure source
* even before a primary is designated.
*/
export async function getPrimaryEventForTournament(
tournamentId: string,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
const primary = await db.query.scoringEvents.findFirst({
where: and(
eq(schema.scoringEvents.tournamentId, tournamentId),
eq(schema.scoringEvents.isPrimary, true)
),
});
if (primary) return primary;
return db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.tournamentId, tournamentId),
orderBy: asc(schema.scoringEvents.createdAt),
});
}
/**
* A tournament-linked, non-primary event is a read-only mirror: its results are
* owned by the shared major's primary window and fan out to it. Direct scoring
* on such an event must be rejected so windows can't diverge. Single source of
* truth for that predicate, shared by the admin route guards and the loaders.
*/
export function isReadOnlySibling(event: {
tournamentId: string | null;
isPrimary: boolean;
}): boolean {
return !!event.tournamentId && !event.isPrimary;
}
/**
* Make `eventId` the single primary window for its tournament set it primary
* and clear the flag on every sibling. Used by the "Make this the primary
* window" admin action and to seed a primary when the first bracket-major event
* is created.
*/
export async function setPrimaryEvent(
eventId: string,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
const event = await db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.id, eventId),
});
if (!event?.tournamentId) {
throw new Error(`Event ${eventId} is not linked to a tournament`);
}
await db
.update(schema.scoringEvents)
.set({ isPrimary: false, updatedAt: new Date() })
.where(eq(schema.scoringEvents.tournamentId, event.tournamentId));
await db
.update(schema.scoringEvents)
.set({ isPrimary: true, updatedAt: new Date() })
.where(eq(schema.scoringEvents.id, eventId));
}
/**
* Designate `eventId` as the tournament's primary window only if none is set
* yet. Idempotent: returns the existing primary's id when one already exists, so
* creating/linking additional windows never steals primary from the first.
*/
export async function ensurePrimaryEvent(
tournamentId: string,
eventId: string,
providedDb?: ReturnType<typeof database>
): Promise<string> {
const db = providedDb || database();
const existing = await db.query.scoringEvents.findFirst({
where: and(
eq(schema.scoringEvents.tournamentId, tournamentId),
eq(schema.scoringEvents.isPrimary, true)
),
});
if (existing) return existing.id;
await db
.update(schema.scoringEvents)
.set({ isPrimary: true, updatedAt: new Date() })
.where(eq(schema.scoringEvents.id, eventId));
return eventId;
}

View file

@ -1,4 +1,4 @@
import { and, eq, inArray, sql } from "drizzle-orm";
import { and, eq, inArray, isNotNull, sql } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import {
@ -8,6 +8,7 @@ import {
type SimulatorManifestProfile,
} from "~/services/simulations/manifest";
import {
getSimulatorInputPolicy,
ratingRequirementLabel,
resolveRatings,
resolveSourceElos,
@ -82,6 +83,12 @@ export interface SportsSeasonSimulatorSummary {
participantInputCount: number;
lastSimulatedDate: string | null;
readiness: SimulatorReadiness;
/** Whether this simulator exposes a Futures Odds setup section. */
supportsFuturesOdds: boolean;
/** Number of participants that currently have stored futures odds. */
oddsParticipantCount: number;
/** Weight (01) the season's policy gives futures odds when blending into Elo. */
oddsWeight: number;
}
function parseDecimal(value: string | null | undefined): number | null {
@ -125,8 +132,8 @@ function isFallbackMethod(method: string): boolean {
return method === "fallbackElo" || method === "fallbackRating" || method === "averageKnown" || method === "worstKnownMinus";
}
const GENERATED_RATING_METHODS = ["sourceOdds", "fallbackRating", "averageKnown", "worstKnownMinus"] as const;
const GENERATED_SOURCE_ELO_METHODS = ["projectedWins", "projectedTablePoints", "sourceOdds", "fallbackElo", "averageKnown", "worstKnownMinus"] as const;
const GENERATED_RATING_METHODS = ["sourceOdds", "blend", "fallbackRating", "averageKnown", "worstKnownMinus"] as const;
const GENERATED_SOURCE_ELO_METHODS = ["projectedWins", "projectedTablePoints", "sourceOdds", "blend", "fallbackElo", "averageKnown", "worstKnownMinus"] as const;
function isGeneratedRatingMethod(method: unknown): boolean {
return typeof method === "string" && GENERATED_RATING_METHODS.includes(method as (typeof GENERATED_RATING_METHODS)[number]);
@ -300,112 +307,6 @@ export async function getParticipantSimulatorInputs(
});
}
function generatedRatingMethodSql() {
return sql`${schema.seasonParticipantSimulatorInputs.metadata}->>'ratingMethod' in ('sourceOdds', 'fallbackRating', 'averageKnown', 'worstKnownMinus')`;
}
export async function batchSaveParticipantSimulatorSourceOdds(
inputs: Array<{ participantId: string; sportsSeasonId: string; sourceOdds: number }>
): Promise<void> {
if (inputs.length === 0) return;
const db = database();
const now = new Date();
const seasonIds = [...new Set(inputs.map((input) => input.sportsSeasonId))];
await db.transaction(async (tx) => {
await tx
.update(schema.seasonParticipantSimulatorInputs)
.set({
rating: null,
metadata: sql`coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod'`,
updatedAt: now,
})
.where(
and(
inArray(schema.seasonParticipantSimulatorInputs.sportsSeasonId, seasonIds),
generatedRatingMethodSql()
)
);
await tx
.insert(schema.seasonParticipantSimulatorInputs)
.values(
inputs.map((input) => ({
participantId: input.participantId,
sportsSeasonId: input.sportsSeasonId,
sourceOdds: input.sourceOdds,
createdAt: now,
updatedAt: now,
}))
)
.onConflictDoUpdate({
target: [
schema.seasonParticipantSimulatorInputs.participantId,
schema.seasonParticipantSimulatorInputs.sportsSeasonId,
],
set: {
sourceOdds: sql`excluded.source_odds`,
rating: sql`case when ${generatedRatingMethodSql()} then null else ${schema.seasonParticipantSimulatorInputs.rating} end`,
metadata: sql`case when ${generatedRatingMethodSql()} then coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod' else ${schema.seasonParticipantSimulatorInputs.metadata} end`,
updatedAt: now,
},
});
});
}
export async function batchSaveFuturesOddsForSimulator(
inputs: Array<{ participantId: string; sportsSeasonId: string; sourceOdds: number }>
): Promise<void> {
if (inputs.length === 0) return;
const db = database();
const now = new Date();
const participantIds = inputs.map((input) => input.participantId);
const seasonIds = [...new Set(inputs.map((input) => input.sportsSeasonId))];
await db.transaction(async (tx) => {
// Clear ALL ratings (both manual and generated) so the simulation
// re-derives ratings from the newly saved futures odds.
await tx
.update(schema.seasonParticipantSimulatorInputs)
.set({
rating: null,
metadata: sql`coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod'`,
updatedAt: now,
})
.where(
and(
inArray(schema.seasonParticipantSimulatorInputs.sportsSeasonId, seasonIds),
inArray(schema.seasonParticipantSimulatorInputs.participantId, participantIds)
)
);
await tx
.insert(schema.seasonParticipantSimulatorInputs)
.values(
inputs.map((input) => ({
participantId: input.participantId,
sportsSeasonId: input.sportsSeasonId,
sourceOdds: input.sourceOdds,
createdAt: now,
updatedAt: now,
}))
)
.onConflictDoUpdate({
target: [
schema.seasonParticipantSimulatorInputs.participantId,
schema.seasonParticipantSimulatorInputs.sportsSeasonId,
],
set: {
sourceOdds: sql`excluded.source_odds`,
rating: null,
metadata: sql`coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod'`,
updatedAt: now,
},
});
});
}
export async function batchUpsertParticipantSimulatorInputs(
inputs: UpsertParticipantSimulatorInput[]
): Promise<void> {
@ -438,16 +339,34 @@ export async function batchUpsertParticipantSimulatorInputs(
schema.seasonParticipantSimulatorInputs.participantId,
schema.seasonParticipantSimulatorInputs.sportsSeasonId,
],
// Non-destructive: only overwrite a column when the incoming value is
// non-null. This lets a partial import (e.g. odds-only) update just the
// columns it provides without clobbering previously stored inputs like a
// manually entered Elo or rating. Mirrors the COALESCE bridge used for the
// legacy EV table below.
set: {
sourceOdds: sql`excluded.source_odds`,
sourceElo: sql`excluded.source_elo`,
worldRanking: sql`excluded.world_ranking`,
rating: sql`excluded.rating`,
projectedWins: sql`excluded.projected_wins`,
projectedTablePoints: sql`excluded.projected_table_points`,
seed: sql`excluded.seed`,
region: sql`excluded.region`,
metadata: sql`excluded.metadata`,
sourceOdds: sql`COALESCE(excluded.source_odds, ${schema.seasonParticipantSimulatorInputs.sourceOdds})`,
sourceElo: sql`COALESCE(excluded.source_elo, ${schema.seasonParticipantSimulatorInputs.sourceElo})`,
worldRanking: sql`COALESCE(excluded.world_ranking, ${schema.seasonParticipantSimulatorInputs.worldRanking})`,
rating: sql`COALESCE(excluded.rating, ${schema.seasonParticipantSimulatorInputs.rating})`,
projectedWins: sql`COALESCE(excluded.projected_wins, ${schema.seasonParticipantSimulatorInputs.projectedWins})`,
projectedTablePoints: sql`COALESCE(excluded.projected_table_points, ${schema.seasonParticipantSimulatorInputs.projectedTablePoints})`,
seed: sql`COALESCE(excluded.seed, ${schema.seasonParticipantSimulatorInputs.seed})`,
region: sql`COALESCE(excluded.region, ${schema.seasonParticipantSimulatorInputs.region})`,
// Metadata carries the method flags (sourceEloMethod/ratingMethod) that
// tell readers whether the stored Elo/rating is generated vs. a trusted
// direct value. When a caller supplies explicit metadata, use it as-is
// (prepareSimulatorInputsForRun and the projection importer set the
// correct flags). Otherwise preserve existing metadata, but drop the
// method flag for any column receiving a fresh direct value — otherwise a
// stale "generated" flag would cause that newly-entered Elo/rating to be
// filtered out as derived (see getParticipantSimulatorInputs).
metadata: sql`CASE
WHEN excluded.metadata IS NOT NULL THEN excluded.metadata
ELSE COALESCE(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb)
- (CASE WHEN excluded.source_elo IS NOT NULL THEN 'sourceEloMethod' ELSE '' END)
- (CASE WHEN excluded.rating IS NOT NULL THEN 'ratingMethod' ELSE '' END)
END`,
updatedAt: sql`excluded.updated_at`,
},
});
@ -663,6 +582,23 @@ export async function listSportsSeasonSimulatorSummaries(): Promise<SportsSeason
: [];
const lastSnapshotBySeason = new Map(snapshotRows.map((row) => [row.sportsSeasonId, row.lastSimulatedDate]));
const oddsCountRows = seasonIds.length > 0
? await db
.select({
sportsSeasonId: schema.seasonParticipantSimulatorInputs.sportsSeasonId,
oddsCount: sql<number>`count(*)::int`,
})
.from(schema.seasonParticipantSimulatorInputs)
.where(
and(
inArray(schema.seasonParticipantSimulatorInputs.sportsSeasonId, seasonIds),
isNotNull(schema.seasonParticipantSimulatorInputs.sourceOdds)
)
)
.groupBy(schema.seasonParticipantSimulatorInputs.sportsSeasonId)
: [];
const oddsCountBySeason = new Map(oddsCountRows.map((row) => [row.sportsSeasonId, Number(row.oddsCount)]));
// Each season calls getSimulatorProfile + validateSimulatorReadiness (~4 queries each).
// Acceptable for an admin-only page; revisit if season counts grow past ~50.
const summaries = await Promise.all(
@ -670,6 +606,8 @@ export async function listSportsSeasonSimulatorSummaries(): Promise<SportsSeason
const simulatorType = (season.simulatorConfig?.simulatorType ?? season.sport.simulatorType) as SimulatorType;
const profile = await getSimulatorProfile(simulatorType);
const readiness = await validateSimulatorReadiness(season.id);
const mergedConfig = { ...profile.defaultConfig, ...season.simulatorConfig?.config };
const policy = getSimulatorInputPolicy(mergedConfig);
return {
sportsSeasonId: season.id,
seasonName: season.name,
@ -687,6 +625,9 @@ export async function listSportsSeasonSimulatorSummaries(): Promise<SportsSeason
participantInputCount: readiness.participantInputCount,
lastSimulatedDate: lastSnapshotBySeason.get(season.id) ?? null,
readiness,
supportsFuturesOdds: profile.setupSections.includes("futuresOdds"),
oddsParticipantCount: oddsCountBySeason.get(season.id) ?? 0,
oddsWeight: policy.oddsWeight,
};
})
);

View file

@ -169,6 +169,57 @@ export async function findDraftableSportsSeasonBySportId(sportId: string) {
});
}
export type DraftScheduleWindow = {
id: string;
name: string;
year: number;
status: SportsSeasonStatus;
draftOn: string;
draftOff: string;
sport: {
id: string;
name: string;
slug: string;
iconUrl: string | null;
};
};
/**
* Returns admin sport-seasons (fantasySeasonId IS NULL) whose draft window
* [draftOn, draftOff] overlaps the horizon [today, today + monthsAhead]. Uses an
* overlap test (not containment) so windows already open, or spanning the horizon
* edges, are still included. Powers the admin draft-schedule Gantt chart.
*/
export async function findDraftScheduleForHorizon(
monthsAhead: number
): Promise<DraftScheduleWindow[]> {
const db = database();
const horizonEnd = sql`CURRENT_DATE + make_interval(months => ${monthsAhead})`;
const seasons = await db.query.sportsSeasons.findMany({
where: (ss, { and }) =>
and(
isNull(ss.fantasySeasonId),
gte(ss.draftOff, sql`CURRENT_DATE`),
lte(ss.draftOn, horizonEnd)
),
orderBy: (sportsSeasons, { asc }) => [asc(sportsSeasons.draftOn)],
with: {
sport: {
columns: { id: true, name: true, slug: true, iconUrl: true },
},
},
});
return seasons.map((s) => ({
id: s.id,
name: s.name,
year: s.year,
status: s.status,
draftOn: s.draftOn,
draftOff: s.draftOff,
sport: s.sport,
})) as DraftScheduleWindow[];
}
export async function updateSportsSeason(
id: string,
data: Partial<NewSportsSeason>

View file

@ -136,3 +136,17 @@ export async function updateTournamentStatus(
.returning();
return tournament;
}
/**
* Delete a canonical tournament. The DB cascades remove its tournament_results;
* any surviving scoring_events have their tournamentId set to NULL (onDelete:
* "set null"). Callers should only use this once the last linked window has been
* removed, so no scoring events are left pointing at it.
*/
export async function deleteTournament(
id: string,
providedDb?: ReturnType<typeof database>
): Promise<void> {
const db = providedDb || database();
await db.delete(schema.tournaments).where(eq(schema.tournaments.id, id));
}

View file

@ -69,7 +69,7 @@ export default [
route("forgot-password", "routes/forgot-password.tsx"),
route("reset-password", "routes/reset-password.tsx"),
route("onboarding", "routes/onboarding.tsx"),
route("settings", "routes/settings.tsx"),
route("settings/:section?", "routes/settings.tsx"),
route("user-profile", "routes/user-profile-redirect.tsx"),
route("how-to-play", "routes/how-to-play.tsx"),
route("rules", "routes/rules.tsx"),
@ -96,6 +96,7 @@ export default [
route("simulators", "routes/admin.simulators.tsx"),
route("sports/new", "routes/admin.sports.new.tsx"),
route("sports/:id", "routes/admin.sports.$id.tsx"),
route("draft-schedule", "routes/admin.draft-schedule.tsx"),
route("sports-seasons", "routes/admin.sports-seasons.tsx"),
route("sports-seasons/new", "routes/admin.sports-seasons.new.tsx"),
route("sports-seasons/:id", "routes/admin.sports-seasons.$id.tsx"),

View file

@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { newlyDecidedLosers } from "../admin.sports-seasons.$id.events.$eventId.bracket.helpers";
describe("newlyDecidedLosers", () => {
it("returns losers of matches that were not already complete", () => {
expect(
newlyDecidedLosers([
{ wasComplete: false, loserId: "sp-1" },
{ wasComplete: false, loserId: "sp-2" },
])
).toEqual(["sp-1", "sp-2"]);
});
it("drops re-scores of already-complete matches (no re-announce)", () => {
// sp-1's match just finished; sp-2's match was already complete before this
// action — only sp-1 is a NEW knockout.
expect(
newlyDecidedLosers([
{ wasComplete: false, loserId: "sp-1" },
{ wasComplete: true, loserId: "sp-2" },
])
).toEqual(["sp-1"]);
});
it("skips entries with an unknown loser", () => {
expect(
newlyDecidedLosers([
{ wasComplete: false, loserId: null },
{ wasComplete: false, loserId: "sp-3" },
])
).toEqual(["sp-3"]);
});
it("returns an empty array when nothing was newly decided", () => {
expect(
newlyDecidedLosers([
{ wasComplete: true, loserId: "sp-1" },
{ wasComplete: true, loserId: "sp-2" },
])
).toEqual([]);
expect(newlyDecidedLosers([])).toEqual([]);
});
});

View file

@ -0,0 +1,102 @@
import { describe, it, expect, vi } from "vitest";
vi.mock("~/lib/auth.server", () => ({
auth: { api: { getSession: vi.fn() } },
}));
vi.mock("~/lib/cloudinary.server", () => ({ deleteCloudinaryImageByUrl: vi.fn() }));
vi.mock("~/lib/email.server", () => ({
sendEmail: vi.fn(),
wrapInEmailTemplate: vi.fn(),
emailParagraph: vi.fn(),
escapeHtml: vi.fn(),
}));
vi.mock("~/models/user", () => ({
findUserById: vi.fn(),
findUserByUsername: vi.fn(),
isUserInActiveDraft: vi.fn(),
updateUser: vi.fn(),
anonymizeUserAccount: vi.fn(),
USERNAME_RE: /^[a-zA-Z0-9_-]{3,30}$/,
}));
vi.mock("~/models/account", () => ({ findLinkedAccountsByUserId: vi.fn() }));
vi.mock("~/models/team", () => ({ findTeamsByOwnerId: vi.fn(), removeTeamOwner: vi.fn() }));
vi.mock("~/models/commissioner", () => ({ removeAllCommissionersByUserId: vi.fn() }));
import { loader, shouldRevalidate } from "../settings";
import { auth } from "~/lib/auth.server";
type RevalidateArgs = Parameters<typeof shouldRevalidate>[0];
function revalidateArgs(overrides: Partial<RevalidateArgs>): RevalidateArgs {
return {
currentParams: {},
nextParams: {},
formMethod: undefined,
defaultShouldRevalidate: true,
...overrides,
} as unknown as RevalidateArgs;
}
type LoaderArgs = Parameters<typeof loader>[0];
function loaderArgs(section?: string): LoaderArgs {
const path = section ? `/settings/${section}` : "/settings";
return {
params: { section },
request: new Request(`http://localhost${path}`),
context: {},
} as unknown as LoaderArgs;
}
describe("/settings loader section handling", () => {
it("redirects an unknown section back to /settings", async () => {
const result = (await loader(loaderArgs("bogus"))) as Response;
expect(result).toBeInstanceOf(Response);
expect(result.status).toBe(302);
expect(result.headers.get("Location")).toBe("/settings");
// The bad section short-circuits before we ever touch the session.
expect(auth.api.getSession).not.toHaveBeenCalled();
});
it("does not redirect a valid section before auth runs", async () => {
vi.mocked(auth.api.getSession).mockResolvedValue(null as never);
const result = (await loader(loaderArgs("account"))) as Response;
// Falls through to the auth check, which redirects to login (not /settings).
expect(result.headers.get("Location")).toBe("/login?redirectTo=/settings");
expect(auth.api.getSession).toHaveBeenCalled();
});
});
describe("/settings shouldRevalidate", () => {
it("skips revalidation when only the section param changes", () => {
expect(
shouldRevalidate(
revalidateArgs({ currentParams: { section: "profile" }, nextParams: { section: "account" } })
)
).toBe(false);
});
it("revalidates after a mutation regardless of section", () => {
expect(
shouldRevalidate(
revalidateArgs({
currentParams: { section: "profile" },
nextParams: { section: "account" },
formMethod: "POST",
})
)
).toBe(true);
});
it("defers to the default when the section is unchanged", () => {
expect(
shouldRevalidate(
revalidateArgs({
currentParams: { section: "account" },
nextParams: { section: "account" },
defaultShouldRevalidate: true,
})
)
).toBe(true);
});
});

View file

@ -0,0 +1,123 @@
import { Link } from "react-router";
import type { Route } from "./+types/admin.draft-schedule";
import { findAllSports } from "~/models/sport";
import { findDraftScheduleForHorizon } from "~/models/sports-season";
import {
DraftScheduleGantt,
type GanttSport,
} from "~/components/admin/DraftScheduleGantt";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { AlertTriangle, ArrowRight } from "lucide-react";
const ALLOWED_MONTHS = [6, 12] as const;
export function meta(): Route.MetaDescriptors {
return [{ title: "Draft Schedule - Brackt" }];
}
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const monthsParam = Number(url.searchParams.get("months"));
const months = ALLOWED_MONTHS.includes(monthsParam as (typeof ALLOWED_MONTHS)[number])
? monthsParam
: 6;
const [allSports, windows] = await Promise.all([
findAllSports(),
findDraftScheduleForHorizon(months),
]);
const windowsBySport = new Map<string, typeof windows>();
for (const w of windows) {
const list = windowsBySport.get(w.sport.id) ?? [];
list.push(w);
windowsBySport.set(w.sport.id, list);
}
const sports: GanttSport[] = allSports.map((s) => ({
id: s.id,
name: s.name,
slug: s.slug,
iconUrl: s.iconUrl,
windows: windowsBySport.get(s.id) ?? [],
}));
const sportsWithoutCoverage = sports.filter((s) => s.windows.length === 0);
const today = new Date().toISOString().split("T")[0];
return { sports, sportsWithoutCoverage, today, months };
}
export default function AdminDraftSchedule({ loaderData }: Route.ComponentProps) {
const { sports, sportsWithoutCoverage, today, months } = loaderData;
return (
<div className="p-4 md:p-8">
<div className="mb-8 flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-3xl font-bold">Draft Schedule</h1>
<p className="text-muted-foreground mt-2">
See when each sport is scheduled to be drafted and spot gaps in coverage.
</p>
</div>
<div className="flex items-center gap-1 rounded-md border p-1">
{ALLOWED_MONTHS.map((m) => (
<Button
key={m}
variant={m === months ? "default" : "ghost"}
size="sm"
asChild
>
<Link to={`?months=${m}`}>{m} months</Link>
</Button>
))}
</div>
</div>
{sportsWithoutCoverage.length > 0 && (
<Card className="mb-6 border-amber-300 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-amber-800 dark:text-amber-300">
<AlertTriangle className="h-5 w-5" />
Sports without a draft window
</CardTitle>
<CardDescription className="text-amber-700 dark:text-amber-400/80">
{sportsWithoutCoverage.length} sport
{sportsWithoutCoverage.length !== 1 ? "s" : ""}{" "}
{sportsWithoutCoverage.length !== 1 ? "have" : "has"} no draft window
open or upcoming in the next {months} months.
</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{sportsWithoutCoverage.map((s) => (
<li
key={s.id}
className="flex items-center justify-between gap-3 rounded-md border border-amber-200 bg-background/60 px-3 py-2 dark:border-amber-900"
>
<span className="text-sm font-medium">{s.name}</span>
<Button variant="outline" size="sm" asChild className="h-7 text-xs">
<Link to="/admin/sports-seasons/new">
Schedule a season
<ArrowRight className="ml-1 h-3 w-3" />
</Link>
</Button>
</li>
))}
</ul>
</CardContent>
</Card>
)}
<DraftScheduleGantt sports={sports} today={today} months={months} />
</div>
);
}

View file

@ -77,6 +77,12 @@ export async function action({ request }: Route.ActionArgs): Promise<ActionData>
};
}
function futuresBlendLabel(oddsWeight: number): string {
if (oddsWeight >= 1) return "overrides Elo";
if (oddsWeight <= 0) return "Elo only";
return `${Math.round(oddsWeight * 100)}% blend`;
}
function statusBadge(status: string) {
if (status === "ready") return <Badge className="gap-1"><CheckCircle2 className="h-3 w-3" /> Ready</Badge>;
return <Badge variant="secondary" className="gap-1"><XCircle className="h-3 w-3" /> Needs setup</Badge>;
@ -245,7 +251,14 @@ export default function AdminSimulators({ loaderData }: Route.ComponentProps) {
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{sim.participantInputCount}/{sim.participantCount}
<div>{sim.participantInputCount}/{sim.participantCount}</div>
{sim.supportsFuturesOdds && (
<Badge variant={sim.oddsWeight > 0 ? "default" : "outline"} className="mt-1 text-xs font-normal">
{sim.oddsParticipantCount > 0
? `Futures: ${sim.oddsParticipantCount} · ${futuresBlendLabel(sim.oddsWeight)}`
: "No futures odds"}
</Badge>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{sim.lastSimulatedDate ?? "Never"}

View file

@ -0,0 +1,25 @@
/**
* Pure helpers for the bracket-scoring route action, extracted so the
* knockout-detection logic can be unit-tested without the full action harness.
*/
/**
* From a set of matches being scored this action, return the season_participant
* ids knocked out for the FIRST time. A loser counts only when its match was NOT
* already complete a re-score/correction of a finished match must not
* re-announce the exit (mirrors populateBracketFromDraw's first-completion rule in
* the live-sync path). Entries with an unknown loser (`null`) are skipped.
*
* The ids are season_participant ids of the primary window (playoff_matches are
* keyed by season_participant); the fan-out translates them to each mirror
* window's own season_participant id before announcing the "Knocked Out" section.
*/
export function newlyDecidedLosers(
entries: Array<{ wasComplete: boolean; loserId: string | null }>
): string[] {
return entries
.filter((e): e is { wasComplete: boolean; loserId: string } =>
!e.wasComplete && e.loserId !== null
)
.map((e) => e.loserId);
}

View file

@ -1,8 +1,12 @@
import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket";
import { findSportsSeasonById } from "~/models/sports-season";
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
import { getScoringEventById, updateScoringEvent } from "~/models/scoring-event";
import {
findParticipantsBySportsSeasonId,
createParticipant,
updateParticipant,
} from "~/models/season-participant";
import { getScoringEventById, updateScoringEvent, isReadOnlySibling } from "~/models/scoring-event";
import {
findPlayoffMatchesByEventId,
generateBracketFromTemplate,
@ -25,13 +29,20 @@ import {
import {
processPlayoffEvent,
processMatchResult,
processQualifyingBracketEvent,
processQualifyingEvent,
finalizeQualifyingPoints,
recalculateAffectedLeagues,
recalculateStandings,
autoCompleteRoundIfDone,
} from "~/models/scoring-calculator";
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
import { getBracketTemplate, ALL_16_SEEDS, type BracketRegion } from "~/lib/bracket-templates";
import { setParticipantResult, findParticipantResultsBySportsSeasonId } from "~/models/participant-result";
import {
setParticipantResult,
findParticipantResultsBySportsSeasonId,
deleteParticipantResultsBySportsSeasonId,
} from "~/models/participant-result";
import { findSeasonSportsBySportsSeasonId } from "~/models/season-sport";
import { createDailySnapshot } from "~/models/standings";
import {
@ -54,6 +65,10 @@ import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq } from "drizzle-orm";
import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.server";
import { fanOutMajorIfPrimary, syncMajorFromPrimaryEvent } from "~/services/sync-tournament-results";
import { syncTennisDraw, previewTennisDraw } from "~/services/match-sync";
import { articleTitleFromInput } from "~/services/match-sync/wikipedia-tennis";
import { newlyDecidedLosers } from "./admin.sports-seasons.$id.events.$eventId.bracket.helpers";
export async function loader({ params }: Route.LoaderArgs) {
const sportsSeason = await findSportsSeasonById(params.id);
@ -103,10 +118,176 @@ export async function loader({ params }: Route.LoaderArgs) {
};
}
/**
* Score a qualifying-event bracket (e.g. CS2 Champions Stage): derive each team's
* guaranteed-minimum QP from the bracket and refresh affected league standings.
*
* Qualifying brackets award QUALIFYING POINTS, not fantasy points, so this never
* writes seasonParticipantResults and never records team_score_events match
* results surface via the QP Standings and the Discord standings update. Final
* fantasy placements come from finalizeQualifyingPoints across all majors.
*/
async function scoreQualifyingBracket(
event: {
id: string;
sportsSeasonId: string;
name: string | null;
isPrimary: boolean;
tournamentId: string | null;
},
db: ReturnType<typeof database>,
recalcOptions?: Parameters<typeof recalculateAffectedLeagues>[2],
/**
* Season_participant ids of players knocked out for the first time by this
* operation (losers of matches that just reached completion). Forwarded to the
* fan-out so every mirror window announces the "Knocked Out" section a
* non-scoring-round exit earns no QP and is otherwise invisible to the mirror.
* Empty for re-scores/reprocesses, which decide no new losers.
*/
newlyEliminatedParticipantIds?: Set<string>
): Promise<void> {
// processQualifyingEvent derives the bracket QP (via processQualifyingBracketEvent),
// recalcs participant QP totals, AND announces the QP change to this window's leagues.
// Calling processQualifyingBracketEvent directly here would score silently — the QP
// Discord notification only fires from processQualifyingEvent. The fan-out below skips
// this (primary) window via skipEventId, so mirror windows are announced separately
// with no double-post.
await processQualifyingEvent(event.id, db, { newlyEliminatedParticipantIds });
await recalculateAffectedLeagues(
event.sportsSeasonId,
db,
recalcOptions ?? { eventId: event.id, eventName: event.name ?? undefined }
);
// If this is the shared major's primary window, propagate to siblings.
// Mid-tournament (a single round): don't mark complete yet.
await fanOutMajorIfPrimary(event, {
markComplete: false,
newlyEliminatedParticipantIds,
});
}
/**
* Mark the given participants as eliminated (finalPosition = 0) and, for fantasy
* (non-qualifying) events, announce the teams newly eliminated by this run to the
* affected leagues' Discord channels. Returns the number of participants marked.
*
* "Newly eliminated" = participants with no prior result row, so re-running a
* generation step never re-announces the same teams. The announcement is a
* best-effort side effect: a failure must not fail the generation action, since
* the eliminations themselves are already committed. eventId is deliberately
* omitted from the recalc call so the announcement doesn't pull in unrelated
* completed matches as "Scored Matches".
*/
async function markEliminatedAndAnnounce(
event: { id: string; name: string | null; sportsSeasonId: string; isQualifyingEvent: boolean },
participantIds: string[]
): Promise<number> {
const existingResults = await findParticipantResultsBySportsSeasonId(event.sportsSeasonId);
const alreadyHadResult = new Set(existingResults.map((r) => r.participantId));
const newlyEliminatedIds = participantIds.filter((id) => !alreadyHadResult.has(id));
for (const participantId of participantIds) {
await setParticipantResult(participantId, event.sportsSeasonId, 0);
}
// QPs (e.g. tennis/CS2 majors) don't get elimination announcements.
if (!event.isQualifyingEvent && newlyEliminatedIds.length > 0) {
try {
await recalculateAffectedLeagues(event.sportsSeasonId, database(), {
eventName: event.name ?? undefined,
eliminatedParticipantIds: newlyEliminatedIds,
});
} catch (err) {
logger.error("[Eliminations] Discord announcement failed:", err);
}
}
return participantIds.length;
}
export async function action({ request, params }: Route.ActionArgs) {
const formData = await request.formData();
const intent = formData.get("intent");
// Brackets are built/scored only on the major's primary window. A
// tournament-linked, non-primary event is a read-only mirror that receives
// results via fan-out, so reject every mutating action here.
{
const ev = await getScoringEventById(params.eventId);
if (ev && isReadOnlySibling(ev)) {
return {
error:
"This bracket belongs to a shared major. Build and score it on the primary window (linked from Admin → Tournaments); results fan out here automatically.",
};
}
}
if (intent === "create-draw-participant") {
const name = (formData.get("name") as string | null)?.trim();
const externalId = (formData.get("externalId") as string | null)?.trim() || null;
if (!name) return { error: "Participant name is required" };
try {
await createParticipant({ sportsSeasonId: params.id, name, externalId });
return { success: `Created "${name}". Re-run Preview or Sync Draw to apply.` };
} catch (error) {
return { error: error instanceof Error ? error.message : "Failed to create participant" };
}
}
if (intent === "relink-draw-participant") {
const participantId = formData.get("participantId") as string | null;
const name = (formData.get("name") as string | null)?.trim();
const externalId = (formData.get("externalId") as string | null)?.trim() || null;
if (!participantId || !name) return { error: "Participant and name are required" };
try {
await updateParticipant(participantId, { name, externalId });
return { success: `Renamed and linked to "${name}". Re-run Preview or Sync Draw to apply.` };
} catch (error) {
return { error: error instanceof Error ? error.message : "Failed to update participant" };
}
}
if (intent === "preview-draw") {
const rawInput = (formData.get("externalSourceKey") as string | null)?.trim();
const articleTitle = rawInput ? articleTitleFromInput(rawInput) : "";
// Persist the article so the user can preview, then sync, without re-entering.
if (articleTitle) {
await updateScoringEvent(params.eventId, { externalSourceKey: articleTitle });
}
try {
const preview = await previewTennisDraw(params.eventId);
return { drawPreview: preview };
} catch (error) {
logger.error("[preview-draw] error:", error);
return { error: error instanceof Error ? error.message : "Failed to preview draw" };
}
}
if (intent === "sync-draw") {
const rawInput = (formData.get("externalSourceKey") as string | null)?.trim();
// Accept a pasted Wikipedia URL or a plain article title; store the title.
const articleTitle = rawInput ? articleTitleFromInput(rawInput) : "";
if (articleTitle) {
await updateScoringEvent(params.eventId, { externalSourceKey: articleTitle });
}
try {
const result = await syncTennisDraw(params.eventId);
const reviewNote =
result.unmatched.length > 0
? ` ${result.unmatched.length} auto-created player(s) need a quick review for possible duplicates.`
: "";
return {
success:
`Synced draw: ${result.matchesWritten} matches written ` +
`(${result.completed} completed), ${result.participantsCreated} participant(s) added.${reviewNote}`,
drawSyncResult: result,
};
} catch (error) {
logger.error("[sync-draw] error:", error);
return { error: error instanceof Error ? error.message : "Failed to sync draw" };
}
}
if (intent === "generate-bracket") {
const templateId = formData.get("templateId");
@ -170,27 +351,16 @@ export async function action({ request, params }: Route.ActionArgs) {
try {
await generateBracketFromTemplate(params.eventId, templateId, participantIds, regionOverride);
// PHASE 5.3: Mark participants NOT in the bracket as eliminated
// PHASE 5.3: Mark participants NOT in the bracket as eliminated (and announce).
const event = await getScoringEventById(params.eventId);
if (event) {
// Get all participants in the sports season
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
// Create a set of participants in the bracket for fast lookup
const participantsInBracket = new Set(participantIds);
// Mark participants NOT in the bracket as eliminated
for (const participant of allParticipants) {
if (!participantsInBracket.has(participant.id)) {
await setParticipantResult(
participant.id,
event.sportsSeasonId,
0 // 0 = didn't make playoffs, eliminated
);
}
}
logger.log(`[BracketGeneration] Marked ${allParticipants.length - participantIds.length} participants as eliminated`);
const toEliminate = allParticipants
.filter((p) => !participantsInBracket.has(p.id))
.map((p) => p.id);
const eliminatedCount = await markEliminatedAndAnnounce(event, toEliminate);
logger.log(`[BracketGeneration] Marked ${eliminatedCount} participants as eliminated`);
}
// Update the event to store the template ID, scoring start round, and region config
@ -246,6 +416,13 @@ export async function action({ request, params }: Route.ActionArgs) {
return { error: "Could not determine loser" };
}
// A knockout is "newly decided" only when the match wasn't already complete
// (mirrors populateBracketFromDraw's first-completion rule) — a re-score/
// correction of an already-finished match must not re-announce the exit.
const setWinnerNewlyEliminated = new Set(
newlyDecidedLosers([{ wasComplete: match.isComplete, loserId }])
);
// Set the winner
await setMatchWinner(matchId, winnerId, loserId);
@ -267,26 +444,42 @@ export async function action({ request, params }: Route.ActionArgs) {
}
}
// Immediately score this match: loser gets their final placement,
// winner gets provisional floor points (isPartialScore=true).
// Prefer the template-defined isScoring over the DB field: the DB column
// defaults to true, so legacy play-in rows may be incorrectly marked as scoring.
const setWinnerRoundIsScoring = setWinnerTemplate?.rounds.find((r) => r.name === match.round)?.isScoring;
await processMatchResult({
round: match.round,
winnerId,
loserId,
isScoring: setWinnerRoundIsScoring !== undefined ? setWinnerRoundIsScoring : (match.isScoring ?? true),
sportsSeasonId: event.sportsSeasonId,
bracketTemplateId: event.bracketTemplateId,
eventId: event.id,
eventName: event.name ?? undefined,
matchId,
loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""),
});
const db = database();
await autoCompleteRoundIfDone(event.id, match.round, event.sportsSeasonId, db);
if (event.isQualifyingEvent) {
// Qualifying major (e.g. CS2): bracket results award QUALIFYING POINTS, not
// fantasy points. matchIds scopes the Discord notification to just this match.
await scoreQualifyingBracket(
event,
db,
{
eventId: event.id,
eventName: event.name ?? undefined,
matchIds: [matchId],
},
setWinnerNewlyEliminated
);
} else {
// Immediately score this match: loser gets their final placement,
// winner gets provisional floor points (isPartialScore=true).
// Prefer the template-defined isScoring over the DB field: the DB column
// defaults to true, so legacy play-in rows may be incorrectly marked as scoring.
const setWinnerRoundIsScoring = setWinnerTemplate?.rounds.find((r) => r.name === match.round)?.isScoring;
await processMatchResult({
round: match.round,
winnerId,
loserId,
isScoring: setWinnerRoundIsScoring !== undefined ? setWinnerRoundIsScoring : (match.isScoring ?? true),
sportsSeasonId: event.sportsSeasonId,
bracketTemplateId: event.bracketTemplateId,
eventId: event.id,
eventName: event.name ?? undefined,
matchId,
loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""),
});
await autoCompleteRoundIfDone(event.id, match.round, event.sportsSeasonId, db);
}
return { success: "Winner set successfully" };
} catch (error) {
@ -337,6 +530,10 @@ export async function action({ request, params }: Route.ActionArgs) {
let successCount = 0;
const errors: string[] = [];
const processedMatchIds: string[] = [];
// Per-match completion + loser, collected so newlyDecidedLosers() can pick out
// the batch's first-time knockouts to fan out to mirror windows (a non-scoring-
// round exit earns no QP and is otherwise invisible to the mirror).
const decidedEntries: Array<{ wasComplete: boolean; loserId: string | null }> = [];
for (const { matchId, winnerId } of winnerAssignments) {
try {
@ -385,22 +582,30 @@ export async function action({ request, params }: Route.ActionArgs) {
// Score this match without triggering standings/probability recalc yet —
// we batch those side effects into a single call after the loop.
await processMatchResult({
round: match.round,
winnerId,
loserId,
isScoring: roundIsScoring.get(match.round) ?? (match.isScoring ?? true),
sportsSeasonId: event.sportsSeasonId,
bracketTemplateId: event.bracketTemplateId,
eventId: event.id,
eventName: event.name ?? undefined,
matchId,
skipSideEffects: true,
loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""),
});
// Qualifying majors derive QP from the whole bracket once after the loop
// (processQualifyingBracketEvent), so skip the per-match fantasy scoring here.
if (!event.isQualifyingEvent) {
await processMatchResult({
round: match.round,
winnerId,
loserId,
isScoring: roundIsScoring.get(match.round) ?? (match.isScoring ?? true),
sportsSeasonId: event.sportsSeasonId,
bracketTemplateId: event.bracketTemplateId,
eventId: event.id,
eventName: event.name ?? undefined,
matchId,
skipSideEffects: true,
loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""),
});
}
successCount++;
processedMatchIds.push(matchId);
// Only after the match fully succeeded: record its prior completion so
// newlyDecidedLosers() announces this loser only if it's a first-time exit
// (and never for a match whose write failed above).
decidedEntries.push({ wasComplete: match.isComplete, loserId });
} catch (error) {
logger.error(`Error setting winner for match ${matchId}:`, error);
errors.push(
@ -414,6 +619,15 @@ export async function action({ request, params }: Route.ActionArgs) {
// previously completed matches in the event.
if (successCount > 0) {
const db = database();
// Qualifying majors: derive QP from the full bracket once for the batch, and
// announce the QP change to this window's leagues. processQualifyingEvent (not
// processQualifyingBracketEvent) is what sends the QP Discord notification; the
// fan-out below skips this window (skipEventId) so mirrors don't double-post.
if (event.isQualifyingEvent) {
await processQualifyingEvent(event.id, db, {
newlyEliminatedParticipantIds: new Set(newlyDecidedLosers(decidedEntries)),
});
}
// Update probabilities first so recalculateAffectedLeagues reads fresh EVs
// when computing projected points.
try {
@ -422,7 +636,18 @@ export async function action({ request, params }: Route.ActionArgs) {
logger.error(`Error updating probabilities after batch round winners:`, error);
}
await recalculateAffectedLeagues(event.sportsSeasonId, db, { eventId: event.id, eventName: event.name ?? undefined, matchIds: processedMatchIds });
await autoCompleteRoundIfDone(event.id, round, event.sportsSeasonId, db);
// autoCompleteRoundIfDone runs processPlayoffEvent (fantasy path); skip for
// qualifying majors, which are scored via processQualifyingBracketEvent above.
if (!event.isQualifyingEvent) {
await autoCompleteRoundIfDone(event.id, round, event.sportsSeasonId, db);
} else {
// Shared major primary window: propagate this round to siblings, carrying
// the batch's newly-decided knockouts so mirrors announce them too.
await fanOutMajorIfPrimary(event, {
markComplete: false,
newlyEliminatedParticipantIds: new Set(newlyDecidedLosers(decidedEntries)),
});
}
}
if (errors.length > 0 && successCount === 0) {
@ -474,6 +699,14 @@ export async function action({ request, params }: Route.ActionArgs) {
return { error: `Not all matches in ${round} are complete` };
}
// Qualifying majors (e.g. CS2): QP is derived directly from the bracket via
// processQualifyingBracketEvent — there are no seasonParticipantResults rows to
// validate against, and final fantasy placements come from finalizeQualifyingPoints.
if (event.isQualifyingEvent) {
await scoreQualifyingBracket(event, database());
return { success: `${round} completed and qualifying points updated` };
}
// Validate round order: ensure previous rounds are complete
const existingResults = await findParticipantResultsBySportsSeasonId(
params.id
@ -559,6 +792,52 @@ export async function action({ request, params }: Route.ActionArgs) {
const matches = await findPlayoffMatchesByEventId(params.eventId);
const completed = matches.filter((m) => m.isComplete && m.winnerId && m.loserId);
// Qualifying majors: this is also the cleanup tool for majors that wrongly banked
// fantasy points under the old path. Delete the stale seasonParticipantResults rows
// (erasing those points), then rebuild correct QP from the bracket. Qualifying sports
// have no legitimate per-major fantasy placements — those come from
// finalizeQualifyingPoints across all majors.
if (event.isQualifyingEvent) {
const db = database();
await deleteParticipantResultsBySportsSeasonId(event.sportsSeasonId, db);
await processQualifyingBracketEvent(params.eventId, db);
// If the season's QP was already finalized, the delete above wiped the final
// placements — recompute them from QP totals so standings aren't left blank.
const sportsSeason = await findSportsSeasonById(params.id);
if (sportsSeason?.qualifyingPointsFinalized) {
await finalizeQualifyingPoints(event.sportsSeasonId, db); // recalcs leagues itself
} else {
// skipDiscord: reprocess is a data-correction tool, not a result announcement.
await recalculateAffectedLeagues(event.sportsSeasonId, db, { skipDiscord: true });
}
// Re-propagate corrected QP to sibling/mirror windows (data-correction; not
// final). Call syncMajorFromPrimaryEvent directly rather than the
// swallow-and-log fanOutMajorIfPrimary so the admin actually sees whether the
// mirrors were re-scored — a silent failure here is exactly how mirrors got
// left showing stale QP behind a green "success".
const baseMessage = `Reprocessed qualifying bracket: cleared stale fantasy points and recomputed QP (${completed.length} completed match(es)).`;
if (event.isPrimary && event.tournamentId) {
try {
const report = await syncMajorFromPrimaryEvent(event.id, { markComplete: false });
// Surface a partial fan-out as an error so it renders as a warning
// banner, not a green success the admin might skim past while some
// mirror windows are left stale.
if (report.windowsFailed > 0) {
const reasons = report.failures.map((f) => f.error).join("; ");
return {
error: `${baseMessage} Synced ${report.windowsSynced} mirror window(s), but ${report.windowsFailed} failed (those windows may be stale): ${reasons}`,
};
}
return { success: `${baseMessage} Synced ${report.windowsSynced} mirror window(s).` };
} catch (error) {
return {
error: `Primary window recomputed, but mirror fan-out failed (mirror windows may be stale): ${error instanceof Error ? error.message : String(error)}`,
};
}
}
return { success: `${baseMessage} No mirror windows to sync.` };
}
if (completed.length === 0) {
return { error: "No completed matches to reprocess" };
}
@ -567,11 +846,7 @@ export async function action({ request, params }: Route.ActionArgs) {
// Only deleting partial rows leaves stale finalized rows that block
// the "never un-finalize" guard in upsertParticipantResult.
const db = database();
await db
.delete(schema.seasonParticipantResults)
.where(
eq(schema.seasonParticipantResults.sportsSeasonId, event.sportsSeasonId)
);
await deleteParticipantResultsBySportsSeasonId(event.sportsSeasonId, db);
// Replay each completed match in bracket order (earlier rounds first).
const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null;
@ -669,6 +944,31 @@ export async function action({ request, params }: Route.ActionArgs) {
};
}
// Qualifying majors (e.g. CS2): lock in final bracket placements/QP, then run the
// standard qualifying finalizer for THIS event. Do not mark the whole sports season
// completed or recalc fantasy standings — a season spans multiple majors and final
// fantasy placements come from finalizeQualifyingPoints across all of them.
if (event.isQualifyingEvent) {
const db = database();
// processQualifyingEvent derives the bracket QP (via processQualifyingBracketEvent)
// and recalcs participant QP totals. majorsCompleted is derived on read from
// completed qualifying events (see getMajorsCompleted) — marking this event
// complete below is what advances it. Season-wide fantasy finalization stays with
// finalizeQualifyingPoints across all majors.
await processQualifyingEvent(params.eventId, db);
await db
.update(schema.scoringEvents)
.set({ isComplete: true, completedAt: new Date(), updatedAt: new Date() })
.where(eq(schema.scoringEvents.id, params.eventId));
await recalculateAffectedLeagues(event.sportsSeasonId, db, {
eventId: params.eventId,
eventName: event.name ?? undefined,
});
// Finalize: propagate to siblings AND mark every window complete.
await fanOutMajorIfPrimary(event, { markComplete: true });
return { success: "Major bracket finalized — qualifying points awarded." };
}
// Get all participants in this sports season
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
@ -783,14 +1083,16 @@ export async function action({ request, params }: Route.ActionArgs) {
await generateBracketFromTemplate(params.eventId, templateId);
// Eliminate participants from this sport season who are not in any group
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
let eliminatedCount = 0;
for (const participant of allParticipants) {
if (!uniqueParticipants.has(participant.id)) {
await setParticipantResult(participant.id, params.id, 0);
eliminatedCount++;
}
// (and announce to leagues for fantasy events).
const groupsEvent = await getScoringEventById(params.eventId);
if (!groupsEvent) {
return { error: "Event not found" };
}
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
const toEliminate = allParticipants
.filter((p) => !uniqueParticipants.has(p.id))
.map((p) => p.id);
const eliminatedCount = await markEliminatedAndAnnounce(groupsEvent, toEliminate);
return {
success: `Groups and knockout bracket structure created successfully${eliminatedCount > 0 ? ` (${eliminatedCount} participant(s) not in any group marked as eliminated)` : ""}`,
@ -915,11 +1217,10 @@ export async function action({ request, params }: Route.ActionArgs) {
// Assign participants to knockout bracket
await assignParticipantsToKnockout(params.eventId, assignments);
// Mark eliminated group participants with finalPosition = 0
// Mark eliminated group participants with finalPosition = 0 (and announce
// the group-stage eliminations to leagues for fantasy events).
const eliminatedIds = await getEliminatedParticipantIds(params.eventId);
for (const participantId of eliminatedIds) {
await setParticipantResult(participantId, event.sportsSeasonId, 0);
}
await markEliminatedAndAnnounce(event, eliminatedIds);
logger.log(
`[PopulateKnockout] Assigned 32 participants to knockout, marked ${eliminatedIds.length} as eliminated`

View file

@ -1,6 +1,6 @@
import { Form, Link } from "react-router";
import { Fragment, useState, useEffect, useMemo } from "react";
import { localDateTimeToUtcIso } from "~/lib/date-utils";
import { Form, Link, useFetcher } from "react-router";
import { Fragment, useState, useEffect, useMemo, useRef } from "react";
import { localDateTimeToUtcIso, utcIsoToLocalDateTime } from "~/lib/date-utils";
import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket";
import { loader, action } from "./admin.sports-seasons.$id.events.$eventId.bracket.server";
@ -39,8 +39,116 @@ export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Bracket — ${data?.event?.name ?? "Event"} - Brackt Admin` }];
}
/**
* Schedule editor for a single group-stage match.
*
* The admin enters a local wall-clock time; on submit it is converted to a UTC
* ISO string (in the hidden `scheduledAt` field) so the DB stores a true UTC
* instant. The stored UTC value is rendered back into the input in the
* browser's local timezone via a client-only effect (avoids SSR hydration
* mismatch, since the server runs in UTC).
*/
function GroupMatchScheduleForm({
match,
localTzAbbr,
}: {
match: { id: string; scheduledAt: string | Date | null | undefined };
localTzAbbr: string;
}) {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (inputRef.current) {
inputRef.current.value = utcIsoToLocalDateTime(match.scheduledAt);
}
}, [match.scheduledAt]);
return (
<Form
method="post"
className="flex items-center gap-1"
onSubmit={(e) => {
const form = e.currentTarget;
const local = form.elements.namedItem("scheduledAtLocal") as HTMLInputElement;
const hidden = form.elements.namedItem("scheduledAt") as HTMLInputElement;
if (hidden) hidden.value = localDateTimeToUtcIso(local?.value) ?? "";
}}
>
<input type="hidden" name="intent" value="update-group-match-schedule" />
<input type="hidden" name="matchId" value={match.id} />
{/* Hidden field holds the UTC ISO string written by onSubmit */}
<input type="hidden" name="scheduledAt" defaultValue="" />
<Calendar className="h-3 w-3 text-muted-foreground shrink-0" />
<Input
ref={inputRef}
type="datetime-local"
name="scheduledAtLocal"
title={`Time zone: ${localTzAbbr}`}
className="h-6 text-xs px-1 py-0"
/>
<Button type="submit" size="sm" variant="ghost" className="h-6 px-1.5 text-xs">
Save
</Button>
</Form>
);
}
export { loader, action };
/**
* One "possible duplicate" row in the draw preview. Uses a fetcher so resolving
* it (rename existing / create as new) submits in the background the preview
* stays on screen instead of reloading the page and forcing a fresh dry run.
*/
function DuplicateRow({
p,
}: {
p: { name: string; externalId: string | null; suggestion: string; suggestionId: string };
}) {
const fetcher = useFetcher<{ success?: string; error?: string }>();
const busy = fetcher.state !== "idle";
return (
<li className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span>
<span className="font-medium">{p.name}</span>
<span className="text-muted-foreground"> looks like </span>
<span className="font-medium">{p.suggestion}</span>
</span>
{fetcher.data?.success ? (
<span className="text-xs font-medium text-emerald-500"> {fetcher.data.success}</span>
) : (
<fetcher.Form method="post" className="flex items-center gap-2">
<input type="hidden" name="name" value={p.name} />
<input type="hidden" name="externalId" value={p.externalId ?? ""} />
<input type="hidden" name="participantId" value={p.suggestionId} />
<Button
type="submit"
name="intent"
value="relink-draw-participant"
size="sm"
variant="outline"
disabled={busy}
>
Rename {p.suggestion} {p.name}
</Button>
<Button
type="submit"
name="intent"
value="create-draw-participant"
size="sm"
variant="outline"
disabled={busy}
>
Create as new
</Button>
</fetcher.Form>
)}
{fetcher.data?.error && (
<span className="text-xs text-destructive">{fetcher.data.error}</span>
)}
</li>
);
}
export default function EventBracket({
loaderData,
actionData,
@ -323,6 +431,164 @@ export default function EventBracket({
</div>
)}
{/* Possible-duplicate review after a draw sync */}
{actionData?.drawSyncResult && actionData.drawSyncResult.unmatched.length > 0 && (
<Card className="border-amber-500/40">
<CardHeader>
<CardTitle className="text-amber-500">
Review {actionData.drawSyncResult.unmatched.length} possible duplicate
{actionData.drawSyncResult.unmatched.length === 1 ? "" : "s"}
</CardTitle>
<CardDescription>
These players were auto-created but closely resemble an existing participant
if a duplicate, results won&apos;t reach the drafted copy. To fix: on the{" "}
<Link
to={`/admin/sports-seasons/${sportsSeason.id}/participants`}
className="underline font-medium"
>
participants page
</Link>
, set the correct participant&apos;s external ID to the Wikipedia name, delete the
duplicate, then click <span className="font-medium">Sync Draw</span> again.
</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-1 text-sm">
{actionData.drawSyncResult.unmatched.map((p) => (
<li key={p.externalId ?? p.name} className="flex items-center gap-2">
<span className="font-medium">{p.name}</span>
{p.externalId && p.externalId !== p.name && (
<span className="text-muted-foreground text-xs">({p.externalId})</span>
)}
</li>
))}
</ul>
</CardContent>
</Card>
)}
{/* Dry-run preview of a draw sync */}
{actionData?.drawPreview && (
<Card className="border-sky-500/40">
<CardHeader>
<CardTitle className="text-sky-500">Draw preview (no changes made)</CardTitle>
<CardDescription>{actionData.drawPreview.article}</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="grid grid-cols-2 gap-x-6 gap-y-1 sm:grid-cols-3">
<div>
Players: <span className="font-medium">{actionData.drawPreview.totalPlayers}</span>
</div>
<div>
Matched:{" "}
<span className="font-medium text-emerald-500">
{actionData.drawPreview.matched}
</span>
</div>
<div>
Will create:{" "}
<span className="font-medium">{actionData.drawPreview.willCreate.length}</span>
</div>
<div>
Matches:{" "}
<span className="font-medium">
{actionData.drawPreview.completedMatches}/{actionData.drawPreview.totalMatches}{" "}
done
</span>
</div>
<div>
Unfilled R1 slots:{" "}
<span className="font-medium">{actionData.drawPreview.tbdFirstRound}</span>
</div>
</div>
{actionData.drawPreview.possibleDuplicates.length > 0 && (
<div>
<p className="font-medium text-amber-500">
Possible duplicates ({actionData.drawPreview.possibleDuplicates.length}) would
be created but resemble an existing participant:
</p>
<ul className="mt-2 space-y-2">
{actionData.drawPreview.possibleDuplicates.map((p) => (
<DuplicateRow key={p.externalId ?? p.name} p={p} />
))}
</ul>
<p className="mt-2 text-xs text-muted-foreground">
<span className="font-medium">Rename</span> if it&apos;s the same player (links
the existing participant so it matches);{" "}
<span className="font-medium">Create as new</span> if they&apos;re different
people. Then re-run Preview or Sync Draw.
</p>
</div>
)}
{actionData.drawPreview.willCreate.length > 0 && (
<details>
<summary className="cursor-pointer text-muted-foreground">
Show all {actionData.drawPreview.willCreate.length} players that would be created
</summary>
<ul className="mt-1 columns-2 sm:columns-3">
{actionData.drawPreview.willCreate.map((p) => (
<li key={p.externalId ?? p.name}>{p.name}</li>
))}
</ul>
</details>
)}
<p className="text-xs text-muted-foreground">
Fix any duplicates on the{" "}
<Link
to={`/admin/sports-seasons/${sportsSeason.id}/participants`}
className="underline font-medium"
>
participants page
</Link>{" "}
first, then click <span className="font-medium">Sync Draw</span>.
</p>
</CardContent>
</Card>
)}
{/* Sync Draw from Wikipedia — tennis Grand Slam auto-populate + auto-score */}
{event.isQualifyingEvent &&
sportsSeason.sport.simulatorType === "tennis_qualifying_points" && (
<Card>
<CardHeader>
<CardTitle>Sync Draw from Wikipedia</CardTitle>
<CardDescription>
Auto-populate and score this Grand Slam bracket from its Wikipedia draw
article. Re-run during the tournament to pull in completed matches.
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="externalSourceKey">Wikipedia draw page (URL or title)</Label>
<Input
id="externalSourceKey"
name="externalSourceKey"
placeholder="https://en.wikipedia.org/wiki/2025_Wimbledon_Championships__Men's_singles"
defaultValue={event.externalSourceKey ?? ""}
/>
<p className="text-xs text-muted-foreground">
Paste the article URL or type the title both work. Use{" "}
<span className="font-medium">Preview</span> first to see matches and any
new/duplicate players before writing anything.
</p>
</div>
<div className="flex gap-2">
<Button type="submit" name="intent" value="preview-draw" variant="outline">
Preview (dry run)
</Button>
<Button type="submit" name="intent" value="sync-draw">
Sync Draw
</Button>
</div>
</Form>
</CardContent>
</Card>
)}
{/* Reprocess Bracket - Full rebuild of participant results.
Hidden once every match is complete at that point all placements are final
and "finalize bracket" should be used instead. */}
@ -806,24 +1072,7 @@ export default function EventBracket({
{groupMatches.map((match) => (
<div key={match.id} className="text-xs space-y-1">
{/* Schedule row */}
<Form method="post" className="flex items-center gap-1">
<input type="hidden" name="intent" value="update-group-match-schedule" />
<input type="hidden" name="matchId" value={match.id} />
<Calendar className="h-3 w-3 text-muted-foreground shrink-0" />
<Input
type="datetime-local"
name="scheduledAt"
defaultValue={
match.scheduledAt
? new Date(match.scheduledAt).toISOString().slice(0, 16)
: ""
}
className="h-6 text-xs px-1 py-0"
/>
<Button type="submit" size="sm" variant="ghost" className="h-6 px-1.5 text-xs">
Save
</Button>
</Form>
<GroupMatchScheduleForm match={match} localTzAbbr={localTzAbbr} />
{/* Score row */}
<Form method="post" className="flex items-center gap-1">
<input type="hidden" name="intent" value="update-group-match" />

View file

@ -2,18 +2,19 @@ import { Form, useLoaderData, useActionData, useNavigation, useFetcher, Link } f
import type { Route } from './+types/admin.sports-seasons.$id.events.$eventId.cs2-setup';
import { findSportsSeasonById } from '~/models/sports-season';
import { getScoringEventById } from '~/models/scoring-event';
import { getScoringEventById, isReadOnlySibling } from '~/models/scoring-event';
import { findParticipantsBySportsSeasonId } from '~/models/season-participant';
import {
getCs2StageResultsForEvent,
upsertCs2StageAssignments,
markCs2StageEliminations,
clearCs2StageAssignments,
resetCs2Event,
clearCs2EliminationsAtStage,
assignCs2EliminationQP,
} from '~/models/cs2-major-stage';
import { findSeasonMatchesByScoringEventId } from '~/models/season-match';
import { syncMatches } from '~/services/match-sync';
import { fanOutMajorIfPrimary } from '~/services/sync-tournament-results';
import { Button } from '~/components/ui/button';
import {
Card,
@ -61,9 +62,22 @@ export async function action({ request, params }: Route.ActionArgs) {
const formData = await request.formData();
const intent = formData.get('intent') as string;
// CS2 stages are set up only on the major's primary window; a tournament-linked
// non-primary event is a read-only mirror fed by fan-out.
const guardEvent = await getScoringEventById(eventId);
if (guardEvent && isReadOnlySibling(guardEvent)) {
return {
success: false,
message:
'This CS2 major is set up on its primary window; results fan out here automatically.',
};
}
if (intent === 'reset') {
await clearCs2StageAssignments(eventId);
return { success: true, message: 'Stage assignments cleared.' };
// Local recovery tool: clears only the primary's torn state. Siblings are not
// touched here — re-entering results re-propagates via the scoring fan-out.
await resetCs2Event(eventId, params.id);
return { success: true, message: 'Stage assignments and recorded results cleared.' };
}
if (intent === 'assign') {
@ -126,6 +140,10 @@ export async function action({ request, params }: Route.ActionArgs) {
}
await markCs2StageEliminations(eventId, eliminations);
await assignCs2EliminationQP(eventId, params.id);
// Propagate the derived QP/placements to every sibling window (in-progress:
// don't mark complete — that happens when the bracket is finalized).
const event = await getScoringEventById(eventId);
if (event) await fanOutMajorIfPrimary(event, { markComplete: false });
return { success: true, message: `Marked ${eliminations.length} eliminations.` };
} catch (err) {
return { success: false, message: err instanceof Error ? err.message : 'Failed to save eliminations.' };
@ -268,7 +286,14 @@ export default function AdminCs2Setup() {
<CardTitle className="flex items-center justify-between">
Stage Assignments
{stageResults.length > 0 && (
<Form method="post">
<Form
method="post"
onSubmit={(e) => {
if (!confirm('Reset this event? This clears all stage assignments AND recorded results/QP for the event so you can set it up again. The Champions Stage bracket is kept.')) {
e.preventDefault();
}
}}
>
<input type="hidden" name="intent" value="reset" />
<Button type="submit" variant="ghost" size="sm" className="text-destructive">
<Trash2 className="h-4 w-4 mr-1" />

View file

@ -6,6 +6,7 @@ import {
getScoringEventById,
completeScoringEvent,
updateScoringEvent,
isReadOnlySibling,
} from "~/models/scoring-event";
import {
getEventResults,
@ -77,6 +78,9 @@ export async function loader({ params }: Route.LoaderArgs) {
)
: new Set();
// A tournament-linked event that is NOT the primary is a read-only mirror:
// scoring happens once on the canonical tournament (or its primary window) and
// fans out here. Surface this so the UI can point the admin to the right place.
return {
sportsSeason: sportsSeason as typeof sportsSeason & {
sport: { id: string; name: string; type: string; slug: string; simulatorType: string | null };
@ -89,13 +93,45 @@ export async function loader({ params }: Route.LoaderArgs) {
qpConfig,
qpStandings,
notParticipatingIds,
isReadOnlySibling: isReadOnlySibling(event),
canonicalTournamentId: event.tournamentId,
};
}
// Scoring intents that mutate this window's results directly. For a read-only
// sibling (tournament-linked, non-primary) these are rejected — score on the
// canonical tournament page instead, and the result fans out automatically.
//
// mark/unmark-not-participating are deliberately NOT here: they are per-window
// simulator inputs (exclude a participant from this window's EV draws), have no
// canonical-tournament equivalent, and must stay editable on every window.
const SCORING_INTENTS = new Set([
"process-qp",
"complete",
"uncomplete",
"add-result",
"update-result",
"delete-result",
"update-standings",
"batch-add-results",
]);
export async function action({ request, params }: Route.ActionArgs) {
const formData = await request.formData();
const intent = formData.get("intent");
// Enforce single-source scoring: reject direct scoring on a read-only sibling.
if (typeof intent === "string" && SCORING_INTENTS.has(intent)) {
const ev = await getScoringEventById(params.eventId);
if (ev && isReadOnlySibling(ev)) {
return {
error:
"This event belongs to a shared major. Score it once on the tournament page (Admin → Tournaments) and it fans out to every window automatically.",
canonicalTournamentId: ev.tournamentId,
};
}
}
if (intent === "mark-qualifying") {
try {
const event = await getScoringEventById(params.eventId);

View file

@ -21,7 +21,7 @@ import {
} from "~/components/ui/select";
import { Badge } from "~/components/ui/badge";
import { ArrowLeft, Trophy, CheckCircle2, Pencil, Trash2, Brackets, Save } from "lucide-react";
import { getEventTypeLabel } from "~/models/scoring-event";
import { getEventTypeLabel } from "~/models/scoring-event-types";
import { isBracketMajor } from "~/lib/event-utils";
import {
Table,
@ -95,9 +95,24 @@ export default function EventResults({
loaderData,
actionData,
}: Route.ComponentProps) {
const { sportsSeason, event, participants, results, participantResults, seasonResults, notParticipatingIds } = loaderData;
const { sportsSeason, event, participants, results, participantResults, seasonResults, notParticipatingIds, isReadOnlySibling, canonicalTournamentId } = loaderData;
const [hasChanges, setHasChanges] = useState(false);
// Banner shown on read-only sibling events: scoring lives on the canonical
// tournament page, and results fan out here automatically.
const readOnlySiblingBanner = isReadOnlySibling ? (
<div className="bg-amber-500/15 text-amber-500 border border-amber-500/30 px-4 py-3 rounded-md text-sm mb-4">
This event is part of a shared major. Scoring is done once on the{" "}
<Link
to={`/admin/tournaments/${canonicalTournamentId}`}
className="underline font-medium"
>
tournament page
</Link>{" "}
and fans out to every window automatically the controls here are read-only.
</div>
) : null;
const isBracketEvent = event.eventType === "playoff_game" || isBracketMajor(sportsSeason.sport?.simulatorType);
// Create a map of participants with results for easy lookup
@ -218,6 +233,7 @@ export default function EventResults({
return (
<div className="p-8">
<div className="max-w-4xl">
{readOnlySiblingBanner}
<div className="mb-6">
<Button variant="ghost" size="sm" asChild className="mb-2">
<Link to={`/admin/sports-seasons/${sportsSeason.id}/events`}>

View file

@ -7,8 +7,12 @@ import {
createScoringEvent,
deleteScoringEvent,
bulkCreateScoringEvents,
ensurePrimaryEvent,
countWindowsByTournament,
getMajorsCompleted,
type CreateScoringEventData,
} from "~/models/scoring-event";
import { isBracketMajor } from "~/lib/event-utils";
import { getQPStandings } from "~/models/qualifying-points";
import { finalizeQualifyingPoints } from "~/models/scoring-calculator";
import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.server";
@ -25,10 +29,14 @@ export async function loader({ params }: Route.LoaderArgs) {
const events = await getScoringEventsForSportsSeason(params.id);
// For qualifying sports seasons, get QP standings with global ranks attached
// For qualifying sports seasons, get QP standings with global ranks attached.
// majorsCompleted is derived on read (count of completed qualifying events), not a
// stored counter — see getMajorsCompleted.
let qpStandings = null;
const scoringRules = null;
let majorsCompleted = 0;
if (sportsSeason.scoringPattern === "qualifying_points") {
majorsCompleted = await getMajorsCompleted(params.id);
const standings = await getQPStandings(params.id);
let prevQP = -1;
let prevRankStart = 1;
@ -57,11 +65,38 @@ export async function loader({ params }: Route.LoaderArgs) {
);
const availableTournaments = allTournamentsForSport.filter((t) => !linkedTournamentIds.has(t.id));
// Shared-tournament context for the delete confirmation dialog: for each event
// linked to a canonical tournament, how many OTHER seasons ("windows") also use
// it, and the tournament's name. Lets the UI explain exactly what a delete does.
const sharedInfo = new Map<string, { name: string; windowCount: number }>();
await Promise.all(
[...linkedTournamentIds].map(async (tid) => {
const cached = allTournamentsForSport.find((t) => t.id === tid);
const [tournament, windowCount] = await Promise.all([
cached ?? getTournamentById(tid),
countWindowsByTournament(tid),
]);
sharedInfo.set(tid, {
name: tournament?.name ?? "shared tournament",
windowCount,
});
})
);
const eventsWithSharing = events.map((e) => {
const info = e.tournamentId ? sharedInfo.get(e.tournamentId) : null;
return {
...e,
tournamentName: info?.name ?? null,
otherWindowCount: info ? Math.max(0, info.windowCount - 1) : 0,
};
});
return {
sportsSeason: sportsSeason as typeof sportsSeason & {
sportsSeason: { ...sportsSeason, majorsCompleted } as typeof sportsSeason & {
sport: { id: string; name: string; type: string; slug: string };
},
events,
events: eventsWithSharing,
qpStandings,
scoringRules,
availableTournaments,
@ -92,6 +127,12 @@ export async function action({ request, params }: Route.ActionArgs) {
eventDate,
eventStartsAt: tournament.startsAt ? new Date(tournament.startsAt) : undefined,
});
// Bracket majors (tennis/CS2) need a primary window to be scorable. Seed it
// on the first linked window; later windows stay siblings (idempotent).
const ss = await findSportsSeasonById(params.id);
if (isBracketMajor(ss?.sport?.simulatorType)) {
await ensurePrimaryEvent(tournament.id, event.id);
}
return redirect(`/admin/sports-seasons/${params.id}/events/${event.id}`);
} catch (error) {
logger.error("Error adding scoring event from tournament:", error);
@ -185,7 +226,13 @@ export async function action({ request, params }: Route.ActionArgs) {
}
try {
await bulkCreateScoringEvents(params.id, validEvents);
const created = await bulkCreateScoringEvents(params.id, validEvents);
// Seed a primary window per bracket-major tournament that lacks one.
if (isBracketMajor(sportsSeason.sport?.simulatorType)) {
for (const ev of created) {
if (ev.tournamentId) await ensurePrimaryEvent(ev.tournamentId, ev.id);
}
}
return { success: `Created ${validEvents.length} event${validEvents.length !== 1 ? "s" : ""} successfully` };
} catch (error) {
logger.error("Error bulk creating events:", error);
@ -201,8 +248,19 @@ export async function action({ request, params }: Route.ActionArgs) {
}
try {
await deleteScoringEvent(eventId);
return { success: "Event deleted successfully" };
const result = await deleteScoringEvent(eventId, undefined, {
deleteOrphanTournament: formData.get("deleteTournament") === "1",
});
let success = "Event deleted";
if (result.deletedTournament) {
success = "Event deleted and its shared tournament removed";
} else if (result.tournamentId && result.remainingWindows > 0) {
success = `Event removed from this season (shared tournament kept — still used by ${result.remainingWindows} season${result.remainingWindows !== 1 ? "s" : ""})`;
if (result.promotedPrimaryId) {
success += "; another season was promoted to primary";
}
}
return { success };
} catch (error) {
logger.error("Error deleting event:", error);
return { error: "Failed to delete event" };
@ -282,6 +340,11 @@ export async function action({ request, params }: Route.ActionArgs) {
try {
const event = await createScoringEvent(eventData);
// Bracket majors need a primary window to be scorable — seed it on the first
// linked window (idempotent for later windows).
if (eventData.tournamentId && isBracketMajor(sportsSeason.sport?.simulatorType)) {
await ensurePrimaryEvent(eventData.tournamentId, event.id);
}
return redirect(`/admin/sports-seasons/${params.id}/events/${event.id}`);
} catch (error) {
logger.error("Error creating scoring event:", error);

View file

@ -37,7 +37,7 @@ import {
import { Calendar, Trophy, ArrowLeft, Trash2, ListPlus } from "lucide-react";
import { format, parseISO } from "date-fns";
import { QualifyingPointsStandings } from "~/components/scoring/QualifyingPointsStandings";
import { getEventTypeLabel } from "~/models/scoring-event";
import { getEventTypeLabel } from "~/models/scoring-event-types";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Events — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
@ -305,7 +305,11 @@ export default function SportsSeasonEvents({
</p>
) : (
<div className="space-y-3">
{events.map((event: { id: string; name: string; eventType: string; eventDate?: string | null; eventStartsAt?: Date | string | null; isComplete: boolean }) => (
{events.map((event: { id: string; name: string; eventType: string; eventDate?: string | null; eventStartsAt?: Date | string | null; isComplete: boolean; tournamentId: string | null; isPrimary: boolean; tournamentName: string | null; otherWindowCount: number }) => {
const isLinked = !!event.tournamentId;
const hasOtherWindows = event.otherWindowCount > 0;
const isLastWindow = isLinked && !hasOtherWindows;
return (
<Card key={event.id} className="hover:border-primary/50 transition-colors">
<CardContent className="pt-6">
<div className="flex items-start justify-between gap-4">
@ -352,28 +356,65 @@ export default function SportsSeasonEvents({
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete "{event.name}"?</AlertDialogTitle>
<AlertDialogDescription>
This will also delete all results for this event. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Form method="post">
<input type="hidden" name="intent" value="delete-event" />
<input type="hidden" name="eventId" value={event.id} />
<Form method="post">
<input type="hidden" name="intent" value="delete-event" />
<input type="hidden" name="eventId" value={event.id} />
<AlertDialogHeader>
<AlertDialogTitle>Delete "{event.name}"?</AlertDialogTitle>
<AlertDialogDescription asChild>
{!isLinked ? (
<span>
This will also delete all results for this event. This action cannot be undone.
</span>
) : hasOtherWindows ? (
<span>
This event is part of the shared tournament{" "}
<span className="font-medium">"{event.tournamentName}"</span>, also used by{" "}
<span className="font-medium">
{event.otherWindowCount} other season{event.otherWindowCount !== 1 ? "s" : ""}
</span>
. Deleting removes it from <span className="font-medium">this season only</span> the
tournament and the other seasons keep their data.
{event.isPrimary && (
<> This is the primary scoring window, so another season will be promoted to primary automatically.</>
)}
</span>
) : (
<span>
This is the only season linked to the shared tournament{" "}
<span className="font-medium">"{event.tournamentName}"</span>. Deleting removes this
event and its results. This action cannot be undone.
</span>
)}
</AlertDialogDescription>
</AlertDialogHeader>
{isLastWindow && (
<label className="flex items-start gap-2 text-sm my-2 cursor-pointer">
<input
type="checkbox"
name="deleteTournament"
value="1"
className="mt-0.5 h-4 w-4 rounded border-input"
/>
<span>
Also delete the shared tournament and its recorded results
</span>
</label>
)}
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction type="submit" className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Delete
</AlertDialogAction>
</Form>
</AlertDialogFooter>
</AlertDialogFooter>
</Form>
</AlertDialogContent>
</AlertDialog>
</div>
</CardContent>
</Card>
))}
);
})}
</div>
)}
</CardContent>

View file

@ -1,394 +1,9 @@
import { Form, redirect, useLoaderData, useActionData, useNavigation } from 'react-router';
import { redirect } from 'react-router';
import type { Route } from './+types/admin.sports-seasons.$id.futures-odds';
import { logger } from '~/lib/logger';
import { findSportsSeasonById } from '~/models/sports-season';
import { findParticipantsBySportsSeasonId } from '~/models/season-participant';
import { getAllParticipantEVsForSeason, batchSaveSourceOdds, clearSourceOddsForParticipants } from '~/models/participant-expected-value';
import { batchSaveFuturesOddsForSimulator } from '~/models/simulator';
import { Button } from '~/components/ui/button';
import { Input } from '~/components/ui/input';
import { Label } from '~/components/ui/label';
import { Textarea } from '~/components/ui/textarea';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '~/components/ui/card';
import { useState } from 'react';
import { Loader2, Info, CheckCircle2, AlertCircle } from 'lucide-react';
import { runSportsSeasonSimulation } from '~/services/simulations/runner';
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Futures Odds — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
}
// Futures odds entry has been consolidated into the Bulk Simulator Inputs card on
// the simulator setup page (paste sportsbook lines like `Chiefs +450` or a CSV with
// a sourceOdds column). This route now just redirects old bookmarks/links there.
export async function loader({ params }: Route.LoaderArgs) {
const sportsSeasonId = params.id;
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
if (!sportsSeason) {
throw new Response('Sports season not found', { status: 404 });
}
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
const existingEVs = await getAllParticipantEVsForSeason(sportsSeasonId);
// Show any saved odds regardless of what simulation ran afterwards
const existingOdds = new Map(
existingEVs
.filter(ev => ev.sourceOdds !== null)
.map(ev => [ev.participantId, ev.sourceOdds])
);
return {
sportsSeason,
participants,
existingOdds,
};
}
interface ActionData {
success?: boolean;
message?: string;
}
export async function action({ request, params }: Route.ActionArgs) {
const sportsSeasonId = params.id;
const formData = await request.formData();
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
if (!sportsSeason) {
return { success: false, message: 'Sports season not found' };
}
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
// Parse odds from form
const futuresOdds: Array<{ participantId: string; odds: number; name: string }> = [];
for (const participant of participants) {
const oddsValue = formData.get(`odds_${participant.id}`) as string;
if (oddsValue && oddsValue.trim() !== '') {
const odds = Number(oddsValue);
if (!isNaN(odds)) {
futuresOdds.push({
participantId: participant.id,
odds,
name: participant.name,
});
}
}
}
if (futuresOdds.length === 0) {
return { success: false, message: 'Please enter odds for at least one participant' };
}
if (!sportsSeason.sport?.simulatorType) {
return { success: false, message: 'This sport has no simulator type configured. Set one on the sport in the admin panel.' };
}
if (sportsSeason.simulationStatus === 'running') {
return { success: false, message: 'A simulation is already running. Please wait.' };
}
const shouldClearExisting = formData.get('clearExisting') === '1';
try {
const oddsInputs = futuresOdds.map(({ participantId, odds }) => ({ participantId, sportsSeasonId, sourceOdds: odds }));
// Save to legacy table (EV-page display), then clear all ratings and save
// sourceOdds to the simulator inputs table so odds always drive the run.
// Sequential: batchSaveFuturesOddsForSimulator must win on the inputs table.
await batchSaveSourceOdds(oddsInputs);
await batchSaveFuturesOddsForSimulator(oddsInputs);
if (shouldClearExisting) {
const keptIds = new Set(futuresOdds.map(f => f.participantId));
const clearedIds = participants.filter(p => !keptIds.has(p.id)).map(p => p.id);
await clearSourceOddsForParticipants(sportsSeasonId, clearedIds);
}
await runSportsSeasonSimulation(sportsSeasonId);
} catch (error) {
logger.error('Error running simulation:', error);
return {
success: false,
message: error instanceof Error ? error.message : 'Simulation failed',
};
}
return redirect(`/admin/sports-seasons/${sportsSeasonId}/expected-values`);
}
function normalizeName(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9\s]/g, '').replace(/\s+/g, ' ').trim();
}
export default function AdminSportsSeasonFuturesOdds() {
const { sportsSeason, participants, existingOdds } = useLoaderData<typeof loader>();
const actionData = useActionData<ActionData>();
const navigation = useNavigation();
// Initialize odds values from existing data
const [oddsValues, setOddsValues] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
participants.forEach(p => {
const existingOdd = existingOdds.get(p.id);
if (existingOdd !== undefined && existingOdd !== null) {
initial[p.id] = existingOdd.toString();
}
});
return initial;
});
// Bulk import state
const [clearExisting, setClearExisting] = useState(false);
const [bulkText, setBulkText] = useState('');
const [parseResults, setParseResults] = useState<{
matched: Array<{ participantId: string; name: string; odds: number; inputName: string }>;
unmatched: Array<{ inputName: string; odds: number }>;
} | null>(null);
function findParticipantMatch(inputName: string) {
const normalizedInput = normalizeName(inputName);
const normalized = participants.map(p => ({ p, n: normalizeName(p.name) }));
const exact = normalized.find(({ n }) => n === normalizedInput);
if (exact) return exact.p;
const contains = normalized.find(({ n }) => n.includes(normalizedInput) || normalizedInput.includes(n));
if (contains) return contains.p;
const inputWords = normalizedInput.split(' ').filter(w => w.length > 2);
const overlap = normalized.find(({ n }) => {
const pWords = n.split(' ').filter(w => w.length > 2);
const shared = inputWords.filter(w => pWords.includes(w));
return shared.length > 0 && shared.length >= Math.min(inputWords.length, pWords.length) * 0.5;
});
return overlap?.p ?? null;
}
function parseBulkText() {
const lines = bulkText.split('\n');
const oddsPattern = /^(.+?)\s+([+-]\d{2,6})\s*$/;
const matched: Array<{ participantId: string; name: string; odds: number; inputName: string }> = [];
const unmatched: Array<{ inputName: string; odds: number }> = [];
const seenParticipants = new Set<string>();
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.length < 3) continue;
const match = oddsPattern.exec(trimmed);
if (!match) continue;
const inputName = match[1].trim();
const odds = parseInt(match[2], 10);
if (isNaN(odds)) continue;
const participant = findParticipantMatch(inputName);
if (participant && !seenParticipants.has(participant.id)) {
seenParticipants.add(participant.id);
matched.push({ participantId: participant.id, name: participant.name, odds, inputName });
} else if (!participant) {
unmatched.push({ inputName, odds });
}
}
setParseResults({ matched, unmatched });
}
function applyMatches() {
if (!parseResults) return;
const newOdds = clearExisting ? {} : { ...oddsValues };
for (const m of parseResults.matched) {
newOdds[m.participantId] = m.odds.toString();
}
setOddsValues(newOdds);
setParseResults(null);
setBulkText('');
setClearExisting(false);
}
const isSubmitting = navigation.state === 'submitting';
return (
<div className="container mx-auto py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold mb-2">Futures Odds Entry</h1>
<p className="text-muted-foreground">
{sportsSeason.sport.name} - {sportsSeason.name}
</p>
</div>
{/* Bulk Import */}
<Card className="mb-6">
<CardHeader>
<CardTitle>Bulk Import</CardTitle>
<CardDescription>
Paste odds from FanDuel, DraftKings, OddsChecker, or any site. Each line should end with
American odds (e.g. <code>Kansas City Chiefs +450</code>). Team names are fuzzy-matched to
participants.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Textarea
placeholder={`Paste odds here, one team per line:\nKansas City Chiefs +450\nSan Francisco 49ers +600\nBaltimore Ravens +700`}
value={bulkText}
onChange={e => { setBulkText(e.target.value); setParseResults(null); }}
rows={8}
className="font-mono text-sm"
/>
<div className="flex items-center gap-4">
<Button type="button" variant="outline" onClick={parseBulkText} disabled={!bulkText.trim()}>
Parse Odds
</Button>
<label className="flex items-center gap-2 text-sm cursor-pointer select-none">
<input
type="checkbox"
checked={clearExisting}
onChange={e => setClearExisting(e.target.checked)}
className="h-4 w-4"
/>
Clear existing odds not in this import
</label>
</div>
{parseResults && (
<div className="space-y-3">
{parseResults.matched.length > 0 && (
<div>
<div className="flex items-center gap-2 text-sm font-medium text-emerald-400 mb-2">
<CheckCircle2 className="h-4 w-4" />
Matched ({parseResults.matched.length})
</div>
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/10 divide-y divide-emerald-500/20 text-sm">
{parseResults.matched.map(m => (
<div key={m.participantId} className="flex justify-between px-3 py-1.5">
<span className="text-muted-foreground">{m.inputName}</span>
<span className="font-medium">
{m.name} &rarr; {m.odds > 0 ? '+' : ''}{m.odds}
</span>
</div>
))}
</div>
</div>
)}
{parseResults.unmatched.length > 0 && (
<div>
<div className="flex items-center gap-2 text-sm font-medium text-amber-700 mb-2">
<AlertCircle className="h-4 w-4" />
Not matched ({parseResults.unmatched.length}) enter manually below
</div>
<div className="rounded-md border border-amber-200 bg-amber-50 divide-y divide-amber-100 text-sm">
{parseResults.unmatched.map((u) => (
<div key={u.inputName} className="flex justify-between px-3 py-1.5">
<span>{u.inputName}</span>
<span className="font-medium">{u.odds > 0 ? '+' : ''}{u.odds}</span>
</div>
))}
</div>
</div>
)}
{parseResults.matched.length === 0 && parseResults.unmatched.length === 0 && (
<p className="text-sm text-muted-foreground">No odds found. Make sure each line ends with a value like +450 or -200.</p>
)}
{parseResults.matched.length > 0 && (
<Button type="button" onClick={applyMatches}>
Apply {parseResults.matched.length} matched odds to form
</Button>
)}
</div>
)}
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-2">
<div>
<Card>
<CardHeader>
<CardTitle>Championship Futures Odds</CardTitle>
<CardDescription>
Enter American odds (e.g., +550, -200) for each participant's championship probability.
Enter American odds, then run the ICM simulation to compute and save probability distributions.
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="space-y-4">
<input type="hidden" name="clearExisting" value={clearExisting ? "1" : ""} />
<div className="space-y-3">
{participants.map((participant) => (
<div key={participant.id} className="grid grid-cols-2 gap-4 items-center">
<Label htmlFor={`odds_${participant.id}`}>{participant.name}</Label>
<Input
type="number"
id={`odds_${participant.id}`}
name={`odds_${participant.id}`}
placeholder="+550"
value={oddsValues[participant.id] ?? ''}
onChange={(e) =>
setOddsValues((prev) => ({
...prev,
[participant.id]: e.target.value,
}))
}
/>
</div>
))}
</div>
<div className="bg-muted p-4 rounded-lg space-y-2">
<div className="flex items-center gap-2 font-medium">
<Info className="h-4 w-4" />
ICM Calculation
</div>
<div className="space-y-1 text-sm">
<div>Uses Independent Chip Model from poker tournaments</div>
<div>Works with any number of participants</div>
<div className="text-xs text-muted-foreground mt-2">
Every team gets probabilities for 1st-8th place, even longshots.
</div>
</div>
</div>
{actionData && !actionData.success && actionData.message && (
<div className="text-sm text-destructive">{actionData.message}</div>
)}
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isSubmitting ? 'Running...' : 'Run Simulation'}
</Button>
</Form>
</CardContent>
</Card>
<Card className="mt-4">
<CardHeader>
<CardTitle>How It Works</CardTitle>
</CardHeader>
<CardContent className="text-sm space-y-2">
<ol className="list-decimal list-inside space-y-2">
<li>Converts American odds to championship win probabilities</li>
<li>Removes bookmaker vig (normalizes to 100%)</li>
<li>Uses ICM algorithm to distribute probabilities across all placements</li>
<li>Generates probability distribution (1st through 8th place) for ALL participants</li>
<li>Even teams with +100000 odds get non-zero probabilities</li>
</ol>
</CardContent>
</Card>
</div>
</div>
</div>
);
return redirect(`/admin/sports-seasons/${params.id}/simulator`);
}

View file

@ -1,3 +1,4 @@
import { useMemo, useState } from "react";
import { Form, Link, redirect, useActionData, useNavigation } from "react-router";
import type { Route } from "./+types/admin.sports-seasons.$id.simulator";
@ -25,7 +26,15 @@ import {
type UpsertParticipantSimulatorInput,
} from "~/models/simulator";
import { normalizeName } from "~/lib/fuzzy-match";
import { getSimulatorInputPolicy, type MissingEloStrategy, type MissingRatingStrategy } from "~/services/simulations/input-policy";
import {
simulatorInputLabel,
type SimulatorInputKey,
} from "~/services/simulations/manifest";
import {
getSimulatorInputPolicy,
type MissingEloStrategy,
type MissingRatingStrategy,
} from "~/services/simulations/input-policy";
import { runSportsSeasonSimulation } from "~/services/simulations/runner";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
@ -57,7 +66,22 @@ export async function loader({ params }: Route.LoaderArgs) {
const inputPolicy = getSimulatorInputPolicy(config.config);
return { sportsSeason, participants, config, inputRows, readiness, inputPolicy };
// Sport-aware preview columns: the intersection of the displayable numeric keys
// with this simulator's required + optional inputs, so each season shows exactly
// the inputs its simulator consumes (F1 = odds, NBA = Elo, NCAA = rating, ...).
// Resolved here (server-only) so the client bundle never imports the simulator
// manifest/registry, which transitively pulls in `.server` modules.
const relevantInputs = new Set<SimulatorInputKey>([
...config.profile.requiredInputs,
...config.profile.optionalInputs,
]);
const inputColumns = DISPLAY_INPUT_ORDER.filter((key) => relevantInputs.has(key)).map((key) => ({
key,
label: simulatorInputLabel(key),
required: config.profile.requiredInputs.includes(key),
}));
return { sportsSeason, participants, config, inputRows, readiness, inputPolicy, inputColumns };
}
interface ActionData {
@ -65,6 +89,43 @@ interface ActionData {
message: string;
}
/**
* Input keys the participant preview can render as a numeric column, in the order
* they appear. Keys not listed (e.g. `region`, `metadata`) are not shown as
* columns; the visible columns for a season are the intersection of this order
* with the simulator's required + optional inputs.
*/
const DISPLAY_INPUT_ORDER: SimulatorInputKey[] = [
"sourceElo",
"sourceOdds",
"worldRanking",
"rating",
"projectedWins",
"projectedTablePoints",
"seed",
];
const PARTICIPANT_PAGE_SIZE = 50;
/**
* Engine knobs that a simulator (or the shared input-policy resolver) actually
* reads from config. The structured Engine fields are limited to these so the UI
* never shows a control that silently does nothing bespoke per-sim constants
* (e.g. homeFieldElo, eloDivisor, srsEloScale, raceNoise) that live in a profile
* but are not read from config stay editable only via the raw-JSON escape hatch.
*/
const HONORED_ENGINE_KNOBS = new Set([
"iterations",
"parityFactor",
"seasonGames",
"overtimeRate",
"matchParityFactor",
"averageOpponentElo",
"baseDrawRate",
"drawDecay",
"ratingScaleFactor",
]);
function parseOptionalNumber(value: string | undefined): number | null {
if (value === undefined || value.trim() === "") return null;
const parsed = Number(value);
@ -106,6 +167,39 @@ function findParticipantId(name: string, participants: Array<{ id: string; name:
);
}
const ODDS_LINE_PATTERN = /^(.+?)\s+([+-]\d{2,6})\s*$/;
function parseOddsLines(
lines: string[],
sportsSeasonId: string,
participants: Array<{ id: string; name: string }>
): { inputs: UpsertParticipantSimulatorInput[]; unmatched: string[] } {
const inputs: UpsertParticipantSimulatorInput[] = [];
const unmatched: string[] = [];
const seen = new Set<string>();
for (const line of lines) {
const match = ODDS_LINE_PATTERN.exec(line);
if (!match) continue;
const name = match[1].trim();
const sourceOdds = Number(match[2]);
if (!Number.isFinite(sourceOdds)) continue;
const participantId = findParticipantId(name, participants);
if (!participantId) {
unmatched.push(name);
continue;
}
if (seen.has(participantId)) continue;
seen.add(participantId);
inputs.push({ participantId, sportsSeasonId, sourceOdds });
}
return { inputs, unmatched };
}
function parseInputCsv(
text: string,
sportsSeasonId: string,
@ -118,7 +212,10 @@ function parseInputCsv(
const indexes = new Map(header.map((value, index) => [value, index]));
const nameIndex = indexes.get("name");
if (nameIndex === undefined) {
throw new Error("Bulk input CSV must include a `name` column.");
// No CSV header — treat the paste as sportsbook futures odds, one team per
// line ending in American odds (e.g. `Kansas City Chiefs +450`). This is the
// friendly bulk-futures path; team names are fuzzy-matched to participants.
return parseOddsLines(lines, sportsSeasonId, participants);
}
const inputs: UpsertParticipantSimulatorInput[] = [];
@ -192,31 +289,48 @@ export async function action({ request, params }: Route.ActionArgs): Promise<Act
}
}
if (intent === "save-input-policy") {
if (intent === "save-configuration") {
const currentConfig = await getSportsSeasonSimulatorConfig(sportsSeasonId);
if (!currentConfig) return { success: false, message: "Simulator config not found." };
const currentPolicy = getSimulatorInputPolicy(currentConfig.config);
// Start from the current merged config so keys not exposed as structured
// fields (e.g. string knobs) are preserved untouched.
const next: Record<string, unknown> = { ...currentConfig.config };
// Engine knobs: every numeric field rendered as `engine.<key>`.
for (const [field, value] of formData.entries()) {
if (typeof value !== "string" || !field.startsWith("engine.")) continue;
const key = field.slice("engine.".length);
const parsed = Number(value);
if (value.trim() !== "" && Number.isFinite(parsed)) next[key] = parsed;
}
// Input-derivation policy (only when the simulator consumes Elo/ratings).
if (formData.get("hasInputPolicy") === "1") {
const currentPolicy = getSimulatorInputPolicy(currentConfig.config);
next.inputPolicy = {
...currentPolicy,
missingEloStrategy: parseMissingEloStrategy(formData.get("missingEloStrategy")),
missingRatingStrategy: parseMissingRatingStrategy(formData.get("missingRatingStrategy")),
// Stored as-is; getSimulatorInputPolicy clamps to [0,1] on read.
oddsWeight: parsePolicyNumber(formData, "oddsWeight", currentPolicy.oddsWeight),
fallbackElo: parsePolicyNumber(formData, "fallbackElo", currentPolicy.fallbackElo),
fallbackEloDelta: parsePolicyNumber(formData, "fallbackEloDelta", currentPolicy.fallbackEloDelta),
eloMin: parsePolicyNumber(formData, "eloMin", currentPolicy.eloMin),
eloMax: parsePolicyNumber(formData, "eloMax", currentPolicy.eloMax),
fallbackRating: parsePolicyNumber(formData, "fallbackRating", currentPolicy.fallbackRating),
fallbackRatingDelta: parsePolicyNumber(formData, "fallbackRatingDelta", currentPolicy.fallbackRatingDelta),
ratingMin: parsePolicyNumber(formData, "ratingMin", currentPolicy.ratingMin),
ratingMax: parsePolicyNumber(formData, "ratingMax", currentPolicy.ratingMax),
};
}
await upsertSportsSeasonSimulatorConfig({
sportsSeasonId,
simulatorType: currentConfig.simulatorType,
config: {
...currentConfig.config,
inputPolicy: {
missingEloStrategy: parseMissingEloStrategy(formData.get("missingEloStrategy")),
missingRatingStrategy: parseMissingRatingStrategy(formData.get("missingRatingStrategy")),
fallbackElo: parsePolicyNumber(formData, "fallbackElo", currentPolicy.fallbackElo),
fallbackEloDelta: parsePolicyNumber(formData, "fallbackEloDelta", currentPolicy.fallbackEloDelta),
eloMin: parsePolicyNumber(formData, "eloMin", currentPolicy.eloMin),
eloMax: parsePolicyNumber(formData, "eloMax", currentPolicy.eloMax),
fallbackRating: parsePolicyNumber(formData, "fallbackRating", currentPolicy.fallbackRating),
fallbackRatingDelta: parsePolicyNumber(formData, "fallbackRatingDelta", currentPolicy.fallbackRatingDelta),
ratingMin: parsePolicyNumber(formData, "ratingMin", currentPolicy.ratingMin),
ratingMax: parsePolicyNumber(formData, "ratingMax", currentPolicy.ratingMax),
},
},
config: next,
});
return { success: true, message: "Simulator input policy saved." };
return { success: true, message: "Simulator configuration saved." };
}
if (intent === "save-inputs") {
@ -235,7 +349,26 @@ export async function action({ request, params }: Route.ActionArgs): Promise<Act
const suffix = parsed.unmatched.length > 0
? ` ${parsed.unmatched.length} row(s) were unmatched: ${parsed.unmatched.join(", ")}.`
: "";
return { success: true, message: `Saved ${parsed.inputs.length} simulator input row(s).${suffix}` };
const saved = `Saved ${parsed.inputs.length} simulator input row(s).${suffix}`;
// Auto-run the simulation so saved inputs immediately drive the standings.
// The save itself already succeeded, so a run that never started (e.g.
// participants missing required inputs) is reported as success with the
// readiness gap — never as a failed save.
try {
await runSportsSeasonSimulation(sportsSeasonId);
return redirect(`/admin/sports-seasons/${sportsSeasonId}/expected-values`);
} catch (runError) {
const reason = runError instanceof Error ? runError.message : "could not run.";
// Distinguish "saved but never ran" (readiness/already-running) from
// "started running and failed mid-run": the runner only flips the season
// to status 'failed' once the simulation itself throws.
const season = await findSportsSeasonById(sportsSeasonId);
if (season?.simulationStatus === "failed") {
return { success: false, message: `${saved} The simulation failed: ${reason}` };
}
return { success: true, message: `${saved} Simulation not run yet: ${reason}` };
}
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to save simulator inputs." };
}
@ -255,6 +388,49 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
const showsInputPolicy =
config.profile.requiredInputs.includes("sourceElo") || config.profile.requiredInputs.includes("rating");
// Structured engine knobs: every top-level numeric config key (inputPolicy is a
// nested object edited in its own section). Driving the fields from the merged
// config means each simulator shows exactly the knobs it actually reads.
const engineEntries = Object.entries(config.config)
.filter(([key, value]) => HONORED_ENGINE_KNOBS.has(key) && typeof value === "number")
.map(([key, value]) => [key, value as unknown as number] as [string, number]);
// Preview columns are resolved server-side in the loader (see note there) and
// arrive as plain data, so this client component never imports the manifest.
const { inputColumns } = loaderData;
const requiredInputs = config.profile.requiredInputs;
const gridTemplate = `2fr repeat(${Math.max(inputColumns.length, 1)}, 1fr)`;
// For this sport the inputs live on a dedicated page, not the shared bulk paste.
const externalInputsSection = requiredInputs.length === 0
? (setupSections.includes("surfaceElo")
? { label: "Surface Elo", to: `/admin/sports-seasons/${sportsSeason.id}/surface-elo` }
: setupSections.includes("golfSkills")
? { label: "Golf Skills", to: `/admin/sports-seasons/${sportsSeason.id}/golf-skills` }
: null)
: null;
const isRowIncomplete = (input: (typeof inputRows)[number]["input"]) =>
requiredInputs.some((key) => input?.[key] === null || input?.[key] === undefined);
const [search, setSearch] = useState("");
const [onlyMissing, setOnlyMissing] = useState(false);
const [page, setPage] = useState(0);
const filteredRows = useMemo(() => {
const normalizedSearch = normalizeName(search);
return inputRows.filter(({ participant, input }) => {
if (normalizedSearch && !normalizeName(participant.name).includes(normalizedSearch)) return false;
if (onlyMissing && !isRowIncomplete(input)) return false;
return true;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inputRows, search, onlyMissing, requiredInputs]);
const totalPages = Math.max(1, Math.ceil(filteredRows.length / PARTICIPANT_PAGE_SIZE));
const safePage = Math.min(page, totalPages - 1);
const pageStart = safePage * PARTICIPANT_PAGE_SIZE;
const pageRows = filteredRows.slice(pageStart, pageStart + PARTICIPANT_PAGE_SIZE);
return (
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center gap-4">
@ -328,8 +504,7 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
)}
<div className="flex flex-wrap gap-2">
{setupSections.includes("futuresOdds") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/futures-odds`}>Futures Odds</Link></Button>}
{setupSections.includes("eloRatings") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/elo-ratings`}>Elo Ratings</Link></Button>}
{setupSections.includes("eloRatings") &&<Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/elo-ratings`}>Elo Ratings</Link></Button>}
{setupSections.includes("surfaceElo") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/surface-elo`}>Surface Elo</Link></Button>}
{setupSections.includes("golfSkills") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/golf-skills`}>Golf Skills</Link></Button>}
{setupSections.includes("regularStandings") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/regular-standings`}>Regular Standings</Link></Button>}
@ -347,120 +522,164 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
</CardContent>
</Card>
{showsInputPolicy && (
<Card>
<CardHeader>
<CardTitle>Input Policy</CardTitle>
<CardDescription>
Direct inputs win. This simulator can derive Elo from{" "}
{sourceEloAlternatives.length > 0 ? sourceEloAlternatives.join(", ") : "no alternate Elo inputs"}
{ratingAlternatives.length > 0 ? ` and ratings from ${ratingAlternatives.join(", ")}` : ""}.
Tail fallbacks are explicit so low-impact missing participants do not silently get invented ratings.
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="grid gap-4 md:grid-cols-5">
<input type="hidden" name="intent" value="save-input-policy" />
{config.profile.requiredInputs.includes("sourceElo") && (
<>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="missingEloStrategy">Missing Elo Strategy</Label>
<select
id="missingEloStrategy"
name="missingEloStrategy"
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
defaultValue={inputPolicy.missingEloStrategy}
>
<option value="block">Block until complete</option>
<option value="fallbackElo">Use fixed fallback Elo</option>
<option value="averageKnown">Use average known Elo</option>
<option value="worstKnownMinus">Use worst known Elo minus delta</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="fallbackElo">Fallback Elo</Label>
<Input id="fallbackElo" name="fallbackElo" type="number" defaultValue={inputPolicy.fallbackElo} />
</div>
<div className="space-y-2">
<Label htmlFor="fallbackEloDelta">Worst Elo Delta</Label>
<Input id="fallbackEloDelta" name="fallbackEloDelta" type="number" defaultValue={inputPolicy.fallbackEloDelta} />
</div>
<div className="space-y-2">
<Label htmlFor="eloMin">Elo Floor</Label>
<Input id="eloMin" name="eloMin" type="number" defaultValue={inputPolicy.eloMin} />
</div>
<div className="space-y-2">
<Label htmlFor="eloMax">Elo Ceiling</Label>
<Input id="eloMax" name="eloMax" type="number" defaultValue={inputPolicy.eloMax} />
</div>
</>
)}
{config.profile.requiredInputs.includes("rating") && (
<>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="missingRatingStrategy">Missing Rating Strategy</Label>
<select
id="missingRatingStrategy"
name="missingRatingStrategy"
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
defaultValue={inputPolicy.missingRatingStrategy}
>
<option value="block">Block until complete</option>
<option value="fallbackRating">Use fixed fallback rating</option>
<option value="averageKnown">Use average known rating</option>
<option value="worstKnownMinus">Use worst known rating minus delta</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="fallbackRating">Fallback Rating</Label>
<Input id="fallbackRating" name="fallbackRating" type="number" step="0.01" defaultValue={inputPolicy.fallbackRating} />
</div>
<div className="space-y-2">
<Label htmlFor="fallbackRatingDelta">Worst Rating Delta</Label>
<Input id="fallbackRatingDelta" name="fallbackRatingDelta" type="number" step="0.01" defaultValue={inputPolicy.fallbackRatingDelta} />
</div>
</>
)}
<div className="space-y-2">
<Label htmlFor="ratingMin">Rating Floor</Label>
<Input id="ratingMin" name="ratingMin" type="number" step="0.01" defaultValue={inputPolicy.ratingMin} />
</div>
<div className="space-y-2">
<Label htmlFor="ratingMax">Rating Ceiling</Label>
<Input id="ratingMax" name="ratingMax" type="number" step="0.01" defaultValue={inputPolicy.ratingMax} />
</div>
<div className="md:col-span-5">
<Button type="submit" disabled={isSubmitting}>
<Save className="mr-2 h-4 w-4" />
Save Input Policy
</Button>
</div>
</Form>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Season Config</CardTitle>
<CardTitle>Simulator Configuration</CardTitle>
<CardDescription>
JSON overrides for this specific sports season. Defaults come from the simulator profile.
One place for this season's settings. <strong>Engine</strong> controls how the Monte Carlo
runs; <strong>Input derivation</strong> controls how raw inputs become the single Elo/rating
the engine consumes. Defaults come from the simulator profile; values set here override them
for this season only. Both sections write the same stored config.
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="space-y-4">
<input type="hidden" name="intent" value="save-config" />
<Textarea
name="config"
rows={10}
className="font-mono text-sm"
defaultValue={JSON.stringify(config.config, null, 2)}
/>
<CardContent className="space-y-6">
<Form method="post" className="space-y-6">
<input type="hidden" name="intent" value="save-configuration" />
{showsInputPolicy && <input type="hidden" name="hasInputPolicy" value="1" />}
<section className="space-y-3">
<div>
<h3 className="text-sm font-semibold">Engine</h3>
<p className="text-xs text-muted-foreground">
How the simulation runs. A higher <code>parityFactor</code> flattens the finish-position
distribution (favorites win less often); <code>iterations</code> trades speed for precision.
</p>
</div>
{engineEntries.length > 0 ? (
<div className="grid gap-4 md:grid-cols-3">
{engineEntries.map(([key, value]) => (
<div key={key} className="space-y-2">
<Label htmlFor={`engine.${key}`} className="font-mono text-xs">{key}</Label>
<Input id={`engine.${key}`} name={`engine.${key}`} type="number" step="any" defaultValue={value} />
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">This simulator exposes no numeric engine knobs.</p>
)}
</section>
{showsInputPolicy && (
<section className="space-y-3">
<div>
<h3 className="text-sm font-semibold">Input derivation</h3>
<p className="text-xs text-muted-foreground">
Direct inputs win. This simulator can derive Elo from{" "}
{sourceEloAlternatives.length > 0 ? sourceEloAlternatives.join(", ") : "no alternate Elo inputs"}
{ratingAlternatives.length > 0 ? ` and ratings from ${ratingAlternatives.join(", ")}` : ""}.
Tail fallbacks are explicit so low-impact missing participants do not silently get invented ratings.
</p>
</div>
<div className="grid gap-4 md:grid-cols-5">
<div className="space-y-2 md:col-span-5">
<Label htmlFor="oddsWeight">Futures vs. Elo Odds Blend Weight (01)</Label>
<Input id="oddsWeight" name="oddsWeight" type="number" step="0.05" min="0" max="1" defaultValue={inputPolicy.oddsWeight} />
<p className="text-xs text-muted-foreground">
Every source (raw Elo, projections, futures odds) becomes an Elo, then they blend into
the single Elo that feeds the simulator. This is the weight given to futures odds:
<strong> 0</strong> = Elo / projections only, <strong>1</strong> = futures fully override,
in between = blend (e.g. 0.3 = 70% Elo / 30% futures). Odds enter the engine only through
this Elo they are not blended again per game.
</p>
</div>
{config.profile.requiredInputs.includes("sourceElo") && (
<>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="missingEloStrategy">Missing Elo Strategy</Label>
<select
id="missingEloStrategy"
name="missingEloStrategy"
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
defaultValue={inputPolicy.missingEloStrategy}
>
<option value="block">Block until complete</option>
<option value="fallbackElo">Use fixed fallback Elo</option>
<option value="averageKnown">Use average known Elo</option>
<option value="worstKnownMinus">Use worst known Elo minus delta</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="fallbackElo">Fallback Elo</Label>
<Input id="fallbackElo" name="fallbackElo" type="number" defaultValue={inputPolicy.fallbackElo} />
</div>
<div className="space-y-2">
<Label htmlFor="fallbackEloDelta">Worst Elo Delta</Label>
<Input id="fallbackEloDelta" name="fallbackEloDelta" type="number" defaultValue={inputPolicy.fallbackEloDelta} />
</div>
<div className="space-y-2">
<Label htmlFor="eloMin">Elo Floor</Label>
<Input id="eloMin" name="eloMin" type="number" defaultValue={inputPolicy.eloMin} />
</div>
<div className="space-y-2">
<Label htmlFor="eloMax">Elo Ceiling</Label>
<Input id="eloMax" name="eloMax" type="number" defaultValue={inputPolicy.eloMax} />
</div>
</>
)}
{config.profile.requiredInputs.includes("rating") && (
<>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="missingRatingStrategy">Missing Rating Strategy</Label>
<select
id="missingRatingStrategy"
name="missingRatingStrategy"
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
defaultValue={inputPolicy.missingRatingStrategy}
>
<option value="block">Block until complete</option>
<option value="fallbackRating">Use fixed fallback rating</option>
<option value="averageKnown">Use average known rating</option>
<option value="worstKnownMinus">Use worst known rating minus delta</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="fallbackRating">Fallback Rating</Label>
<Input id="fallbackRating" name="fallbackRating" type="number" step="0.01" defaultValue={inputPolicy.fallbackRating} />
</div>
<div className="space-y-2">
<Label htmlFor="fallbackRatingDelta">Worst Rating Delta</Label>
<Input id="fallbackRatingDelta" name="fallbackRatingDelta" type="number" step="0.01" defaultValue={inputPolicy.fallbackRatingDelta} />
</div>
</>
)}
<div className="space-y-2">
<Label htmlFor="ratingMin">Rating Floor</Label>
<Input id="ratingMin" name="ratingMin" type="number" step="0.01" defaultValue={inputPolicy.ratingMin} />
</div>
<div className="space-y-2">
<Label htmlFor="ratingMax">Rating Ceiling</Label>
<Input id="ratingMax" name="ratingMax" type="number" step="0.01" defaultValue={inputPolicy.ratingMax} />
</div>
</div>
</section>
)}
<Button type="submit" disabled={isSubmitting}>
<Save className="mr-2 h-4 w-4" />
Save Config
Save Configuration
</Button>
</Form>
<details>
<summary className="cursor-pointer text-sm font-medium">Advanced: edit raw config JSON</summary>
<Form method="post" className="space-y-3 mt-3">
<input type="hidden" name="intent" value="save-config" />
<Textarea
name="config"
rows={10}
className="font-mono text-sm"
defaultValue={JSON.stringify(config.config, null, 2)}
/>
<p className="text-xs text-muted-foreground">
Power-user escape hatch for keys not shown above. This is the same stored config the fields
edit; saving here writes the whole object.
</p>
<Button type="submit" variant="outline" disabled={isSubmitting}>
<Save className="mr-2 h-4 w-4" />
Save Raw JSON
</Button>
</Form>
</details>
</CardContent>
</Card>
@ -468,8 +687,12 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
<CardHeader>
<CardTitle>Bulk Simulator Inputs</CardTitle>
<CardDescription>
Paste CSV with a header row. Supported columns: name, sourceElo, sourceOdds, worldRanking, rating,
projectedWins, projectedTablePoints, seed, region. Values must not contain commas.
Two ways to paste, then the simulation re-runs automatically on save:
<strong> CSV</strong> with a header row supported columns: name, sourceElo, sourceOdds,
worldRanking, rating, projectedWins, projectedTablePoints, seed, region (values must not
contain commas); or <strong>futures odds</strong>, one team per line ending in American
odds (e.g. <code>Kansas City Chiefs +450</code>). Team names are fuzzy-matched to
participants. A partial paste only updates the columns it includes other inputs are kept.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@ -479,7 +702,7 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
name="bulkInputs"
rows={8}
className="font-mono text-sm"
placeholder={`name,sourceElo,sourceOdds,worldRanking,rating\n${inputRows[0]?.participant.name ?? "Team Name"},1500,+2500,12,28.5`}
placeholder={`name,sourceElo,sourceOdds,worldRanking,rating\n${inputRows[0]?.participant.name ?? "Team Name"},1500,+2500,12,28.5\n\n— or paste futures odds only —\n${inputRows[0]?.participant.name ?? "Team Name"} +2500`}
/>
<Button type="submit" disabled={isSubmitting}>
<Save className="mr-2 h-4 w-4" />
@ -487,29 +710,125 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
</Button>
</Form>
<div className="rounded-md border">
<div className="grid grid-cols-6 gap-2 border-b px-3 py-2 text-xs font-medium text-muted-foreground">
<div className="col-span-2">Participant</div>
<div>Elo</div>
<div>Odds</div>
<div>Rank</div>
<div>Rating</div>
{externalInputsSection && (
<div className="rounded-md border border-sky-500/30 bg-sky-500/10 p-3 text-sm flex items-center justify-between gap-4">
<span>
This simulator's participant inputs are managed on the{" "}
<strong>{externalInputsSection.label}</strong> page the list below is a roster only.
</span>
<Button variant="outline" size="sm" asChild>
<Link to={externalInputsSection.to}>Go to {externalInputsSection.label}</Link>
</Button>
</div>
{inputRows.slice(0, 20).map(({ participant, input }) => (
<div key={participant.id} className="grid grid-cols-6 gap-2 border-b last:border-b-0 px-3 py-2 text-sm">
<div className="col-span-2 font-medium">{participant.name}</div>
<div>{input?.sourceElo ?? "—"}</div>
<div>{input?.sourceOdds ?? "—"}</div>
<div>{input?.worldRanking ?? "—"}</div>
<div>{input?.rating ?? "—"}</div>
</div>
))}
{inputRows.length > 20 && (
<div className="px-3 py-2 text-xs text-muted-foreground">
Showing 20 of {inputRows.length} participants
</div>
)}
<div className="flex flex-wrap items-center gap-3">
<Input
type="search"
placeholder="Search participants…"
value={search}
onChange={(event) => {
setSearch(event.target.value);
setPage(0);
}}
className="max-w-xs"
/>
{requiredInputs.length > 0 && (
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
checked={onlyMissing}
onChange={(event) => {
setOnlyMissing(event.target.checked);
setPage(0);
}}
/>
Only show participants missing a required input
</label>
)}
</div>
<div className="rounded-md border">
<div
className="grid gap-2 border-b px-3 py-2 text-xs font-medium text-muted-foreground"
style={{ gridTemplateColumns: gridTemplate }}
>
<div>Participant</div>
{inputColumns.length > 0 ? (
inputColumns.map((column) => (
<div key={column.key} className="capitalize">
{column.label}
{column.required && <span className="text-amber-500"> *</span>}
</div>
))
) : (
<div></div>
)}
</div>
{pageRows.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
No participants match.
</div>
) : (
pageRows.map(({ participant, input }) => {
const incomplete = isRowIncomplete(input);
return (
<div
key={participant.id}
className="grid gap-2 border-b last:border-b-0 px-3 py-2 text-sm"
style={{ gridTemplateColumns: gridTemplate }}
>
<div className="font-medium flex items-center gap-2">
{incomplete && <span className="h-2 w-2 shrink-0 rounded-full bg-amber-500" title="Missing a required input" />}
{participant.name}
</div>
{inputColumns.length > 0 ? (
inputColumns.map((column) => {
const value = input?.[column.key];
return <div key={column.key}>{typeof value === "number" || typeof value === "string" ? value : "—"}</div>;
})
) : (
<div></div>
)}
</div>
);
})
)}
<div className="flex items-center justify-between gap-4 px-3 py-2 text-xs text-muted-foreground">
<span>
{filteredRows.length === 0
? "0 participants"
: `Showing ${pageStart + 1}${pageStart + pageRows.length} of ${filteredRows.length}${
filteredRows.length !== inputRows.length ? ` (${inputRows.length} total)` : ""
}`}
</span>
{totalPages > 1 && (
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={safePage === 0}
onClick={() => setPage(safePage - 1)}
>
Previous
</Button>
<span>
Page {safePage + 1} of {totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={safePage >= totalPages - 1}
onClick={() => setPage(safePage + 1)}
>
Next
</Button>
</div>
)}
</div>
</div>
</CardContent>
</Card>
</div>

View file

@ -675,14 +675,6 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
<Calculator className="mr-2 h-4 w-4" />
Simulator Setup
</Button>
<Button
size="sm"
variant="outline"
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/futures-odds`)}
>
<Calculator className="mr-2 h-4 w-4" />
Futures Odds
</Button>
<Button
size="sm"
variant="outline"

View file

@ -17,7 +17,13 @@ import {
syncTournamentResults,
type SyncReport,
} from "~/services/sync-tournament-results";
import { getSportsSeasonsByTournament } from "~/models/scoring-event";
import {
getSportsSeasonsByTournament,
getPrimaryEventForTournament,
setPrimaryEvent,
deleteScoringEvent,
} from "~/models/scoring-event";
import { isBracketMajor } from "~/lib/event-utils";
import {
Card,
CardContent,
@ -25,6 +31,17 @@ import {
CardHeader,
CardTitle,
} from "~/components/ui/card";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "~/components/ui/alert-dialog";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import {
@ -52,13 +69,30 @@ export async function loader({ params }: Route.LoaderArgs) {
throw new Response("Not Found", { status: 404 });
}
const [results, canonicalParticipants, linkedSportsSeasons] = await Promise.all([
getTournamentResults(tournament.id),
findCanonicalParticipantsBySport(tournament.sportId),
getSportsSeasonsByTournament(tournament.id),
]);
const [results, canonicalParticipants, linkedSportsSeasons, primaryEvent] =
await Promise.all([
getTournamentResults(tournament.id),
findCanonicalParticipantsBySport(tournament.sportId),
getSportsSeasonsByTournament(tournament.id),
getPrimaryEventForTournament(tournament.id),
]);
return { tournament, results, canonicalParticipants, linkedSportsSeasons };
// Bracket majors (tennis, CS2) are scored on their primary window's bracket /
// stage UI; golf is entered right here. Surface a link for the bracket case.
const simulatorType =
linkedSportsSeasons[0]?.sportsSeason?.sport?.simulatorType ?? null;
const bracketMajor = isBracketMajor(simulatorType);
const isCs2 = simulatorType === "cs2_major_qualifying_points";
return {
tournament,
results,
canonicalParticipants,
linkedSportsSeasons,
primaryEvent,
bracketMajor,
isCs2,
};
}
export async function action(args: Route.ActionArgs) {
@ -127,11 +161,17 @@ export async function action(args: Route.ActionArgs) {
});
}
if (tournament.status !== "completed") {
await updateTournamentStatus(tournament.id, "completed");
const syncReport = await syncTournamentResults(tournament.id);
// Status reflects reality: completed only when every linked window synced.
// If any window failed, leave it in_progress so the failure is visible and
// retryable rather than masked by a green "completed" badge.
const newStatus =
syncReport.windowsFailed === 0 ? "completed" : "in_progress";
if (tournament.status !== newStatus) {
await updateTournamentStatus(tournament.id, newStatus);
}
const syncReport = await syncTournamentResults(tournament.id);
return {
success: true as const,
error: null,
@ -151,6 +191,11 @@ export async function action(args: Route.ActionArgs) {
if (intent === "retry-window-sync") {
try {
const syncReport = await syncTournamentResults(tournament.id);
const newStatus =
syncReport.windowsFailed === 0 ? "completed" : "in_progress";
if (tournament.status !== newStatus) {
await updateTournamentStatus(tournament.id, newStatus);
}
return { success: true as const, error: null, syncReport };
} catch (error) {
logger.error("retry-window-sync failed:", error);
@ -163,6 +208,44 @@ export async function action(args: Route.ActionArgs) {
}
}
if (intent === "set-primary") {
const eventId = formData.get("eventId");
if (typeof eventId !== "string" || !eventId) {
return { success: false as const, error: "Event ID is required", syncReport: null };
}
try {
await setPrimaryEvent(eventId);
return { success: true as const, error: null, syncReport: null };
} catch (error) {
logger.error("set-primary failed:", error);
return {
success: false as const,
error: error instanceof Error ? error.message : "Failed to set primary window",
syncReport: null,
};
}
}
if (intent === "unlink-window") {
const eventId = formData.get("eventId");
if (typeof eventId !== "string" || !eventId) {
return { success: false as const, error: "Event ID is required", syncReport: null };
}
try {
// Remove that season's scoring event. The tournament stays (we're on its
// page); if this was the primary window, another is auto-promoted.
await deleteScoringEvent(eventId);
return { success: true as const, error: null, syncReport: null };
} catch (error) {
logger.error("unlink-window failed:", error);
return {
success: false as const,
error: error instanceof Error ? error.message : "Failed to remove season",
syncReport: null,
};
}
}
return {
success: false as const,
error: "Invalid intent",
@ -174,8 +257,26 @@ export default function AdminTournamentDetail({
loaderData,
actionData,
}: Route.ComponentProps) {
const { tournament, results, canonicalParticipants, linkedSportsSeasons } = loaderData;
const {
tournament,
results,
canonicalParticipants,
linkedSportsSeasons,
primaryEvent,
bracketMajor,
isCs2,
} = loaderData;
const retryFetcher = useFetcher<typeof action>();
const primaryFetcher = useFetcher<typeof action>();
const unlinkFetcher = useFetcher<typeof action>();
// For bracket majors, scoring lives on the primary window's bracket/stage UI.
const primaryScoringHref =
bracketMajor && primaryEvent
? isCs2
? `/admin/sports-seasons/${primaryEvent.sportsSeasonId}/events/${primaryEvent.id}/cs2-setup`
: `/admin/sports-seasons/${primaryEvent.sportsSeasonId}/events/${primaryEvent.id}/bracket`
: null;
// Prefer the latest action/retry response for the sync report
const liveReport: SyncReport | null =
@ -195,19 +296,28 @@ export default function AdminTournamentDetail({
Back to tournaments
</Link>
</Button>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold">{tournament.name}</h1>
<Badge
variant={
tournament.status === "completed"
? "default"
: tournament.status === "in_progress"
? "secondary"
: "outline"
}
>
{tournament.status}
</Badge>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold">{tournament.name}</h1>
<Badge
variant={
tournament.status === "completed"
? "default"
: tournament.status === "in_progress"
? "secondary"
: "outline"
}
>
{tournament.status}
</Badge>
</div>
{primaryScoringHref && (
<Button asChild>
<Link to={primaryScoringHref}>
{isCs2 ? "Manage stages & bracket →" : "Manage bracket →"}
</Link>
</Button>
)}
</div>
<p className="text-muted-foreground mt-1">
{tournament.year}
@ -313,17 +423,81 @@ export default function AdminTournamentDetail({
{link.sportsSeason.scoringType.replace("_", " ")}
</p>
</div>
<Badge
variant={
link.sportsSeason.status === "completed"
? "default"
: link.sportsSeason.status === "active"
? "secondary"
: "outline"
}
>
{link.sportsSeason.status}
</Badge>
<div className="flex items-center gap-2">
{bracketMajor &&
(link.isPrimary ? (
<Badge variant="default">Primary</Badge>
) : (
<primaryFetcher.Form method="post">
<input type="hidden" name="intent" value="set-primary" />
<input type="hidden" name="eventId" value={link.id} />
<Button
type="submit"
variant="outline"
size="sm"
disabled={primaryFetcher.state !== "idle"}
>
Make primary
</Button>
</primaryFetcher.Form>
))}
<Badge
variant={
link.sportsSeason.status === "completed"
? "default"
: link.sportsSeason.status === "active"
? "secondary"
: "outline"
}
>
{link.sportsSeason.status}
</Badge>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
disabled={unlinkFetcher.state !== "idle"}
>
Remove
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Remove {link.sportsSeason.sport.name} - {link.sportsSeason.name}?
</AlertDialogTitle>
<AlertDialogDescription asChild>
<span>
This deletes that season's scoring event (and its results) but keeps{" "}
<span className="font-medium">{tournament.name}</span> and the other linked seasons.
{link.isPrimary && linkedSportsSeasons.length > 1 && (
<> Because this is the primary scoring window, another season will be promoted to primary automatically.</>
)}
{linkedSportsSeasons.length === 1 && (
<> This is the only linked season the tournament will remain but have no windows until you link one again.</>
)}
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<unlinkFetcher.Form method="post">
<input type="hidden" name="intent" value="unlink-window" />
<input type="hidden" name="eventId" value={link.id} />
<AlertDialogAction
type="submit"
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Remove
</AlertDialogAction>
</unlinkFetcher.Form>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
))}
</div>
@ -382,17 +556,43 @@ export default function AdminTournamentDetail({
</CardContent>
</Card>
<BatchResultEntry
participants={canonicalParticipants.map((p) => ({
id: p.id,
name: p.name,
}))}
sportsSeasonId=""
existingResultParticipantIds={
new Set(results.map((r) => r.participantId))
}
intent="batch-upsert-results"
/>
{bracketMajor ? (
<Card>
<CardHeader>
<CardTitle>Scoring</CardTitle>
<CardDescription>
This is a bracket major. Results are derived from the
bracket/stage workflow on the primary window and fan out here
automatically.
</CardDescription>
</CardHeader>
<CardContent>
{primaryScoringHref ? (
<Button asChild>
<Link to={primaryScoringHref}>
{isCs2 ? "Manage stages & bracket →" : "Manage bracket →"}
</Link>
</Button>
) : (
<p className="text-sm text-muted-foreground">
No primary window is set up yet for this tournament.
</p>
)}
</CardContent>
</Card>
) : (
<BatchResultEntry
participants={canonicalParticipants.map((p) => ({
id: p.id,
name: p.name,
}))}
sportsSeasonId=""
existingResultParticipantIds={
new Set(results.map((r) => r.participantId))
}
intent="batch-upsert-results"
/>
)}
</div>
</div>
</div>

View file

@ -23,6 +23,7 @@ import {
Users,
Menu,
Shield,
CalendarRange,
} from "lucide-react";
export function meta(): Route.MetaDescriptors {
@ -72,6 +73,12 @@ function AdminNavLinks({ onNavigate }: { onNavigate?: () => void }) {
Sports Seasons
</Link>
</Button>
<Button variant="ghost" className="w-full justify-start" asChild onClick={onNavigate}>
<Link to="/admin/draft-schedule">
<CalendarRange className="mr-2 h-4 w-4" />
Draft Schedule
</Link>
</Button>
<Button variant="ghost" className="w-full justify-start" asChild onClick={onNavigate}>
<Link to="/admin/simulators">
<Activity className="mr-2 h-4 w-4" />

View file

@ -2,7 +2,7 @@ import { and, eq, isNotNull } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { requireCronSecret } from "~/lib/cron-auth";
import { syncMatches } from "~/services/match-sync";
import { syncMatches, syncTennisDraw } from "~/services/match-sync";
export async function action({ request }: { request: Request }) {
requireCronSecret(request);
@ -37,5 +37,39 @@ export async function action({ request }: { request: Request }) {
}
}
return Response.json({ synced, errors }, { status: errors.length > 0 && synced.length === 0 ? 500 : 200 });
// Tennis Grand Slam draws: sync each active tennis major's primary window from
// its configured Wikipedia article. (Tennis uses a per-event externalSourceKey,
// not a per-season externalSeasonId, so it isn't covered by the loop above.)
const tennisEvents = await db.query.scoringEvents.findMany({
where: and(
isNotNull(schema.scoringEvents.externalSourceKey),
eq(schema.scoringEvents.isQualifyingEvent, true),
eq(schema.scoringEvents.isComplete, false),
),
with: { sportsSeason: { with: { sport: true } } },
});
const drawSynced: string[] = [];
for (const ev of tennisEvents) {
if (ev.sportsSeason?.status !== "active") continue;
if (ev.sportsSeason?.sport?.simulatorType !== "tennis_qualifying_points") continue;
// Skip read-only siblings of a shared major — results fan out from the primary.
if (ev.tournamentId && !ev.isPrimary) continue;
try {
await syncTennisDraw(ev.id);
drawSynced.push(ev.id);
} catch (err) {
errors.push({
id: ev.id,
name: ev.name ?? "(tennis draw)",
error: err instanceof Error ? err.message : String(err),
});
}
}
const okCount = synced.length + drawSynced.length;
return Response.json(
{ synced, drawSynced, errors },
{ status: errors.length > 0 && okCount === 0 ? 500 : 200 },
);
}

View file

@ -12,7 +12,7 @@ import {
findDraftSlotsBySeasonId,
} from "~/models";
import { findCurrentSeasonWithSports } from "~/models/season";
import { getSeasonStandings } from "~/models/standings";
import { getSevenDayStandingsChange } from "~/models/standings";
import { getUpcomingEventsForDraftedParticipants } from "~/models/scoring-event";
import { getUpcomingGroupStageMatchesForParticipants } from "~/models/group-stage-match";
import { getDraftedParticipantsBySportsSeason, getDraftedParticipantsWithPoints, type DraftedParticipantWithPoints } from "~/models/draft-pick";
@ -77,7 +77,7 @@ export async function loader(args: Route.LoaderArgs) {
// Fetch standings for active/completed seasons
const standings =
season && (season.status === "active" || season.status === "completed")
? await getSeasonStandings(season.id)
? await getSevenDayStandingsChange(season.id)
: [];
// Batch-fetch all users needed for owner and commissioner maps in one query

View file

@ -8,11 +8,13 @@ import {
getUserDisplayName,
} from "~/models";
import { getDraftPicks } from "~/models/draft-pick";
import { getScoringEventById } from "~/models/scoring-event";
import { getScoringEventById, getPrimaryEventForTournament, isReadOnlySibling } from "~/models/scoring-event";
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
import { getCs2StageResultsForEvent } from "~/models/cs2-major-stage";
import { findSeasonMatchesByScoringEventId } from "~/models/season-match";
import { getEventResults } from "~/models/event-result";
import { getBracketTemplate, getOrderedRoundsFromMatches } from "~/lib/bracket-templates";
import { isBracketMajor } from "~/lib/event-utils";
import { findGroupsByEventId } from "~/models/tournament-group";
import { findMatchesByGroupIds, computeGroupStandings } from "~/models/group-stage-match";
import type { PlayoffMatch } from "~/models/playoff-match";
@ -99,6 +101,55 @@ export async function loader({ params, request }: Route.LoaderArgs) {
const simulatorType = sportsSeason.sport?.simulatorType ?? null;
// Shared-major fan-out for DISPLAY: a tournament-linked, non-primary event has
// no bracket/stage rows of its own — those live on the primary window. Read the
// structure from the primary, then re-key this league's ownership overlay from
// its own season_participant ids to the PRIMARY's season_participant ids (the
// ids the bracket structure uses) via the shared canonical participant. QP/
// results branches keep using this window's local rows and are untouched.
let structureEventId = eventId;
let structureBracketTemplateId = scoringEvent.bracketTemplateId;
let bracketTeamOwnerships = teamOwnerships;
let bracketUserParticipantIds = userParticipantIds;
if (isReadOnlySibling(scoringEvent) && scoringEvent.tournamentId) {
const primaryEvent = await getPrimaryEventForTournament(scoringEvent.tournamentId);
if (primaryEvent && primaryEvent.id !== eventId) {
structureEventId = primaryEvent.id;
structureBracketTemplateId = primaryEvent.bracketTemplateId;
const [siblingParticipants, primaryParticipants] = await Promise.all([
findParticipantsBySportsSeasonId(sportsSeasonId),
findParticipantsBySportsSeasonId(primaryEvent.sportsSeasonId),
]);
// sibling season_participant id → canonical participant id
const siblingSpToCanonical = new Map(
siblingParticipants
.filter((p) => p.participantId)
.map((p) => [p.id, p.participantId as string])
);
// canonical participant id → primary season_participant id
const canonicalToPrimarySp = new Map(
primaryParticipants
.filter((p) => p.participantId)
.map((p) => [p.participantId as string, p.id])
);
const toPrimarySp = (siblingSpId: string): string | undefined => {
const canonical = siblingSpToCanonical.get(siblingSpId);
return canonical ? canonicalToPrimarySp.get(canonical) : undefined;
};
bracketTeamOwnerships = teamOwnerships.flatMap((o) => {
const primarySpId = toPrimarySp(o.participantId);
return primarySpId ? [{ ...o, participantId: primarySpId }] : [];
});
bracketUserParticipantIds = userParticipantIds.flatMap((id) => {
const primarySpId = toPrimarySp(id);
return primarySpId ? [primarySpId] : [];
});
}
}
type PlayoffMatchWithRelations = Omit<PlayoffMatch, "createdAt" | "updatedAt"> & {
createdAt: string;
updatedAt: string;
@ -118,10 +169,10 @@ export async function loader({ params, request }: Route.LoaderArgs) {
// CS2 Major — load Swiss stage data + bracket
if (simulatorType === "cs2_major_qualifying_points") {
const [cs2StageResults, seasonMatches, playoffMatchRows] = await Promise.all([
getCs2StageResultsForEvent(eventId),
findSeasonMatchesByScoringEventId(eventId),
getCs2StageResultsForEvent(structureEventId),
findSeasonMatchesByScoringEventId(structureEventId),
db.query.playoffMatches.findMany({
where: eq(schema.playoffMatches.scoringEventId, eventId),
where: eq(schema.playoffMatches.scoringEventId, structureEventId),
with: { participant1: true, participant2: true, winner: true, loser: true },
}),
]);
@ -131,7 +182,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
createdAt: m.createdAt.toISOString(),
updatedAt: m.updatedAt.toISOString(),
}));
const templateId = scoringEvent.bracketTemplateId ?? undefined;
const templateId = structureBracketTemplateId ?? undefined;
const template = templateId ? getBracketTemplate(templateId) : undefined;
const playoffRounds = getOrderedRoundsFromMatches(playoffMatches, template);
@ -140,8 +191,8 @@ export async function loader({ params, request }: Route.LoaderArgs) {
league,
sportsSeason,
scoringEvent,
teamOwnerships,
userParticipantIds,
teamOwnerships: bracketTeamOwnerships,
userParticipantIds: bracketUserParticipantIds,
cs2StageResults,
seasonMatches: seasonMatches.map((m) => ({
...m,
@ -155,10 +206,73 @@ export async function loader({ params, request }: Route.LoaderArgs) {
};
}
// Tennis Grand Slam major — bracket draw (primary-keyed) + this window's QP results.
// major_tournament + tennis_qualifying_points. CS2 (also a bracket major) is handled
// above; golf (golf_qualifying_points) returns false from isBracketMajor and falls
// through to the plain QP results branch.
if (isBracketMajor(simulatorType) && scoringEvent.eventType === "major_tournament") {
const [playoffMatchRows, eventResultRows] = await Promise.all([
db.query.playoffMatches.findMany({
where: eq(schema.playoffMatches.scoringEventId, structureEventId),
with: { participant1: true, participant2: true, winner: true, loser: true },
}),
// QP/results rows live on this window (local eventId), not the primary structure.
getEventResults(eventId),
]);
const playoffMatches: PlayoffMatchWithRelations[] = playoffMatchRows.map((m) => ({
...m,
createdAt: m.createdAt.toISOString(),
updatedAt: m.updatedAt.toISOString(),
}));
const templateId = structureBracketTemplateId ?? undefined;
const template = templateId ? getBracketTemplate(templateId) : undefined;
const playoffRounds = getOrderedRoundsFromMatches(playoffMatches, template);
const allResults = await db.query.seasonParticipantResults.findMany({
where: and(
eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId),
eq(schema.seasonParticipantResults.finalPosition, 0)
),
with: { participant: true },
});
const preEliminatedParticipants = allResults
.filter((r): r is typeof r & { participant: NonNullable<typeof r.participant> } => r.participant !== null)
.map((r) => ({ id: r.participant.id, name: r.participant.name }));
const eventResults = eventResultRows
.filter((r): r is typeof r & { placement: number } => r.placement !== null && !r.notParticipating)
.map((r) => ({
id: r.id,
placement: r.placement,
qualifyingPointsAwarded: r.qualifyingPointsAwarded,
rawScore: r.rawScore,
seasonParticipantId: r.seasonParticipantId,
participantName: r.seasonParticipant?.name ?? null,
}));
return {
kind: "tennis" as const,
league,
sportsSeason,
scoringEvent,
// Bracket structure + ownership keyed to the primary window (shared majors).
playoffMatches,
playoffRounds,
bracketTemplateId: templateId ?? null,
preEliminatedParticipants,
bracketTeamOwnerships,
bracketUserParticipantIds,
// QP results table uses this window's local ownership + participant ids.
teamOwnerships,
userParticipantIds,
eventResults,
};
}
// Bracket events (tennis/golf bracket majors, soccer/UCL group+bracket)
if (scoringEvent.eventType === "playoff_game") {
const playoffMatchRows = await db.query.playoffMatches.findMany({
where: eq(schema.playoffMatches.scoringEventId, eventId),
where: eq(schema.playoffMatches.scoringEventId, structureEventId),
with: { participant1: true, participant2: true, winner: true, loser: true },
});
const playoffMatches: PlayoffMatchWithRelations[] = playoffMatchRows.map((m) => ({
@ -166,13 +280,13 @@ export async function loader({ params, request }: Route.LoaderArgs) {
createdAt: m.createdAt.toISOString(),
updatedAt: m.updatedAt.toISOString(),
}));
const templateId = scoringEvent.bracketTemplateId ?? undefined;
const templateId = structureBracketTemplateId ?? undefined;
const template = templateId ? getBracketTemplate(templateId) : undefined;
const playoffRounds = getOrderedRoundsFromMatches(playoffMatches, template);
let groupStandings: GroupStandingData[] = [];
if (template?.groupStage) {
const groups = await findGroupsByEventId(eventId);
const groups = await findGroupsByEventId(structureEventId);
const matchesByGroupId = await findMatchesByGroupIds(groups.map((g) => g.id));
groupStandings = groups.map((group) => {
const groupMatches = matchesByGroupId.get(group.id) ?? [];
@ -216,8 +330,8 @@ export async function loader({ params, request }: Route.LoaderArgs) {
league,
sportsSeason,
scoringEvent,
teamOwnerships,
userParticipantIds,
teamOwnerships: bracketTeamOwnerships,
userParticipantIds: bracketUserParticipantIds,
playoffMatches,
playoffRounds,
bracketTemplateId: templateId ?? null,

View file

@ -30,6 +30,79 @@ export function meta({ data }: Route.MetaArgs) {
export { loader };
type QpResult = {
id: string;
placement: number;
qualifyingPointsAwarded: string | null;
rawScore: string | null;
seasonParticipantId: string;
participantName: string | null;
};
function QpResultsTable({
eventResults,
ownershipMap,
userParticipantIds,
}: {
eventResults: QpResult[];
ownershipMap: Record<string, { teamName: string; ownerName: string; teamId: string }>;
userParticipantIds: string[];
}) {
if (eventResults.length === 0) {
return (
<Card>
<CardContent className="py-8 text-center">
<p className="text-muted-foreground text-sm">Results not yet available for this event.</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Results</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead>Participant</TableHead>
<TableHead>Manager</TableHead>
<TableHead className="text-right">QP</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{eventResults.map((r) => {
const ownership = ownershipMap[r.seasonParticipantId];
const isUser = userParticipantIds.includes(r.seasonParticipantId);
return (
<TableRow key={r.id} className={isUser ? "bg-electric/5" : undefined}>
<TableCell className="text-muted-foreground">{r.placement}</TableCell>
<TableCell className="font-medium">
{r.participantName ?? "—"}
{isUser && <span className="ml-1.5 text-xs text-electric"></span>}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{ownership?.teamName ?? "—"}
</TableCell>
<TableCell className="text-right">
{r.qualifyingPointsAwarded !== null
? parseFloat(r.qualifyingPointsAwarded).toFixed(2)
: r.rawScore !== null
? r.rawScore
: "—"}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
);
}
export default function EventDetailPage({ loaderData }: Route.ComponentProps) {
const { league, sportsSeason, scoringEvent } = loaderData;
const leagueId = league.id;
@ -114,58 +187,42 @@ export default function EventDetailPage({ loaderData }: Route.ComponentProps) {
</div>
)}
{/* Tennis Grand Slam major — draw bracket + qualifying-points results */}
{loaderData.kind === "tennis" && (
<div className="space-y-6">
{loaderData.playoffMatches.length > 0 ? (
<PlayoffBracket
matches={loaderData.playoffMatches}
rounds={loaderData.playoffRounds}
bracketTemplateId={loaderData.bracketTemplateId}
preEliminatedParticipants={loaderData.preEliminatedParticipants}
teamOwnerships={loaderData.bracketTeamOwnerships}
userParticipantIds={loaderData.bracketUserParticipantIds}
showOwnership={true}
mode="bracket"
/>
) : (
<Card>
<CardContent className="py-8 text-center">
<p className="text-muted-foreground text-sm">Bracket not yet available.</p>
</CardContent>
</Card>
)}
<QpResultsTable
eventResults={loaderData.eventResults}
ownershipMap={ownershipMap}
userParticipantIds={loaderData.userParticipantIds}
/>
</div>
)}
{/* Results table (QP tournaments, racing events) */}
{loaderData.kind === "results" && (
loaderData.eventResults.length > 0 ? (
<Card>
<CardHeader>
<CardTitle className="text-base">Results</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead>Participant</TableHead>
<TableHead>Manager</TableHead>
<TableHead className="text-right">QP</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loaderData.eventResults.map((r) => {
const ownership = ownershipMap[r.seasonParticipantId];
const isUser = loaderData.userParticipantIds.includes(r.seasonParticipantId);
return (
<TableRow key={r.id} className={isUser ? "bg-electric/5" : undefined}>
<TableCell className="text-muted-foreground">{r.placement}</TableCell>
<TableCell className="font-medium">
{r.participantName ?? "—"}
{isUser && <span className="ml-1.5 text-xs text-electric"></span>}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{ownership?.teamName ?? "—"}
</TableCell>
<TableCell className="text-right">
{r.qualifyingPointsAwarded !== null
? parseFloat(r.qualifyingPointsAwarded).toFixed(2)
: r.rawScore !== null
? r.rawScore
: "—"}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="py-8 text-center">
<p className="text-muted-foreground text-sm">Results not yet available for this event.</p>
</CardContent>
</Card>
)
<QpResultsTable
eventResults={loaderData.eventResults}
ownershipMap={ownershipMap}
userParticipantIds={loaderData.userParticipantIds}
/>
)}
</div>
);

View file

@ -14,6 +14,7 @@ import { getQPStandings } from "~/models/qualifying-points";
import {
getUpcomingScoringEvents,
getRecentCompletedEvents,
getMajorsCompleted,
} from "~/models/scoring-event";
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
import { getAllParticipantEVsForSeason } from "~/models/participant-expected-value";
@ -157,6 +158,7 @@ export async function loader(args: Route.LoaderArgs) {
let seasonStandings: SeasonStanding[] = [];
type QPStanding = Awaited<ReturnType<typeof getQPStandings>>[number] & { globalRank: number };
let qpStandings: QPStanding[] = [];
let majorsCompleted = 0;
// Group standings for group-stage events (e.g. FIFA World Cup)
type RawGroupMatch = Awaited<ReturnType<typeof findMatchesByGroupIds>> extends Map<string, Array<infer T>> ? T : never;
@ -293,6 +295,9 @@ export async function loader(args: Route.LoaderArgs) {
},
}));
} else if (scoringPattern === "qualifying_points") {
// majorsCompleted is derived on read (count of completed qualifying events), not a
// stored counter — see getMajorsCompleted.
majorsCompleted = await getMajorsCompleted(sportsSeasonId);
const standings = await getQPStandings(sportsSeasonId);
// Compute global ranks with tie handling across the full field before filtering,
// so the displayed rank numbers remain correct after undrafted/zero-QP rows are removed.
@ -341,7 +346,7 @@ export async function loader(args: Route.LoaderArgs) {
return {
league,
season,
sportsSeason,
sportsSeason: { ...sportsSeason, majorsCompleted },
scoringPattern,
playoffMatches,
playoffRounds,

View file

@ -140,6 +140,8 @@ export default function LeagueStandings() {
points: s.totalPoints,
rankChange: s.rankChange,
pointChange: s.sevenDayPointChange,
projectedPoints: s.projectedPoints,
participantsRemaining: s.participantsRemaining,
href: `/leagues/${league.id}/standings/${season.id}/teams/${s.teamId}`,
}));
@ -181,6 +183,7 @@ export default function LeagueStandings() {
<StandingsPreview
entries={previewEntries}
description={seasonComplete ? "Final Standings" : "Current Standings"}
showProjected={!seasonComplete}
/>
{progressionData.chartData.length > 0 && (

View file

@ -116,7 +116,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
currentRank: standing?.currentRank,
points: standing ? (standing.actualPoints ?? standing.totalPoints) : 0,
href: season ? `/leagues/${league.id}/standings/${season.id}/teams/${team.id}` : undefined,
rankChange: standing?.rankChange,
rankChange: standing?.sevenDayRankChange,
pointChange: standing?.sevenDayPointChange,
};
});

View file

@ -1,5 +1,4 @@
import { redirect, useSearchParams } from "react-router";
import { useState } from "react";
import { redirect, type ShouldRevalidateFunctionArgs } from "react-router";
import { Bell, Key, Lock, Shield, User } from "lucide-react";
import { auth } from "~/lib/auth.server";
import { findUserById, findUserByUsername, isUserInActiveDraft, updateUser, anonymizeUserAccount, USERNAME_RE } from "~/models/user";
@ -57,7 +56,26 @@ export function meta(): Route.MetaDescriptors {
return [{ title: "Settings - Brackt" }];
}
export function shouldRevalidate({
currentParams,
nextParams,
formMethod,
defaultShouldRevalidate,
}: ShouldRevalidateFunctionArgs) {
// Mutations (profile/avatar/notification updates) must refresh loader data.
if (formMethod && formMethod !== "GET") return true;
// Switching sections only changes the path param, and the loader payload is the
// same for every section, so don't refetch user/draft status/linked accounts.
if (currentParams.section !== nextParams.section) return false;
return defaultShouldRevalidate;
}
export async function loader(args: Route.LoaderArgs) {
const section = args.params.section;
if (section && !(VALID_SECTION_IDS as Set<string>).has(section)) {
return redirect("/settings");
}
const session = await auth.api.getSession({ headers: args.request.headers });
if (!session) {
return redirect("/login?redirectTo=/settings");
@ -213,18 +231,12 @@ export async function action(args: Route.ActionArgs): Promise<ActionData | Respo
return { intent: intent as string, error: "Unknown action." };
}
export default function SettingsPage({ loaderData, actionData }: Route.ComponentProps) {
export default function SettingsPage({ loaderData, actionData, params }: Route.ComponentProps) {
const { user, isInActiveDraft, linkedAccounts, dataRequestCooldownUntil } = loaderData;
const [searchParams] = useSearchParams();
const raw = searchParams.get("section");
const initialSection: SectionId = raw && (VALID_SECTION_IDS as Set<string>).has(raw) ? (raw as SectionId) : "profile";
const [activeSection, setActiveSection] = useState<SectionId>(initialSection);
const [mobileView, setMobileView] = useState<"grid" | "section">("grid");
const handleSectionChange = (id: string, mobile = false) => {
setActiveSection(id as SectionId);
if (mobile) setMobileView("section");
};
const section = params.section;
const activeSection: SectionId =
section && (VALID_SECTION_IDS as Set<string>).has(section) ? (section as SectionId) : "profile";
const mobileView: "grid" | "section" = section ? "section" : "grid";
const ad = actionData as ActionData | undefined;
const profileSuccess = ad?.intent === "update-profile" && "success" in ad;
@ -246,20 +258,21 @@ export default function SettingsPage({ loaderData, actionData }: Route.Component
<div className="lg:hidden">
<SettingsMobileGridNav
sections={SECTIONS}
onSectionChange={(id) => handleSectionChange(id, true)}
buildHref={(id) => `/settings/${id}`}
/>
</div>
)}
{mobileView === "section" && (
<SettingsMobileSectionPill onShowGrid={() => setMobileView("grid")} />
<SettingsMobileSectionPill backHref="/settings" />
)}
<div className={`grid gap-6 lg:grid-cols-[220px_minmax(0,1fr)] ${mobileView === "grid" ? "hidden lg:grid" : "grid"}`}>
<SettingsDesktopNav
sections={SECTIONS}
activeSection={activeSection}
onSectionChange={(id) => handleSectionChange(id)}
buildHref={(id) => `/settings/${id}`}
navLabel="Account settings"
/>
<main className="min-w-0">
@ -279,7 +292,6 @@ export default function SettingsPage({ loaderData, actionData }: Route.Component
discordPingEnabled={user.discordPingEnabled}
hasDiscordLinked={linkedAccounts.some((a) => a.providerId === "discord")}
draftEmailNotificationsEnabled={user.draftEmailNotificationsEnabled}
onNavigateToAccount={() => handleSectionChange("account")}
/>
)}
{activeSection === "api" && <ApiSection />}

View file

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { sendDiscordWebhook, sendStandingsUpdateNotification, sendDraftOrderNotification, sendPickAnnouncementNotification } from "../discord";
import { sendDiscordWebhook, sendStandingsUpdateNotification, sendDraftOrderNotification, sendPickAnnouncementNotification, sendQualifyingPointsUpdateNotification } from "../discord";
const WEBHOOK_URL = "https://discord.com/api/webhooks/123/abc";
@ -186,6 +186,60 @@ describe("sendStandingsUpdateNotification", () => {
expect(desc).toContain("• **Sporting (christhrowsrocks)** def. Bodø/Glimt (apatel)");
});
it("names a non-eliminated loser for context without @-pinging them", async () => {
// Argentina beats England in the World Cup semifinal. Argentina scored, so it's
// pinged; England advances to the 3rd-place playoff (not eliminated, no points
// change), so its manager is shown by plain username but NOT @-pinged.
await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Diablo League 2026",
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 160, rank: 7 }],
previousStandings: new Map([["a", 130]]),
scoredMatches: [
{
winnerName: "Argentina",
loserName: "England",
winnerUsername: "philosohraptors",
winnerDiscordUserId: "111",
loserUsername: "elementsoul",
// no loserDiscordUserId — still alive, no ping
},
],
});
const payload = getPayload();
const desc = payload.embeds[0].description as string;
// Winner scored → rendered as an @-mention; loser is named by plain username.
expect(desc).toContain("• **Argentina (<@111>)** def. England (elementsoul)");
// England's manager is named but not mentioned/pinged.
expect(desc).not.toContain("England (<@");
expect(payload.content ?? "").toContain("<@111>");
expect(payload.content ?? "").not.toContain("elementsoul");
});
it("shows winner's manager for context when the match fires due to an owned loser", async () => {
// Brazil beats Japan (R32, non-scoring). Japan's manager is the reason for the
// notification; Brazil's manager is shown for context even though they didn't score.
// scoring-calculator.ts controls whether to include the match; discord.ts just renders.
await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Rumble League 2026",
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }],
previousStandings: new Map([["a", 100]]),
scoredMatches: [
{
winnerName: "Brazil",
loserName: "Japan",
winnerUsername: "aliceManager",
loserUsername: "ikyn",
},
],
});
const desc = getDescription();
expect(desc).toContain("• **Brazil (aliceManager)** def. Japan (ikyn)");
});
it("omits scored matches where neither side has a username", async () => {
await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL,
@ -254,6 +308,43 @@ describe("sendStandingsUpdateNotification", () => {
expect(desc).not.toContain("Gamma");
});
it("pings scorers but not rank-only shufflers", async () => {
await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "My League 2025",
standings: [
// Alpha scored — points changed, moved up.
{ teamId: "a", teamName: "Alpha", totalPoints: 150, rank: 1, discordUserId: "111" },
// Beta was displaced down without scoring — rank changed only.
{ teamId: "b", teamName: "Beta", totalPoints: 125, rank: 2, username: "beta_owner", discordUserId: "222" },
],
previousStandings: new Map([
["a", 125],
["b", 125],
]),
previousRanks: new Map([
["a", 2],
["b", 1],
]),
});
const desc = getDescription();
// Beta is still displayed with its rank movement…
expect(desc).toContain("Beta");
expect(desc).toContain("↓1");
// …but by name, not as an @-mention.
expect(desc).not.toContain("<@222>");
// Alpha scored, so it keeps its mention.
expect(desc).toContain("<@111>");
const payload = getPayload();
// Only the scorer is pinged.
expect(payload.content).toContain("111");
expect(payload.content).not.toContain("222");
expect(payload.allowed_mentions.users).toContain("111");
expect(payload.allowed_mentions.users).not.toContain("222");
});
it("omits rank delta when no previousRanks provided", async () => {
await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL,
@ -441,6 +532,70 @@ describe("sendStandingsUpdateNotification", () => {
expect(desc).not.toContain("Alpha");
expect(desc).not.toContain("Beta");
});
it("lists eliminated teams with their managers", async () => {
await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "My League 2025",
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }],
previousStandings: new Map([["a", 100]]),
eliminatedTeams: [
{ participantName: "Phoenix Suns", username: "manager1" },
{ participantName: "Toronto Raptors" },
],
});
const desc = getDescription();
expect(desc).toContain("**Eliminated**");
expect(desc).toContain("• **Phoenix Suns (manager1)**");
expect(desc).toContain("• **Toronto Raptors**");
});
it("uses Discord mention for opted-in eliminated managers and pings them", async () => {
await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "My League 2025",
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }],
previousStandings: new Map([["a", 100]]),
eliminatedTeams: [
{ participantName: "Phoenix Suns", username: "manager1", discordUserId: "555" },
],
});
const desc = getDescription();
expect(desc).toContain("• **Phoenix Suns (<@555>)**");
expect(desc).not.toContain("manager1");
const payload = getPayload();
expect(payload.content).toBe("<@555>");
expect(payload.allowed_mentions).toEqual({ parse: [], users: ["555"] });
});
it("escapes Discord markdown in eliminated team names", async () => {
await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "My League 2025",
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }],
previousStandings: new Map([["a", 100]]),
eliminatedTeams: [{ participantName: "Team__Bold", username: "user_name" }],
});
const desc = getDescription();
expect(desc).toContain("Team\\_\\_Bold");
expect(desc).toContain("user\\_name");
});
it("omits the eliminated section when none provided", async () => {
await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "My League 2025",
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 150, rank: 1 }],
previousStandings: new Map([["a", 125]]),
});
const desc = getDescription();
expect(desc).not.toContain("**Eliminated**");
});
});
describe("sendDraftOrderNotification", () => {
@ -588,6 +743,416 @@ describe("sendDraftOrderNotification", () => {
});
});
// The caller now supplies each entry's rank in the FULL season field. This helper
// mirrors the production ranking (qualifying-points-discord.server.ts): sort by
// qpTotal desc, competition ranking with ties sharing the lower rank, so existing
// tests keep asserting ranks derived from qpTotal.
function withRanks<T extends { qpTotal: number }>(entries: T[]) {
const sorted = [...entries].toSorted((a, b) => b.qpTotal - a.qpTotal);
const rankByTotal = new Map<number, number>();
let prevTotal = Number.NaN;
let prevRank = 0;
sorted.forEach((e, i) => {
const rank = i > 0 && Math.abs(e.qpTotal - prevTotal) < 0.001 ? prevRank : i + 1;
rankByTotal.set(e.qpTotal, rank);
prevTotal = e.qpTotal;
prevRank = rank;
});
const countByTotal = new Map<number, number>();
for (const e of entries) countByTotal.set(e.qpTotal, (countByTotal.get(e.qpTotal) ?? 0) + 1);
return entries.map((e) => ({
...e,
globalRank: rankByTotal.get(e.qpTotal) ?? 0,
globalRankTied: (countByTotal.get(e.qpTotal) ?? 0) > 1,
}));
}
describe("sendQualifyingPointsUpdateNotification", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch(204));
});
const BASE_ENTRIES = withRanks([
{ participantName: "Novak Djokovic", qpEarned: 20, qpTotal: 45, ownerUsername: "alex" },
{ participantName: "Carlos Alcaraz", qpEarned: 14, qpTotal: 34, ownerUsername: "chris" },
{ participantName: "Rafael Nadal", qpEarned: 0, qpTotal: 20, ownerUsername: "alex" },
]);
it("sends an embed with gold color, QP title, no footer, and links the title to the standings page", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: BASE_ENTRIES,
standingsUrl: "https://brackt.com/leagues/abc/sports-seasons/ss-1",
});
const payload = getPayload();
expect(payload.embeds[0].title).toBe("🏅 Qualifying Points Update — Slam League 2025");
expect(payload.embeds[0].color).toBe(0x5865f2);
expect(payload.embeds[0].url).toBe("https://brackt.com/leagues/abc/sports-seasons/ss-1");
expect(payload.embeds[0].footer).toBeUndefined();
});
it("shows sport and event header", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
sportName: "ATP Tennis",
eventName: "Wimbledon 2025",
entries: BASE_ENTRIES,
});
const desc = getDescription();
expect(desc).toContain("**ATP Tennis — Wimbledon 2025**");
});
it("shows Points Awarded section for entries with qpEarned > 0, sorted by QP desc", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: BASE_ENTRIES,
});
const desc = getDescription();
expect(desc).toContain("**Points Awarded**");
expect(desc).toContain("• **Novak Djokovic (alex)** — 20 QP");
expect(desc).toContain("• **Carlos Alcaraz (chris)** — 14 QP");
// Djokovic (20 QP) should appear before Alcaraz (14 QP)
expect(desc.indexOf("Novak Djokovic")).toBeLessThan(desc.indexOf("Carlos Alcaraz"));
});
it("omits zero-QP drafted participants entirely from the Drafted Participants section", async () => {
// Only participants who have actually scored (qpTotal > 0) are listed; a 0-QP drafted
// player no longer appears anywhere in the standings section.
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: [
{ participantName: "Champ", qpEarned: 10, qpTotal: 100, globalRank: 1, globalRankTied: false, ownerUsername: "alex" },
],
scoreboard: [
{ participantName: "Champ", qpEarned: 10, qpTotal: 100, globalRank: 1, globalRankTied: false, ownerUsername: "alex" },
{ participantName: "Also Ran", qpEarned: 0, qpTotal: 5, globalRank: 9, globalRankTied: false, ownerUsername: "chris" },
{ participantName: "Winless Wonder", qpEarned: 0, qpTotal: 0, globalRank: 0, globalRankTied: false, ownerUsername: "sam" },
],
});
const desc = getDescription();
expect(desc).toContain("**Drafted Participants**");
// Both scorers appear as ranked rows...
expect(desc).toContain("1\\. Champ (alex) — 100 QP");
expect(desc).toContain("9\\. Also Ran (chris) — 5 QP");
// ...with a Points Bubble divider separating the rank-9 scorer.
expect(desc).toContain("**═══ Points Bubble ═══**");
// The 0-QP player is omitted entirely.
expect(desc).not.toContain("Winless Wonder");
// The old Non-scoring section is gone.
expect(desc).not.toContain("Non-scoring Participants");
});
it("shows the Drafted Participants section for scored participants sorted by rank", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: BASE_ENTRIES,
scoreboard: BASE_ENTRIES,
});
const desc = getDescription();
expect(desc).toContain("**Drafted Participants**");
expect(desc).toContain("1\\. Novak Djokovic (alex) — 45 QP");
expect(desc).toContain("2\\. Carlos Alcaraz (chris) — 34 QP");
expect(desc).toContain("3\\. Rafael Nadal (alex) — 20 QP");
// Djokovic should rank above Alcaraz
expect(desc.indexOf("1\\. Novak")).toBeLessThan(desc.indexOf("2\\. Carlos"));
// Everyone is rank <= 8, so no divider is emitted.
expect(desc).not.toContain("Points Bubble");
});
it("inserts a Points Bubble divider between the rank-8 and rank-9 scorers", async () => {
const scoreboard = [
{ participantName: "Player Eight", qpEarned: 5, qpTotal: 12, globalRank: 8, globalRankTied: false, ownerUsername: "chris" },
{ participantName: "Player Nine", qpEarned: 3, qpTotal: 8, globalRank: 9, globalRankTied: false, ownerUsername: "alex" },
];
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: scoreboard,
scoreboard,
});
const desc = getDescription();
const eightIdx = desc.indexOf("8\\. Player Eight");
const bubbleIdx = desc.indexOf("**═══ Points Bubble ═══**");
const nineIdx = desc.indexOf("9\\. Player Nine");
expect(eightIdx).toBeGreaterThan(-1);
expect(bubbleIdx).toBeGreaterThan(-1);
expect(nineIdx).toBeGreaterThan(-1);
// Divider sits between the rank-8 and rank-9 rows.
expect(eightIdx).toBeLessThan(bubbleIdx);
expect(bubbleIdx).toBeLessThan(nineIdx);
});
it("omits the Points Bubble divider when every scorer is below the cutoff", async () => {
// globalRank is a season-wide rank but the scoreboard is scoped to one league's drafts,
// so a league can have drafted nobody in the global top 8. The divider must not lead the
// section with nothing above it.
const scoreboard = [
{ participantName: "Player Nine", qpEarned: 3, qpTotal: 8, globalRank: 9, globalRankTied: false, ownerUsername: "alex" },
{ participantName: "Player Ten", qpEarned: 2, qpTotal: 5, globalRank: 10, globalRankTied: false, ownerUsername: "chris" },
];
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: scoreboard,
scoreboard,
});
const desc = getDescription();
expect(desc).toContain("**Drafted Participants**");
expect(desc).toContain("9\\. Player Nine (alex) — 8 QP");
expect(desc).toContain("10\\. Player Ten (chris) — 5 QP");
// No rank <= 8 row exists, so the divider must not appear.
expect(desc).not.toContain("Points Bubble");
});
it("uses T-prefix for tied QP totals in standings", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: withRanks([
{ participantName: "Player A", qpEarned: 10, qpTotal: 25 },
{ participantName: "Player B", qpEarned: 5, qpTotal: 25 },
{ participantName: "Player C", qpEarned: 2, qpTotal: 10 },
]),
scoreboard: withRanks([
{ participantName: "Player A", qpEarned: 10, qpTotal: 25 },
{ participantName: "Player B", qpEarned: 5, qpTotal: 25 },
{ participantName: "Player C", qpEarned: 2, qpTotal: 10 },
]),
});
const desc = getDescription();
expect(desc).toContain("T1\\. Player A");
expect(desc).toContain("T1\\. Player B");
expect(desc).toContain("3\\. Player C");
});
it("does not round fractional QP — a 1.5 QP award shows as 1.5, not 2", async () => {
// Regression for the reported bug: a tennis Round-of-16 loser earns 1.5 QP
// (positions 916 split) but Discord rounded it to 2 via Math.round.
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Rumble League 2026",
sportName: "Tennis - Men",
eventName: "Wimbledon",
entries: [
{
participantName: "Novak Djokovic",
qpEarned: 1.5,
qpTotal: 1.5,
globalRank: 9,
globalRankTied: true,
ownerUsername: "snarkymcgee",
},
],
});
const desc = getDescription();
// Mirrors the web UI's formatQP: fractional QP renders with up to 2 decimals and
// trailing zeros trimmed ("1.5"), never rounded to an integer. The Points Awarded
// line no longer carries a "+".
expect(desc).toContain("• **Novak Djokovic (snarkymcgee)** — 1.5 QP");
expect(desc).not.toContain("+");
expect(desc).not.toContain("2 QP");
expect(desc).not.toContain("1.50");
});
it("lists a rank-9 scorer below the Points Bubble but omits a 0-QP participant", async () => {
// Rank-9 scorers now appear in the standings section (below the bubble), while a drafted
// participant with no points is dropped entirely.
const both = [
{
participantName: "Player Eight",
qpEarned: 5,
qpTotal: 10,
globalRank: 8,
globalRankTied: false,
ownerUsername: "eighthowner",
},
{
participantName: "Player Nine",
qpEarned: 3,
qpTotal: 8,
globalRank: 9,
globalRankTied: false,
ownerUsername: "ninthowner",
},
{
participantName: "Player Winless",
qpEarned: 0,
qpTotal: 0,
globalRank: 10,
globalRankTied: false,
ownerUsername: "winlessowner",
},
];
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Rumble League 2026",
sportName: "Tennis - Men",
eventName: "Wimbledon",
entries: both,
scoreboard: both,
});
const desc = getDescription();
expect(desc).toContain("**Drafted Participants**");
expect(desc).toContain("8\\. Player Eight (eighthowner) — 10 QP");
// The rank-9 scorer now appears as a ranked row below the bubble.
expect(desc).toContain("**═══ Points Bubble ═══**");
expect(desc).toContain("9\\. Player Nine (ninthowner) — 8 QP");
// The 0-QP player is omitted from the standings section entirely.
expect(desc).not.toContain("Player Winless");
// ...but both scorers still earned points, so both remain in Points Awarded.
expect(desc).toContain("• **Player Nine (ninthowner)** — 3 QP");
});
it("uses Discord mention instead of username when ownerDiscordUserId provided", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: withRanks([
{
participantName: "Novak Djokovic",
qpEarned: 20,
qpTotal: 45,
ownerUsername: "alex",
ownerDiscordUserId: "111222333",
},
]),
scoreboard: withRanks([
{
participantName: "Novak Djokovic",
qpEarned: 20,
qpTotal: 45,
ownerUsername: "alex",
},
]),
});
const desc = getDescription();
// Points Awarded (a pinged section) uses the Discord mention...
expect(desc).toContain("• **Novak Djokovic (<@111222333>)** — 20 QP");
// ...while the (non-pinged) Drafted Participants standings section uses the plain username.
expect(desc).toContain("1\\. Novak Djokovic (alex) — 45 QP");
});
it("pings awarded owners but not non-scoring owners", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: withRanks([
{ participantName: "Player A", qpEarned: 20, qpTotal: 20, ownerDiscordUserId: "111" },
{ participantName: "Player B", qpEarned: 0, qpTotal: 0, ownerDiscordUserId: "222" },
]),
});
const payload = getPayload();
expect(payload.content).toContain("<@111>");
expect(payload.content).not.toContain("<@222>");
expect(payload.allowed_mentions.users).toContain("111");
expect(payload.allowed_mentions.users).not.toContain("222");
});
it("does not send when entries array is empty", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: [],
});
expect(fetch).not.toHaveBeenCalled();
});
it("shows Knocked Out section for eliminated entries, tagging the manager", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: BASE_ENTRIES,
eliminated: [{ participantName: "Jakob Mensik", ownerUsername: "chris" }],
});
const desc = getDescription();
expect(desc).toContain("**Knocked Out**");
expect(desc).toContain("• Jakob Mensik (chris)");
});
it("sends with only a Knocked Out section when there are no QP entries, omitting the standings section", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: [],
eliminated: [{ participantName: "Jakob Mensik", ownerUsername: "chris" }],
});
expect(fetch).toHaveBeenCalledOnce();
const desc = getDescription();
expect(desc).toContain("**Knocked Out**");
expect(desc).not.toContain("**Drafted Participants**");
expect(desc).not.toContain("**Points Awarded**");
});
it("pings opted-in owners who appear only in the Knocked Out section", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: [],
eliminated: [{ participantName: "Jakob Mensik", ownerDiscordUserId: "777" }],
});
const payload = getPayload();
expect(payload.content).toContain("<@777>");
expect(payload.allowed_mentions.users).toContain("777");
});
it("escapes markdown in participant names and usernames", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: withRanks([
{ participantName: "Player_One", qpEarned: 10, qpTotal: 10, ownerUsername: "user_name" },
]),
});
const desc = getDescription();
expect(desc).toContain("Player\\_One");
expect(desc).toContain("user\\_name");
});
it("truncates description at 4096 characters", async () => {
const longEntries = withRanks(
Array.from({ length: 200 }, (_, i) => ({
participantName: `Very Long Participant Name Number ${i}`,
qpEarned: i % 2 === 0 ? 5 : 0,
qpTotal: 200 - i,
ownerUsername: `owner_with_long_username_${i}`,
}))
);
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: longEntries,
scoreboard: longEntries,
});
const desc = getDescription();
expect(desc.length).toBeLessThanOrEqual(4096);
expect(desc.endsWith("...")).toBe(true);
});
});
describe("sendPickAnnouncementNotification", () => {
const BASE = {
webhookUrl: WEBHOOK_URL,

View file

@ -0,0 +1,498 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
vi.mock("~/services/discord", () => ({
sendQualifyingPointsUpdateNotification: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("~/models/account", () => ({
findDiscordIdsByUserIds: vi.fn().mockResolvedValue(new Map()),
}));
vi.mock("~/models/user", () => ({
getUserDisplayName: vi.fn((u: { username?: string }) => u.username ?? null),
}));
import { notifyQualifyingPointsUpdate } from "../qualifying-points-discord.server";
import { sendQualifyingPointsUpdateNotification } from "~/services/discord";
import { findDiscordIdsByUserIds } from "~/models/account";
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const SPORTS_SEASON_ID = "ss-1";
const SCORING_EVENT_ID = "ev-1";
const SEASON_ID = "season-1";
const LEAGUE_ID = "league-1";
const PARTICIPANT_ID = "p-1";
const OWNER_ID = "u-1";
const WEBHOOK_URL = "https://discord.com/api/webhooks/123/abc";
function makeEvent(overrides = {}) {
return {
id: SCORING_EVENT_ID,
name: "Roland Garros 2025",
sportsSeason: { sport: { name: "Tennis" } },
...overrides,
};
}
function makeDb(overrides: Record<string, unknown> = {}) {
return {
query: {
scoringEvents: {
findFirst: vi.fn().mockResolvedValue(makeEvent()),
},
seasonSports: {
findMany: vi.fn().mockResolvedValue([{ seasonId: SEASON_ID }]),
},
eventResults: {
findMany: vi.fn().mockResolvedValue([
{
seasonParticipantId: PARTICIPANT_ID,
qualifyingPointsAwarded: "10",
scoringEventId: SCORING_EVENT_ID,
},
]),
},
seasonParticipantQualifyingTotals: {
findMany: vi.fn().mockResolvedValue([
{ participantId: PARTICIPANT_ID, totalQualifyingPoints: "45", sportsSeasonId: SPORTS_SEASON_ID },
]),
},
seasons: {
findMany: vi.fn().mockResolvedValue([
{
id: SEASON_ID,
year: 2025,
league: { id: LEAGUE_ID, name: "Slam League", discordWebhookUrl: WEBHOOK_URL },
},
]),
},
draftPicks: {
findMany: vi.fn().mockResolvedValue([
{
participantId: PARTICIPANT_ID,
seasonId: SEASON_ID,
team: { id: "t-1", name: "Alpha FC", ownerId: OWNER_ID },
},
]),
},
seasonParticipants: {
findMany: vi.fn().mockResolvedValue([{ id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID }]),
},
users: {
findMany: vi.fn().mockResolvedValue([
{ id: OWNER_ID, username: "chris", discordPingEnabled: false },
]),
},
...overrides,
},
};
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(findDiscordIdsByUserIds).mockResolvedValue(new Map());
});
describe("notifyQualifyingPointsUpdate", () => {
it("returns early when the scoring event is not found", async () => {
const db = makeDb({
scoringEvents: { findFirst: vi.fn().mockResolvedValue(null) },
});
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
expect(sendQualifyingPointsUpdateNotification).not.toHaveBeenCalled();
});
it("returns early when no season sports exist for the sports season", async () => {
const db = makeDb({
seasonSports: { findMany: vi.fn().mockResolvedValue([]) },
});
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
expect(sendQualifyingPointsUpdateNotification).not.toHaveBeenCalled();
});
it("returns early when no qualifying points were awarded in the event", async () => {
const db = makeDb({
eventResults: {
findMany: vi.fn().mockResolvedValue([
{ seasonParticipantId: PARTICIPANT_ID, qualifyingPointsAwarded: null },
]),
},
});
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
expect(sendQualifyingPointsUpdateNotification).not.toHaveBeenCalled();
});
it("skips leagues without a Discord webhook URL", async () => {
const db = makeDb({
seasons: {
findMany: vi.fn().mockResolvedValue([
{ id: SEASON_ID, year: 2025, league: { name: "Slam League", discordWebhookUrl: null } },
]),
},
});
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
expect(sendQualifyingPointsUpdateNotification).not.toHaveBeenCalled();
});
it("skips leagues where no QP participants were drafted", async () => {
const db = makeDb({
draftPicks: {
findMany: vi.fn().mockResolvedValue([]),
},
});
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
expect(sendQualifyingPointsUpdateNotification).not.toHaveBeenCalled();
});
it("calls sendQualifyingPointsUpdateNotification with correct participant data", async () => {
const db = makeDb();
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
expect(sendQualifyingPointsUpdateNotification).toHaveBeenCalledOnce();
expect(sendQualifyingPointsUpdateNotification).toHaveBeenCalledWith(
expect.objectContaining({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
eventName: "Roland Garros 2025",
sportName: "Tennis",
standingsUrl: `${process.env.APP_URL ?? "https://brackt.com"}/leagues/${LEAGUE_ID}/sports-seasons/${SPORTS_SEASON_ID}`,
entries: [
expect.objectContaining({
participantName: "Carlos Alcaraz",
qpEarned: 10,
qpTotal: 45,
ownerUsername: "chris",
ownerDiscordUserId: undefined,
}),
],
})
);
});
it("sends once per league, not once per participant", async () => {
const db = makeDb({
seasonSports: {
findMany: vi.fn().mockResolvedValue([{ seasonId: SEASON_ID }, { seasonId: "season-2" }]),
},
seasons: {
findMany: vi.fn().mockResolvedValue([
{ id: SEASON_ID, year: 2025, league: { name: "League A", discordWebhookUrl: WEBHOOK_URL } },
{ id: "season-2", year: 2025, league: { name: "League B", discordWebhookUrl: "https://discord.com/api/webhooks/456/def" } },
]),
},
draftPicks: {
findMany: vi.fn().mockResolvedValue([
{ participantId: PARTICIPANT_ID, seasonId: SEASON_ID, team: { name: "Alpha FC", ownerId: null } },
{ participantId: PARTICIPANT_ID, seasonId: "season-2", team: { name: "Beta FC", ownerId: null } },
]),
},
});
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
expect(sendQualifyingPointsUpdateNotification).toHaveBeenCalledTimes(2);
});
it("resolves owner Discord ID and passes it when user has discordPingEnabled", async () => {
vi.mocked(findDiscordIdsByUserIds).mockResolvedValue(new Map([[OWNER_ID, "discord-999"]]));
const db = makeDb({
users: {
findMany: vi.fn().mockResolvedValue([
{ id: OWNER_ID, username: "chris", discordPingEnabled: true },
]),
},
});
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
expect(sendQualifyingPointsUpdateNotification).toHaveBeenCalledWith(
expect.objectContaining({
entries: [expect.objectContaining({ ownerDiscordUserId: "discord-999" })],
})
);
});
it("omits ownerDiscordUserId when user has discordPingEnabled false", async () => {
const db = makeDb();
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
expect(findDiscordIdsByUserIds).toHaveBeenCalledWith([]);
expect(sendQualifyingPointsUpdateNotification).toHaveBeenCalledWith(
expect.objectContaining({
entries: [expect.objectContaining({ ownerDiscordUserId: undefined })],
})
);
});
it("participantIdFilter limits notification to only specified participants", async () => {
const db = makeDb({
eventResults: {
findMany: vi.fn().mockResolvedValue([
{ seasonParticipantId: PARTICIPANT_ID, qualifyingPointsAwarded: "10" },
{ seasonParticipantId: "p-2", qualifyingPointsAwarded: "5" },
]),
},
draftPicks: {
findMany: vi.fn().mockResolvedValue([
{ participantId: PARTICIPANT_ID, seasonId: SEASON_ID, team: { name: "Alpha FC", ownerId: null } },
{ participantId: "p-2", seasonId: SEASON_ID, team: { name: "Beta FC", ownerId: null } },
]),
},
seasonParticipants: {
findMany: vi.fn().mockResolvedValue([
{ id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID },
{ id: "p-2", name: "Rafael Nadal", sportsSeasonId: SPORTS_SEASON_ID },
]),
},
});
await notifyQualifyingPointsUpdate(
SPORTS_SEASON_ID,
SCORING_EVENT_ID,
db as never,
new Set([PARTICIPANT_ID])
);
const call = vi.mocked(sendQualifyingPointsUpdateNotification).mock.calls[0][0];
expect(call.entries).toHaveLength(1);
expect(call.entries[0].participantName).toBe("Carlos Alcaraz");
});
it("scoreboard includes every drafted participant even when entries are filtered", async () => {
// The scoreboard powers the Drafted Participants section and must reflect the full
// drafted field, not just this sync's changed participants. Here Nadal (p-2) did not
// change this sync (filtered out of entries) but is drafted, so he belongs on the
// scoreboard with his running total and no QP earned this event.
const db = makeDb({
eventResults: {
findMany: vi.fn().mockResolvedValue([
{ seasonParticipantId: PARTICIPANT_ID, qualifyingPointsAwarded: "10" },
{ seasonParticipantId: "p-2", qualifyingPointsAwarded: "5" },
]),
},
draftPicks: {
findMany: vi.fn().mockResolvedValue([
{ participantId: PARTICIPANT_ID, seasonId: SEASON_ID, team: { name: "Alpha FC", ownerId: null } },
{ participantId: "p-2", seasonId: SEASON_ID, team: { name: "Beta FC", ownerId: null } },
]),
},
seasonParticipantQualifyingTotals: {
findMany: vi.fn().mockResolvedValue([
{ participantId: PARTICIPANT_ID, totalQualifyingPoints: "45", sportsSeasonId: SPORTS_SEASON_ID },
{ participantId: "p-2", totalQualifyingPoints: "20", sportsSeasonId: SPORTS_SEASON_ID },
]),
},
seasonParticipants: {
findMany: vi.fn().mockResolvedValue([
{ id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID },
{ id: "p-2", name: "Rafael Nadal", sportsSeasonId: SPORTS_SEASON_ID },
]),
},
});
await notifyQualifyingPointsUpdate(
SPORTS_SEASON_ID,
SCORING_EVENT_ID,
db as never,
new Set([PARTICIPANT_ID])
);
const call = vi.mocked(sendQualifyingPointsUpdateNotification).mock.calls[0][0];
// entries is scoped to the changed participant…
expect(call.entries).toHaveLength(1);
expect(call.entries[0].participantName).toBe("Carlos Alcaraz");
// …but the scoreboard carries the whole drafted field.
expect(call.scoreboard).toEqual(
expect.arrayContaining([
expect.objectContaining({ participantName: "Carlos Alcaraz", qpTotal: 45 }),
expect.objectContaining({ participantName: "Rafael Nadal", qpEarned: 0, qpTotal: 20 }),
])
);
expect(call.scoreboard).toHaveLength(2);
});
it("excludes participants from other sports seasons drafted in the same fantasy season", async () => {
// Draft picks span every sport in a fantasy season, so a golf pick can share the
// league with this tennis event. It must not leak into the tennis scoreboard.
const db = makeDb({
draftPicks: {
findMany: vi.fn().mockResolvedValue([
{ participantId: PARTICIPANT_ID, seasonId: SEASON_ID, team: { name: "Alpha FC", ownerId: null } },
{ participantId: "p-golf", seasonId: SEASON_ID, team: { name: "Alpha FC", ownerId: null } },
]),
},
seasonParticipants: {
findMany: vi.fn().mockResolvedValue([
{ id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID },
{ id: "p-golf", name: "Rory McIlroy", sportsSeasonId: "ss-golf" },
]),
},
});
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
const call = vi.mocked(sendQualifyingPointsUpdateNotification).mock.calls[0][0];
expect(call.scoreboard).toHaveLength(1);
expect(call.scoreboard?.[0]?.participantName).toBe("Carlos Alcaraz");
});
it("does not call sendQualifyingPointsUpdateNotification when all participants are filtered out", async () => {
const db = makeDb();
await notifyQualifyingPointsUpdate(
SPORTS_SEASON_ID,
SCORING_EVENT_ID,
db as never,
new Set(["p-nonexistent"])
);
expect(sendQualifyingPointsUpdateNotification).not.toHaveBeenCalled();
});
// A knocked-out drafted player (e.g. a 2nd-round loser) earns no QP and so has
// no event_results row — surfaced only via the eliminatedParticipantIds arg.
const MENSIK_ID = "p-2";
function makeDbWithEliminated() {
return makeDb({
draftPicks: {
findMany: vi.fn().mockResolvedValue([
{
participantId: PARTICIPANT_ID,
seasonId: SEASON_ID,
team: { id: "t-1", name: "Alpha FC", ownerId: OWNER_ID },
},
{
participantId: MENSIK_ID,
seasonId: SEASON_ID,
team: { id: "t-1", name: "Alpha FC", ownerId: OWNER_ID },
},
]),
},
seasonParticipants: {
findMany: vi.fn().mockResolvedValue([
{ id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID },
{ id: MENSIK_ID, name: "Jakob Mensik", sportsSeasonId: SPORTS_SEASON_ID },
]),
},
});
}
it("announces a knocked-out drafted player who earned no QP", async () => {
const db = makeDbWithEliminated();
await notifyQualifyingPointsUpdate(
SPORTS_SEASON_ID,
SCORING_EVENT_ID,
db as never,
new Set([PARTICIPANT_ID]),
new Set([MENSIK_ID])
);
expect(sendQualifyingPointsUpdateNotification).toHaveBeenCalledWith(
expect.objectContaining({
eliminated: [
expect.objectContaining({ participantName: "Jakob Mensik", ownerUsername: "chris" }),
],
})
);
});
it("does not announce a knocked-out player who is not drafted in the league", async () => {
const db = makeDb();
await notifyQualifyingPointsUpdate(
SPORTS_SEASON_ID,
SCORING_EVENT_ID,
db as never,
new Set([PARTICIPANT_ID]),
new Set(["p-undrafted"])
);
const call = vi.mocked(sendQualifyingPointsUpdateNotification).mock.calls[0][0];
expect(call.eliminated).toEqual([]);
});
it("does not double-list a QP earner that is also passed as eliminated", async () => {
const db = makeDb();
// PARTICIPANT_ID earned 10 QP this sync AND is passed as eliminated
// (e.g. a Round-of-16 loss). It should stay in entries, not the eliminated list.
await notifyQualifyingPointsUpdate(
SPORTS_SEASON_ID,
SCORING_EVENT_ID,
db as never,
new Set([PARTICIPANT_ID]),
new Set([PARTICIPANT_ID])
);
const call = vi.mocked(sendQualifyingPointsUpdateNotification).mock.calls[0][0];
expect(call.entries).toHaveLength(1);
expect(call.eliminated).toEqual([]);
});
it("short-circuits before the QP-total/season lookups when nothing drafted is involved", async () => {
// A knockout occurred but the player isn't drafted in any league. The notifier
// is invoked (the sync fires it on any elimination) but must not run the rest
// of its query battery just to send nothing.
const db = makeDb({
draftPicks: { findMany: vi.fn().mockResolvedValue([]) },
});
await notifyQualifyingPointsUpdate(
SPORTS_SEASON_ID,
SCORING_EVENT_ID,
db as never,
new Set(["p-nonexistent"]),
new Set(["p-undrafted"])
);
expect(db.query.draftPicks.findMany).toHaveBeenCalledOnce();
expect(db.query.seasonParticipantQualifyingTotals.findMany).not.toHaveBeenCalled();
expect(db.query.seasons.findMany).not.toHaveBeenCalled();
expect(db.query.seasonParticipants.findMany).not.toHaveBeenCalled();
expect(sendQualifyingPointsUpdateNotification).not.toHaveBeenCalled();
});
it("fires when a drafted player is knocked out even though no QP changed", async () => {
const db = makeDbWithEliminated();
// participantIdFilter matches nobody → no QP entries, but a knockout exists.
await notifyQualifyingPointsUpdate(
SPORTS_SEASON_ID,
SCORING_EVENT_ID,
db as never,
new Set(["p-nonexistent"]),
new Set([MENSIK_ID])
);
expect(sendQualifyingPointsUpdateNotification).toHaveBeenCalledOnce();
const call = vi.mocked(sendQualifyingPointsUpdateNotification).mock.calls[0][0];
expect(call.entries).toEqual([]);
expect(call.eliminated).toEqual([
expect.objectContaining({ participantName: "Jakob Mensik" }),
]);
});
});

View file

@ -1,17 +1,58 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Mock } from "vitest";
import type * as DrizzleOrm from "drizzle-orm";
import type * as ScoringCalculatorModule from "~/models/scoring-calculator";
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
vi.mock("~/models/scoring-calculator", () => ({
processQualifyingEvent: vi.fn(),
vi.mock("~/models/scoring-calculator", async () => {
// Keep the real implementations of the PURE helpers so the tie-count map handed
// to processQualifyingEvent reflects the mock canonical results AND the bracket's
// structural tie span (deriveBracketQualifyingStates / getRoundConfig). Only the
// DB-touching orchestrators are stubbed.
const actual = await vi.importActual<typeof ScoringCalculatorModule>(
"~/models/scoring-calculator"
);
return {
processQualifyingEvent: vi.fn(),
recalculateAffectedLeagues: vi.fn(),
buildTieCountByPlacement: actual.buildTieCountByPlacement,
deriveBracketQualifyingStates: actual.deriveBracketQualifyingStates,
getRoundConfig: actual.getRoundConfig,
};
});
vi.mock("~/models/scoring-event", () => ({
completeScoringEvent: vi.fn(),
getScoringEventById: vi.fn(),
}));
import { syncTournamentResults } from "../sync-tournament-results";
vi.mock("~/models/event-result", () => ({
getEventResults: vi.fn(),
}));
vi.mock("~/models/tournament-result", () => ({
upsertTournamentResult: vi.fn(),
}));
vi.mock("~/lib/logger", () => ({
logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import {
syncTournamentResults,
syncMajorFromPrimaryEvent,
} from "../sync-tournament-results";
import { database } from "~/database/context";
import { processQualifyingEvent } from "~/models/scoring-calculator";
import {
processQualifyingEvent,
recalculateAffectedLeagues,
} from "~/models/scoring-calculator";
import { completeScoringEvent, getScoringEventById } from "~/models/scoring-event";
import { getEventResults } from "~/models/event-result";
import { upsertTournamentResult } from "~/models/tournament-result";
// ---------------------------------------------------------------------------
// Mock data types
@ -28,6 +69,7 @@ interface FakeScoringEvent {
id: string;
sportsSeasonId: string;
tournamentId: string | null;
name?: string | null;
}
interface FakeSeasonParticipant {
@ -61,11 +103,21 @@ interface FakeEventResult {
// - event_results
// ---------------------------------------------------------------------------
interface FakePlayoffMatch {
scoringEventId: string;
round: string;
winnerId: string | null;
loserId: string | null;
participant1Id: string | null;
participant2Id: string | null;
}
interface FakeDbState {
tournamentResults: FakeTournamentResult[];
scoringEvents: FakeScoringEvent[];
seasonParticipants: FakeSeasonParticipant[];
eventResults: FakeEventResult[];
playoffMatches: FakePlayoffMatch[];
}
/**
@ -113,6 +165,8 @@ function makeFakeDb(state: FakeDbState) {
return state.seasonParticipants;
case "event_results":
return state.eventResults;
case "playoff_matches":
return state.playoffMatches;
default:
throw new Error(`Unknown table in fake db: ${name}`);
}
@ -220,6 +274,12 @@ vi.mock("drizzle-orm", async () => {
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
and: (...preds: any[]) => (row: any) => preds.every((p) => p(row)),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
inArray: (col: any, vals: any[]) => {
const key = colKey(col);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (row: any) => vals.includes(row[key]);
},
};
});
@ -253,6 +313,7 @@ function seedBasicState(overrides: Partial<FakeDbState> = {}): FakeDbState {
scoringEvents: [],
seasonParticipants: [],
eventResults: [],
playoffMatches: [],
...overrides,
};
}
@ -332,7 +393,10 @@ describe("syncTournamentResults", () => {
expect(c?.qualifyingPointsAwarded).toBe("0");
expect(processQualifyingEvent).toHaveBeenCalledTimes(1);
expect(processQualifyingEvent).toHaveBeenCalledWith("ev-1", db);
expect(processQualifyingEvent).toHaveBeenCalledWith("ev-1", db, {
skipNotifications: false,
canonicalTieCountByPlacement: expect.any(Map),
});
});
// -------------------------------------------------------------------------
@ -384,8 +448,14 @@ describe("syncTournamentResults", () => {
expect(bRows.find((r) => r.seasonParticipantId === "sp-BZ")?.placement).toBe(3);
expect(processQualifyingEvent).toHaveBeenCalledTimes(2);
expect(processQualifyingEvent).toHaveBeenNthCalledWith(1, "ev-A", db);
expect(processQualifyingEvent).toHaveBeenNthCalledWith(2, "ev-B", db);
expect(processQualifyingEvent).toHaveBeenNthCalledWith(1, "ev-A", db, {
skipNotifications: false,
canonicalTieCountByPlacement: expect.any(Map),
});
expect(processQualifyingEvent).toHaveBeenNthCalledWith(2, "ev-B", db, {
skipNotifications: false,
canonicalTieCountByPlacement: expect.any(Map),
});
});
// -------------------------------------------------------------------------
@ -574,4 +644,498 @@ describe("syncTournamentResults", () => {
expect(c?.placement).toBeNull();
expect(c?.qualifyingPointsAwarded).toBe("0");
});
// -------------------------------------------------------------------------
// Test 7: Completion fan-out (the core "score once" fix)
// -------------------------------------------------------------------------
it("marks each synced window complete by default", async () => {
const state = seedBasicState({
tournamentResults: [
{ tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: null },
],
scoringEvents: [
{ id: "ev-A", sportsSeasonId: "ss-A", tournamentId: "t-1" },
{ id: "ev-B", sportsSeasonId: "ss-B", tournamentId: "t-1" },
],
seasonParticipants: [
{ id: "sp-AA", sportsSeasonId: "ss-A", participantId: "cp-A", name: "A" },
{ id: "sp-BA", sportsSeasonId: "ss-B", participantId: "cp-A", name: "A" },
],
});
const db = makeFakeDb(state);
vi.mocked(database).mockReturnValue(db as never);
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
vi.mocked(completeScoringEvent).mockResolvedValue(undefined as never);
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(undefined);
await syncTournamentResults("t-1");
expect(completeScoringEvent).toHaveBeenCalledTimes(2);
expect(completeScoringEvent).toHaveBeenCalledWith("ev-A", db);
expect(completeScoringEvent).toHaveBeenCalledWith("ev-B", db);
});
it("does NOT mark windows complete when markComplete is false", async () => {
const state = seedBasicState({
tournamentResults: [
{ tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: null },
],
scoringEvents: [{ id: "ev-A", sportsSeasonId: "ss-A", tournamentId: "t-1" }],
seasonParticipants: [
{ id: "sp-AA", sportsSeasonId: "ss-A", participantId: "cp-A", name: "A" },
],
});
const db = makeFakeDb(state);
vi.mocked(database).mockReturnValue(db as never);
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(undefined);
await syncTournamentResults("t-1", { markComplete: false });
expect(completeScoringEvent).not.toHaveBeenCalled();
});
// -------------------------------------------------------------------------
// Test 8: League recalculation per synced window
// -------------------------------------------------------------------------
it("recalculates affected leagues once per cleanly-synced window", async () => {
const state = seedBasicState({
tournamentResults: [
{ tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: null },
],
scoringEvents: [
{ id: "ev-A", sportsSeasonId: "ss-A", tournamentId: "t-1", name: "Major A" },
{ id: "ev-B", sportsSeasonId: "ss-B", tournamentId: "t-1", name: "Major B" },
],
seasonParticipants: [
{ id: "sp-AA", sportsSeasonId: "ss-A", participantId: "cp-A", name: "A" },
{ id: "sp-BA", sportsSeasonId: "ss-B", participantId: "cp-A", name: "A" },
],
});
const db = makeFakeDb(state);
vi.mocked(database).mockReturnValue(db as never);
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
vi.mocked(completeScoringEvent).mockResolvedValue(undefined as never);
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(undefined);
await syncTournamentResults("t-1");
expect(recalculateAffectedLeagues).toHaveBeenCalledTimes(2);
expect(recalculateAffectedLeagues).toHaveBeenCalledWith("ss-A", undefined, {
eventId: "ev-A",
eventName: "Major A",
skipDiscord: false,
});
expect(recalculateAffectedLeagues).toHaveBeenCalledWith("ss-B", undefined, {
eventId: "ev-B",
eventName: "Major B",
skipDiscord: false,
});
});
// -------------------------------------------------------------------------
// Test 9: skipEventId excludes the primary window
// -------------------------------------------------------------------------
it("skips the primary window when skipEventId is given", async () => {
const state = seedBasicState({
tournamentResults: [
{ tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: null },
],
scoringEvents: [
{ id: "ev-PRIMARY", sportsSeasonId: "ss-P", tournamentId: "t-1" },
{ id: "ev-SIBLING", sportsSeasonId: "ss-S", tournamentId: "t-1" },
],
seasonParticipants: [
{ id: "sp-PA", sportsSeasonId: "ss-P", participantId: "cp-A", name: "A" },
{ id: "sp-SA", sportsSeasonId: "ss-S", participantId: "cp-A", name: "A" },
],
});
const db = makeFakeDb(state);
vi.mocked(database).mockReturnValue(db as never);
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
vi.mocked(completeScoringEvent).mockResolvedValue(undefined as never);
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(undefined);
const report = await syncTournamentResults("t-1", {
skipEventId: "ev-PRIMARY",
});
expect(report.windowsSynced).toBe(1);
expect(processQualifyingEvent).toHaveBeenCalledTimes(1);
expect(processQualifyingEvent).toHaveBeenCalledWith("ev-SIBLING", db, {
skipNotifications: false,
canonicalTieCountByPlacement: expect.any(Map),
});
// No event_results written for the skipped primary window.
expect(
state.eventResults.some((r) => r.scoringEventId === "ev-PRIMARY")
).toBe(false);
});
});
// ---------------------------------------------------------------------------
// syncMajorFromPrimaryEvent: bracket/CS2 majors promote primary results to
// canonical tournament_results, then fan out to siblings (primary skipped).
// ---------------------------------------------------------------------------
describe("syncMajorFromPrimaryEvent", () => {
it("promotes primary event_results to canonical and fans out to siblings", async () => {
const state = seedBasicState({
scoringEvents: [
{ id: "ev-PRIMARY", sportsSeasonId: "ss-P", tournamentId: "t-1", name: "Major" },
{ id: "ev-SIBLING", sportsSeasonId: "ss-S", tournamentId: "t-1", name: "Major" },
],
seasonParticipants: [
{ id: "sp-PA", sportsSeasonId: "ss-P", participantId: "cp-A", name: "A" },
{ id: "sp-PB", sportsSeasonId: "ss-P", participantId: "cp-B", name: "B" },
{ id: "sp-SA", sportsSeasonId: "ss-S", participantId: "cp-A", name: "A" },
{ id: "sp-SB", sportsSeasonId: "ss-S", participantId: "cp-B", name: "B" },
],
});
const db = makeFakeDb(state);
vi.mocked(database).mockReturnValue(db as never);
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
vi.mocked(completeScoringEvent).mockResolvedValue(undefined as never);
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(undefined);
vi.mocked(getScoringEventById).mockResolvedValue({
id: "ev-PRIMARY",
sportsSeasonId: "ss-P",
tournamentId: "t-1",
name: "Major",
} as never);
// Primary window's derived results: cp-A 1st, cp-B 2nd.
vi.mocked(getEventResults).mockResolvedValue([
{
placement: 1,
rawScore: "1",
notParticipating: false,
seasonParticipantId: "sp-PA",
seasonParticipant: { participantId: "cp-A" },
},
{
placement: 2,
rawScore: "2",
notParticipating: false,
seasonParticipantId: "sp-PB",
seasonParticipant: { participantId: "cp-B" },
},
] as never);
// upsertTournamentResult writes into our fake canonical table so the
// subsequent syncTournamentResults can read them back.
vi.mocked(upsertTournamentResult).mockImplementation(
async (data: { tournamentId: string; participantId: string; placement?: number | null; rawScore?: string | null }) => {
state.tournamentResults.push({
tournamentId: data.tournamentId,
participantId: data.participantId,
placement: data.placement ?? null,
rawScore: data.rawScore ?? null,
});
return data as never;
}
);
const report = await syncMajorFromPrimaryEvent("ev-PRIMARY");
// Two canonical results promoted from the primary.
expect(upsertTournamentResult).toHaveBeenCalledTimes(2);
expect(state.tournamentResults).toHaveLength(2);
// Only the sibling window is synced (primary is skipped).
expect(report.windowsSynced).toBe(1);
expect(
state.eventResults.some((r) => r.scoringEventId === "ev-PRIMARY")
).toBe(false);
const sib = state.eventResults.filter((r) => r.scoringEventId === "ev-SIBLING");
expect(sib.find((r) => r.seasonParticipantId === "sp-SA")?.placement).toBe(1);
expect(sib.find((r) => r.seasonParticipantId === "sp-SB")?.placement).toBe(2);
// In-progress fan-out: siblings are NOT marked complete by default.
expect(completeScoringEvent).not.toHaveBeenCalled();
});
it("splits mirror QP by the bracket's structural tie span, not the canonical row count (R16 in progress)", async () => {
// Tennis R16 in progress: two R16 matches are decided (losers cp-L1/cp-L2 land
// final at 9th) and two players (cp-F1/cp-F2) won their R32 match and are still
// "floored" at the R16 tier (also placement 9). So only FOUR players sit at
// placement 9 right now — the canonical row-count is 4, which would wrongly
// split 9th16th four ways (→ 2 QP). The R16 tier structurally spans 8 slots,
// so every window must split (2+2+2+2+1+1+1+1)/8 = 1.5. This asserts the map
// handed to each mirror's processQualifyingEvent carries the structural span (8),
// not the live count (4).
const state = seedBasicState({
scoringEvents: [
{
id: "ev-PRIMARY",
sportsSeasonId: "ss-P",
tournamentId: "t-1",
name: "Wimbledon",
},
{
id: "ev-MIRROR",
sportsSeasonId: "ss-M",
tournamentId: "t-1",
name: "Wimbledon",
},
],
seasonParticipants: [
{ id: "sp-ML1", sportsSeasonId: "ss-M", participantId: "cp-L1", name: "L1" },
{ id: "sp-MF1", sportsSeasonId: "ss-M", participantId: "cp-F1", name: "F1" },
],
// Primary bracket state. R32 wins establish the "floored at R16" players;
// decided R16 matches establish the final 9th-place losers and the QF-floored
// winners (placement 5).
playoffMatches: [
{
scoringEventId: "ev-PRIMARY",
round: "Round of 32",
winnerId: "cp-F1",
loserId: "cp-out1",
participant1Id: "cp-F1",
participant2Id: "cp-out1",
},
{
scoringEventId: "ev-PRIMARY",
round: "Round of 32",
winnerId: "cp-F2",
loserId: "cp-out2",
participant1Id: "cp-F2",
participant2Id: "cp-out2",
},
{
scoringEventId: "ev-PRIMARY",
round: "Round of 16",
winnerId: "cp-W1",
loserId: "cp-L1",
participant1Id: "cp-W1",
participant2Id: "cp-L1",
},
{
scoringEventId: "ev-PRIMARY",
round: "Round of 16",
winnerId: "cp-W2",
loserId: "cp-L2",
participant1Id: "cp-W2",
participant2Id: "cp-L2",
},
],
});
const db = makeFakeDb(state);
vi.mocked(database).mockReturnValue(db as never);
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
vi.mocked(completeScoringEvent).mockResolvedValue(undefined as never);
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(undefined);
vi.mocked(getScoringEventById).mockResolvedValue({
id: "ev-PRIMARY",
sportsSeasonId: "ss-P",
tournamentId: "t-1",
name: "Wimbledon",
bracketTemplateId: "tennis_128",
} as never);
// Primary window's derived results (what processQualifyingBracketEvent wrote):
// four players at placement 9 (2 final losers + 2 floored), two at placement 5.
vi.mocked(getEventResults).mockResolvedValue(
[
["sp-PL1", "cp-L1", 9],
["sp-PL2", "cp-L2", 9],
["sp-PF1", "cp-F1", 9],
["sp-PF2", "cp-F2", 9],
["sp-PW1", "cp-W1", 5],
["sp-PW2", "cp-W2", 5],
].map(([seasonParticipantId, participantId, placement]) => ({
placement,
rawScore: null,
notParticipating: false,
seasonParticipantId,
seasonParticipant: { participantId },
})) as never
);
vi.mocked(upsertTournamentResult).mockImplementation(
async (data: {
tournamentId: string;
participantId: string;
placement?: number | null;
rawScore?: string | null;
}) => {
state.tournamentResults.push({
tournamentId: data.tournamentId,
participantId: data.participantId,
placement: data.placement ?? null,
rawScore: data.rawScore ?? null,
});
return data as never;
}
);
await syncMajorFromPrimaryEvent("ev-PRIMARY", { markComplete: false });
// Sanity: only four canonical rows sit at placement 9 (the live count is 4).
expect(
state.tournamentResults.filter((r) => r.placement === 9)
).toHaveLength(4);
// The mirror window was scored with the STRUCTURAL span, not the row count.
const mirrorCall = (processQualifyingEvent as Mock).mock.calls.find(
(c) => c[0] === "ev-MIRROR"
);
expect(mirrorCall).toBeDefined();
if (!mirrorCall) return;
const tieMap = mirrorCall[2].canonicalTieCountByPlacement as Map<number, number>;
expect(tieMap.get(9)).toBe(8); // R16 tier spans 8, not the 4 rows currently there
expect(tieMap.get(5)).toBe(4); // QF tier spans 4
});
it("fans a non-scoring-round elimination out to each mirror window (translated to its own season_participant id)", async () => {
// The reported bug: a player knocked out in a non-scoring round earns no QP, so
// canonical promotion skips them (null placement) and the mirror can't detect the
// knockout locally. The primary passes its eliminated season_participant id
// (sp-PX) down; syncMajorFromPrimaryEvent must translate it to the canonical
// participant (cp-X) and each mirror window must re-translate to ITS own
// season_participant (sp-MX) before handing it to processQualifyingEvent.
const state = seedBasicState({
scoringEvents: [
{ id: "ev-PRIMARY", sportsSeasonId: "ss-P", tournamentId: "t-1", name: "Wimbledon" },
{ id: "ev-MIRROR", sportsSeasonId: "ss-M", tournamentId: "t-1", name: "Wimbledon" },
],
seasonParticipants: [
// Placed finalist, present on both windows.
{ id: "sp-PA", sportsSeasonId: "ss-P", participantId: "cp-A", name: "A" },
{ id: "sp-MA", sportsSeasonId: "ss-M", participantId: "cp-A", name: "A" },
// Knocked-out player: different season_participant row per window, same
// canonical participant cp-X.
{ id: "sp-PX", sportsSeasonId: "ss-P", participantId: "cp-X", name: "X" },
{ id: "sp-MX", sportsSeasonId: "ss-M", participantId: "cp-X", name: "X" },
],
});
const db = makeFakeDb(state);
vi.mocked(database).mockReturnValue(db as never);
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
vi.mocked(completeScoringEvent).mockResolvedValue(undefined as never);
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(undefined);
vi.mocked(getScoringEventById).mockResolvedValue({
id: "ev-PRIMARY",
sportsSeasonId: "ss-P",
tournamentId: "t-1",
name: "Wimbledon",
} as never);
// Primary derived results promote only the PLACED player. cp-X (the non-scoring
// loser) has no placement and is not promoted to canonical — exactly why the
// mirror needs the elimination threaded separately.
vi.mocked(getEventResults).mockResolvedValue([
{
placement: 1,
rawScore: "1",
notParticipating: false,
seasonParticipantId: "sp-PA",
seasonParticipant: { participantId: "cp-A" },
},
] as never);
vi.mocked(upsertTournamentResult).mockImplementation(
async (data: { tournamentId: string; participantId: string; placement?: number | null; rawScore?: string | null }) => {
state.tournamentResults.push({
tournamentId: data.tournamentId,
participantId: data.participantId,
placement: data.placement ?? null,
rawScore: data.rawScore ?? null,
});
return data as never;
}
);
await syncMajorFromPrimaryEvent("ev-PRIMARY", {
newlyEliminatedParticipantIds: new Set(["sp-PX"]),
});
const mirrorCall = (processQualifyingEvent as Mock).mock.calls.find(
(c) => c[0] === "ev-MIRROR"
);
expect(mirrorCall).toBeDefined();
if (!mirrorCall) return;
const eliminated = mirrorCall[2].newlyEliminatedParticipantIds as Set<string>;
// cp-X → the MIRROR's own season_participant id, not the primary's.
expect([...eliminated]).toEqual(["sp-MX"]);
});
it("throws when the primary event is not linked to a tournament", async () => {
const db = makeFakeDb(seedBasicState());
vi.mocked(database).mockReturnValue(db as never);
vi.mocked(getScoringEventById).mockResolvedValue({
id: "ev-X",
sportsSeasonId: "ss-X",
tournamentId: null,
name: "Standalone",
} as never);
await expect(syncMajorFromPrimaryEvent("ev-X")).rejects.toThrow(
/not linked to a tournament/
);
});
});
// ---------------------------------------------------------------------------
// Regression: review fixes #1 (recalc failure counts) and #2 (stale removal)
// ---------------------------------------------------------------------------
describe("syncTournamentResults — recalc failure & stale removal", () => {
it("counts a league-recalc failure as a failed window (so status can't show completed)", async () => {
const state = seedBasicState({
tournamentResults: [
{ tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: null },
],
scoringEvents: [{ id: "ev-1", sportsSeasonId: "ss-1", tournamentId: "t-1" }],
seasonParticipants: [
{ id: "sp-A", sportsSeasonId: "ss-1", participantId: "cp-A", name: "A" },
],
});
const db = makeFakeDb(state);
vi.mocked(database).mockReturnValue(db as never);
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
vi.mocked(completeScoringEvent).mockResolvedValue(undefined as never);
vi.mocked(recalculateAffectedLeagues).mockRejectedValue(new Error("recalc boom"));
const report = await syncTournamentResults("t-1");
expect(report.windowsFailed).toBe(1);
expect(report.failures.some((f) => f.error.includes("League recalc failed"))).toBe(true);
});
it("resets a previously-placed participant to filler when dropped from canonical", async () => {
const state = seedBasicState({
tournamentResults: [
{ tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: "10" },
],
scoringEvents: [{ id: "ev-1", sportsSeasonId: "ss-1", tournamentId: "t-1" }],
seasonParticipants: [
{ id: "sp-A", sportsSeasonId: "ss-1", participantId: "cp-A", name: "A" },
],
});
const db = makeFakeDb(state);
vi.mocked(database).mockReturnValue(db as never);
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
vi.mocked(completeScoringEvent).mockResolvedValue(undefined as never);
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(undefined);
await syncTournamentResults("t-1");
expect(state.eventResults.find((r) => r.seasonParticipantId === "sp-A")?.placement).toBe(1);
// Correction: cp-A is removed from the canonical results entirely.
state.tournamentResults.length = 0;
await syncTournamentResults("t-1");
const row = state.eventResults.find((r) => r.seasonParticipantId === "sp-A");
expect(row?.placement).toBeNull();
expect(row?.rawScore).toBeNull();
expect(row?.qualifyingPointsAwarded).toBe("0");
});
});

View file

@ -63,6 +63,17 @@ function escapeMarkdown(text: string): string {
return text.replace(/[_*~`|\\]/g, "\\$&");
}
/**
* Format a QP value for display. QP is genuinely fractional (e.g. a tennis R16 loser
* earns 1.5 QP from the 916 split), so we must NOT round: show whole numbers plainly
* and fractional values to at most 2 decimals with trailing zeros trimmed (1.501.5).
* Mirrors the web UI's formatQP (app/components/scoring/QualifyingPointsStandings.tsx)
* so Discord and the site agree.
*/
function formatQPValue(n: number): string {
return parseFloat(n.toFixed(2)).toString();
}
export interface StandingEntry {
teamId: string;
teamName: string;
@ -81,6 +92,12 @@ export interface ScoredMatch {
loserDiscordUserId?: string;
}
export interface EliminatedTeam {
participantName: string;
username?: string;
discordUserId?: string;
}
export async function sendStandingsUpdateNotification({
webhookUrl,
seasonName,
@ -90,6 +107,7 @@ export async function sendStandingsUpdateNotification({
sportName,
eventName,
scoredMatches,
eliminatedTeams,
}: {
webhookUrl: string;
seasonName: string;
@ -99,6 +117,7 @@ export async function sendStandingsUpdateNotification({
sportName?: string;
eventName?: string;
scoredMatches?: ScoredMatch[];
eliminatedTeams?: EliminatedTeam[];
}): Promise<void> {
const sections: string[] = [];
@ -136,16 +155,40 @@ export async function sendStandingsUpdateNotification({
}
}
// Eliminated teams section — drafted teams knocked out when a bracket/knockout
// was generated (e.g. NBA teams that missed the playoffs, FIFA group-stage
// losers). These produce no score change, so they wouldn't appear above.
if (eliminatedTeams && eliminatedTeams.length > 0) {
sections.push("\n**Eliminated**");
for (const team of eliminatedTeams) {
const managerLabel = team.discordUserId
? `<@${team.discordUserId}>`
: team.username
? escapeMarkdown(team.username)
: undefined;
const label = managerLabel
? `${escapeMarkdown(team.participantName)} (${managerLabel})`
: escapeMarkdown(team.participantName);
sections.push(`• **${label}**`);
}
}
const isTied = buildTiedRankChecker(standings.map((s) => s.rank));
const rankLabel = (rank: number) => (isTied(rank) ? `T${rank}` : `${rank}`);
// A team's points genuinely changed this event (vs. merely being displaced in
// rank because someone else scored). Only real scorers are pinged; rank-only
// shufflers are still displayed, but by name and without an @-mention.
const pointsChanged = (s: StandingEntry) => {
const prevPoints = previousStandings.get(s.teamId);
return prevPoints !== undefined && prevPoints !== s.totalPoints;
};
// Standings changes section — show teams whose points or rank changed.
const changedTeams = standings.filter((s) => {
const prevPoints = previousStandings.get(s.teamId);
const pointsChanged = prevPoints !== undefined && prevPoints !== s.totalPoints;
const prevRank = previousRanks?.get(s.teamId);
const rankChanged = prevRank !== undefined && prevRank !== s.rank;
return pointsChanged || rankChanged;
return pointsChanged(s) || rankChanged;
});
if (changedTeams.length > 0) {
@ -170,7 +213,7 @@ export async function sendStandingsUpdateNotification({
}
const escapedName = escapeMarkdown(s.teamName);
const managerLabel = s.discordUserId
const managerLabel = s.discordUserId && pointsChanged(s)
? `<@${s.discordUserId}>`
: s.username
? escapeMarkdown(s.username)
@ -189,12 +232,15 @@ export async function sendStandingsUpdateNotification({
// Collect Discord user IDs of all opted-in managers appearing in this notification.
const pingUserIds = new Set<string>();
for (const s of changedTeams) {
if (s.discordUserId) pingUserIds.add(s.discordUserId);
if (s.discordUserId && pointsChanged(s)) pingUserIds.add(s.discordUserId);
}
for (const m of relevantMatches ?? []) {
if (m.winnerDiscordUserId) pingUserIds.add(m.winnerDiscordUserId);
if (m.loserDiscordUserId) pingUserIds.add(m.loserDiscordUserId);
}
for (const t of eliminatedTeams ?? []) {
if (t.discordUserId) pingUserIds.add(t.discordUserId);
}
const pingIds = [...pingUserIds];
const payload: DiscordWebhookPayload = {
@ -202,7 +248,7 @@ export async function sendStandingsUpdateNotification({
{
title: `📊 Standings Update — ${seasonName}`,
description,
color: 0x5865f2, // Discord blurple
color: 0xffd700, // Gold
footer: { text: "brackt.com" },
},
],
@ -218,6 +264,167 @@ export async function sendStandingsUpdateNotification({
await sendDiscordWebhook(webhookUrl, payload);
}
export interface QPEventEntry {
participantName: string;
qpEarned: number;
qpTotal: number;
/**
* The participant's rank in the FULL season QP standings (all participants), not
* their position among this event's scorers. Computed by the caller so the "QP
* Standings" block reflects the whole sport season e.g. two R16 losers on 1.5 QP
* show as T9 (8 players ahead) rather than T1 among just the two of them.
*/
globalRank: number;
/** True when another participant in the full field shares this globalRank. */
globalRankTied: boolean;
ownerUsername?: string;
ownerDiscordUserId?: string;
}
/** A drafted player knocked out this sync in a non-scoring round (0 QP). */
export interface QPEliminatedEntry {
participantName: string;
ownerUsername?: string;
ownerDiscordUserId?: string;
}
export async function sendQualifyingPointsUpdateNotification({
webhookUrl,
seasonName,
sportName,
eventName,
entries,
eliminated = [],
scoreboard = [],
standingsUrl,
}: {
webhookUrl: string;
seasonName: string;
sportName?: string;
eventName?: string;
entries: QPEventEntry[];
eliminated?: QPEliminatedEntry[];
/**
* The full current scoreboard for the league every drafted participant, not just
* those whose QP changed this sync. Drives the "Drafted Participants" standings section.
* `entries`/`eliminated` remain scoped to this sync's changes and drive the
* "Points Awarded"/"Knocked Out" sections and the ping list.
*/
scoreboard?: QPEventEntry[];
standingsUrl?: string;
}): Promise<void> {
if (entries.length === 0 && eliminated.length === 0) return;
const sections: string[] = [];
if (sportName || eventName) {
const parts = [sportName, eventName].filter(Boolean);
sections.push(`**${parts.join(" — ")}**`);
}
const awardedEntries = entries
.filter((e) => e.qpEarned > 0)
.toSorted((a, b) => b.qpEarned - a.qpEarned);
if (awardedEntries.length > 0) {
sections.push("\n**Points Awarded**");
for (const e of awardedEntries) {
const managerLabel = e.ownerDiscordUserId
? `<@${e.ownerDiscordUserId}>`
: e.ownerUsername
? escapeMarkdown(e.ownerUsername)
: undefined;
const label = managerLabel
? `${escapeMarkdown(e.participantName)} (${managerLabel})`
: escapeMarkdown(e.participantName);
sections.push(`• **${label}** — ${formatQPValue(e.qpEarned)} QP`);
}
}
// Knocked-out section — drafted players eliminated this sync in a non-scoring
// round. They earn no QP, so they'd otherwise never be surfaced to their manager.
if (eliminated.length > 0) {
sections.push("\n**Knocked Out**");
for (const e of eliminated) {
const managerLabel = e.ownerDiscordUserId
? `<@${e.ownerDiscordUserId}>`
: e.ownerUsername
? escapeMarkdown(e.ownerUsername)
: undefined;
const label = managerLabel
? `${escapeMarkdown(e.participantName)} (${managerLabel})`
: escapeMarkdown(e.participantName);
sections.push(`${label}`);
}
}
// Drafted Participants: every drafted participant that has actually scored (qpTotal > 0),
// drawn from the FULL drafted field (`scoreboard`) not just this sync's movers, so it reads
// as a live standings snapshot. Rendered as ranked lines ordered by full-season standing. A
// "Points Bubble" divider marks the cutoff between those currently in the points (rank <= 8)
// and those below it (rank >= 9). Participants with 0 QP are omitted entirely. Never pinged,
// so managers are shown by plain username, never as a <@id> mention.
const scored = [...scoreboard]
.filter((e) => e.qpTotal > 0)
.toSorted((a, b) => a.globalRank - b.globalRank);
// Skip the section entirely when no drafted participant has scored (e.g. a sync that only
// reported knockouts) so we don't emit an empty header.
if (scored.length > 0) {
sections.push("\n**Drafted Participants**");
// Insert the divider once, before the first below-the-cutoff (rank >= 9) row. `>= 9`
// (not `> 8`) keeps a tie AT rank 8 above the bubble ("top 8 plus ties"). Only emit it
// after at least one above-the-bubble row exists: globalRank is a season-wide rank while
// this list is scoped to one league's drafts, so a league can have drafted nobody in the
// global top 8 — guarding on rowsAbove avoids a leading divider with nothing above it.
let bubbleInserted = false;
let rowsAbove = 0;
for (const e of scored) {
if (!bubbleInserted && rowsAbove > 0 && e.globalRank >= 9) {
sections.push("**═══ Points Bubble ═══**");
bubbleInserted = true;
}
if (e.globalRank <= 8) rowsAbove++;
const rankPrefix = e.globalRankTied ? `T${e.globalRank}` : `${e.globalRank}`;
const managerLabel = e.ownerUsername ? ` (${escapeMarkdown(e.ownerUsername)})` : "";
sections.push(`${rankPrefix}\\. ${escapeMarkdown(e.participantName)}${managerLabel}${formatQPValue(e.qpTotal)} QP`);
}
}
const MAX_DESCRIPTION = 4096;
let description = sections.join("\n");
if (description.length > MAX_DESCRIPTION) {
description = description.slice(0, MAX_DESCRIPTION - 3) + "...";
}
// Ping only managers who earned points or lost a drafted player this sync —
// never the non-scoring (zeroEntries) managers.
const pingUserIds = new Set<string>();
for (const e of [...awardedEntries, ...eliminated]) {
if (e.ownerDiscordUserId) pingUserIds.add(e.ownerDiscordUserId);
}
const pingIds = [...pingUserIds];
const payload: DiscordWebhookPayload = {
embeds: [
{
title: `🏅 Qualifying Points Update — ${seasonName}`,
url: standingsUrl,
description,
color: 0x5865f2, // Discord blurple
},
],
};
if (pingIds.length > 0) {
const cappedIds = pingIds.slice(0, 100);
payload.content = cappedIds.map((id) => `<@${id}>`).join(" ");
payload.allowed_mentions = { parse: [], users: cappedIds };
}
await sendDiscordWebhook(webhookUrl, payload);
}
export async function sendPickAnnouncementNotification({
webhookUrl,
draftUrl,

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,120 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type * as QualifyingPointsModule from "~/models/qualifying-points";
// The helper under test re-derives QP via processQualifyingBracketEvent and
// recalculates dropped participants — both stubbed so the test drives the
// before/after event_results rows directly and asserts the change-detection diff.
vi.mock("~/models/scoring-calculator", () => ({
processMatchResult: vi.fn(),
autoCompleteRoundIfDone: vi.fn(),
processQualifyingBracketEvent: vi.fn().mockResolvedValue(undefined),
recalculateAffectedLeagues: vi.fn(),
}));
// Keep the real diffChangedQualifyingPoints (the change-detection primitive under
// test here); only stub the dropped-participant total recalc.
vi.mock("~/models/qualifying-points", async (importActual) => {
const actual = await importActual<typeof QualifyingPointsModule>();
return {
...actual,
recalculateParticipantQP: vi.fn().mockResolvedValue(undefined),
};
});
import { rescoreTennisBracketAndDetectChanges } from "../index";
import { processQualifyingBracketEvent } from "~/models/scoring-calculator";
import { recalculateParticipantQP } from "~/models/qualifying-points";
const EVENT_ID = "ev-1";
const SPORTS_SEASON_ID = "ss-1";
type Row = { id: string; qp: string | null };
/**
* Fake Drizzle db whose two `select().from().where()` calls resolve, in order, to
* the provided before-rows then after-rows. `transaction` runs its callback with
* the same fake, and `delete().where()` is a no-op.
*/
function makeDb(beforeRows: Row[], afterRows: Row[]) {
const results = [beforeRows, afterRows];
let selectCall = 0;
const db = {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => Promise.resolve(results[selectCall++] ?? [])),
})),
})),
delete: vi.fn(() => ({ where: vi.fn(() => Promise.resolve(undefined)) })),
transaction: vi.fn(async (fn: (tx: unknown) => Promise<void>) => fn(db)),
};
return db;
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("rescoreTennisBracketAndDetectChanges", () => {
it("flags participants whose QP changed and newly-scored participants, ignoring unchanged ones", async () => {
const db = makeDb(
[
{ id: "A", qp: "10" },
{ id: "B", qp: "5" },
],
[
{ id: "A", qp: "10" }, // unchanged
{ id: "B", qp: "8" }, // changed 5 -> 8
{ id: "C", qp: "3" }, // newly scored
],
);
const changed = await rescoreTennisBracketAndDetectChanges(EVENT_ID, SPORTS_SEASON_ID, db as never);
expect([...changed].toSorted()).toEqual(["B", "C"]);
expect(processQualifyingBracketEvent).toHaveBeenCalledWith(EVENT_ID, db);
// No participant dropped out, so no manual total recalc.
expect(recalculateParticipantQP).not.toHaveBeenCalled();
});
it("treats a null-before → value-after transition as a change", async () => {
const db = makeDb([{ id: "A", qp: null }], [{ id: "A", qp: "10" }]);
const changed = await rescoreTennisBracketAndDetectChanges(EVENT_ID, SPORTS_SEASON_ID, db as never);
expect([...changed]).toEqual(["A"]);
});
it("announces nothing when a re-sync produces identical QP", async () => {
const db = makeDb(
[
{ id: "A", qp: "10" },
{ id: "B", qp: "5" },
],
[
{ id: "A", qp: "10" },
{ id: "B", qp: "5" },
],
);
const changed = await rescoreTennisBracketAndDetectChanges(EVENT_ID, SPORTS_SEASON_ID, db as never);
expect(changed.size).toBe(0);
expect(recalculateParticipantQP).not.toHaveBeenCalled();
});
it("recalculates totals for participants dropped from the rewritten results", async () => {
const db = makeDb(
[
{ id: "A", qp: "10" },
{ id: "D", qp: "2" }, // present before, gone after (early-round loser)
],
[{ id: "A", qp: "10" }],
);
const changed = await rescoreTennisBracketAndDetectChanges(EVENT_ID, SPORTS_SEASON_ID, db as never);
expect(changed.size).toBe(0); // A unchanged; D no longer in results
expect(recalculateParticipantQP).toHaveBeenCalledTimes(1);
expect(recalculateParticipantQP).toHaveBeenCalledWith("D", SPORTS_SEASON_ID, db);
});
});

View file

@ -0,0 +1,147 @@
import { describe, it, expect } from "vitest";
import {
playerKey,
canonicalPlayerName,
buildResolvedMatches,
} from "../tennis-draw-mapping";
import type { FetchedDraw, FetchedPlayer } from "../types";
const sinner: FetchedPlayer = { name: "J Sinner", externalId: "Jannik Sinner", seed: 1 };
const djokovic: FetchedPlayer = { name: "N Djokovic", externalId: "Novak Djokovic", seed: 6 };
const noLink: FetchedPlayer = { name: "Some Qualifier", externalId: null, seed: null };
describe("playerKey", () => {
it("prefers the external id", () => {
expect(playerKey(sinner)).toBe("id:Jannik Sinner");
});
it("falls back to a normalized name when there is no external id", () => {
expect(playerKey(noLink)).toBe("name:some qualifier");
});
});
describe("canonicalPlayerName", () => {
it("uses the full name from the external id, not the abbreviated display", () => {
expect(canonicalPlayerName(sinner)).toBe("Jannik Sinner");
});
it("uses the display name when there is no external id", () => {
expect(canonicalPlayerName(noLink)).toBe("Some Qualifier");
});
it("strips the trailing Wikipedia disambiguator", () => {
expect(
canonicalPlayerName({ name: "Arthur Fils", externalId: "Arthur Fils (tennis)", seed: null }),
).toBe("Arthur Fils");
});
});
describe("buildResolvedMatches", () => {
const draw: FetchedDraw = {
templateId: "tennis_128",
rounds: [
{
roundName: "Round of 128",
matches: [
{
externalMatchId: "m-r128-1",
round: "Round of 128",
matchNumber: 1,
player1: sinner,
player2: noLink,
winner: 1,
},
],
},
{
roundName: "Final",
matches: [
{
externalMatchId: "m-final",
round: "Final",
matchNumber: 1,
player1: djokovic,
player2: sinner,
winner: 2,
},
],
},
],
};
const idByKey = new Map<string, string>([
[playerKey(sinner), "sp-sinner"],
[playerKey(djokovic), "sp-djokovic"],
[playerKey(noLink), "sp-qual"],
]);
const roundIsScoring = new Map<string, boolean>([
["Round of 128", false],
["Final", true],
]);
const resolved = buildResolvedMatches(draw, idByKey, roundIsScoring);
it("maps winner slot 1 to winnerId/loserId", () => {
const r128 = resolved.find((m) => m.externalMatchId === "m-r128-1");
expect(r128?.winnerId).toBe("sp-sinner");
expect(r128?.loserId).toBe("sp-qual");
expect(r128?.isScoring).toBe(false);
});
it("maps winner slot 2 to the second player", () => {
const final = resolved.find((m) => m.externalMatchId === "m-final");
expect(final?.participant1Id).toBe("sp-djokovic");
expect(final?.participant2Id).toBe("sp-sinner");
expect(final?.winnerId).toBe("sp-sinner");
expect(final?.loserId).toBe("sp-djokovic");
expect(final?.isScoring).toBe(true);
});
it("leaves winner/loser null when the source reported no winner", () => {
const noWinnerDraw: FetchedDraw = {
templateId: "tennis_128",
rounds: [
{
roundName: "Round of 128",
matches: [
{
externalMatchId: "m-x",
round: "Round of 128",
matchNumber: 1,
player1: sinner,
player2: djokovic,
winner: null,
},
],
},
],
};
const [m] = buildResolvedMatches(noWinnerDraw, idByKey, roundIsScoring);
expect(m.winnerId).toBeNull();
expect(m.loserId).toBeNull();
});
it("defaults isScoring to false for an unknown round", () => {
const [m] = buildResolvedMatches(
{
templateId: "tennis_128",
rounds: [
{
roundName: "Mystery",
matches: [
{
externalMatchId: "m-y",
round: "Mystery",
matchNumber: 1,
player1: sinner,
player2: djokovic,
winner: 1,
},
],
},
],
},
idByKey,
new Map(),
);
expect(m.isScoring).toBe(false);
});
});

View file

@ -0,0 +1,176 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
parseTennisDrawWikitext,
parsePlayerCell,
parseTemplateParams,
extractTemplates,
slugifyArticle,
articleTitleFromInput,
} from "../wikipedia-tennis";
import type { FetchedRound } from "../types";
const FIXTURE = readFileSync(
join(__dirname, "fixtures", "wimbledon-2025-mens-singles.wikitext"),
"utf-8",
);
const ARTICLE = "2025 Wimbledon Championships Men's singles";
function roundOrThrow(rounds: FetchedRound[], name: string): FetchedRound {
const round = rounds.find((r) => r.roundName === name);
if (!round) throw new Error(`round ${name} missing`);
return round;
}
describe("parsePlayerCell", () => {
it("extracts name + wikilink target and detects the bolded winner", () => {
const cell = parsePlayerCell("'''{{flagicon|ITA}} [[Jannik Sinner]]'''");
expect(cell).toEqual({
player: { name: "Jannik Sinner", externalId: "Jannik Sinner", seed: null },
isWinner: true,
});
});
it("treats a non-bolded slot as a loser", () => {
const cell = parsePlayerCell("{{flagicon|USA}} [[Ben Shelton]]");
expect(cell?.isWinner).toBe(false);
expect(cell?.player.name).toBe("Ben Shelton");
});
it("handles piped wikilinks (qualifier disambiguation)", () => {
const cell = parsePlayerCell("{{flagicon|FRA}} [[Arthur Fils (tennis)|Arthur Fils]]");
expect(cell?.player.name).toBe("Arthur Fils");
expect(cell?.player.externalId).toBe("Arthur Fils (tennis)");
});
it("parses a player whose cell is wrapped in {{nowrap}} (long names)", () => {
const loser = parsePlayerCell(
"{{nowrap|{{flagicon|ESP}} [[Alejandro Davidovich Fokina|A Davidovich Fokina]]}}",
);
expect(loser?.player.externalId).toBe("Alejandro Davidovich Fokina");
expect(loser?.player.name).toBe("A Davidovich Fokina");
expect(loser?.isWinner).toBe(false);
const winner = parsePlayerCell(
"'''{{nowrap|{{flagicon|ARG}} [[Juan Manuel Cerúndolo|JM Cerúndolo]]}}'''",
);
expect(winner?.player.externalId).toBe("Juan Manuel Cerúndolo");
expect(winner?.isWinner).toBe(true);
});
it("returns null for empty, Bye, and not-yet-determined placeholder slots", () => {
expect(parsePlayerCell("")).toBeNull();
expect(parsePlayerCell("Bye")).toBeNull();
expect(parsePlayerCell("Qualifier")).toBeNull();
expect(parsePlayerCell("{{flagicon|GBR}} Qualifier")).toBeNull();
expect(parsePlayerCell("TBD")).toBeNull();
});
});
describe("parseTemplateParams", () => {
it("splits on top-level pipes only, preserving piped links and templates", () => {
const params = parseTemplateParams(
"RD1-team1={{flagicon|ITA}} [[A B|A]]|RD1-seed1=1|RD1-score1-1=6",
);
expect(params.get("RD1-team1")).toBe("{{flagicon|ITA}} [[A B|A]]");
expect(params.get("RD1-seed1")).toBe("1");
expect(params.get("RD1-score1-1")).toBe("6");
});
});
describe("extractTemplates", () => {
it("finds the 8 section brackets and 1 finals bracket", () => {
expect(extractTemplates(FIXTURE, ["16TeamBracket"]).length).toBe(8);
expect(extractTemplates(FIXTURE, ["8TeamBracket"]).length).toBe(1);
});
});
describe("parseTennisDrawWikitext (2025 Wimbledon men's singles)", () => {
const draw = parseTennisDrawWikitext(FIXTURE, ARTICLE);
it("maps to the tennis_128 template", () => {
expect(draw.templateId).toBe("tennis_128");
});
it("produces the full 127-match draw with correct per-round counts", () => {
const byRound = Object.fromEntries(draw.rounds.map((r) => [r.roundName, r.matches.length]));
expect(byRound).toEqual({
"Round of 128": 64,
"Round of 64": 32,
"Round of 32": 16,
"Round of 16": 8,
Quarterfinals: 4,
Semifinals: 2,
Final: 1,
});
const total = draw.rounds.reduce((sum, r) => sum + r.matches.length, 0);
expect(total).toBe(127);
});
it("populates both players in section rounds (not just the finals bracket)", () => {
const r128 = roundOrThrow(draw.rounds, "Round of 128");
// A full 128 draw has no byes — every R128 slot is filled.
const populated = r128.matches.filter((m) => m.player1 && m.player2);
expect(populated.length).toBe(64);
// Section brackets abbreviate display names ("J Sinner") but the wikilink
// target stays the full name — so externalId is the reliable key.
const sinnerMatch = r128.matches.find(
(m) => m.player1?.externalId === "Jannik Sinner" || m.player2?.externalId === "Jannik Sinner",
);
expect(sinnerMatch).toBeDefined();
const sinner =
sinnerMatch?.player1?.externalId === "Jannik Sinner"
? sinnerMatch?.player1
: sinnerMatch?.player2;
expect(sinner?.name).toBe("J Sinner");
expect(sinner?.seed).toBe(1);
});
it("identifies Sinner as the champion (won the Final)", () => {
const final = roundOrThrow(draw.rounds, "Final").matches[0];
const winner = final.winner === 1 ? final.player1 : final.player2;
expect(winner?.name).toBe("Jannik Sinner");
});
it("gives every match a unique externalMatchId", () => {
const ids = draw.rounds.flatMap((r) => r.matches.map((m) => m.externalMatchId));
expect(new Set(ids).size).toBe(ids.length);
});
it("records seeds for the 32 seeded players in round 1", () => {
const r128 = roundOrThrow(draw.rounds, "Round of 128");
const seeded = r128.matches
.flatMap((m) => [m.player1, m.player2])
.filter((p) => typeof p?.seed === "number");
expect(seeded.length).toBe(32);
});
});
describe("articleTitleFromInput", () => {
it("extracts the title from a pasted Wikipedia URL", () => {
expect(
articleTitleFromInput(
"https://en.wikipedia.org/wiki/2025_Wimbledon_Championships_%E2%80%93_Men%27s_singles",
),
).toBe("2025 Wimbledon Championships Men's singles");
});
it("strips query strings and fragments", () => {
expect(articleTitleFromInput("https://en.wikipedia.org/wiki/French_Open?action=raw#Draw")).toBe(
"French Open",
);
});
it("passes a plain title through unchanged", () => {
expect(articleTitleFromInput(" 2025 US Open Men's singles ")).toBe(
"2025 US Open Men's singles",
);
});
});
describe("slugifyArticle", () => {
it("produces an id-safe slug and strips diacritics", () => {
expect(slugifyArticle(ARTICLE)).toBe("2025-wimbledon-championships-men-s-singles");
});
});

View file

@ -1,15 +1,50 @@
import { database } from "~/database/context";
import { eq } from "drizzle-orm";
import * as schema from "~/database/schema";
import { findParticipantsBySportsSeasonId, updateParticipant } from "~/models/season-participant";
import {
findParticipantsBySportsSeasonId,
updateParticipant,
createParticipant,
createManyParticipants,
} from "~/models/season-participant";
import { upsertSeasonMatchBulk, upsertMatchSubGame } from "~/models/season-match";
import { setMatchWinner, updatePlayoffMatch, advanceWinnerTemplate, findPlayoffMatchesByEventId, doesLoserAdvance } from "~/models/playoff-match";
import { processMatchResult, autoCompleteRoundIfDone } from "~/models/scoring-calculator";
import { findMatchingTeamName } from "~/lib/normalize-team-name";
import {
setMatchWinner,
updatePlayoffMatch,
advanceWinnerTemplate,
findPlayoffMatchesByEventId,
doesLoserAdvance,
populateBracketFromDraw,
} from "~/models/playoff-match";
import {
processMatchResult,
autoCompleteRoundIfDone,
processQualifyingBracketEvent,
recalculateAffectedLeagues,
} from "~/models/scoring-calculator";
import { fanOutMajorIfPrimary } from "~/services/sync-tournament-results";
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
import {
recalculateParticipantQP,
diffChangedQualifyingPoints,
} from "~/models/qualifying-points";
import { getScoringEventById, updateScoringEvent, isReadOnlySibling } from "~/models/scoring-event";
import { findMatchingTeamName, normalizeTeamName } from "~/lib/normalize-team-name";
import { buildUnmatchedTeamResolutionView } from "~/lib/unmatched-team-reconciliation";
import { getBracketTemplate } from "~/lib/bracket-templates";
import { PandaScoreMatchSyncAdapter } from "./pandascore";
import { EspnScheduleAdapter } from "./espn-schedule";
import type { MatchSyncAdapter, MatchSyncResult } from "./types";
import { WikipediaTennisAdapter } from "./wikipedia-tennis";
import { playerKey, canonicalPlayerName, buildResolvedMatches } from "./tennis-draw-mapping";
import type {
MatchSyncAdapter,
MatchSyncResult,
DrawSyncAdapter,
DrawSyncResult,
DrawSyncPreview,
FetchedDraw,
FetchedPlayer,
} from "./types";
import { logger } from "~/lib/logger";
function getMatchSyncAdapter(simulatorType: string): MatchSyncAdapter {
@ -269,3 +304,403 @@ export async function syncMatches(sportsSeasonId: string): Promise<MatchSyncResu
return { swissCreated, swissUpdated, playoffUpdated, unmatchedTeams, errors };
}
// ===========================================================================
// Tennis Grand Slam draw sync
// ===========================================================================
function getDrawAdapter(simulatorType: string): DrawSyncAdapter {
switch (simulatorType) {
case "tennis_qualifying_points":
return new WikipediaTennisAdapter();
default:
throw new Error(
`No draw sync adapter available for simulator type "${simulatorType}". ` +
"Tennis is currently supported.",
);
}
}
/** Validate the event, fetch + parse its draw. Read-only — used by sync & preview. */
async function loadTennisDraw(eventId: string) {
const db = database();
const event = await getScoringEventById(eventId);
if (!event) throw new Error(`Scoring event ${eventId} not found`);
if (isReadOnlySibling(event)) {
throw new Error(
"This event is a read-only window of a shared major. Sync the draw on the primary window; " +
"results fan out here automatically.",
);
}
if (!event.isQualifyingEvent) {
throw new Error(`Event "${event.name}" is not a qualifying event; cannot sync a tennis draw.`);
}
const sourceKey = event.externalSourceKey;
if (!sourceKey) {
throw new Error(
`Event "${event.name}" has no externalSourceKey (Wikipedia article title) configured.`,
);
}
const sportsSeason = await db.query.sportsSeasons.findFirst({
where: eq(schema.sportsSeasons.id, event.sportsSeasonId),
with: { sport: true },
});
const simulatorType = sportsSeason?.sport?.simulatorType;
if (!simulatorType) {
throw new Error(`Sport for season ${event.sportsSeasonId} has no simulator type configured`);
}
const draw = await getDrawAdapter(simulatorType).fetchDraw(sourceKey);
const template = getBracketTemplate(draw.templateId);
if (!template) throw new Error(`Bracket template "${draw.templateId}" not found`);
return { event, sportsSeasonId: event.sportsSeasonId, draw, template };
}
/** Distinct players across the whole draw, keyed by stable identity. */
function collectUniquePlayers(draw: FetchedDraw): Map<string, FetchedPlayer> {
const players = new Map<string, FetchedPlayer>();
for (const round of draw.rounds) {
for (const match of round.matches) {
for (const player of [match.player1, match.player2]) {
if (player) players.set(playerKey(player), player);
}
}
}
return players;
}
type SeasonParticipant = Awaited<ReturnType<typeof findParticipantsBySportsSeasonId>>[number];
type PlayerLookups = {
byExternalId: Map<string, SeasonParticipant>;
byName: Map<string, SeasonParticipant>;
names: string[];
recon: { id: string; name: string; externalId: string | null }[];
};
function buildLookups(participants: SeasonParticipant[]): PlayerLookups {
return {
byExternalId: new Map(
participants.filter((p) => p.externalId).map((p) => [p.externalId as string, p]),
),
byName: new Map(participants.map((p) => [p.name, p])),
names: participants.map((p) => p.name),
recon: participants.map((p) => ({ id: p.id, name: p.name, externalId: p.externalId })),
};
}
type Classification =
| { kind: "matched"; participant: SeasonParticipant; backfill: boolean }
| {
kind: "create";
name: string;
suggestion: string | null;
suggestionId: string | null;
};
/** Read-only: decide how a drawn player resolves against existing participants. */
function classifyPlayer(player: FetchedPlayer, lk: PlayerLookups): Classification {
const name = canonicalPlayerName(player);
const byId = player.externalId ? lk.byExternalId.get(player.externalId) : undefined;
if (byId) return { kind: "matched", participant: byId, backfill: false };
const matchedName = findMatchingTeamName(name, lk.names);
if (matchedName) {
const p = lk.byName.get(matchedName);
if (p) return { kind: "matched", participant: p, backfill: !p.externalId && !!player.externalId };
}
// No confident match. Surface the closest existing participant (if any) as a
// possible duplicate to review.
const view = buildUnmatchedTeamResolutionView(name, lk.recon);
const top = view.confidence !== "none" ? view.topCandidates[0] ?? null : null;
return {
kind: "create",
name,
suggestion: top?.participantName ?? null,
suggestionId: top?.participantId ?? null,
};
}
/**
* Dry run: report what a draw sync would do (matches, would-be participant
* creations, likely duplicates, unfilled first-round slots) without any writes.
*/
export async function previewTennisDraw(eventId: string): Promise<DrawSyncPreview> {
const { event, sportsSeasonId, draw } = await loadTennisDraw(eventId);
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
const lk = buildLookups(participants);
const uniquePlayers = collectUniquePlayers(draw);
let matched = 0;
const willCreate: DrawSyncPreview["willCreate"] = [];
const possibleDuplicates: DrawSyncPreview["possibleDuplicates"] = [];
for (const player of uniquePlayers.values()) {
const c = classifyPlayer(player, lk);
if (c.kind === "matched") {
matched++;
} else {
willCreate.push({ name: c.name, externalId: player.externalId });
if (c.suggestion && c.suggestionId) {
possibleDuplicates.push({
name: c.name,
externalId: player.externalId,
suggestion: c.suggestion,
suggestionId: c.suggestionId,
});
}
}
}
const firstRound = draw.rounds[0];
const tbdFirstRound = firstRound
? firstRound.matches.reduce((n, m) => n + (m.player1 ? 0 : 1) + (m.player2 ? 0 : 1), 0)
: 0;
const allMatches = draw.rounds.flatMap((r) => r.matches);
return {
article: event.externalSourceKey ?? "",
totalPlayers: uniquePlayers.size,
matched,
willCreate,
possibleDuplicates,
tbdFirstRound,
totalMatches: allMatches.length,
completedMatches: allMatches.filter((m) => m.winner !== null).length,
};
}
/**
* Populate and auto-score a tennis major's bracket from its external draw
* (Wikipedia). Resolves every drawn player to a participant matching by
* external id, then name, else auto-creating propagates new participants to
* every sports season linked to the same tournament (so result fan-out lands),
* writes the full bracket, and runs the qualifying-points scorer.
*
* Runs only on a major's primary window; read-only siblings receive results via
* fan-out, not direct scoring.
*/
export async function syncTennisDraw(eventId: string): Promise<DrawSyncResult> {
const db = database();
const { event, sportsSeasonId, draw, template } = await loadTennisDraw(eventId);
const roundIsScoring = new Map(template.rounds.map((r) => [r.name, r.isScoring]));
// ---- Resolve players → participants (primary season) ----------------------
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
const lk = buildLookups(participants);
const uniquePlayers = collectUniquePlayers(draw);
const keyToParticipantId = new Map<string, string>();
const resolvedParticipants = new Map<
string,
{ canonicalId: string | null; name: string; externalId: string | null }
>();
const unmatched: DrawSyncResult["unmatched"] = [];
let participantsCreated = 0;
for (const [key, player] of uniquePlayers) {
const c = classifyPlayer(player, lk);
let participant: SeasonParticipant;
if (c.kind === "matched") {
participant = c.participant;
if (c.backfill && player.externalId) {
await updateParticipant(participant.id, { externalId: player.externalId });
}
} else {
// No confident match → auto-create so the bracket is complete; flag a
// likely duplicate for admin review when it resembles an existing player.
if (c.suggestion) unmatched.push({ name: c.name, externalId: player.externalId });
participant = await createParticipant({
sportsSeasonId,
name: c.name,
externalId: player.externalId ?? null,
});
lk.names.push(participant.name);
lk.byName.set(participant.name, participant);
lk.recon.push({ id: participant.id, name: participant.name, externalId: participant.externalId });
if (participant.externalId) lk.byExternalId.set(participant.externalId, participant);
participantsCreated++;
}
keyToParticipantId.set(key, participant.id);
resolvedParticipants.set(key, {
canonicalId: participant.participantId ?? null,
name: participant.name,
externalId: participant.externalId ?? null,
});
}
// ---- Propagate participants to linked sibling seasons ---------------------
if (event.tournamentId) {
const linkedRows = await db
.select({ sportsSeasonId: schema.scoringEvents.sportsSeasonId })
.from(schema.scoringEvents)
.where(eq(schema.scoringEvents.tournamentId, event.tournamentId));
const linkedSeasonIds = [...new Set(linkedRows.map((r) => r.sportsSeasonId))].filter(
(id) => id !== sportsSeasonId,
);
// Dedupe by canonical participant: two drawn players can fuzzy-match the same
// existing participant, which would otherwise queue duplicate sibling inserts
// and trip the (sportsSeasonId, name) unique index.
const resolved = [
...new Map(
[...resolvedParticipants.values()]
.filter((r) => r.canonicalId)
.map((r) => [r.canonicalId, r]),
).values(),
];
for (const seasonId of linkedSeasonIds) {
const sib = await findParticipantsBySportsSeasonId(seasonId);
const sibCanon = new Set(sib.map((s) => s.participantId).filter(Boolean));
const sibNames = new Set(sib.map((s) => normalizeTeamName(s.name)));
const toCreate = resolved
.filter(
(r) =>
r.canonicalId &&
!sibCanon.has(r.canonicalId) &&
!sibNames.has(normalizeTeamName(r.name)),
)
.map((r) => ({
sportsSeasonId: seasonId,
name: r.name,
externalId: r.externalId,
participantId: r.canonicalId,
}));
if (toCreate.length > 0) {
await createManyParticipants(toCreate);
participantsCreated += toCreate.length;
}
}
}
// ---- Write the bracket ----------------------------------------------------
const resolvedMatches = buildResolvedMatches(draw, keyToParticipantId, roundIsScoring);
// Ensure the event is configured for QP bracket scoring before populating.
if (
event.bracketTemplateId !== draw.templateId ||
event.scoringStartsAtRound !== template.scoringStartsAtRound
) {
await updateScoringEvent(eventId, {
bracketTemplateId: draw.templateId,
scoringStartsAtRound: template.scoringStartsAtRound,
});
}
const { written, completed, newlyDecidedLoserIds } = await populateBracketFromDraw(
eventId,
resolvedMatches,
);
// ---- Score (qualifying points) + fan out to siblings ----------------------
// Re-derive QP from the bracket and learn which participants' QP changed so the
// Discord notification below only announces new/changed results on a re-sync.
const changedParticipantIds = await rescoreTennisBracketAndDetectChanges(
eventId,
sportsSeasonId,
db,
);
await recalculateAffectedLeagues(sportsSeasonId, db, {
eventId,
eventName: event.name ?? undefined,
});
// Players knocked out this sync in a non-scoring round (rounds 13 of a Grand
// Slam) earn no QP and so never appear via changedParticipantIds. Computed here
// so it feeds BOTH the primary's own notification below AND the fan-out, which
// propagates it to every mirror window (whose placement-only data can't detect a
// knockout on its own).
const newlyEliminatedIds = new Set(newlyDecidedLoserIds);
await fanOutMajorIfPrimary(
{ id: event.id, isPrimary: event.isPrimary, tournamentId: event.tournamentId },
{ markComplete: false, newlyEliminatedParticipantIds: newlyEliminatedIds },
);
// Announce QP changes for the primary window. Sibling windows are announced by
// the fan-out (via processQualifyingEvent); the primary is scored directly by
// processQualifyingBracketEvent, which does NOT notify — so do it here. Run
// outside the rescore transaction so the webhook HTTP call neither holds the
// transaction open nor rolls back the score if Discord fails.
//
// Fire even when no QP changed, so an early-round elimination is still surfaced.
if (changedParticipantIds.size > 0 || newlyEliminatedIds.size > 0) {
try {
await notifyQualifyingPointsUpdate(
sportsSeasonId,
eventId,
db,
changedParticipantIds,
newlyEliminatedIds,
);
} catch (error) {
logger.error(`[syncTennisDraw] QP Discord notification failed for event ${eventId}:`, error);
}
}
return { matchesWritten: written, completed, participantsCreated, unmatched };
}
/**
* Re-derive a tennis major's qualifying points from its bracket and report which
* season participants' awarded QP changed since the previous scoring.
*
* The synced draw is authoritative for tennis, so this clears ALL event_results
* for the event (including any manually-entered rows, e.g. a notParticipating
* withdrawal) and rebuilds them from the bracket. Change detection therefore
* snapshots each participant's awarded QP *before* the delete and diffs it against
* the freshly written rows: a value that differs, or a participant scored for the
* first time (null value), counts as changed. Wrapped in a transaction so a
* mid-rescore failure can't leave the event with results deleted-but-not-rebuilt.
*
* writeEventResultsQP (inside processQualifyingBracketEvent) upserts but never
* deletes, so QP totals for participants who no longer earn points (e.g. an
* early-round loser dropped from the rewritten results only Round of 16+ losers
* score) are recalculated by hand.
*
* Returns the set of season_participant ids whose QP changed, used to scope the
* QP Discord notification so a re-sync that changes nothing announces nothing.
*/
export async function rescoreTennisBracketAndDetectChanges(
eventId: string,
sportsSeasonId: string,
db: ReturnType<typeof database>,
): Promise<Set<string>> {
let changed = new Set<string>();
await db.transaction(async (tx) => {
const beforeRows = await tx
.select({
id: schema.eventResults.seasonParticipantId,
qp: schema.eventResults.qualifyingPointsAwarded,
})
.from(schema.eventResults)
.where(eq(schema.eventResults.scoringEventId, eventId));
await tx.delete(schema.eventResults).where(eq(schema.eventResults.scoringEventId, eventId));
await processQualifyingBracketEvent(eventId, tx);
const afterRows = await tx
.select({
id: schema.eventResults.seasonParticipantId,
qp: schema.eventResults.qualifyingPointsAwarded,
})
.from(schema.eventResults)
.where(eq(schema.eventResults.scoringEventId, eventId));
const afterIds = new Set(afterRows.map((r) => r.id));
for (const { id } of beforeRows) {
if (!afterIds.has(id)) await recalculateParticipantQP(id, sportsSeasonId, tx);
}
changed = diffChangedQualifyingPoints(beforeRows, afterRows);
});
return changed;
}

View file

@ -0,0 +1,63 @@
/**
* Pure (DB-free) mapping helpers for tennis draw sync, separated so the
* scoring-critical logic winner/loser assignment and which rounds score can
* be unit-tested without a database.
*/
import { normalizeTeamName } from "~/lib/normalize-team-name";
import type { ResolvedDrawMatch } from "~/models/playoff-match";
import type { FetchedDraw, FetchedPlayer } from "./types";
/** Stable identity key for a drawn player (prefer the source's external id). */
export function playerKey(p: FetchedPlayer): string {
return p.externalId ? `id:${p.externalId}` : `name:${normalizeTeamName(p.name)}`;
}
/**
* Canonical participant name for a drawn player. Section brackets abbreviate the
* display name ("J Sinner"); the wikilink target (externalId) is the full name,
* so prefer it. Strips the trailing Wikipedia disambiguator (e.g. "Arthur Fils
* (tennis)" → "Arthur Fils") so the stored name and fuzzy matching use the plain
* name the raw externalId (with the disambiguator) is kept as the stable key.
*/
export function canonicalPlayerName(p: FetchedPlayer): string {
return (p.externalId ?? p.name).replace(/\s*\([^)]*\)\s*$/, "").trim();
}
/**
* Turn a fetched draw into bracket rows with participants/winner resolved to ids.
* `winnerId`/`loserId` are set only when both players resolved and the source
* reported a winner. `isScoring` comes from the bracket template's round config.
*/
export function buildResolvedMatches(
draw: FetchedDraw,
keyToParticipantId: Map<string, string>,
roundIsScoring: Map<string, boolean>,
): ResolvedDrawMatch[] {
const resolved: ResolvedDrawMatch[] = [];
for (const round of draw.rounds) {
for (const m of round.matches) {
const p1 = m.player1 ? keyToParticipantId.get(playerKey(m.player1)) ?? null : null;
const p2 = m.player2 ? keyToParticipantId.get(playerKey(m.player2)) ?? null : null;
let winnerId: string | null = null;
let loserId: string | null = null;
if (m.winner === 1) {
winnerId = p1;
loserId = p2;
} else if (m.winner === 2) {
winnerId = p2;
loserId = p1;
}
resolved.push({
externalMatchId: m.externalMatchId,
round: m.round,
matchNumber: m.matchNumber,
participant1Id: p1,
participant2Id: p2,
winnerId,
loserId,
isScoring: roundIsScoring.get(m.round) ?? false,
});
}
}
return resolved;
}

View file

@ -42,3 +42,103 @@ export interface MatchSyncResult {
unmatchedTeams: { externalId: string; name: string }[];
errors: { externalMatchId: string; error: string }[];
}
// ---------------------------------------------------------------------------
// Tennis Grand Slam draw sync
//
// Unlike the team-sport `MatchSyncAdapter` (result-only updates on a
// pre-existing bracket), a tennis major must be populated from an empty draw:
// 128 players, scaffolded across all 7 rounds, with winners auto-scored as the
// source reports them. `DrawSyncAdapter` returns the full draw; `syncTennisDraw`
// (index.ts) auto-creates/links participants, builds the bracket, propagates
// participants to linked sibling seasons, and runs the qualifying-points scorer.
// ---------------------------------------------------------------------------
/** A competitor in a fetched draw match, as seen by the external source. */
export interface FetchedPlayer {
/** Display name, e.g. "Jannik Sinner". */
name: string;
/**
* Stable source identifier, the primary key against
* `seasonParticipants.externalId`. For Wikipedia this is the article link
* target (stable across years). Null when the source gives only plain text
* (e.g. a qualifier with no article).
*/
externalId: string | null;
/** Tournament seed, if seeded. */
seed: number | null;
}
/** A single fetched draw match. */
export interface FetchedDrawMatch {
/** Stable, source-derived id, e.g. "wimbledon-2025-ms-Round-of-16-m03". */
externalMatchId: string;
/** Template round name, e.g. "Round of 128" … "Final". */
round: string;
/** 1-based position within the round (cosmetic ordering only). */
matchNumber: number;
player1: FetchedPlayer | null;
player2: FetchedPlayer | null;
/** Which slot won; null while undrawn or not yet completed. */
winner: 1 | 2 | null;
}
export interface FetchedRound {
/** Template round name. */
roundName: string;
matches: FetchedDrawMatch[];
}
/** A complete (or partially-completed) knockout draw. */
export interface FetchedDraw {
/** Bracket template this draw maps onto, e.g. "tennis_128". */
templateId: string;
/** Ordered earliest → latest round (Round of 128 → Final). */
rounds: FetchedRound[];
}
/** Adapter that fetches a knockout draw from an external source. */
export interface DrawSyncAdapter {
/** @param sourceKey provider locator (Wikipedia article title, etc.). */
fetchDraw(sourceKey: string): Promise<FetchedDraw>;
}
/** A drawn player that couldn't be confidently auto-resolved to a participant. */
export interface UnmatchedPlayer {
name: string;
externalId: string | null;
}
export interface DrawSyncResult {
/** Matches inserted or updated in the bracket. */
matchesWritten: number;
/** Of those, how many carried a completed result this sync. */
completed: number;
/** New participants auto-created (across all linked seasons). */
participantsCreated: number;
/** Ambiguous players queued for admin reconciliation rather than guessed. */
unmatched: UnmatchedPlayer[];
}
/** Read-only preview of what a draw sync would do, with no DB writes. */
export interface DrawSyncPreview {
/** Resolved Wikipedia article title. */
article: string;
totalPlayers: number;
/** Players that resolve to an existing participant. */
matched: number;
/** Players that would be auto-created. */
willCreate: UnmatchedPlayer[];
/** Of those, ones that resemble an existing participant (likely duplicates). */
possibleDuplicates: {
name: string;
externalId: string | null;
/** Existing participant this resembles. */
suggestion: string;
suggestionId: string;
}[];
/** Unfilled slots in the first round (e.g. a qualifier not yet drawn). */
tbdFirstRound: number;
totalMatches: number;
completedMatches: number;
}

View file

@ -0,0 +1,307 @@
/**
* Wikipedia tennis Grand Slam draw adapter.
*
* Source of truth: the singles-draw article (e.g.
* "2025 Wimbledon Championships Men's singles"), whose full bracket is encoded
* as MediaWiki bracket templates:
* - eight `{{16TeamBracket-Compact-Tennis5}}` sections (Round of 128 Round
* of 16), 16 players each = 128, and
* - one `{{8TeamBracket-Tennis5}}` finals bracket (Quarterfinals Final).
* (`-Tennis3` best-of-3 variants render women's draws identically for our needs.)
*
* The winner of each match is the slot whose `team` value is bolded (`'''…'''`)
* losers are never bolded which lets us derive winner/loser without parsing
* set scores. Player names come from `[[wikilinks]]`, whose target is a stable
* cross-year id used to match `seasonParticipants.externalId`.
*
* Parsing is split out (pure functions) so it can be unit-tested against a saved
* wikitext fixture without network access.
*/
import type {
DrawSyncAdapter,
FetchedDraw,
FetchedDrawMatch,
FetchedPlayer,
FetchedRound,
} from "./types";
const WIKIPEDIA_API = "https://en.wikipedia.org/w/api.php";
const CONTACT = process.env.WIKIPEDIA_CONTACT_EMAIL ?? "admin@brackt.com";
const USER_AGENT = `Brackt.com tennis draw sync - ${CONTACT}`;
/** Round names for a 16-player draw section, earliest → latest. */
const SECTION_ROUNDS = ["Round of 128", "Round of 64", "Round of 32", "Round of 16"] as const;
/** Round names for the 8-player finals bracket, earliest → latest. */
const FINALS_ROUNDS = ["Quarterfinals", "Semifinals", "Final"] as const;
/**
* Accept either a plain article title or a pasted Wikipedia URL and return the
* article title. URLs like
* `https://en.wikipedia.org/wiki/2025_Wimbledon_Championships_%E2%80%93_Men%27s_singles`
* become "2025 Wimbledon Championships Men's singles".
*/
export function articleTitleFromInput(input: string): string {
const trimmed = input.trim();
const match = trimmed.match(/\/wiki\/([^?#]+)/);
if (!match) return trimmed;
let title = match[1];
try {
title = decodeURIComponent(title);
} catch {
// leave as-is if it isn't valid percent-encoding
}
return title.replace(/_/g, " ").trim();
}
/** Turn an article title into a stable, id-safe slug. */
export function slugifyArticle(title: string): string {
return title
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "") // strip combining diacritics
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
/**
* Scan wikitext for `{{<TemplateName>...}}` invocations, returning each body
* (text between the outer braces) with balanced-brace handling so nested
* templates like `{{flagicon|ITA}}` don't terminate the capture early.
*/
export function extractTemplates(wikitext: string, namePrefixes: string[]): { name: string; body: string }[] {
const results: { name: string; body: string }[] = [];
for (let i = 0; i < wikitext.length - 1; i++) {
if (wikitext[i] !== "{" || wikitext[i + 1] !== "{") continue;
const afterBraces = wikitext.slice(i + 2);
const match = afterBraces.match(/^\s*([A-Za-z0-9 _-]+)/);
const name = match?.[1]?.trim() ?? "";
if (!namePrefixes.some((p) => name.startsWith(p))) continue;
// Walk forward tracking {{ }} depth to find the matching close.
let depth = 0;
let j = i;
for (; j < wikitext.length - 1; j++) {
if (wikitext[j] === "{" && wikitext[j + 1] === "{") {
depth++;
j++;
} else if (wikitext[j] === "}" && wikitext[j + 1] === "}") {
depth--;
j++;
if (depth === 0) break;
}
}
const body = wikitext.slice(i + 2, j - 1);
results.push({ name, body });
i = j; // continue past this template
}
return results;
}
/**
* Split a template body into `key=value` params on top-level `|` only
* pipes inside `{{…}}` or `[[…]]` are part of a value (flag templates, piped
* wikilinks) and must not split.
*/
export function parseTemplateParams(body: string): Map<string, string> {
const params = new Map<string, string>();
let braceDepth = 0;
let linkDepth = 0;
let current = "";
const parts: string[] = [];
for (let i = 0; i < body.length; i++) {
const two = body.slice(i, i + 2);
if (two === "{{") { braceDepth++; current += two; i++; continue; }
if (two === "}}") { braceDepth--; current += two; i++; continue; }
if (two === "[[") { linkDepth++; current += two; i++; continue; }
if (two === "]]") { linkDepth--; current += two; i++; continue; }
if (body[i] === "|" && braceDepth === 0 && linkDepth === 0) {
parts.push(current);
current = "";
continue;
}
current += body[i];
}
parts.push(current);
for (const part of parts) {
const eq = part.indexOf("=");
if (eq === -1) continue;
const key = part.slice(0, eq).trim();
const value = part.slice(eq + 1).trim();
if (key) params.set(key, value);
}
return params;
}
/**
* Parse a bracket `team` value into a player + whether it won.
* Returns null for an empty/Bye slot.
*/
export function parsePlayerCell(value: string): { player: FetchedPlayer; isWinner: boolean } | null {
if (!value) return null;
// Winner detection: only the winning slot's name is bolded.
const isWinner = value.includes("'''");
let text = value.replace(/'''/g, "");
// Extract the wikilink FIRST, before stripping templates. A team cell may wrap
// the flag + link in a display template, e.g.
// {{nowrap|{{flagicon|ESP}} [[Alejandro Davidovich Fokina|A Davidovich Fokina]]}}
// Blanket template-stripping would, after removing the inner {{flagicon}}, treat
// the outer {{nowrap| … [[link]] … }} as a single template and delete the link
// with it — yielding an empty cell (TBD). Pulling the link out first avoids that.
const linkMatch = text.match(/\[\[([^\]]+)\]\]/);
let name: string;
let externalId: string | null;
if (linkMatch) {
const [target, display] = linkMatch[1].split("|");
externalId = target.trim();
name = (display ?? target).trim();
} else {
// No link: unwrap display templates ({{nowrap|X}}) to their content, drop flag
// templates, and keep whatever plain text remains (e.g. an unlinked qualifier).
let prev: string;
do {
prev = text;
text = text.replace(/\{\{\s*(?:nowrap|nobr)\s*\|([^{}]*)\}\}/gi, "$1");
} while (text !== prev);
do {
prev = text;
text = text.replace(/\{\{[^{}]*\}\}/g, " ");
} while (text !== prev);
name = text.replace(/\[\[|\]\]/g, "").trim();
externalId = null;
}
name = name.replace(/\s+/g, " ").trim();
// Treat empty slots and not-yet-determined placeholders (an unfilled qualifier,
// a bye, a TBD feeder) as no player → the bracket shows TBD rather than
// inventing a shared "Qualifier" participant across many slots.
if (!name || /^(bye|tbd|qualifier|to be determined)$/i.test(name)) return null;
return { player: { name, externalId, seed: null }, isWinner };
}
/**
* Build matches for one round of a bracket. Within a round, consecutive slots
* pair up: (team1,team2)=match1, (team3,team4)=match2,
*/
function parseRound(
params: Map<string, string>,
rdIndex: number,
roundName: string,
idPrefix: string,
matchNumberOffset: number,
): FetchedDrawMatch[] {
// Collect team slot tokens present for this round, preserving the raw token
// (slots are zero-padded in the compact section brackets, e.g. "team01", but
// unpadded in the finals bracket, e.g. "team1"). Sort by numeric value.
const slots: string[] = [];
for (const key of params.keys()) {
const m = key.match(new RegExp(`^RD${rdIndex}-team(\\d+)$`));
if (m) slots.push(m[1]);
}
slots.sort((a, b) => Number(a) - Number(b));
const matches: FetchedDrawMatch[] = [];
for (let p = 0; p < slots.length; p += 2) {
const slot1 = slots[p];
const slot2 = slots[p + 1];
const cell1 = parsePlayerCell(params.get(`RD${rdIndex}-team${slot1}`) ?? "");
const cell2 = slot2 !== undefined ? parsePlayerCell(params.get(`RD${rdIndex}-team${slot2}`) ?? "") : null;
const seed1 = params.get(`RD${rdIndex}-seed${slot1}`);
const seed2 = slot2 !== undefined ? params.get(`RD${rdIndex}-seed${slot2}`) : undefined;
const player1 = cell1 ? { ...cell1.player, seed: parseSeed(seed1) } : null;
const player2 = cell2 ? { ...cell2.player, seed: parseSeed(seed2) } : null;
let winner: 1 | 2 | null = null;
if (cell1?.isWinner) winner = 1;
else if (cell2?.isWinner) winner = 2;
const matchNumber = matchNumberOffset + matches.length + 1;
matches.push({
externalMatchId: `${idPrefix}:${roundName.replace(/\s+/g, "-")}:m${matchNumber}`,
round: roundName,
matchNumber,
player1,
player2,
winner,
});
}
return matches;
}
function parseSeed(raw: string | undefined): number | null {
if (!raw) return null;
const n = parseInt(raw.replace(/[^0-9]/g, ""), 10);
return Number.isFinite(n) ? n : null;
}
/**
* Parse a full Grand Slam singles draw article into a `FetchedDraw`.
* @param wikitext raw article wikitext
* @param articleTitle used to build stable match ids
*/
export function parseTennisDrawWikitext(wikitext: string, articleTitle: string): FetchedDraw {
const slug = slugifyArticle(articleTitle);
const sectionBrackets = extractTemplates(wikitext, ["16TeamBracket"]);
const finalsBrackets = extractTemplates(wikitext, ["8TeamBracket"]);
if (finalsBrackets.length !== 1 || sectionBrackets.length !== 8) {
throw new Error(
`Unexpected tennis draw layout in "${articleTitle}": found ${sectionBrackets.length} ` +
`16-team section bracket(s) and ${finalsBrackets.length} 8-team finals bracket(s); ` +
"expected 8 sections + 1 finals. Template variant may be unsupported.",
);
}
// Accumulate matches per round across all sections (section order is cosmetic).
const roundMatches = new Map<string, FetchedDrawMatch[]>();
const pushMatches = (round: string, ms: FetchedDrawMatch[]) => {
const existing = roundMatches.get(round) ?? [];
roundMatches.set(round, existing.concat(ms));
};
sectionBrackets.forEach((bracket, sectionIdx) => {
const params = parseTemplateParams(bracket.body);
SECTION_ROUNDS.forEach((roundName, rdIdx) => {
const offset = roundMatches.get(roundName)?.length ?? 0;
pushMatches(roundName, parseRound(params, rdIdx + 1, roundName, `${slug}:s${sectionIdx}`, offset));
});
});
const finalsParams = parseTemplateParams(finalsBrackets[0].body);
FINALS_ROUNDS.forEach((roundName, rdIdx) => {
pushMatches(roundName, parseRound(finalsParams, rdIdx + 1, roundName, `${slug}:f`, 0));
});
const orderedRoundNames = [...SECTION_ROUNDS, ...FINALS_ROUNDS];
const rounds: FetchedRound[] = orderedRoundNames.map((roundName) => ({
roundName,
matches: roundMatches.get(roundName) ?? [],
}));
return { templateId: "tennis_128", rounds };
}
export class WikipediaTennisAdapter implements DrawSyncAdapter {
async fetchDraw(sourceKey: string): Promise<FetchedDraw> {
const title = articleTitleFromInput(sourceKey);
const url = `${WIKIPEDIA_API}?action=parse&prop=wikitext&format=json&formatversion=2&redirects=1&page=${encodeURIComponent(title)}`;
const response = await fetch(url, { headers: { "User-Agent": USER_AGENT } });
if (!response.ok) {
throw new Error(`Wikipedia API returned ${response.status}: ${response.statusText}`);
}
const json = (await response.json()) as {
error?: { info?: string };
parse?: { wikitext?: string };
};
if (json.error) {
throw new Error(`Wikipedia API error for "${title}": ${json.error.info ?? "unknown error"}`);
}
const wikitext = json.parse?.wikitext;
if (!wikitext) {
throw new Error(`No wikitext returned for "${title}"`);
}
return parseTennisDrawWikitext(wikitext, title);
}
}

View file

@ -0,0 +1,251 @@
import type { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, inArray } from "drizzle-orm";
import { findDiscordIdsByUserIds } from "~/models/account";
import { getUserDisplayName } from "~/models/user";
import {
sendQualifyingPointsUpdateNotification,
type QPEventEntry,
type QPEliminatedEntry,
} from "~/services/discord";
export async function notifyQualifyingPointsUpdate(
sportsSeasonId: string,
scoringEventId: string,
db: ReturnType<typeof database>,
participantIdFilter?: Set<string>,
/**
* Participants knocked out this sync in a non-scoring round: they earn no QP,
* so they never appear via qualifyingPointsAwarded, but a manager who drafted
* them should still be told their player is out. Surfaced in a "Knocked Out"
* section, deduped against QP earners.
*/
eliminatedParticipantIds?: Set<string>
): Promise<void> {
const event = await db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.id, scoringEventId),
with: {
sportsSeason: {
with: { sport: true },
},
},
});
if (!event) return;
const eventName = event.name;
const sportName = event.sportsSeason?.sport?.name;
const seasonSports = await db.query.seasonSports.findMany({
where: eq(schema.seasonSports.sportsSeasonId, sportsSeasonId),
});
if (seasonSports.length === 0) return;
// QP earned in this specific event (optionally scoped to participants new/changed in this call)
const eventResultRows = await db.query.eventResults.findMany({
where: eq(schema.eventResults.scoringEventId, scoringEventId),
});
const qpEarnedById = new Map<string, number>(
eventResultRows
.filter((r) => r.qualifyingPointsAwarded !== null)
.filter((r) => !participantIdFilter || participantIdFilter.has(r.seasonParticipantId))
.map((r) => [r.seasonParticipantId, parseFloat(r.qualifyingPointsAwarded as string)])
);
// Knocked-out players with no QP change. Exclude anyone who also earned QP this
// sync (e.g. a Round-of-16 loser) so they aren't listed twice.
const eliminatedIds = new Set(
[...(eliminatedParticipantIds ?? [])].filter((id) => !qpEarnedById.has(id))
);
if (qpEarnedById.size === 0 && eliminatedIds.size === 0) return;
const seasonIds = seasonSports.map((s) => s.seasonId);
// Batch-fetch all draft picks for all leagues at once, grouped by season.
// Fetched before the remaining lookups so we can short-circuit when nothing
// that happened this sync was actually drafted: during a Grand Slam's early
// rounds most eliminated players are undrafted, and the notifier now fires on
// every sync that has any elimination — no point running the rest of the
// queries just to send nothing.
const allPicks = await db.query.draftPicks.findMany({
where: inArray(schema.draftPicks.seasonId, seasonIds),
with: { team: true },
});
const picksBySeasonId = new Map<string, (typeof allPicks)[number][]>();
const draftedParticipantIds = new Set<string>();
for (const pick of allPicks) {
draftedParticipantIds.add(pick.participantId);
const bucket = picksBySeasonId.get(pick.seasonId);
if (bucket) {
bucket.push(pick);
} else {
picksBySeasonId.set(pick.seasonId, [pick]);
}
}
const hasDraftedQP = [...qpEarnedById.keys()].some((id) => draftedParticipantIds.has(id));
const hasDraftedEliminated = [...eliminatedIds].some((id) => draftedParticipantIds.has(id));
if (!hasDraftedQP && !hasDraftedEliminated) return;
// Current running QP totals for the season
const qpTotals = await db.query.seasonParticipantQualifyingTotals.findMany({
where: eq(schema.seasonParticipantQualifyingTotals.sportsSeasonId, sportsSeasonId),
});
const qpTotalById = new Map<string, number>(
qpTotals.map((t) => [t.participantId, parseFloat(t.totalQualifyingPoints)])
);
// Rank every participant across the FULL season field (not just this event's
// scorers) so the notification's "QP Standings" block reads as a season-leaderboard
// slice and matches the website's globalRank. Standard competition ranking: ties
// share the lower rank (…, 9, 9, 11, …). Two R16 losers on 1.5 QP with 8 players
// ahead therefore render as T9, not T1 among just the two of them.
const rankedField = [...qpTotalById.entries()]
.map(([id, total]) => ({ id, total }))
.toSorted((a, b) => b.total - a.total);
const globalRankById = new Map<string, number>();
let prevTotal = Number.NaN;
let prevRank = 0;
rankedField.forEach((row, index) => {
const rank =
index > 0 && Math.abs(row.total - prevTotal) < 0.001 ? prevRank : index + 1;
globalRankById.set(row.id, rank);
prevTotal = row.total;
prevRank = rank;
});
const countByRank = new Map<number, number>();
for (const rank of globalRankById.values()) {
countByRank.set(rank, (countByRank.get(rank) ?? 0) + 1);
}
// Batch-fetch all season + league metadata in one query
const seasons = await db.query.seasons.findMany({
where: inArray(schema.seasons.id, seasonIds),
with: { league: true },
});
const seasonMap = new Map(seasons.map((s) => [s.id, s]));
// Batch-fetch participant display names once (same participants across all leagues).
// Includes every drafted participant — the Drafted Participants scoreboard section
// lists the whole scored field, not just this sync's changed participants.
const allParticipantIds = [
...new Set([...qpEarnedById.keys(), ...eliminatedIds, ...draftedParticipantIds]),
];
const participants = await db.query.seasonParticipants.findMany({
where: inArray(schema.seasonParticipants.id, allParticipantIds),
});
const participantNameById = new Map(participants.map((p) => [p.id, p.name]));
// A league's draft picks span every sport in that fantasy season, so the scoreboard
// must be scoped to participants belonging to the sports season being announced —
// otherwise a golf pick would surface in a tennis event's standings section.
const sportsSeasonParticipantIds = new Set(
participants.filter((p) => p.sportsSeasonId === sportsSeasonId).map((p) => p.id)
);
// Batch-fetch all team owners and their Discord IDs in two queries (not N per league)
const allOwnerIds = new Set<string>();
for (const pick of allPicks) {
if (pick.team.ownerId) allOwnerIds.add(pick.team.ownerId);
}
const allUsers =
allOwnerIds.size > 0
? await db.query.users.findMany({ where: inArray(schema.users.id, [...allOwnerIds]) })
: [];
const usernameByUserId = new Map(
allUsers
.map((u) => [u.id, getUserDisplayName(u)] as [string, string | null])
.filter((entry): entry is [string, string] => entry[1] !== null)
);
const optedInUserIds = allUsers.filter((u) => u.discordPingEnabled).map((u) => u.id);
const discordIdByUserId = await findDiscordIdsByUserIds(optedInUserIds);
for (const { seasonId } of seasonSports) {
const season = seasonMap.get(seasonId);
const league = season?.league;
const webhookUrl = league?.discordWebhookUrl;
if (!webhookUrl || !season || !league) continue;
const seasonName = `${league.name} ${season.year}`;
// Link the embed title to this league's sport-season standings page. The
// sportsSeasonId is exactly what the /leagues/:leagueId/sports-seasons/:sportsSeasonId
// route expects (the page filters seasonSports by leagueId).
const appUrl = process.env.APP_URL ?? "https://brackt.com";
const standingsUrl = `${appUrl}/leagues/${league.id}/sports-seasons/${sportsSeasonId}`;
const picks = picksBySeasonId.get(seasonId) ?? [];
const teamByParticipantId = new Map<
string,
{ teamName: string; ownerId: string | null }
>();
for (const pick of picks) {
teamByParticipantId.set(pick.participantId, {
teamName: pick.team.name,
ownerId: pick.team.ownerId,
});
}
// Only include participants that appear in both the event and this league's draft
const relevantParticipantIds = [...qpEarnedById.keys()].filter((id) =>
teamByParticipantId.has(id)
);
// Knocked-out players drafted in this league (0 QP, non-scoring-round exits)
const eliminatedForLeague = [...eliminatedIds].filter((id) =>
teamByParticipantId.has(id)
);
if (relevantParticipantIds.length === 0 && eliminatedForLeague.length === 0) continue;
const entries: QPEventEntry[] = relevantParticipantIds.map((participantId) => {
const ownerId = teamByParticipantId.get(participantId)?.ownerId ?? null;
const globalRank = globalRankById.get(participantId) ?? 0;
return {
participantName: participantNameById.get(participantId) ?? participantId,
qpEarned: qpEarnedById.get(participantId) ?? 0,
qpTotal: qpTotalById.get(participantId) ?? 0,
globalRank,
globalRankTied: (countByRank.get(globalRank) ?? 0) > 1,
ownerUsername: ownerId ? (usernameByUserId.get(ownerId) ?? undefined) : undefined,
ownerDiscordUserId: ownerId ? discordIdByUserId.get(ownerId) : undefined,
};
});
const eliminated: QPEliminatedEntry[] = eliminatedForLeague.map((participantId) => {
const ownerId = teamByParticipantId.get(participantId)?.ownerId ?? null;
return {
participantName: participantNameById.get(participantId) ?? participantId,
ownerUsername: ownerId ? (usernameByUserId.get(ownerId) ?? undefined) : undefined,
ownerDiscordUserId: ownerId ? discordIdByUserId.get(ownerId) : undefined,
};
});
// Full current scoreboard for this league: every drafted participant, regardless of
// whether their QP changed this sync. Drives the Drafted Participants section so
// it reads as a season-standings snapshot rather than only this event's movers.
// Never pinged, so ownerDiscordUserId is intentionally omitted.
const scoreboard: QPEventEntry[] = [...teamByParticipantId.keys()]
.filter((participantId) => sportsSeasonParticipantIds.has(participantId))
.map((participantId) => {
const ownerId = teamByParticipantId.get(participantId)?.ownerId ?? null;
const globalRank = globalRankById.get(participantId) ?? 0;
return {
participantName: participantNameById.get(participantId) ?? participantId,
qpEarned: qpEarnedById.get(participantId) ?? 0,
qpTotal: qpTotalById.get(participantId) ?? 0,
globalRank,
globalRankTied: (countByRank.get(globalRank) ?? 0) > 1,
ownerUsername: ownerId ? (usernameByUserId.get(ownerId) ?? undefined) : undefined,
};
});
await sendQualifyingPointsUpdateNotification({
webhookUrl,
seasonName,
sportName,
eventName,
entries,
eliminated,
scoreboard,
standingsUrl,
});
}
}

View file

@ -7,8 +7,10 @@ import {
simulateChampionsStage,
calcStage3ExitQP,
simulateOneMajor,
reconcileAdvancers,
} from "../cs-major-simulator";
import type { AdvancedTeam } from "../cs-major-simulator";
import { buildSpTranslator } from "../shared-major";
import type { AdvancedTeam, BracketMatchInput, SwissMatchInput, StageResultsMap } from "../cs-major-simulator";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@ -542,3 +544,344 @@ describe("not-participating exclusion (CS2)", () => {
expect(resultEvent2.has(excluded.id)).toBe(false); // excluded from event 2
});
});
// ─── simulateSwiss partial-stage locking (initialRecords) ─────────────────────
describe("simulateSwiss with initialRecords (partial stage locking)", () => {
it("locks in seeded results: 3-loss team stays eliminated, 3-win team stays advanced", () => {
const teams = makeTeams(16);
// Two pre-resolved teams keeps the remaining active count even.
const initial = new Map([
["team-0", { wins: 1, losses: 3 }], // eliminated, carries 1 win
["team-15", { wins: 3, losses: 2 }], // advanced, carries 2 losses
]);
for (let i = 0; i < 50; i++) {
const r = simulateSwiss(teams, false, false, initial);
const elim0 = r.eliminated.find((t) => t.id === "team-0");
const adv15 = r.advanced.find((t) => t.id === "team-15");
expect(elim0).toBeDefined();
expect(elim0?.wins).toBe(1); // recorded win count preserved
expect(r.advanced.some((t) => t.id === "team-0")).toBe(false);
expect(adv15).toBeDefined();
expect(adv15?.losses).toBe(2); // recorded loss count preserved
expect(r.eliminated.some((t) => t.id === "team-15")).toBe(false);
}
});
it("a fresh stage (empty initialRecords) still returns 8 advanced / 8 eliminated", () => {
const r = simulateSwiss(makeTeams(16), false, false, new Map());
expect(r.advanced).toHaveLength(8);
expect(r.eliminated).toHaveLength(8);
});
});
// ─── reconcileAdvancers (ragged-snapshot repair, protecting locked teams) ─────
const adv = (id: string, losses: number, rank: number): AdvancedTeam => ({ id, elo: 1500, rank, losses });
const elim = (id: string, wins: number, rank: number) => ({ id, elo: 1500, rank, wins });
describe("reconcileAdvancers", () => {
it("is a no-op when the advancer count already matches the target", () => {
const input = {
advanced: [adv("a", 0, 1), adv("b", 1, 2)],
eliminated: [elim("c", 2, 3)],
};
expect(reconcileAdvancers(input, 2)).toBe(input); // same reference, untouched
});
it("demotes simulated advancers before locked ones when over-filled", () => {
// 3 advancers, target 2. The locked team is the weakest (most losses) so a
// naive demotion would drop it; protection must keep it and demote a
// simulated team instead.
const input = {
advanced: [adv("sim-strong", 0, 1), adv("sim-weak", 2, 3), adv("locked", 2, 2)],
eliminated: [] as Array<{ id: string; elo: number; rank: number; wins: number }>,
};
const result = reconcileAdvancers(input, 2, new Set(["locked"]));
expect(result.advanced.map((t) => t.id).toSorted()).toEqual(["locked", "sim-strong"]);
expect(result.eliminated.map((t) => t.id)).toEqual(["sim-weak"]);
});
it("promotes non-locked eliminated teams before locked ones when under-filled", () => {
// 1 advancer, target 2 → must promote one eliminated team. The locked
// (real) elimination must be preserved; the non-locked team is promoted
// even though it is the weaker (fewer wins) of the two.
const input = {
advanced: [adv("a", 0, 1)],
eliminated: [elim("locked-strong", 2, 2), elim("sim-weak", 0, 3)],
};
const result = reconcileAdvancers(input, 2, new Set(["locked-strong"]));
expect(result.advanced.map((t) => t.id).toSorted()).toEqual(["a", "sim-weak"]);
expect(result.eliminated.map((t) => t.id)).toEqual(["locked-strong"]);
});
it("falls back to locked teams only when non-locked candidates run out", () => {
// 3 advancers, target 1, all locked → forced to demote locked teams,
// keeping the strongest (fewest losses).
const input = {
advanced: [adv("x", 2, 3), adv("y", 0, 1), adv("z", 1, 2)],
eliminated: [] as Array<{ id: string; elo: number; rank: number; wins: number }>,
};
const result = reconcileAdvancers(input, 1, new Set(["x", "y", "z"]));
expect(result.advanced.map((t) => t.id)).toEqual(["y"]); // strongest kept
});
});
// ─── simulateChampionsStage honoring a real bracket ───────────────────────────
function makeChampTeams(): AdvancedTeam[] {
// a strongest … h weakest, all 0 losses.
return ["a", "b", "c", "d", "e", "f", "g", "h"].map((id, i) => ({
id,
elo: 2000 - i * 100,
rank: i + 1,
losses: 0,
}));
}
/** Bracket where the weakest team (h) wins everything, contradicting Elo. */
function makeFullBracket(): BracketMatchInput[] {
return [
{ round: "Quarterfinals", matchNumber: 1, participant1Id: "a", participant2Id: "h", winnerId: "h", isComplete: true },
{ round: "Quarterfinals", matchNumber: 2, participant1Id: "b", participant2Id: "g", winnerId: "g", isComplete: true },
{ round: "Quarterfinals", matchNumber: 3, participant1Id: "c", participant2Id: "f", winnerId: "f", isComplete: true },
{ round: "Quarterfinals", matchNumber: 4, participant1Id: "d", participant2Id: "e", winnerId: "e", isComplete: true },
{ round: "Semifinals", matchNumber: 1, participant1Id: "h", participant2Id: "g", winnerId: "h", isComplete: true },
{ round: "Semifinals", matchNumber: 2, participant1Id: "f", participant2Id: "e", winnerId: "f", isComplete: true },
{ round: "Finals", matchNumber: 1, participant1Id: "h", participant2Id: "f", winnerId: "h", isComplete: true },
];
}
describe("simulateChampionsStage honoring a real bracket", () => {
it("a fully completed bracket yields deterministic placements (ignores Elo)", () => {
const teams = makeChampTeams();
const bracket = makeFullBracket();
for (let i = 0; i < 50; i++) {
const result = simulateChampionsStage(teams, bracket);
expect(result.placements.get("h")).toBe(1); // weakest team won it all
expect(result.placements.get("f")).toBe(2);
expect(result.placements.get("g")).toBe(3); // SF losers
expect(result.placements.get("e")).toBe(3);
for (const id of ["a", "b", "c", "d"]) { // QF losers
expect(result.placements.get(id)).toBe(5);
}
}
});
it("a partially completed bracket fixes decided rounds, randomizes only the rest", () => {
const teams = makeChampTeams();
// QF + SF complete, Final undecided → finalists fixed (h, f), champion varies.
const bracket = makeFullBracket().map((m) =>
m.round === "Finals" ? { ...m, winnerId: null, isComplete: false } : m
);
const championSeen = new Set<number>();
for (let i = 0; i < 100; i++) {
const result = simulateChampionsStage(teams, bracket);
// SF losers and QF losers are locked regardless of the Final.
expect(result.placements.get("g")).toBe(3);
expect(result.placements.get("e")).toBe(3);
for (const id of ["a", "b", "c", "d"]) expect(result.placements.get(id)).toBe(5);
// Champion/finalist are always the two SF winners.
const champion = [...result.placements.entries()].find(([, p]) => p === 1)?.[0];
const finalist = [...result.placements.entries()].find(([, p]) => p === 2)?.[0];
expect(["h", "f"]).toContain(champion);
expect(["h", "f"]).toContain(finalist);
expect(champion).not.toBe(finalist);
championSeen.add(result.placements.get("h") === 1 ? 1 : 0);
}
// Over 100 runs with the Final undecided, h should win at least once and lose
// at least once (it's the weaker of the two finalists by Elo but not certain).
expect(championSeen.size).toBeGreaterThanOrEqual(1);
});
it("falls back to Elo-based seeding when no bracket is provided", () => {
const teams = makeChampTeams();
let aWins = 0;
for (let i = 0; i < 500; i++) {
if (simulateChampionsStage(teams).placements.get("a") === 1) aWins++;
}
expect(aWins / 500).toBeGreaterThan(0.25); // strongest team wins well above 1/8
});
it("ignores recorded results when the bracket's teams are not this field", () => {
// Bracket describes a different set of teams (x1…x8) than the actual
// qualifiers (a…h), so it is not a usable real bracket: recorded winners
// must not bleed into the Elo-seeded simulation of the real field.
const teams = makeChampTeams();
const foreignBracket: BracketMatchInput[] = makeFullBracket().map((m) => ({
...m,
participant1Id: m.participant1Id ? `x-${m.participant1Id}` : null,
participant2Id: m.participant2Id ? `x-${m.participant2Id}` : null,
winnerId: m.winnerId ? `x-${m.winnerId}` : null,
}));
let aWins = 0;
for (let i = 0; i < 500; i++) {
const result = simulateChampionsStage(teams, foreignBracket);
// All 8 real teams still get a placement (the bracket was not applied).
expect(result.placements.size).toBe(8);
if (result.placements.get("a") === 1) aWins++;
}
// Strongest real team wins well above 1/8 → Elo seeding drove it, not the
// foreign bracket (which would have forced its own — absent — winner).
expect(aWins / 500).toBeGreaterThan(0.25);
});
it("discards the whole bracket when a single QF participant is not in the field", () => {
// Regression for IEM Cologne 2026: the Champions field held "Spirit Academy"
// while the bracket seeded the (different) "Team Spirit" participant in QF1.
// The realBracket gate is all-or-nothing, so one out-of-field participant
// makes the entire bracket — including the 3 valid completed QFs — be ignored
// and the stage falls back to Elo seeding. (When this changes to per-match
// honoring, update this test deliberately.)
const teams = makeChampTeams();
const bracket = makeFullBracket().map((mm) =>
mm.round === "Quarterfinals" && mm.matchNumber === 1
? { ...mm, participant1Id: "ghost-not-in-field" }
: mm
);
let hWins = 0; // h wins everything if (and only if) the bracket is honored.
for (let i = 0; i < 500; i++) {
const result = simulateChampionsStage(teams, bracket);
expect(result.placements.size).toBe(8);
if (result.placements.get("h") === 1) hWins++;
}
// Weakest team h would be champion every run if the bracket applied; under
// Elo fallback it wins far less than the ~1.0 a honored bracket would force.
expect(hWins / 500).toBeLessThan(0.5);
});
});
// ─── simulateOneMajor full conditioning (stages + bracket + recorded QP) ──────
/**
* Stage results that fully lock the field down to 8 Champions Stage qualifiers
* (team-24team-31): team-07 out at stage 1, team-815 out at stage 2,
* team-1623 out at stage 3.
*/
function makeLockedStageResults(): StageResultsMap {
const stageResults: StageResultsMap = new Map();
for (let i = 0; i < 8; i++) stageResults.set(`team-${i}`, { stageEntry: 1, stageEliminated: 1, stageEliminatedWins: 1 });
for (let i = 8; i < 16; i++) stageResults.set(`team-${i}`, { stageEntry: 1, stageEliminated: 2, stageEliminatedWins: 1 });
for (let i = 16; i < 24; i++) stageResults.set(`team-${i}`, { stageEntry: 2, stageEliminated: 3, stageEliminatedWins: 1 });
for (let i = 24; i < 32; i++) stageResults.set(`team-${i}`, { stageEntry: 3, stageEliminated: null, stageEliminatedWins: null });
return stageResults;
}
const m = (round: string, matchNumber: number, p1: string, p2: string, winner: string): BracketMatchInput =>
({ round, matchNumber, participant1Id: p1, participant2Id: p2, winnerId: winner, isComplete: true });
/** Champions bracket among team-24…team-31 where team-31 wins everything. */
function makeQualifierBracket(): BracketMatchInput[] {
return [
m("Quarterfinals", 1, "team-31", "team-24", "team-31"),
m("Quarterfinals", 2, "team-30", "team-25", "team-30"),
m("Quarterfinals", 3, "team-29", "team-26", "team-29"),
m("Quarterfinals", 4, "team-28", "team-27", "team-28"),
m("Semifinals", 1, "team-31", "team-30", "team-31"),
m("Semifinals", 2, "team-29", "team-28", "team-29"),
m("Finals", 1, "team-31", "team-29", "team-31"),
];
}
const loss = (loser: string, winner: string): SwissMatchInput =>
({ matchStage: 1, participant1Id: loser, participant2Id: winner, winnerId: winner });
describe("simulateOneMajor full conditioning", () => {
const qpConfig = makeQPConfig();
it("honors a completed bracket: the recorded champion deterministically earns 1st-place QP", () => {
const pool = makeTeams(32);
const stageResults = makeLockedStageResults();
const bracket = makeQualifierBracket();
for (let run = 0; run < 10; run++) {
const result = simulateOneMajor(pool, stageResults, qpConfig, { bracketMatches: bracket });
expect(result.get("team-31")).toBe(qpConfig.get(1)); // 500, the champion
// team-29 lost the final → 2nd place QP (400).
expect(result.get("team-29")).toBe(qpConfig.get(2));
// Stage 1 exits earn nothing.
for (let i = 0; i < 8; i++) expect(result.get(`team-${i}`)).toBe(0);
}
});
it("uses recorded provisional QP verbatim for locked-eliminated teams", () => {
const pool = makeTeams(32);
const stageResults = makeLockedStageResults();
const recordedResults = new Map([["team-0", 123]]); // a stage-1 exit (normally 0)
const result = simulateOneMajor(pool, stageResults, qpConfig, { recordedResults });
expect(result.get("team-0")).toBe(123); // recorded value overrides the derived 0
expect(result.get("team-1")).toBe(0); // no recorded entry → derived value stays
});
it("locks in partial stage progress from Swiss match results", () => {
const pool = makeTeams(32);
// Stage entrants assigned, but NO eliminations recorded (stage 1 incomplete).
const stageResults: StageResultsMap = new Map();
for (let i = 0; i < 16; i++) stageResults.set(`team-${i}`, { stageEntry: 1, stageEliminated: null, stageEliminatedWins: null });
for (let i = 16; i < 24; i++) stageResults.set(`team-${i}`, { stageEntry: 2, stageEliminated: null, stageEliminatedWins: null });
for (let i = 24; i < 32; i++) stageResults.set(`team-${i}`, { stageEntry: 3, stageEliminated: null, stageEliminatedWins: null });
// Swiss results give team-0 and team-1 three stage-1 losses each (eliminated).
const swissMatches: SwissMatchInput[] = [
loss("team-0", "team-2"), loss("team-0", "team-3"), loss("team-0", "team-4"),
loss("team-1", "team-5"), loss("team-1", "team-6"), loss("team-1", "team-7"),
];
for (let run = 0; run < 20; run++) {
const result = simulateOneMajor(pool, stageResults, qpConfig, { swissMatches });
// Teams with 3 recorded stage-1 losses are eliminated → 0 QP, never champions.
expect(result.get("team-0")).toBe(0);
expect(result.get("team-1")).toBe(0);
}
});
});
// ─── buildSpTranslator (shared-major id bridging) ───────────────────────────────
describe("buildSpTranslator", () => {
const primary = [
{ id: "P-1", participantId: "C-A" },
{ id: "P-2", participantId: "C-B" },
{ id: "P-3", participantId: "C-C" },
];
const local = [
{ id: "L-1", participantId: "C-A" },
{ id: "L-2", participantId: "C-B" },
{ id: "L-3", participantId: "C-C" },
];
it("maps primary season_participant ids to the local window's via canonical", () => {
const tr = buildSpTranslator(primary, local);
expect(tr("P-1")).toBe("L-1");
expect(tr("P-2")).toBe("L-2");
expect(tr("P-3")).toBe("L-3");
});
it("passes null/undefined through unchanged", () => {
const tr = buildSpTranslator(primary, local);
expect(tr(null)).toBeNull();
});
it("passes ids through when no canonical link exists on the primary side", () => {
const tr = buildSpTranslator(
[{ id: "P-X", participantId: null }],
local
);
expect(tr("P-X")).toBe("P-X");
});
it("passes ids through when the local window has no counterpart", () => {
const tr = buildSpTranslator(primary, [
{ id: "L-1", participantId: "C-A" },
// C-B and C-C absent from the local field
]);
expect(tr("P-1")).toBe("L-1"); // bridged
expect(tr("P-2")).toBe("P-2"); // no local counterpart → unchanged
});
});

View file

@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { resolveRatings, resolveSourceElos } from "../input-policy";
import {
getSimulatorInputPolicy,
resolveRatings,
resolveSourceElos,
DEFAULT_BASE_ELO_PRIORITY,
} from "../input-policy";
import type { SimulatorManifestProfile } from "../manifest";
const profile = {
@ -77,6 +82,54 @@ describe("simulator input policy", () => {
expect(resolved.get("tail")).toMatchObject({ sourceElo: 1400, method: "worstKnownMinus" });
});
it("derives Elo from futures odds when no direct Elo is present", () => {
const resolved = resolveSourceElos(
[
{ participantId: "favorite", sourceElo: null, rating: null, sourceOdds: 300, projectedWins: null, projectedTablePoints: null },
{ participantId: "longshot", sourceElo: null, rating: null, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null },
],
profile,
{}
);
expect(resolved.get("favorite")?.method).toBe("sourceOdds");
expect(resolved.get("longshot")?.method).toBe("sourceOdds");
expect(resolved.get("favorite")?.sourceElo).toBeGreaterThan(resolved.get("longshot")?.sourceElo ?? Infinity);
});
it("ignores a lone sourceOdds participant rather than assigning a flat Elo", () => {
const inputs = [
{ participantId: "only", sourceElo: null, rating: null, sourceOdds: 300, projectedWins: null, projectedTablePoints: null },
];
// Needs at least 2 odds to derive a spread; with block strategy it stays unresolved.
expect(resolveSourceElos(inputs, profile, {}).has("only")).toBe(false);
// With a fallback strategy it resolves via that method, not "sourceOdds".
expect(
resolveSourceElos(inputs, profile, {
inputPolicy: { missingEloStrategy: "fallbackElo", fallbackElo: 1400 },
}).get("only")
).toMatchObject({ sourceElo: 1400, method: "fallbackElo" });
});
it("derives Elo from odds once a prior direct Elo has been cleared", () => {
// Regression: after entering futures odds the simulator inputs row keeps
// sourceOdds but has its sourceElo nulled. The resolver must pick "sourceOdds",
// not resurrect a "direct" Elo.
const resolved = resolveSourceElos(
[
{ participantId: "favorite", sourceElo: null, rating: null, sourceOdds: 300, projectedWins: null, projectedTablePoints: null },
{ participantId: "longshot", sourceElo: null, rating: null, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null },
],
profile,
{}
);
expect(resolved.get("favorite")?.method).toBe("sourceOdds");
expect(resolved.get("longshot")?.method).toBe("sourceOdds");
});
it("derives ratings from futures odds when the profile allows it", () => {
const resolved = resolveRatings(
[
@ -135,6 +188,113 @@ describe("simulator input policy", () => {
expect(resolved.get("missing-tail")).toMatchObject({ rating: -10, method: "worstKnownMinus" });
});
it("does not clamp a directly entered Elo above the policy ceiling", () => {
// Snooker/darts enter manual Elos well above the default 1900 ceiling and
// have no odds source — the direct value must pass through unclamped.
const eloOnlyProfile = {} as Pick<SimulatorManifestProfile, "derivableInputs">;
const resolved = resolveSourceElos(
[
{ participantId: "ronnie", sourceElo: 2450, rating: null, sourceOdds: null, projectedWins: null, projectedTablePoints: null },
{ participantId: "judd", sourceElo: 2300, rating: null, sourceOdds: null, projectedWins: null, projectedTablePoints: null },
],
eloOnlyProfile,
{}
);
expect(resolved.get("ronnie")).toMatchObject({ sourceElo: 2450, method: "direct" });
expect(resolved.get("judd")).toMatchObject({ sourceElo: 2300, method: "direct" });
});
it("blends base Elo and futures odds into a single Elo by oddsWeight", () => {
const inputs = [
{ participantId: "favorite", sourceElo: 1800, rating: null, sourceOdds: 300, projectedWins: null, projectedTablePoints: null },
{ participantId: "longshot", sourceElo: 1200, rating: null, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null },
];
// oddsWeight 0: the stored base Elo wins outright.
const eloOnly = resolveSourceElos(inputs, profile, { inputPolicy: { oddsWeight: 0 } });
expect(eloOnly.get("favorite")).toMatchObject({ sourceElo: 1800, method: "direct" });
// oddsWeight 1: futures fully override the base Elo.
const oddsOnly = resolveSourceElos(inputs, profile, { inputPolicy: { oddsWeight: 1 } });
expect(oddsOnly.get("favorite")?.method).toBe("sourceOdds");
expect(oddsOnly.get("longshot")?.method).toBe("sourceOdds");
// Between: a blend that sits strictly between the base and the odds-derived Elo.
const oddsElo = oddsOnly.get("favorite")?.sourceElo ?? 0;
const blended = resolveSourceElos(inputs, profile, { inputPolicy: { oddsWeight: 0.5 } });
expect(blended.get("favorite")?.method).toBe("blend");
const blendedElo = blended.get("favorite")?.sourceElo ?? 0;
expect(blendedElo).toBeGreaterThan(Math.min(1800, oddsElo));
expect(blendedElo).toBeLessThan(Math.max(1800, oddsElo));
expect(blendedElo).toBeCloseTo(Math.round(0.5 * 1800 + 0.5 * oddsElo), 0);
});
it("blends projected wins with futures odds when both are present", () => {
const inputs = [
{ participantId: "team-1", sourceElo: null, rating: null, sourceOdds: 300, projectedWins: 60, projectedTablePoints: null },
{ participantId: "team-2", sourceElo: null, rating: null, sourceOdds: 20000, projectedWins: 20, projectedTablePoints: null },
];
// oddsWeight 0: projectedWins is the base.
expect(resolveSourceElos(inputs, profile, { seasonGames: 82, inputPolicy: { oddsWeight: 0 } }).get("team-1")?.method)
.toBe("projectedWins");
// oddsWeight 1: futures override the projection.
expect(resolveSourceElos(inputs, profile, { seasonGames: 82, inputPolicy: { oddsWeight: 1 } }).get("team-1")?.method)
.toBe("sourceOdds");
// Between: blended.
expect(resolveSourceElos(inputs, profile, { seasonGames: 82, inputPolicy: { oddsWeight: 0.4 } }).get("team-1")?.method)
.toBe("blend");
});
it("blends a direct rating with futures odds by oddsWeight", () => {
const ratingProfile = { derivableInputs: { rating: ["sourceOdds"] } } as Pick<SimulatorManifestProfile, "derivableInputs">;
const inputs = [
{ participantId: "favorite", sourceElo: null, rating: 30, sourceOdds: 300, projectedWins: null, projectedTablePoints: null },
{ participantId: "longshot", sourceElo: null, rating: -5, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null },
];
// oddsWeight 0: direct rating wins.
expect(resolveRatings(inputs, ratingProfile, { inputPolicy: { ratingMin: -10, ratingMax: 35, oddsWeight: 0 } }).get("favorite"))
.toMatchObject({ rating: 30, method: "direct" });
// oddsWeight 1: odds override the rating.
expect(
resolveRatings(inputs, ratingProfile, {
inputPolicy: { ratingMin: -10, ratingMax: 35, oddsWeight: 1 },
}).get("favorite")?.method
).toBe("sourceOdds");
// Between: blended.
expect(
resolveRatings(inputs, ratingProfile, {
inputPolicy: { ratingMin: -10, ratingMax: 35, oddsWeight: 0.5 },
}).get("favorite")?.method
).toBe("blend");
});
it("parses and clamps the base Elo priority and odds weight", () => {
const defaults = getSimulatorInputPolicy({});
expect(defaults.baseEloPriority).toEqual(DEFAULT_BASE_ELO_PRIORITY);
expect(defaults.oddsWeight).toBe(0.3);
// oddsWeight lives under inputPolicy and is clamped to [0, 1]; a top-level
// config.oddsWeight is ignored (single home); junk base-priority entries drop.
expect(getSimulatorInputPolicy({ inputPolicy: { oddsWeight: 0.6 } }).oddsWeight).toBe(0.6);
expect(getSimulatorInputPolicy({ oddsWeight: 0.6 }).oddsWeight).toBe(0.3);
expect(getSimulatorInputPolicy({ inputPolicy: { oddsWeight: 5 } }).oddsWeight).toBe(1);
expect(getSimulatorInputPolicy({ inputPolicy: { oddsWeight: -2 } }).oddsWeight).toBe(0);
expect(
getSimulatorInputPolicy({ inputPolicy: { baseEloPriority: ["projectedWins", "sourceOdds", "sourceElo"] } }).baseEloPriority
).toEqual(["projectedWins", "sourceElo"]);
// An empty/all-invalid priority falls back to the default.
expect(getSimulatorInputPolicy({ inputPolicy: { baseEloPriority: ["nope"] } }).baseEloPriority).toEqual(
DEFAULT_BASE_ELO_PRIORITY
);
});
it("uses NCAAW-style rating scale when deriving and falling back", () => {
const resolved = resolveRatings(
[

View file

@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import * as schema from "~/database/schema";
import { SIMULATOR_MANIFEST } from "../manifest";
import { SIMULATOR_TYPES } from "../registry";
import { getSimulatorInputPolicy } from "../input-policy";
import { assertRegistrySchemaDriftFree } from "~/models/simulator";
describe("simulator manifest", () => {
@ -40,6 +41,17 @@ describe("simulator manifest", () => {
}
});
it("exposes the per-profile futures blend weight through the input policy", () => {
expect(getSimulatorInputPolicy(SIMULATOR_MANIFEST.ncaa_football_bracket.defaultConfig).oddsWeight).toBe(0.4);
expect(getSimulatorInputPolicy(SIMULATOR_MANIFEST.world_cup.defaultConfig).oddsWeight).toBe(0.3);
expect(getSimulatorInputPolicy(SIMULATOR_MANIFEST.ucl_bracket.defaultConfig).oddsWeight).toBe(0.3);
// College hockey blends odds internally, so the central blend is disabled.
expect(getSimulatorInputPolicy(SIMULATOR_MANIFEST.college_hockey_bracket.defaultConfig).oddsWeight).toBe(0);
// Every profile resolves to the default base-Elo priority unless overridden.
expect(getSimulatorInputPolicy(SIMULATOR_MANIFEST.nba_bracket.defaultConfig).baseEloPriority)
.toEqual(["sourceElo", "projectedWins", "projectedTablePoints"]);
});
it("keeps EPL projection and match parity as separate config knobs", () => {
expect(SIMULATOR_MANIFEST.epl_standings.defaultConfig).toMatchObject({
parityFactor: 400,

View file

@ -143,8 +143,9 @@ describe("NCAAFootballSimulator", () => {
}
});
it("uses futures odds when sourceOdds present", async () => {
setupMockDb(TEAM_IDS, { includeOdds: true });
it("runs from the resolved Elo (futures already blended upstream)", async () => {
// sourceElo is the single resolved Elo; the sim no longer reads sourceOdds.
setupMockDb(TEAM_IDS);
const results = await new NCAAFootballSimulator().simulate("season-1");
expect(results).toHaveLength(12);
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);

View file

@ -119,6 +119,13 @@ describe("eloWinProbability (PARITY_FACTOR = 1000)", () => {
const p = eloWinProbability(1700, 1500);
expect(p).toBeCloseTo(0.613, 2);
});
it("a higher parity factor flattens the per-game win probability toward 0.5", () => {
const standard = eloWinProbability(1700, 1500); // default parity 1000
const flatter = eloWinProbability(1700, 1500, 2500); // season config override
expect(flatter).toBeLessThan(standard);
expect(flatter).toBeGreaterThan(0.5);
});
});
// ─── simulateProjectedPoints ──────────────────────────────────────────────────

View file

@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type * as SimulationsRegistry from "~/services/simulations/registry";
vi.mock("~/models/sports-season", () => ({
findSportsSeasonById: vi.fn(),
@ -21,9 +22,12 @@ vi.mock("~/models/ev-snapshot", () => ({
vi.mock("~/models/scoring-calculator", () => ({
recalculateStandings: vi.fn(),
}));
vi.mock("~/services/simulations/registry", () => ({
getSimulator: vi.fn(),
}));
vi.mock("~/services/simulations/registry", async (importOriginal) => {
// Keep the real SIMULATOR_TYPES / getSimulatorInfo so the manifest (pulled in
// transitively via input-policy) can build; only stub getSimulator.
const actual = await importOriginal<typeof SimulationsRegistry>();
return { ...actual, getSimulator: vi.fn() };
});
vi.mock("~/services/simulations/simulation-probabilities", () => ({
normalizeSimulationResultColumns: vi.fn(),
}));

View file

@ -1,5 +1,15 @@
import { describe, it, expect } from "vitest";
import { eloWinProb, buildDraw, simulateMajor } from "../tennis-simulator";
import {
eloWinProb,
buildDraw,
simulateMajor,
drawFromBracket,
buildHonoredMap,
DEFAULT_SLAM_QP,
type RealBracketMatch,
} from "../tennis-simulator";
import { deriveBracketQualifyingStates, getRoundConfig } from "~/models/scoring-calculator";
import { BRACKET_TEMPLATES } from "~/lib/bracket-templates";
// ─── eloWinProb ───────────────────────────────────────────────────────────────
@ -337,3 +347,187 @@ describe("QP accumulation ranking (unit)", () => {
expect(winRate).toBeGreaterThan(0.02); // > 2% (well above random 0.78%)
});
});
// ─── drawFromBracket ────────────────────────────────────────────────────────────
describe("drawFromBracket", () => {
const ids = makeIds(128);
// 64 Round-of-128 matches: match N holds ids[2N-2], ids[2N-1].
const fullR128 = Array.from({ length: 64 }, (_, i) => ({
round: "Round of 128",
matchNumber: i + 1,
participant1Id: ids[i * 2],
participant2Id: ids[i * 2 + 1],
}));
it("reconstructs a 128-slot draw in match order", () => {
const draw = drawFromBracket(fullR128, (id) => id);
expect(draw).not.toBeNull();
expect(draw).toHaveLength(128);
expect(draw?.[0]).toBe("p001");
expect(draw?.[1]).toBe("p002");
expect(draw?.[127]).toBe("p128");
});
it("returns null when the draw is not fully populated", () => {
const partial = fullR128.slice(0, 60); // only 60 of 64 matches
expect(drawFromBracket(partial, (id) => id)).toBeNull();
const withNull = fullR128.map((m, i) =>
i === 0 ? { ...m, participant1Id: null } : m
);
expect(drawFromBracket(withNull, (id) => id)).toBeNull();
});
it("applies the id translator", () => {
const draw = drawFromBracket(fullR128, (id) => (id ? `x-${id}` : id));
expect(draw?.[0]).toBe("x-p001");
});
});
// ─── simulateMajor honoring a real bracket ──────────────────────────────────────
describe("simulateMajor with realBracket", () => {
const ids = makeIds(128);
const eloMap = makeEloMap(ids, ids.map(() => 1500));
// A fully-decided bracket where the lower draw index always wins. Champion is
// therefore ids[0]; the result is deterministic regardless of RNG.
function decideBracket(draw: string[]): RealBracketMatch[] {
const rounds = [
"Round of 128", "Round of 64", "Round of 32", "Round of 16",
"Quarterfinals", "Semifinals", "Final",
];
const order = new Map(draw.map((id, i) => [id, i]));
const matches: RealBracketMatch[] = [];
let current = [...draw];
for (const round of rounds) {
const next: string[] = [];
for (let i = 0; i < current.length; i += 2) {
const p1 = current[i];
const p2 = current[i + 1];
const winner = (order.get(p1) ?? 0) < (order.get(p2) ?? 0) ? p1 : p2;
matches.push({ round, matchNumber: i / 2 + 1, winnerId: winner, isComplete: true });
next.push(winner);
}
current = next;
}
return matches;
}
it("honors a fully-decided bracket deterministically (champion = draw[0])", () => {
const draw = [...ids];
const realBracket = decideBracket(draw);
const results = simulateMajor(draw, eloMap, "hard", { realBracket });
const champ = results.find((r) => r.qp === DEFAULT_SLAM_QP.winner);
expect(champ?.participantId).toBe(draw[0]);
// Structural counts are unchanged by honoring.
expect(results.filter((r) => r.qp === DEFAULT_SLAM_QP.winner)).toHaveLength(1);
expect(results.filter((r) => r.qp === DEFAULT_SLAM_QP.finalist)).toHaveLength(1);
expect(results.filter((r) => r.qp === DEFAULT_SLAM_QP.sf)).toHaveLength(2);
expect(results.filter((r) => r.qp === DEFAULT_SLAM_QP.qf)).toHaveLength(4);
expect(results.filter((r) => r.qp === DEFAULT_SLAM_QP.r16)).toHaveLength(8);
});
it("honors a completed Final winner even against a heavy Elo underdog", () => {
// Underdog at draw[0] (low Elo) but the Final is recorded as their win.
const draw = [...ids];
const skewed = makeEloMap(draw, draw.map((_, i) => (i === 0 ? 100 : 3000)));
const realBracket = decideBracket(draw); // draw[0] wins everything
const results = simulateMajor(draw, skewed, "hard", { realBracket });
const champ = results.find((r) => r.qp === DEFAULT_SLAM_QP.winner);
expect(champ?.participantId).toBe(draw[0]);
});
it("uses config QP values from opts.qp", () => {
const draw = [...ids];
const realBracket = decideBracket(draw);
const qp = { winner: 100, finalist: 70, sf: 40, qf: 20, r16: 5 };
const results = simulateMajor(draw, eloMap, "hard", { realBracket, qp });
expect(results.find((r) => r.participantId === draw[0])?.qp).toBe(100);
expect(results.filter((r) => r.qp === 70)).toHaveLength(1);
expect(results.filter((r) => r.qp === 5)).toHaveLength(8);
});
});
// ─── tennis_128 bracket scoring (QP placement derivation) ───────────────────────
describe("tennis_128 qualifying bracket scoring", () => {
it("derives placements 1/2/3/5/9 from the tennis_128 round config", () => {
const template = BRACKET_TEMPLATES.tennis_128;
// A wins R16 → QF → SF → Final (champion). Losers exit at each scoring round.
const matches = [
{ round: "Round of 16", winnerId: "A", loserId: "I", participant1Id: "A", participant2Id: "I" },
{ round: "Quarterfinals", winnerId: "A", loserId: "E", participant1Id: "A", participant2Id: "E" },
{ round: "Semifinals", winnerId: "A", loserId: "C", participant1Id: "A", participant2Id: "C" },
{ round: "Final", winnerId: "A", loserId: "B", participant1Id: "A", participant2Id: "B" },
];
const states = deriveBracketQualifyingStates(
matches,
template.rounds,
(round) => getRoundConfig(round, "tennis_128")
);
expect(states.get("A")?.placement).toBe(1); // champion
expect(states.get("B")?.placement).toBe(2); // Final loser
expect(states.get("C")).toMatchObject({ placement: 3, tieCount: 2 }); // SF loser
expect(states.get("E")).toMatchObject({ placement: 5, tieCount: 4 }); // QF loser
expect(states.get("I")).toMatchObject({ placement: 9, tieCount: 8 }); // R16 loser
});
});
// ─── exclusions (not-participating) + prebuilt honored map ──────────────────────
describe("simulateMajor exclusions & prebuilt honored", () => {
const ids = makeIds(128);
it("a withdrawn player walks over (earns 0 QP) even with a dominant Elo", () => {
// draw[0] would otherwise win almost everything; excluding them must zero them.
const dominant = makeEloMap(ids, ids.map((_, i) => (i === 0 ? 5000 : 1000)));
const excluded = new Set([ids[0]]);
for (let t = 0; t < 20; t++) {
const results = simulateMajor(ids, dominant, "hard", { excluded });
expect(results.find((r) => r.participantId === ids[0])?.qp).toBe(0);
}
});
it("a completed match is still honored even if the winner is later excluded", () => {
// Fully-decided bracket says draw[0] wins; excluding them must NOT override a
// recorded real result (the match already happened).
function decide(draw: string[]): RealBracketMatch[] {
const rounds = [
"Round of 128", "Round of 64", "Round of 32", "Round of 16",
"Quarterfinals", "Semifinals", "Final",
];
const order = new Map(draw.map((id, i) => [id, i]));
const matches: RealBracketMatch[] = [];
let cur = [...draw];
for (const round of rounds) {
const next: string[] = [];
for (let i = 0; i < cur.length; i += 2) {
const w = (order.get(cur[i]) ?? 0) < (order.get(cur[i + 1]) ?? 0) ? cur[i] : cur[i + 1];
matches.push({ round, matchNumber: i / 2 + 1, winnerId: w, isComplete: true });
next.push(w);
}
cur = next;
}
return matches;
}
const honored = buildHonoredMap(decide(ids));
const eloMap = makeEloMap(ids, ids.map(() => 1500));
const results = simulateMajor(ids, eloMap, "hard", {
honored,
excluded: new Set([ids[0]]),
});
expect(results.find((r) => r.qp === DEFAULT_SLAM_QP.winner)?.participantId).toBe(ids[0]);
});
it("prebuilt honored map produces the same champion as realBracket input", () => {
const realBracket: RealBracketMatch[] = [
{ round: "Final", matchNumber: 1, winnerId: null, isComplete: false },
];
const honored = buildHonoredMap(realBracket);
// Empty honored (no completed matches) → no crash, full simulation runs.
const eloMap = makeEloMap(ids, ids.map(() => 1500));
const results = simulateMajor(ids, eloMap, "hard", { honored });
expect(results.filter((r) => r.qp === DEFAULT_SLAM_QP.winner)).toHaveLength(1);
});
});

View file

@ -78,6 +78,30 @@ function makeParticipants(count = 48) {
}));
}
/** 12 canonical groups AL, 4 members each (p0..p47), no completed matches. */
function makeCanonicalGroups() {
return Array.from({ length: 12 }, (_, gi) => ({
id: `group-${gi}`,
groupName: String.fromCharCode(65 + gi), // A..L
scoringEventId: "event-1",
members: [0, 1, 2, 3].map((s) => ({ participantId: `p${gi * 4 + s}` })),
matches: [],
}));
}
/** 16 Round-of-32 matches with both slots filled (p0..p31), seq pairing. */
function makeFullR32Draw() {
return Array.from({ length: 16 }, (_, i) => ({
round: "Round of 32",
matchNumber: i + 1,
participant1Id: `p${i * 2}`,
participant2Id: `p${i * 2 + 1}`,
winnerId: null as string | null,
loserId: null as string | null,
isComplete: false,
}));
}
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
@ -87,7 +111,7 @@ import { database } from "~/database/context";
const mockDb = {
query: {
seasonParticipants: { findMany: vi.fn() },
scoringEvents: { findFirst: vi.fn() },
scoringEvents: { findMany: vi.fn() },
tournamentGroups: { findMany: vi.fn() },
playoffMatches: { findMany: vi.fn() },
},
@ -96,10 +120,15 @@ const mockDb = {
where: vi.fn().mockResolvedValue([]),
};
/** Set the resolved bracket scoring event (or null for none). */
function mockBracketEvent(event: Record<string, unknown> | null) {
mockDb.query.scoringEvents.findMany.mockResolvedValue(event ? [event] : []);
}
beforeEach(() => {
vi.mocked(database).mockReturnValue(mockDb as never);
mockDb.query.seasonParticipants.findMany.mockReset();
mockDb.query.scoringEvents.findFirst.mockReset();
mockDb.query.scoringEvents.findMany.mockReset();
mockDb.query.tournamentGroups.findMany.mockReset();
mockDb.query.playoffMatches.findMany.mockReset();
mockDb.select.mockReturnValue(mockDb);
@ -110,7 +139,7 @@ beforeEach(() => {
describe("WorldCupSimulator", () => {
it("throws when no participants are found", async () => {
mockDb.query.seasonParticipants.findMany.mockResolvedValue([]);
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
mockBracketEvent(null);
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
@ -121,7 +150,7 @@ describe("WorldCupSimulator", () => {
it("returns one result per participant", async () => {
const participants = makeParticipants(48);
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
mockBracketEvent(null);
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
@ -137,7 +166,7 @@ describe("WorldCupSimulator", () => {
it("column sums for champion, runner-up, 3rd, 4th are each ≈1.0", async () => {
const participants = makeParticipants(48);
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
mockBracketEvent(null);
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
@ -158,7 +187,7 @@ describe("WorldCupSimulator", () => {
it("SF losers land in 3rd or 4th, never 1st or 2nd", async () => {
const participants = makeParticipants(48);
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
mockBracketEvent(null);
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
@ -180,7 +209,7 @@ describe("WorldCupSimulator", () => {
it("a team with pre-completed group stage result is fixed in simulation", async () => {
const participants = makeParticipants(48);
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
mockDb.query.scoringEvents.findFirst.mockResolvedValue({ id: "event-1" });
mockBracketEvent({ id: "event-1" });
// One group fully complete: p0 wins everything, p3 loses everything
const group = {
@ -234,7 +263,7 @@ describe("WorldCupSimulator", () => {
it("probFifth through probEighth are equal for each participant (QF losers split evenly)", async () => {
const participants = makeParticipants(48);
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
mockBracketEvent(null);
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
@ -248,4 +277,116 @@ describe("WorldCupSimulator", () => {
expect(probSeventh).toBeCloseTo(probEighth, 10);
}
});
// ─── Regime selection & bracket honoring ──────────────────────────────────
it("regime=draw: honors the real R32 draw and flags source as real bracket", async () => {
const participants = makeParticipants(48);
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
mockBracketEvent({ id: "event-1", bracketTemplateId: "fifa_48" });
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]); // no groups, but draw exists
const r32 = makeFullR32Draw();
// Lock R32 match 1: p0 beats p1, so p1 is eliminated in the Round of 32.
r32[0] = { ...r32[0], winnerId: "p0", loserId: "p1", isComplete: true };
mockDb.query.playoffMatches.findMany.mockResolvedValue(r32);
const sim = new WorldCupSimulator(50);
const results = await sim.simulate("season-1");
expect(results[0].source).toContain("real bracket");
// p1 lost in the R32 → never earns any placement (scoring starts at QF).
const p1 = results.find((r) => r.participantId === "p1")?.probabilities;
const p1Mass =
(p1?.probFirst ?? 0) + (p1?.probSecond ?? 0) + (p1?.probThird ?? 0) + (p1?.probFourth ?? 0) +
(p1?.probFifth ?? 0) + (p1?.probSixth ?? 0) + (p1?.probSeventh ?? 0) + (p1?.probEighth ?? 0);
expect(p1Mass).toBe(0);
// Teams p32..p47 are not in the 32-team draw → also zero.
const p40 = results.find((r) => r.participantId === "p40")?.probabilities;
expect((p40?.probFirst ?? 0) + (p40?.probFifth ?? 0)).toBe(0);
// Champion mass still normalizes to ~1.
const sumFirst = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
expect(sumFirst).toBeCloseTo(1.0, 2);
});
it("partial R32 draw never places an already-drawn team into an empty slot twice", async () => {
const participants = makeParticipants(48);
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
mockBracketEvent({ id: "event-1", bracketTemplateId: "fifa_48" });
// Real, canonical groups so the empty slots get filled from group seeding.
mockDb.query.tournamentGroups.findMany.mockResolvedValue(makeCanonicalGroups());
// Partial draw: only match 1 is populated (p0 vs p1); matches 216 are empty,
// so the seeded group results fill them — but must not re-place p0 or p1.
const r32 = makeFullR32Draw().map((m, i) =>
i === 0 ? m : { ...m, participant1Id: null, loserId: null, participant2Id: null }
);
mockDb.query.playoffMatches.findMany.mockResolvedValue(r32);
const sim = new WorldCupSimulator(40);
const results = await sim.simulate("season-1");
// A team can finish at most one placement per sim, so its total probability
// mass is ≤ 1. A duplicated team could exceed that (e.g. champion + QF-loser
// in the same iteration). Assert no team's mass exceeds 1.
for (const r of results) {
const p = r.probabilities;
const mass =
p.probFirst + p.probSecond + p.probThird + p.probFourth +
p.probFifth + p.probSixth + p.probSeventh + p.probEighth;
expect(mass).toBeLessThanOrEqual(1 + 1e-9);
}
// Champion mass still normalizes to ~1 (exactly one champion per sim).
const sumFirst = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
expect(sumFirst).toBeCloseTo(1.0, 2);
});
it("completed Final locks in the champion and runner-up", async () => {
const participants = makeParticipants(48);
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
mockBracketEvent({ id: "event-1" });
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([
{ round: "Finals", matchNumber: 1, participant1Id: "p5", participant2Id: "p6", winnerId: "p5", loserId: "p6", isComplete: true },
]);
const sim = new WorldCupSimulator(30);
const results = await sim.simulate("season-1");
expect(results.find((r) => r.participantId === "p5")?.probabilities.probFirst).toBe(1);
expect(results.find((r) => r.participantId === "p6")?.probabilities.probSecond).toBe(1);
});
it("regime=groups: real 12-group stage uses the 2026 bracket seeding source", async () => {
const participants = makeParticipants(48);
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
mockBracketEvent({ id: "event-1", bracketTemplateId: "fifa_48" });
mockDb.query.tournamentGroups.findMany.mockResolvedValue(makeCanonicalGroups());
mockDb.query.playoffMatches.findMany.mockResolvedValue([]); // no draw yet
const sim = new WorldCupSimulator(40);
const results = await sim.simulate("season-1");
expect(results[0].source).toContain("2026 bracket seeding");
const sumFirst = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
expect(sumFirst).toBeCloseTo(1.0, 2);
});
it("regime=futures: no groups and no draw falls back with a flagged source", async () => {
const participants = makeParticipants(48);
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
mockBracketEvent(null);
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
const sim = new WorldCupSimulator(30);
const results = await sim.simulate("season-1");
expect(results[0].source).toContain("futures fallback");
const sumFirst = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
expect(sumFirst).toBeCloseTo(1.0, 2);
});
});

Some files were not shown because too many files have changed in this diff Show more