Compare commits

..

99 commits

Author SHA1 Message Date
8006d514a4 Merge pull request 'claude/gifted-allen-npeql8' (#150) from claude/gifted-allen-npeql8 into main
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 2m55s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m23s
🚀 Deploy / 🐳 Build (push) Successful in 1m15s
🚀 Deploy / 🚀 Deploy (push) Successful in 11s
Reviewed-on: #150
2026-09-14 23:14:31 +00:00
Claude
ed9c62571b
Fix three defects in the Sentry 404 filter
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m21s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m20s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Code review of 41a0237 found the same-origin Referer exception never
fires in production, which defeats the guarantee that change was built
on: broken internal links stay visible while scanners are dropped.

@react-router/express builds request.url from req.protocol and nothing
sets `trust proxy`, so request.url reads http:// inside the container
while browsers send an https:// Referer. Comparing full origins never
matched, so every router-internal 4xx was dropped. Compare host instead
-- protocol says nothing about whether a link was ours, and this is the
same mismatch $leagueId.server.ts already works around for invite URLs.
Adding `trust proxy` would fix it too but changes req.ip and req.secure
server-wide for one comparison.

Removing ignoreErrors also dropped the .css/.js filters with nothing to
replace them. Stale clients request the previous deploy's hashed bundles
with a same-host Referer, so once the Referer check worked, every deploy
would spike Sentry. Assets now never report, referer or not.

Narrow the dropped statuses from all 4xx to 404 and 405, the two that
mean nothing matched the request. React Router's internal 400 (route
missing a loader) and 403 (route does not match URL) are real
misconfigurations and were being swallowed whenever no Referer was sent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESrxBMZMx2BD9rcKTCgLe4
2026-09-14 21:18:33 +00:00
Claude
41a0237a87
Stop reporting bot-scanner 404s to Sentry
Automated scanners probing for WordPress paths (/blog/wp/v2/posts/*,
POST /) generate a React Router 404 or 405 on every hit, and handleError
forwarded all of them to Sentry, exhausting the quota. The existing
defence was a list of ignoreErrors regexes that needed a new entry for
each scanner pattern.

Filter on what the error is instead: shouldReportServerError drops 4xx
responses React Router generated itself (internal: true) for requests
that matched nothing. Responses the app throws deliberately still report,
as do all 5xx and real exceptions.

A request carrying a same-origin Referer is still reported, so a broken
internal link remains visible in Sentry -- only cold scanner hits are
dropped. No HTTP response changes; every URL returns the status and page
it did before.

Also adds /blog to the Express bot-probe filter so the highest-volume
path 404s without an SSR render, and removes the now-redundant
route-404 ignoreErrors entries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESrxBMZMx2BD9rcKTCgLe4
2026-09-14 20:11:46 +00:00
2639102f06 Merge pull request 'claude/admiring-bohr-2zvef9' (#149) from claude/admiring-bohr-2zvef9 into main
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m3s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m18s
🚀 Deploy / 🐳 Build (push) Successful in 1m12s
🚀 Deploy / 🚀 Deploy (push) Successful in 13s
Reviewed-on: #149
2026-09-11 19:34:33 +00:00
Claude
caff5093ae
Report a Semi-Final slot that was emptied without being refilled
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m5s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m18s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
The re-seed banner listed only the slots that were filled, so a re-seed that
just vacated one — un-recording an Elimination Final result takes its winner
back out of the semi — rendered as "Re-seeded the Semi-Finals: ." with nothing
after the colon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSDeNWAXvK7nznJqjxn7Jo
2026-09-11 18:25:15 +00:00
Claude
cdf86d1682
Feed each AFL Elimination Final into the Semi-Final of the same number
The Elimination Final winners were crossed into the Semi-Finals — EF1's winner
met the QF2 loser and EF2's the QF1 loser. The AFL feeds them straight through:
SF1 is the QF1 loser against the EF1 winner and SF2 the QF2 loser against the
EF2 winner. The crossover in this system lands a round later, at Semi-Final →
Preliminary Final, so a Qualifying Final loser cannot meet the side that just
beat it — that part was already right and is unchanged.

In 2026 that drew Fremantle v Adelaide and Brisbane v Geelong, when Fremantle
played Geelong and Brisbane played Adelaide.

Placement now reconciles both Semi-Final slots on every Elimination Final
result rather than writing the one it was called for, so correcting a recorded
result moves the qualifier instead of leaving the beaten team alive in a semi.
A slot held by anyone who never played an Elimination Final still raises
"already filled", and a Semi-Final that has been played refuses the move rather
than rewriting who contested it.

The simulator paired the Semi-Finals the same crossed way, which biased every
projection running off an undecided Elimination Final; it now feeds straight
through too.

Brackets already advanced under the crossover keep their wrong pairings, since
no admin action re-runs advancement — a completed match cannot be re-submitted.
Admin → the event's bracket gains a "Fix Semi-Final Pairings" button that runs
the same reconciliation over a bracket as it stands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSDeNWAXvK7nznJqjxn7Jo
2026-09-11 18:10:30 +00:00
10363da100 Merge pull request 'claude/session-crx3tm' (#148) from claude/session-crx3tm into main
All checks were successful
🚀 Deploy / 🐳 Build (push) Successful in 1m9s
🚀 Deploy / 🚀 Deploy (push) Successful in 12s
🚀 Deploy / 🧪 Test (push) Successful in 3m12s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m18s
Reviewed-on: #148
2026-09-04 22:06:00 +00:00
Claude
95acc6fcba
Add a Re-seed Wildcard Winners button to the bracket admin
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m7s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m17s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Repairing a bracket advanced before the re-seeding rule needed a script and
a shell. Add the same repair as an admin action on the event's bracket page,
shown for afl_10 brackets: it runs reseedAflEliminationFinals and reports
which team each Elimination Final now hosts, or says the pairings were
already right.

Only the qualifier slots move, so no scoring runs and nothing is announced —
a test asserts the action calls neither the scoring path nor Discord. A
bracket whose Elimination Final has already been played still refuses, with
the model's message surfaced to the admin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDbHrCce1UhahbkwKkc7hK
2026-09-04 21:40:08 +00:00
Claude
4e48a23f6b
Add a repair script for brackets advanced before the re-seed fix
The fix only changes how new results advance, so a bracket already advanced
under the fixed crossover keeps its wrong Elimination Finals pairings — and
the admin UI cannot re-trigger advancement, since a completed match renders
as "Complete" with no way to re-submit the winner.

Extract the reconcile step as reseedAflEliminationFinals, which resolves the
pairings from the results recorded so far (the winner being advanced is
passed in, since that row can predate the write), and add a script that runs
it over afl_10 events. It moves only Wildcard teams between the two
Elimination Final slots, leaves a correct bracket untouched, and reports
rather than rewrites an Elimination Final that has already been played.

  npx tsx scripts/fix-afl-wildcard-reseed.ts --dry

An event with no Wildcard/Elimination Finals matches now raises instead of
reporting nothing to do — a mistyped event id should not read as success.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDbHrCce1UhahbkwKkc7hK
2026-09-04 21:34:56 +00:00
e7c5b38953 Merge pull request 'claude/session-crx3tm' (#147) from claude/session-crx3tm into main
All checks were successful
🚀 Deploy / 🐳 Build (push) Successful in 1m9s
🚀 Deploy / 🚀 Deploy (push) Successful in 12s
🚀 Deploy / 🧪 Test (push) Successful in 3m8s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m30s
Reviewed-on: #147
2026-09-04 21:05:42 +00:00
Claude
a7b7921b9b
Rename a shadowed variable in the AFL advancement test mock
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m9s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m19s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
The db mock's local match lookup shadowed the test helper of the same name,
which oxlint's no-shadow rejects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDbHrCce1UhahbkwKkc7hK
2026-09-04 20:49:10 +00:00
Claude
a747d73a4c
Refuse to re-seed an Elimination Final that has been played
Some checks failed
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m12s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Failing after 46s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Reconciling both slots on every Wildcard result means a late correction
could otherwise rewrite who contested a game that already has a recorded
winner. Raise instead — with a message callers do not swallow, so the admin
sees it and can clear and regenerate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDbHrCce1UhahbkwKkc7hK
2026-09-04 15:30:05 +00:00
Claude
273735e572
Re-resolve both Elimination Final slots on every Wildcard result
Now that a Wildcard winner's destination depends on both games, guarding
only the slot about to be written is not enough. Correcting a recorded
result placed the new winner in the other Elimination Final and left the
beaten team alive in the one it had been written to, and a bracket already
advanced under the old fixed crossover ended up with one team in both
Elimination Finals once the second result came in — the second write hit
"already filled", which every caller swallows.

Each result now reconciles both slots against the resolved pairings:
a Wildcard team sitting in the wrong slot is vacated, the right ones are
written, and a slot held by anyone outside the Wildcard Round still raises
"already filled" rather than being taken. Clearing and filling share one
transaction, so a half-applied re-seed cannot leave a team in both games,
and pairings that are already correct do no writes at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDbHrCce1UhahbkwKkc7hK
2026-09-04 15:27:16 +00:00
Claude
b566c5f1a5
Re-seed AFL Wildcard winners into the Elimination Finals
The two Wildcard Round winners were crossed into the Elimination Finals by
which game they came out of — the 7v10 winner always met 6th and the 8v9
winner always met 5th. The AFL pairs those games by ladder position, as the
classic final eight pairs 5v8 and 6v7: the higher host draws the
lower-ranked survivor. So when 10th wins through, 5th should meet 10th, not
the 8v9 winner.

Placement now resolves both games together. A 7v10 winner is placed as soon
as it is decided (7th outranks either possible opponent, 10th is outranked
by both), while an 8v9 winner is held until the other game is decided rather
than being placed and later moved, so results can be entered in either
order.

The simulator paired the Elimination Finals the same fixed way, which biased
every projection that ran off an undecided Wildcard Round; it now re-seeds
too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDbHrCce1UhahbkwKkc7hK
2026-09-04 15:13:13 +00:00
f3e00fcacf Merge pull request 'claude/mlb-simulator-projected-wins-elo-siess8' (#146) from claude/mlb-simulator-projected-wins-elo-siess8 into main
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m14s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m19s
🚀 Deploy / 🐳 Build (push) Successful in 1m6s
🚀 Deploy / 🚀 Deploy (push) Successful in 11s
Reviewed-on: #146
2026-08-29 07:53:08 +00:00
Claude
101e102e23
Fix review findings on the MLB projected-wins change
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m16s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m18s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
The previous commit did not build. `simulatorInputLabel` was called from the
rendered component for the Base Elo Source select, which defeated the
treeshaking that keeps the simulator manifest — and through it the registry,
every simulator, and ~/database/context — out of the browser. Vite fails the
client build outright with "Server-only module referenced by client". The
label is now resolved in the loader alongside inputColumns, which is the
pattern the loader comment already documents. Typecheck, lint and the unit
suite all passed on the broken commit; none of them run a production build.

A stale projection now yields to Elo instead of clamping to an extreme.
seedingWinRateFor clamped the rest-of-season target into [0.01, 0.99], so a
96-40 team projected for 95 was simulated to go 0-26 and a 40-70 team
projected for 95 was simulated to win out. A target outside (0, 1) is proof
the projection has gone stale, not a reason to bet everything on it, so it
falls back to the Elo rate — the same escape hatch nll-simulator uses. The
blend weight is also clamped inside the helper now, so a stray config value
cannot turn it into an extrapolation.

Seeding only applies a projection that actually produced the resolved Elo,
gated on metadata.sourceEloMethod via projectionForSeeding. Previously the raw
projection drove seeding regardless of the input policy: with the default
Elo-first ordering a projection was ignored as the Elo source yet still
dictated the standings, and with futures odds blended in at oddsWeight,
seeding and playoff matchups ran on two different strength scales.

The simulator page's preview resolved its Elo and rating maps only for
required inputs, but renders those columns solely from the maps, so stored
values displayed as "—" for profiles that treat the input as optional
(playoff_bracket, ncaam_bracket, golf_qualifying_points). Both maps now key
off the same required-plus-optional set the columns do.

The metadata upsert's CASE branches were mutually exclusive, so supplying any
metadata skipped the stale-flag clearing: a bulk row carrying both a direct
rating and a projection kept a stale ratingMethod and hid the rating it had
just set. Stripping now always runs, with the caller's metadata merged over
the result.

Not changed: projectedWinsWeight still defaults to 1 with no decay toward Elo.
That is the final-win-total semantics chosen for this work; the stale-target
fallback removes its pathological case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQSEmmojmqmGdJttgzqCWK
2026-08-29 06:18:41 +00:00
Claude
280a46eb5f
Make MLB projected wins actually drive the simulation
Entering projected wins for an in-progress MLB season did not behave as
expected: the entered numbers came back changed, and the simulation appeared
to ignore them in favour of whatever Elo was already stored. Four separate
defects were involved.

Projections are now stored and shown verbatim. The Elo Ratings page never
kept the number typed into it — the field was a display derived from Elo, so
a pasted 95 rendered as 95.1 the moment it was applied (wins to Elo rounds to
an integer Elo) and drifted again after each run, because a run re-resolves
that Elo through the input policy. The loader now reads back the stored
projection and the paste flow keeps the pasted value as-is; the derived
round-trip survives only as a prefill for seasons that have never had a
projection saved.

A stale Elo no longer silently outranks a projection. baseEloPriority takes
the first available base source, and the simulator page's bulk CSV wrote
projectedWins without stamping metadata.sourceEloMethod, so the
non-destructive upsert left the old Elo in place as a trusted direct value
and it won the race — the projection was stored and then ignored on every
run. The CSV path now stamps the flag like the Elo Ratings page does, the
metadata upsert merges rather than replaces so a flag-only write keeps
unrelated keys, and Base Elo Source is editable per season for the case where
a genuine hand-entered Elo should still lose to projections.

Projected wins now act as a projected final total. The value was baked into a
flat season-long rate (projectedWins / 162) applied to every remaining game,
so a team at 60-50 projected for 95 finished around 90.5 and the projection
was never reached mid-season. seedingWinRateFor spreads the difference over
the games still to play, which is a no-op pre-season where the two rates
coincide; projectedWinsWeight blends it back toward the Elo-implied rate.

Playoff-parity compression is restored for Elo-rated teams. eloToRDif scaled
by RDIF_DIVISOR, making it the exact algebraic inverse of winRateFromRDif, so
any team with an Elo skipped the compression every hardcoded-rdif team gets:
a 95-win projection became RDif +686 and played playoff games at .586 instead
of the documented ~.517. It now scales by SEEDING_RDIF_SCALE, landing at ~+140
alongside the Dodgers' hardcoded +137.

Also fixes the preview table's "missing a required input" marker, which
flagged every projection-configured participant because a generated Elo or
rating is deliberately hidden from getParticipantSimulatorInputs. It now
consults the resolved values, so it agrees with readiness.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQSEmmojmqmGdJttgzqCWK
2026-08-29 05:31:15 +00:00
758166dd46 Merge pull request 'claude/afl-ev-calc-issue-8kcmlh' (#145) from claude/afl-ev-calc-issue-8kcmlh into main
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m16s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m18s
🚀 Deploy / 🐳 Build (push) Successful in 1m10s
🚀 Deploy / 🚀 Deploy (push) Successful in 11s
Reviewed-on: #145
2026-08-29 04:09:36 +00:00
Claude
d83f6976bf
Stop the simulator re-run from trampling its caller's side effects
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m14s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m18s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
A review of this branch found four problems, all downstream of one decision:
calling runSportsSeasonSimulation from inside the result path. That function
does three jobs — recompute probabilities, recalculate standings, write the
daily EV snapshot — and the result path wants only the first.

1. The Discord standings post was silently suppressed on every scored match,
   for all 13 bracket-aware sports.

   recalculateAffectedLeagues detects change by snapshotting teamStandings,
   recalculating, then diffing; changedTeamIds gates the notification. But
   processMatchResult runs updateProbabilitiesAfterResult first, which now
   reached the runner's own recalculateStandings. The new totals were therefore
   already written when the "before" snapshot was taken, the diff came back
   empty, and the post never fired. previousRank went the same way:
   recalculateStandings rolls it forward on every call, so the extra one erased
   rank movement.

   runSportsSeasonSimulation now takes skipStandingsRecalc / skipSnapshots and
   the probability updater passes both. The snapshot is skipped because it is a
   per-day series keyed by snapshotDate — writing it per match result just
   overwrites the day's row with intra-day values.

2. finalizeQualifyingPoints marks the season completed immediately before
   calling the updater, and the runner rejects a completed season outright. With
   the ICM fallback gone that failed every time, stranding anyone still in the
   unfinished set on permanently stale probabilities — reachable for
   cs2_major_qualifying_points, the one bracket-aware qualifying-points sport.

   A completed season is not this branch's case rather than a failure: every
   placement is final and the floor it protects can no longer be contradicted.
   shouldRerunSimulator now excludes it and it falls through to ICM as before.
   The genuine failure modes still leave probabilities alone rather than falling
   back to the path being replaced.

3. match-sync calls processMatchResult in a per-match loop with no
   skipSideEffects, so each synced match ran a full Monte Carlo plus EV rewrite,
   snapshot and standings recalc. processMatchResult gains skipProbabilities —
   mirroring the option processPlayoffEvent already takes, and narrower than
   skipSideEffects — which match-sync passes in the loop before refreshing once
   at the end. Per-match standings and Discord posts are unchanged.

4. A partially-seeded afl_10 bracket now throws from inside the result path.
   The throw is correct and stays; the concern was that it was silent, which the
   error surfacing in 2 covers.

Two claims from the review did not hold up and were left alone: the batch
bracket route already passes skipSideEffects per match and refreshes once after
the loop, and autoCompleteRoundIfDone already passes skipProbabilities, so there
is no double run per round completion.

Tests: the runner honors both skip flags and still writes EVs; the updater asks
for probabilities only; a completed season takes the ICM path without erroring;
processMatchResult skips the refresh but still announces. The two behavioral
ones were confirmed to fail against the previous behavior.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS
2026-08-29 03:46:12 +00:00
Claude
eefe407e7f
Route every bracket-aware sport away from ICM, not just AFL
The previous commit gated the simulator re-run on a `bracketAware` flag set only
on afl_bracket and llws_bracket, on the claim that every other simulator was
bracket-blind and would resurrect eliminated teams if re-run. That claim was
wrong. Eleven more read playoff_matches and honor isComplete/winnerId already:
ucl, ncaam, ncaaw (both via ncaa-basketball), nba, nhl, snooker, world_cup,
darts, cs2_major, college_hockey and nll. All thirteen are now flagged, so any
sport with a bracket the simulator can read absorbs a result by re-running that
simulator rather than through ICM.

Two simulators are deliberately left off. playoff_bracket and
ncaa_football_bracket declare a "bracket" setup section but never read
playoff_matches, so re-running them really would re-draw the field. That
mismatch runs the other way too — world_cup, darts_bracket and
cs2_major_qualifying_points read the bracket without declaring the section — so
setupSections is not a usable signal here and the flag stays separate from it,
with both facts written down on the flag.

The EV-source condition is also gone. It only asked whether the existing EVs
came from a simulation, which protected nothing: the alternative to re-running
was never leaving them alone, it was the ICM branch overwriting them anyway.
Given two overwrites, the bracket-aware one wins regardless of what wrote them.

Tests: a bracket-blind simulator still goes through ICM, futures-odds EVs no
longer divert a bracket-aware season away from the re-run, and the bracket-aware
set is pinned in the manifest test so a new simulator is an explicit decision
rather than a default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS
2026-08-29 02:49:17 +00:00
Claude
cf817cc9ff
Make the AFL simulator read the bracket that was actually drawn
An AFL club seeded into an Elimination Final is awarded 15 fantasy points the
moment the bracket is generated — afl_10's entryFloor: 7 — and cannot finish
worse than the 7th-8th tier. Its EV still read 13.

AFLSimulator was stateless with respect to the live bracket. It read only
participants, sourceElo and the regular-season standings, then re-projected the
whole ladder and re-seeded 1-10 from Elo on every one of its 10,000 iterations.
So a team with a locked Elimination Final berth was re-drawn into the Wildcard
Round, or out of the finals entirely, in a slice of them — and there it scores 0.
Even with the ladder complete the Math.random() tiebreaker reshuffled every club
tied on ladder points, which in the AFL is most of the middle of the table.
Games already played were re-played the same way, so a completed Wildcard win was
worth 50% of nothing. Mixing zeroes into a distribution whose floor is 15 is what
produced 13.

afl_10 is the only template that defines entryFloor at all, which is why this
surfaced here and not on LLWS, whose floors only exist once a team has won
something.

The simulator now mirrors llws-simulator's bracket-aware mode:

  - readAflBracketSeeds reads the 10 seeds from the slots generateAFL10Bracket
    writes them into. The two Elimination Final participant2 slots are TBD by
    design and are never read as seeds, leaving exactly 10 named slots. No draw
    at all falls back to the ladder projection; a partially seeded, duplicated or
    unknown draw throws rather than silently discarding the draw and every
    recorded result with it.
  - makePlayGame replays a completed match from its recorded result whenever both
    recorded teams are the two the simulation routed into that game, so an
    already-played result sticks across all iterations.
  - simAFLFinals labels each game with the round and match number
    generateAFL10Bracket and advanceAFLWinner use, so a result is looked up
    against the game it was played in. Its routing was already correct.

EV >= the banked floor now holds by construction, with no clamping: a team seeded
into an Elimination Final is in that game in every iteration. Column sums stay
exactly 1.0 and the 340-point total-EV invariant is unchanged, since teams
outside the bracket simply score nothing.

Fixing the simulator alone would not have held. processMatchResult calls
updateProbabilitiesAfterResult on every result, and its ICM branch re-derives
each still-alive participant's whole distribution from P(1st) alone, knowing
nothing about the bracket — so the next finals result would have put the EV
straight back under the floor. That branch was built for futures-odds seasons.
It now runs only when the season's EVs did not come from a bracket-aware
simulator; when they did, that simulator is re-run instead, since it already
knows the completed matches. Both conditions matter: re-running a bracket-blind
simulator would re-draw the field and hand equity back to knocked-out teams, so
a new manifest `bracketAware` flag limits this to AFL and LLWS. A failed re-run
leaves probabilities untouched rather than falling back to the ICM path that is
being replaced.

The finalized-participant pinning loop now runs after that recalculation rather
than before. A simulation run rewrites every participant in the season, the
finalized ones included; a finalized placement is a fact, not a projection, so it
is written last and wins.

Tests: seeds clear the entry floors their seeding banked, a Qualifying Final
entrant is structurally absent from the 7th-8th tier, the bracket's draw beats
Elo (the weakest club seeded 1, the strongest seeded 10), completed Wildcard and
Qualifying Finals are replayed with the winner banking its floor, teams outside
the bracket are zeroed, and the column sums and 340 total survive. The bracket
fixtures deliberately seed the ten weakest clubs, because seeding the strongest
ten lets the ladder projection reproduce much the same field by accident. Six of
the seven were confirmed to fail against the previous behavior. Plus the
probability-updater branch in each direction, its failure path, and the pin
ordering.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS
2026-08-29 02:42:31 +00:00
1acef25027 Merge pull request 'llws ev' (#144) from claude/llws-ev-calculation-bug-30xpxg into main
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m12s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m17s
🚀 Deploy / 🐳 Build (push) Successful in 1m8s
🚀 Deploy / 🚀 Deploy (push) Successful in 13s
2026-08-27 17:40:24 +00:00
Claude
1e215c3ac9
Fix three defects found reviewing the bracket entry-floor work
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m14s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m18s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
All three predate the EV fix on this branch and were surfaced by a review
of the full main..HEAD range.

1. reprocess-bracket skipped its wipe exactly when it was needed.

   The wipe was guarded on `completed.length > 0`, but clear-bracket
   deliberately leaves placements alone and tells the admin to "Run
   Reprocess Bracket after rebuilding to clear the placements those
   results produced". After clear then regenerate nothing is completed,
   so the wipe was skipped and the discarded bracket's finalized
   placements survived — and upsertParticipantResult's never-un-finalize
   guard then stopped the entry floors and the replay from correcting
   them. The advertised recovery path could not work.

   The guard was not arbitrary: seasonParticipantResults is keyed by
   sports season, not by event, so a season-wide delete takes every
   other event's placements with it. Rather than flip the condition,
   narrow the delete. New deleteParticipantResultsForParticipants scopes
   it to the participants the bracket actually holds, which removes the
   collateral damage the guard was defending against, so the delete can
   run unconditionally. The participant set was already being computed
   further down for the elimination pass; it is now built once and
   reused. The qualifying branch keeps its season-wide delete, which is
   deliberate and rebuilds via finalizeQualifyingPoints.

2. Banked entry floors could miss teamStandings.totalPoints.

   generate-bracket recalculated standings only when `toEliminate` was
   empty, assuming markEliminatedAndAnnounce covers every other case. It
   does not — it recalculates only when the event is non-qualifying AND
   somebody was *newly* eliminated, i.e. had no prior result row. So the
   second run of a generation (the first wrote 0 for every non-bracket
   participant) recalculated nowhere, and neither did a qualifying event
   with teams to eliminate. The floors never reached the standings.

   markEliminatedAndAnnounce now returns { markedCount, recalculated }
   and the caller drives off that fact instead of re-deriving it, which
   also covers the case where the announcement threw — the catch
   swallows the error, and a failed recalc is precisely when the
   fallback should run.

3. The NBA mobile pager fell back to index geometry.

   Its BracketTreePaginated was the only one of five call sites not
   forwarding feeders/template, so mobile rendered "TBD" where desktop
   rendered "Winner of ...".

Tests: reprocess wipes on a bracket with nothing played, stays scoped to
the bracket, dedupes and skips empty slots, and leaves the qualifying
path alone; generate recalculates in each of the four gaps above and
still does not double-recalculate; and the NBA layout gives its mobile
pane the same slot labels as desktop. Each was confirmed to fail against
the previous behavior.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EmUdy42Rpgx9qZTnpQwirz
2026-08-27 17:26:23 +00:00
Claude
7ec17e417d
Fix EV reporting 20 pts for both LLWS 5-6 and 7-8 locked tiers
After an LLWS simulation, a team locked into the 5th-6th tier and one
locked into the 7th-8th tier both showed 20 points EV. They should show
25 and 15.

The simulator and calculateEV were both right. A team locked into the
5-6 tier comes out of llws-simulator at probFifth = probSixth = 0.5, and
against DEFAULT_SCORING_RULES (100/70/50/40/25/25/15/15) that is 25 —
matching calculateBracketPoints, which already knows llws_20 splits 5-8
into two tiers. The Admin -> Expected Values page just wasn't using that
table. It hardcoded its own stale copy:

  const SCORING = [100, 70, 45, 45, 20, 20, 20, 20] as const;

0.5*20 + 0.5*20 = 20 for either tier.

It is not LLWS-specific. Four places carried that same stale table, and
it stayed invisible because a standard single-elimination bracket puts
all four quarterfinal losers in one tier worth avg(25,25,15,15) = 20 —
the same number. It only diverges for the templates that split 5-8
(llws_20, afl_10) and those with a distinct 3rd/4th (llws_20, fifa_48,
where 45/45 should be 50/40). Two of the four *persist* EVs computed
that way, so the wrong values reached the database:

  - expected-values.tsx      displayed EV, the total, and the sort order
  - expected-values.server   manual EV entry, written to expected_value
  - golf-skills.tsx          simulation EVs + snapshots, written
  - surface-elo.tsx          simulation EVs + snapshots, written

All four now use the shared DEFAULT_SCORING_RULES. probability-updater
had a fourth inline copy with the right values; it is folded in too so
there is one table left. The page's 340 total-EV invariant is unchanged
— both tables sum to 340.

A second path collapses the same two tiers, this time in real fantasy
points. calculateBracketPoints falls back to the flat avg([5,6,7,8])
when bracketTemplateId is null, and four call sites resolved the
template by taking an arbitrary scoringEvents row for the sports season
— unordered, and not filtered to rows that actually carry a template. A
season can own several events (a bracket plus schedule events, or a
re-created bracket beside a stale one), so a null row wins at random and
llws_20 is lost. New getBracketTemplateIdsForSportsSeasons in
models/bracket-template.ts filters to events with a template and takes
the most recent, the same rule llws-simulator uses to pick its bracket
event; standings, calculateTeamScore, calculateTeamProjectedScore and
getDraftedParticipantsWithPoints all go through it.

Tests: evFromProbs pinned to 25 / 15 / 20-for-a-single-5-8-tier and the
340 invariant; the new lookup against a mixed set of events; and two
llws-simulator tests that play out a full U.S. side so a team really is
locked into each tier and must come out at exactly 50/50 across it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EmUdy42Rpgx9qZTnpQwirz
2026-08-27 16:11:59 +00:00
3d89db3d0a Merge pull request 'Award AFL top-4 their guaranteed points when the bracket is set' (#143) from claude/afl-top-4-guaranteed-points-lof6ze into main
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m11s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m17s
🚀 Deploy / 🐳 Build (push) Successful in 1m8s
🚀 Deploy / 🚀 Deploy (push) Successful in 12s
Reviewed-on: #143
2026-08-24 17:24:25 +00:00
Claude
21422a7e92
Fix four issues found reviewing the entry-floor change
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m16s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m17s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
- Provisional rows were being treated as finished by
  updateProbabilitiesAfterResult, whose finishedMap filtered on
  finalPosition alone. Entry floors made that fire for the whole seeded
  field: on the first match result, AFL seeds 1-6 would each be pinned
  to 100% at their floor position and dropped from the ICM recalc,
  zeroing the championship odds of six teams that had not played. Filter
  partial rows out of finishedMap so they stay in the unfinished set.
  Finalized 0-position eliminations still finalize as before.

- generate-bracket recalculated standings only inside
  markEliminatedAndAnnounce, which no-ops when nothing was eliminated.
  A season whose participants exactly equal the bracket field would
  never surface the floors in teamStandings.totalPoints. Recalculate
  explicitly in that case (skipDiscord: seeding is not a result).

- applyBracketEntryFloors upserted unconditionally, so regenerating a
  bracket mid-tournament could downgrade a team already sitting on a
  better placement. Read existing placements first and only write when
  the floor improves on what a participant already has; position 0 is
  eliminated, not a placement, so it never blocks a floor.

- Relaxing the reprocess guard to matches.length made the season-wide
  deleteParticipantResultsBySportsSeasonId reachable with zero completed
  matches, wiping other events' placements with no replay able to
  rebuild them. Skip the wipe when there is nothing to replay; entry
  floors and elimination marking are additive and need no wipe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JkhdcbUCvramxJdVdoBsKd
2026-08-24 17:13:45 +00:00
Claude
ea58db9595
Award AFL top-4 their guaranteed points when the bracket is set
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m14s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m18s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
An AFL top-4 seed has the double chance from the moment the bracket is
drawn: lose the Qualifying Final, lose the Semi-Final, and you still
finish in the 5th-6th tier. Nothing was awarding that. Seeds 1-4 sat on
0 fantasy points until their first game resolved, which understated
every roster holding them.

Add an `entryFloor` field to BracketRound for floors a seeding locks in
before anyone plays, plus `applyBracketEntryFloors` to bank them, wired
into both bracket generation and reprocess-bracket. For afl_10 that is 5
for the Qualifying Finals (seeds 1-4) and 7 for the Elimination Finals
(seeds 5-6). Every write is provisional, so a real result supersedes it,
and upsertParticipantResult's never-un-finalize guard leaves finalized
rows alone.

Two related floors were also wrong, both from the generic
"winning into a scoring round means top-8" default in
nonScoringWinnerFloorFor:

  - Qualifying Finals winners banked 5 when the bye to a Preliminary
    Final guarantees the 3rd-4th tier. progressive-floor-scoring.test.ts
    already asserted 3 here, but via an isScoring=true call the runtime
    never makes.
  - Wildcard winners banked 5 when winning only buys an Elimination
    Final, whose losers are the 7th-8th tier — an over-award of a full
    tier until that game was played.

Both are now explicit nonScoringWinnerFloor values on the template.

reprocess-bracket now applies entry floors after wiping results and
before replaying matches, and no longer refuses a bracket with no
completed matches, so setting a bracket and reprocessing awards the
guaranteed points. It stays silent on Discord as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JkhdcbUCvramxJdVdoBsKd
2026-08-24 16:56:33 +00:00
3c7272392e Merge pull request 'claude/llws-simulator-logic-0aqbjb' (#142) from claude/llws-simulator-logic-0aqbjb into main
All checks were successful
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m19s
🚀 Deploy / 🧪 Test (push) Successful in 3m11s
🚀 Deploy / 🐳 Build (push) Successful in 1m10s
🚀 Deploy / 🚀 Deploy (push) Successful in 20s
Reviewed-on: #142
2026-08-23 01:57:37 +00:00
Claude
3fae78c521
Address review findings in LLWS simulator
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m48s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m20s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Four fixes from code review of the previous commit.

1. Preserve the futures board's dispersion (llws-simulator.ts).
   convertFuturesToElo finishes by rescaling any field onto a fixed
   1250-1750 Elo span, discarding how spread out the board actually is: a
   board with a 22%-priced favorite and one with a 6%-priced favorite both
   came out 500 Elo wide. On a tight board that inflated the favorite from
   6% to 13% -- worse than the raw-futures model it replaced (RMSE 0.025 vs
   0.005), so the previous commit was a regression in that regime.

   buildLLWSElos now maps decompressed strengths by their log-ratio to the
   field's geometric mean, so Elo span tracks real dispersion. Re-swept the
   parity factor across three board shapes rather than one: 550 minimizes
   total error. Simulated vs priced favorite, with RMSE:
     wide       21.8% -> 20.7%  (0.0055), elo span 1355-1682
     top-heavy  28.2% -> 25.6%  (0.0085), elo span 1354-1733
     tight       6.0% ->  5.8%  (0.0026), elo span 1484-1518

2. Rate an unpriced team at the field's median, not 1500.
   DEFAULT_ELO is the midpoint of the Elo output range, not of the field;
   on a typical board it ranked an unpriced team ~6th of 20, so blanking a
   team's odds promoted it. buildLLWSElos now returns the priced field's
   median alongside the ratings (ranks 11th of 20 on the same board).

3. Pick the bracket event deterministically.
   scoringEvents.findFirst with no ordering returned an arbitrary row when a
   season had more than one llws_20 playoff event; landing on a stale one
   silently reverted to a randomized draw that ignored all recorded results.
   Now takes the most recent, matching world-cup-simulator.ts.

4. Fail on a partially seeded bracket instead of discarding it.
   readBracketSlots returned null on any single missing participant, throwing
   away the draw and every completed result with no warning. Since
   playoff_matches participant columns are ON DELETE SET NULL, removing and
   re-adding one participant mid-tournament was enough to put eliminated
   teams back in contention. It now distinguishes "generated but not seeded"
   (0 slots filled -> randomized draw) from "partially seeded" (throws).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X5vQZMPeokzfMqHQjq1RDZ
2026-08-21 22:08:04 +00:00
Claude
d4df0b65fb
Make LLWS simulator bracket-aware and calibrate its futures model
The LLWS simulator was overestimating favorites and ignoring games that had
already been played. Two separate causes:

1. Championship futures were used directly as single-game strength
   (p1 / (p1 + p2)). A future already compounds the ~6 wins needed to take
   the title, so this made every individual game as lopsided as the whole
   tournament and re-compounded that edge round after round. Against a
   representative 20-team board the favorite priced at 21.8% simulated at
   44.9%, and the longest shot fell to ~0%.

   Futures are now decompressed to single-game Elo via convertFuturesToElo,
   the same pipeline the other bracket simulators use, and games are played
   with eloWinProbabilityWithParity. The parity factor was calibrated by
   sweeping it until a randomized-draw simulation reproduces the board it
   was fed: at 1000 the favorite simulates at 21.8% and field-wide RMSE
   drops from 0.062 to 0.003. It is overridable per season via config.

2. The simulator never read playoff_matches, so it re-ran the tournament
   from an empty bracket every time and shuffled the draw at random each
   iteration. A recorded loss changed nothing.

   It now loads the seeded llws_20 bracket, places teams in their real
   slots, and replays completed games from their recorded result instead of
   re-simulating them, so an eliminated team correctly drops to zero. When
   no bracket exists (or it has no participants seeded) it falls back to the
   previous randomized-draw behavior, and a seeded bracket is authoritative
   about which side a team is on, so externalId is only required on the
   pre-bracket path.

Guards: a recorded result is only honored when its two participants are the
ones the simulation routed into that game, so a corrupt or out-of-order row
cannot desynchronize the rest of the bracket; brackets seeding an unknown or
duplicated participant now fail loudly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X5vQZMPeokzfMqHQjq1RDZ
2026-08-21 21:09:47 +00:00
2356e37163 Merge pull request 'claude/llws-bracket-alignment-gworyh' (#140) from claude/llws-bracket-alignment-gworyh into main
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m9s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m24s
🚀 Deploy / 🐳 Build (push) Successful in 1m17s
🚀 Deploy / 🚀 Deploy (push) Successful in 24s
Reviewed-on: #140
2026-08-21 19:01:41 +00:00
Claude
569081fe29
Only derive feeders where the halving rule actually holds
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m55s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m25s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Review caught that the generic feeder rule was being applied to templates
that route by their own logic. It was harmless as dead code; driving the
renderer with it made several brackets worse than before.

The rule pairs rounds by array order and assumes match n is fed by 2n-1
and 2n. That describes advanceWinnerTemplate, not every bracket:

- afl_10's Wildcard Round feeds the Elimination Finals, skipping the round
  listed next to it, so array order fabricated the entire chain and drew
  ten wrong connectors contradicting advanceAFLWinner.
- fifa_48's Third Place Game sits between the Semifinals and the Finals,
  so the Finals came out fed by the third place game. Once BracketTreeView
  filtered the consolation round out, the group had three roots and the
  whole World Cup bracket rendered with no connectors at all.
- ncaa_68 labelled Round of 64 #1/#2 with First Four feeds that
  advanceFirstFourWinner doesn't use.
- nba_20's play-in halves in size but pairs the 7v8 loser with the 9v10
  winner.

Follow each round's declared feedsInto, and derive edges only where the
round halves exactly — the condition under which the generic ceil(n/2)
mapping is true. Bespoke transitions that happen to halve are named
explicitly. Slots left without a feeder read TBD, which is honest.

Dropping those edges sends the group to the fallback, so the fallback now
has to keep drawing what those brackets already drew: halving U-shapes by
round size, and winner tracing through irregular shapes. Previously it
drew nothing, which also silently removed every connector from brackets
with no bracketTemplateId.

Also from review:
- clear-bracket deleted seasonParticipantResults for the entire sports
  season with no rebuild. That table is keyed by season, not event, so it
  wiped placements for every other event in the season — permanently
  zeroing standings on a finalized qualifying season. Delete only the
  matches and point the admin at Reprocess Bracket, which rebuilds
  placements correctly.
- The clear-bracket form sent confirm=true from a hidden field, making the
  server's completed-match guard unreachable. It's a checkbox now, so the
  guard is real, including without JS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HnzbrCHoM8ESbtbDamaqFb
2026-08-21 18:18:05 +00:00
Claude
89ceee432a
Lay out brackets from the feeder graph
The LLWS bracket didn't read as a bracket: cards sat above games that
don't feed them, connectors joined the wrong pairs, and several games had
no line at all.

The stored data was correct — LLWS_ADVANCEMENT already matches the
official 2026 LLBWS bracket game for game. The renderer was the problem.
TreeColumns placed cards at `index * (height / roundSize)` and
ConnectorColumn assumed matches 2k and 2k+1 feed match k, which holds
only for an exact halving. The LLWS winners bracket is not one: two of
the four Opening Round games skip Winners Round 2 and go straight to the
semifinals, so those two got stranded in column one with nothing beside
them, and the halving branch drew confident, wrong connectors for the
rest.

Lay out from the graph instead. app/lib/bracket-layout.ts inverts a
template's advancement into "what fills each slot", then assigns columns
by depth from the group's final, orders each column by the parent's slot
order, and centres each card on its feeders. Counting back from the final
is what makes a printed bracket line up: a team entering late is drawn in
the column where it actually plays. This reproduces the official
International bracket exactly, and fixes Elimination Round 3, where the
official bracket prints the later game on top but match-number sort put
it below.

Because column is depth, every in-group edge spans exactly one gutter, so
connectors now draw for unplayed games too. Cards also take a fixed
height rather than stretching to fill their column, which is what made a
lone final tower over the rest.

Empty slots name their source — "Loser of Winners SF 1" rather than
"TBD". That is the only way to show the feeds crossing between the
winners and elimination brackets, which render as separate trees.

Also:
- Move the LLWS routing table to app/lib/llws-bracket.ts so the renderer
  can import it without pulling the database context into the browser
  bundle; models/playoff-match re-exports it.
- Page the mobile view one group at a time, matching desktop. A whole
  double-elimination phase is a DAG, not a tree, so its columns would be
  arbitrary.
- Add a clear-bracket admin action. Nothing else could rewrite a match's
  participants, so a mis-seeded bracket had no repair path at all.
- Lift the PDF transcription into app/test/fixtures/llws-bracket.ts so the
  routing and layout tests check against one copy of the official bracket.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HnzbrCHoM8ESbtbDamaqFb
2026-08-21 17:50:59 +00:00
8edb4293c5 claude/indycar-ev-sims-probability-fpzfx6 (#139)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m1s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m21s
🚀 Deploy / 🐳 Build (push) Successful in 1m28s
🚀 Deploy / 🚀 Deploy (push) Successful in 25s
Co-authored-by: Claude <noreply@anthropic.com>
Reviewed-on: #139
2026-08-17 22:50:35 +00:00
81d813d3f3 Merge pull request 'claude/llws-double-elimination-bracket-oaj093' (#138) from claude/llws-double-elimination-bracket-oaj093 into main
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m19s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m29s
🚀 Deploy / 🐳 Build (push) Successful in 1m16s
🚀 Deploy / 🚀 Deploy (push) Successful in 12s
Reviewed-on: #138
2026-08-04 04:54:47 +00:00
Claude
37af2f1fc3
Fix final rankings for double-elimination brackets
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m2s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m22s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Review of the llws_20 bracket turned up two display bugs in the shared
ranking code, both triggered by the template setting loserFeedsInto on
every winners-bracket round.

findConsolationRound took the FIRST round with loserFeedsInto and
treated its target as a third-place game. That is sound for fifa_48,
whose only such round is the Semifinals, but llws_20 uses loserFeedsInto
to route losers into the elimination bracket — so "Elimination Round 1"
was being read as the consolation game. Its four matches were then
placed as exact positions inside the Opening Round tier, corrupting the
final rankings list. A consolation round now has to be terminal: its
winner plays no further game, which is precisely what lets its result
split two exact positions.

The rank walk also advanced by a round's match count on the assumption
that every match places its loser. That holds in single elimination and
must be kept for undecided rounds — four semifinalists occupy positions
1-4 whether or not the games have been played — but a winners-bracket
loss places nobody, since the loser drops into the elimination bracket
and is ranked by whatever knocks them out later. Those rounds consumed
positions they never filled, so a 20-team bracket ranked its last teams
T23. Rounds whose losers are placed later now consume nothing, leaving
LLWS at 1, T2, 3, 4, T5, T7, T9, T13, T17 for the full 20-team field.
Single-elimination templates are unaffected; afl_10's sub-8th ranks were
inflated the same way and are now correct too.

Also from review: the llws_20 participant labels were 21-24 characters
in an 80px fixed-width admin column and would have wrapped to three
lines across all 20 rows. Shortened them and widened the column.

Tests play a full 20-team tournament through the real advancement map
and assert the resulting rank labels.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAxqBVzfmKJe6WQ6VF9guj
2026-08-03 18:33:22 +00:00
Claude
1bd23a4419
Add LLWS 20-team double-elimination bracket
The Little League Baseball World Series runs two independent 10-team
double-elimination brackets — United States and International — each
producing a side champion, then a World Championship game and a
Consolation Third Place game between the side runners-up. 38 games in
all. No existing template could express it: every one is single
elimination, at most with a bolted-on third-place game.

Adds the llws_20 template plus dedicated generation and advancement,
following the same bespoke-routing pattern afl_10 and nba_20 use rather
than the generic ceil(matchNumber / 2) advancement.

The core of the change is loser routing. In the winners bracket a loss
is not an elimination — it drops the team into the elimination bracket
at a specific slot, including the deliberate cross-overs the official
bracket uses (Elimination Round 1 pairs L4/L6 and L2/L8; Elimination
Round 3 pairs each semifinal loser with the winner from the opposite
half). In the elimination bracket a loss is final. Matching the official
modified double-elimination format, there is no "if necessary" game: the
winners-bracket champion is eliminated if it loses the side
championship, dropping to the consolation game.

Rounds are shared across both sides, U.S. taking the low match numbers
and International the high ones, so the scoring config stays one entry
per stage. The existing phases/groups display machinery splits them back
apart into United States / International / Championship tabs.

Scoring lands on exactly 8 point-earning teams, which is the field size
when Elimination Round 4 begins: the two finals decide 1st–4th,
Elimination Final losers take 5th–6th, and Elimination Round 4 losers
7th–8th. 3rd and 4th are distinct because the consolation game is real,
and 5–8 splits into two two-team tiers so surviving Elimination Round 4
is worth more than losing it.

Also:

- Adds an optional nonScoringWinnerFloor to BracketRound. The engine
  hardcoded a 5th-place floor for winners of non-scoring rounds feeding
  a scoring one, which is wrong inside a losers bracket where a win can
  guarantee only 7th. Opt-in, so no existing template changes behavior.

- Fixes TabbedBracketLayout's mobile path, which built its match map
  unfiltered and so would have merged U.S. and International games into
  one column. No-op for NCAA and NBA, whose groups already cover every
  match in their phases.

- Rewrites the LLWS Monte Carlo simulator, which still modelled the
  retired pool-play format (5 teams per pool, then a 4-team bracket per
  side) and no longer described the tournament being scored. It now runs
  the real 10-team double elimination and splits the 5–8 probabilities
  into the correct tiers instead of one even four-way split. Legacy
  "US:A"/"Intl:B" externalIds are still accepted, read as the side
  alone, so seasons configured for the old format keep loading.

Tests replay all 38 games through the pure advancement resolver and
assert each one against the feed labels printed on the official bracket.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAxqBVzfmKJe6WQ6VF9guj
2026-08-03 18:06:56 +00:00
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
d9ea815f50 world cup times (#97)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m2s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m19s
🚀 Deploy / 🐳 Build (push) Successful in 18s
🚀 Deploy / 🚀 Deploy (push) Successful in 8s
2026-06-18 00:36:26 +00:00
496ecba464 claude/cool-lamport-etpwsi (#96)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m16s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m24s
🚀 Deploy / 🐳 Build (push) Successful in 1m19s
🚀 Deploy / 🚀 Deploy (push) Successful in 13s
Co-authored-by: Claude <noreply@anthropic.com>
Reviewed-on: #96
2026-06-17 19:46:58 +00:00
Claude
fa98d514f2
Code review fixes: earliestGameTime fallback, isNaN guard, and missing index
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m58s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m18s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
- getUpcomingScoringEvents: fall back to eventStartsAt.toISOString() when no
  game-level scheduledAt exists, matching the same pattern already used in
  getUpcomingEventsForDraftedParticipants — prevents F1/golf events from
  showing a date with no time in SportSeasonCard and similar consumers
- EventSchedule: replace bare toLocaleTimeString call with an isNaN-guarded
  block so a malformed timestamp renders nothing instead of "Invalid Date"
- database/schema.ts: add index on tournament_groups(scoring_event_id) to
  support the inArray filter added in the previous commit
  (run npm run db:generate && npm run db:migrate to apply)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KiVGo8gSBXe3WuRniVNKsd
2026-06-17 17:47:24 +00:00
Claude
9a04e25c20
Also fetch group stage match times for sport-season event schedule
The previous fix only queried playoffMatchGames.scheduledAt for bracket
events. FIFA World Cup group stage matches live in groupStageMatches
(linked via tournamentGroups), not playoffMatchGames. Now
getUpcomingScoringEvents fetches both tables in parallel and uses
the earliest scheduledAt from either source as earliestGameTime,
matching what UpcomingCalendarPanel already shows on the Upcoming Events page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KiVGo8gSBXe3WuRniVNKsd
2026-06-17 16:57:40 +00:00
Claude
363413cd38
Fix league sport-season page to show correct game times for FIFA World Cup
getUpcomingScoringEvents already fetched per-game scheduledAt times from
playoffMatchGames for sorting, but discarded them from the return value.
EventSchedule was showing eventStartsAt (event-level) instead of the
accurate per-game times. Now getUpcomingScoringEvents includes earliestGameTime
in each returned event, and EventSchedule prefers earliestGameTime over
eventStartsAt — matching how UpcomingCalendarPanel works on the Upcoming Events page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KiVGo8gSBXe3WuRniVNKsd
2026-06-17 16:47:17 +00:00
08ae2bb88a Fix SSR/hydration timezone flip in event date and time display (#95)
All checks were successful
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m38s
🚀 Deploy / 🧪 Test (push) Successful in 3m30s
🚀 Deploy / 🐳 Build (push) Successful in 1m9s
🚀 Deploy / 🚀 Deploy (push) Successful in 11s
## Summary

- Adds `useHasMounted` hook (using `useSyncExternalStore` — no extra render cycle) to gate UTC timestamp formatting to client-only
- Fixes the visible time-flip in `EventSchedule` and `UpcomingCalendarPanel` where SSR rendered UTC time (e.g. "9:00 PM") and the client re-rendered with local time (e.g. "2:00 PM PDT") after hydration, with `suppressHydrationWarning` silently hiding the mismatch
- Fixes `formatEventDate` in `date-utils.ts` (and `EventSchedule`'s private copy) to parse `YYYY-MM-DD` strings as local midnight instead of UTC midnight, correcting an off-by-one day bug for UTC-negative users across all callers

## Test plan

- [ ] Navigate to a sports season page — confirm event times show in local timezone with no flash/flip on load
- [ ] Hard-reload the page — confirm no brief UTC time visible before settling on local time
- [ ] Check the home page `UpcomingCalendarPanel` — confirm same behavior
- [ ] Verify dates display correctly (e.g. no "Jun 17" showing for a "Jun 18" event near midnight UTC)

Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #95
2026-06-17 06:09:29 +00:00
8c9d328301 claude/inspiring-turing-9w84hu (#94)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m21s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m19s
🚀 Deploy / 🐳 Build (push) Successful in 1m12s
🚀 Deploy / 🚀 Deploy (push) Successful in 10s
Co-authored-by: Claude <noreply@anthropic.com>
Reviewed-on: #94
2026-06-17 04:29:23 +00:00
7da8be9978 claude/charming-tesla-re9ur7 (#93)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 2m48s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m17s
🚀 Deploy / 🐳 Build (push) Successful in 1m8s
🚀 Deploy / 🚀 Deploy (push) Successful in 10s
2026-06-16 22:11:01 +00:00
201 changed files with 40594 additions and 3637 deletions

View file

@ -9,17 +9,14 @@
"command": ".claude/hooks/lint-on-edit.sh", "command": ".claude/hooks/lint-on-edit.sh",
"timeout": 30, "timeout": 30,
"statusMessage": "Linting..." "statusMessage": "Linting..."
} },
]
}
],
"Stop": [
{
"hooks": [
{ {
"type": "command", "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", "if": "Write(*.ts)|Write(*.tsx)|Edit(*.ts)|Edit(*.tsx)|MultiEdit(*.ts)|MultiEdit(*.tsx)",
"timeout": 60 "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. # Must match the CRON_SECRET repo secret in Forgejo.
# Generate with: openssl rand -hex 32 # Generate with: openssl rand -hex 32
CRON_SECRET="" 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", () => { describe("QualifyingPointsStandings", () => {
it("renders fractional QP to hundredths", () => { it("renders fractional QP to at most hundredths with trailing zeros trimmed", () => {
render( render(
<QualifyingPointsStandings <QualifyingPointsStandings
standings={[ standings={[
@ -52,7 +52,8 @@ describe("QualifyingPointsStandings", () => {
); );
expect(screen.getByText("14.67 QP")).toBeInTheDocument(); 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(); 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> </div>
{/* Stats — second row on mobile, right side on desktop */} {/* Stats — second row on mobile, right side on desktop */}
{showStats && ( {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 && ( {displayRank !== undefined && (
<RankingDisplay <RankingDisplay
displayRank={displayRank} displayRank={displayRank}
rankChange={previousRank !== undefined && currentRank !== undefined && previousRank !== currentRank ? previousRank - currentRank : undefined} 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 && ( {totalPoints !== undefined && (
<StatColumn label="Points"> <StatColumn
label="Points"
value={
<span className="text-2xl font-bold leading-none text-electric"> <span className="text-2xl font-bold leading-none text-electric">
{Math.round(totalPoints).toLocaleString("en-US")} {Math.round(totalPoints).toLocaleString("en-US")}
</span> </span>
</StatColumn> }
/>
)} )}
</div> </div>
)} )}
@ -169,17 +172,21 @@ function PreDraftRow({
</div> </div>
{/* Stats — second row on mobile, right side on desktop */} {/* 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"> <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"> <StatColumn
<span className="text-2xl font-bold leading-none">{draftTimeValue}</span> label="Draft"
</StatColumn> value={<span className="text-2xl font-bold leading-none">{draftTimeValue}</span>}
/>
{draftPosition !== undefined && ( {draftPosition !== undefined && (
<> <>
<StatDivider /> <StatDivider className="h-8" />
<StatColumn label="Position"> <StatColumn
label="Position"
value={
<span className="text-2xl font-bold leading-none"> <span className="text-2xl font-bold leading-none">
{ordinal(draftPosition)} {ordinal(draftPosition)}
</span> </span>
</StatColumn> }
/>
</> </>
)} )}
</div> </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 = { export const WithTies: Story = {
args: { args: {
entries: [ entries: [

View file

@ -5,7 +5,17 @@ import { Card, CardContent, CardHeader } from "~/components/ui/card";
import { GradientIcon } from "~/components/ui/GradientIcon"; import { GradientIcon } from "~/components/ui/GradientIcon";
import { TeamAvatar } from "~/components/TeamAvatar"; import { TeamAvatar } from "~/components/TeamAvatar";
import type { AvatarData, RawFlagConfig } from "~/lib/flag-types"; 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 ─────────────────────────────────────────────────────────────── // ─── Row styles ───────────────────────────────────────────────────────────────
@ -23,7 +33,20 @@ function rowClasses(currentRank: number | undefined, hasHref: boolean): string {
// ─── Row content ────────────────────────────────────────────────────────────── // ─── 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 ( return (
<> <>
{/* Left: avatar + name */} {/* Left: avatar + name */}
@ -46,16 +69,44 @@ function RowContent({ entry }: { entry: StandingsPreviewEntry }) {
{/* Right: stats — second row on mobile */} {/* 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"> <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 /> <StatDivider />
<StatColumn label="Points"> <StatColumn
label="Points"
reserveDelta={rowHasChange}
value={
<span className="text-2xl font-bold leading-none text-electric"> <span className="text-2xl font-bold leading-none text-electric">
{Math.round(entry.points).toLocaleString("en-US")} {Math.round(entry.points).toLocaleString("en-US")}
</span> </span>
{entry.pointChange !== undefined && entry.pointChange !== 0 && ( }
<PointChangeIndicator delta={entry.pointChange} /> delta={
)} entry.pointChange !== undefined && entry.pointChange !== 0 ? (
</StatColumn> <DeltaBadge delta={entry.pointChange} kind="points" />
) : undefined
}
/>
</div> </div>
</> </>
); );
@ -81,15 +132,26 @@ export interface StandingsPreviewEntry {
rankChange?: number; rankChange?: number;
/** 7-day point delta. From TeamStanding.sevenDayPointChange. */ /** 7-day point delta. From TeamStanding.sevenDayPointChange. */
pointChange?: number; pointChange?: number;
/** Projected final points. From TeamStanding.projectedPoints. */
projectedPoints?: number | null;
/** Live participants left; projection only shown while > 0. */
participantsRemaining?: number;
} }
export interface StandingsPreviewProps { export interface StandingsPreviewProps {
entries: StandingsPreviewEntry[]; entries: StandingsPreviewEntry[];
description?: string; description?: string;
fullStandingsHref?: 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 ( return (
<Card className="gap-2"> <Card className="gap-2">
<CardHeader className="px-3 sm:px-6 pb-2"> <CardHeader className="px-3 sm:px-6 pb-2">
@ -119,11 +181,11 @@ export function StandingsPreview({ entries, description, fullStandingsHref }: St
to={entry.href} to={entry.href}
className={rowClasses(entry.currentRank, true)} className={rowClasses(entry.currentRank, true)}
> >
<RowContent entry={entry} /> <RowContent entry={entry} showProjected={showProjected} />
</Link> </Link>
) : ( ) : (
<div key={entry.teamId} className={rowClasses(entry.currentRank, false)}> <div key={entry.teamId} className={rowClasses(entry.currentRank, false)}>
<RowContent entry={entry} /> <RowContent entry={entry} showProjected={showProjected} />
</div> </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({ export function StatColumn({
label, label,
children, value,
delta,
reserveDelta = false,
}: { }: {
label: string; 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 ( return (
<div className="text-right shrink-0 flex-1 sm:flex-none"> <div className="text-right shrink-0 flex-1 sm:flex-none">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{label} {label}
</p> </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> </div>
); );
} }
export function StatDivider() { export function StatDivider({ className = "h-12" }: { className?: string } = {}) {
return <div className="h-8 w-px bg-border shrink-0 self-center" />; return <div className={`${className} w-px bg-border shrink-0 self-center`} />;
} }
export function RankChangeIndicator({ delta }: { delta: number }) { /**
if (delta === 0) return null; * 7-day change indicator. `rank` deltas are unitless places; `points` deltas are
if (delta > 0) { * 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 ( return (
<span className="text-xs font-semibold text-primary" aria-label={`up ${delta}`}> <span className="text-xs font-semibold text-primary" aria-label={label}>
{delta} {magnitude}
</span> </span>
); );
} }
@ -32,9 +73,9 @@ export function RankChangeIndicator({ delta }: { delta: number }) {
<span <span
className="text-xs font-semibold" className="text-xs font-semibold"
style={{ color: "var(--coral-accent, #ef4444)" }} style={{ color: "var(--coral-accent, #ef4444)" }}
aria-label={`down ${Math.abs(delta)}`} aria-label={label}
> >
{Math.abs(delta)} {magnitude}
</span> </span>
); );
} }
@ -42,35 +83,22 @@ export function RankChangeIndicator({ delta }: { delta: number }) {
export function RankingDisplay({ export function RankingDisplay({
displayRank, displayRank,
rankChange, rankChange,
reserveDelta = false,
}: { }: {
displayRank: string | number; displayRank: string | number;
rankChange?: number; rankChange?: number;
reserveDelta?: boolean;
}) { }) {
return ( return (
<StatColumn label="Ranking"> <StatColumn
<span className="text-2xl font-bold leading-none">{displayRank}</span> label="Ranking"
{rankChange !== undefined && rankChange !== 0 && ( reserveDelta={reserveDelta}
<RankChangeIndicator delta={rankChange} /> value={<span className="text-2xl font-bold leading-none">{displayRank}</span>}
)} delta={
</StatColumn> rankChange !== undefined && rankChange !== 0 ? (
); <DeltaBadge delta={rankChange} kind="rank" />
} ) : undefined
}
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>
); );
} }

View file

@ -103,4 +103,54 @@ describe("StandingsPreview", () => {
expect(link).toHaveAttribute("href", "/leagues/1/standings/1"); 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 { ComponentType } from "react";
import type { LucideProps } from "lucide-react"; import type { LucideProps } from "lucide-react";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { Link } from "react-router";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
type SettingsNavSection = { type SettingsNavSection = {
@ -17,28 +18,20 @@ export type SettingsGridSection = SettingsNavSection & {
export function SettingsMobileGridNav({ export function SettingsMobileGridNav({
sections, sections,
onSectionChange, onSectionChange,
buildHref,
}: { }: {
sections: readonly SettingsGridSection[]; sections: readonly SettingsGridSection[];
onSectionChange: (sectionId: string) => void; onSectionChange?: (sectionId: string) => void;
buildHref?: (sectionId: string) => string;
}) { }) {
return ( const cardClassName = (section: SettingsGridSection) =>
<div className="mb-5 lg:hidden"> cn(
<div className="mb-3">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Manage
</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]", "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" section.isDanger && "border-destructive/40"
)} );
>
const cardInner = (section: SettingsGridSection) => (
<>
<div <div
className={cn( className={cn(
"flex h-10 w-10 items-center justify-center rounded-lg", "flex h-10 w-10 items-center justify-center rounded-lg",
@ -53,8 +46,33 @@ export function SettingsMobileGridNav({
<p className="text-sm font-semibold leading-tight">{section.label}</p> <p className="text-sm font-semibold leading-tight">{section.label}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{section.subtitle}</p> <p className="mt-0.5 text-xs text-muted-foreground">{section.subtitle}</p>
</div> </div>
</>
);
return (
<div className="mb-5 lg:hidden">
<div className="mb-3">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Manage
</p>
</div>
<div className="grid grid-cols-2 gap-3">
{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)}
>
{cardInner(section)}
</button> </button>
))} )
)}
</div> </div>
</div> </div>
); );
@ -62,19 +80,30 @@ export function SettingsMobileGridNav({
export function SettingsMobileSectionPill({ export function SettingsMobileSectionPill({
onShowGrid, onShowGrid,
backHref,
}: { }: {
onShowGrid: () => void; onShowGrid?: () => void;
backHref?: string;
}) { }) {
return ( const className =
<div className="mb-5 lg:hidden"> "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";
<button const content = (
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" /> <ArrowLeft className="h-3.5 w-3.5" />
Back to all settings Back to all settings
</>
);
return (
<div className="mb-5 lg:hidden">
{backHref ? (
<Link to={backHref} className={className}>
{content}
</Link>
) : (
<button type="button" onClick={onShowGrid} className={className}>
{content}
</button> </button>
)}
</div> </div>
); );
} }
@ -83,33 +112,57 @@ export function SettingsDesktopNav({
sections, sections,
activeSection, activeSection,
onSectionChange, onSectionChange,
buildHref,
navLabel = "League settings",
}: { }: {
sections: readonly SettingsGridSection[]; sections: readonly SettingsGridSection[];
activeSection: string; 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 ( return (
<aside className="hidden lg:block"> <aside className="hidden lg:block">
<div className="sticky top-6 rounded-xl border bg-card p-3"> <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"> <p className="px-3 pb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Manage Manage
</p> </p>
<nav aria-label="League settings" className="space-y-1"> <nav aria-label={navLabel} className="space-y-1">
{sections.map((section) => ( {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 <button
key={section.id} key={section.id}
type="button" type="button"
onClick={() => onSectionChange(section.id)} onClick={() => onSectionChange?.(section.id)}
aria-current={activeSection === section.id ? "page" : undefined} aria-current={activeSection === section.id ? "page" : undefined}
className={cn( className={itemClassName(section)}
"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" /> {itemInner(section)}
{section.label}
</button> </button>
))} )
)}
</nav> </nav>
</div> </div>
</aside> </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

@ -1,13 +1,16 @@
import { ChevronLeft, ChevronRight } from "lucide-react"; import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { useRoundTransition } from "~/hooks/useRoundTransition"; import { useRoundTransition } from "~/hooks/useRoundTransition";
import type { FeederMap } from "~/lib/bracket-layout";
import type { BracketTemplate } from "~/lib/bracket-templates";
import { import {
TreeColumns, TreeColumns,
BracketMatchSlot, BracketMatchSlot,
bracketGeometry,
windowGeometry,
SLOT_WIDTH, SLOT_WIDTH,
LABEL_HEIGHT, LABEL_HEIGHT,
DESIRED_CARD_HEIGHT, DESIRED_CARD_HEIGHT,
CARD_GAP,
MAX_CARD_HEIGHT, MAX_CARD_HEIGHT,
type BracketMatch, type BracketMatch,
type BracketOwnership, type BracketOwnership,
@ -21,6 +24,8 @@ interface BracketTreePaginatedProps {
/** Index of the first scoring round — default page starts here */ /** Index of the first scoring round — default page starts here */
firstScoringRoundIdx?: number; firstScoringRoundIdx?: number;
thirdPlaceRound?: string; thirdPlaceRound?: string;
feeders?: FeederMap;
template?: BracketTemplate;
} }
export function BracketTreePaginated({ export function BracketTreePaginated({
@ -30,63 +35,68 @@ export function BracketTreePaginated({
userParticipantIds, userParticipantIds,
firstScoringRoundIdx, firstScoringRoundIdx,
thirdPlaceRound, thirdPlaceRound,
feeders,
template,
}: BracketTreePaginatedProps) { }: BracketTreePaginatedProps) {
const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds; const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds;
const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined; const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined;
// Pages are pairs of layout columns, not pairs of rounds: a column can mix rounds
// when teams enter the bracket at different points (see computeGroupLayout).
const geometry = bracketGeometry(
mainRounds,
matchesByRound,
feeders,
template?.rounds.map((r) => r.name) ?? mainRounds
);
const columns = geometry.layout.columns;
const lastPage = Math.max(columns.length - 2, 0);
const defaultPage = Math.max( const defaultPage = Math.max(
0, 0,
Math.min( Math.min(
firstScoringRoundIdx !== undefined firstScoringRoundIdx !== undefined ? Math.max(0, firstScoringRoundIdx - 1) : lastPage,
? Math.max(0, firstScoringRoundIdx - 1) lastPage,
: mainRounds.length - 2,
mainRounds.length - 2,
), ),
); );
const { page, anim, stripRef, navigate, handleTransitionEnd } = useRoundTransition( const { page, anim, stripRef, navigate, handleTransitionEnd } = useRoundTransition(
mainRounds.length - 2, lastPage,
defaultPage, defaultPage,
); );
const targetPage = anim ? anim.toPage : page; const pageGeometry = (p: number) => windowGeometry(geometry, p, p + 1);
const labelRounds = mainRounds.slice(targetPage, targetPage + 2); const labelFor = (p: number) => {
const label = labelRounds[1] ? `${labelRounds[0]}${labelRounds[1]}` : labelRounds[0]; const [a, b] = [columns[p]?.label, columns[p + 1]?.label];
return b ? `${a}${b}` : (a ?? "");
const calcHeight = (p: number) => {
const rs = mainRounds.slice(p, p + 2);
const max = Math.max(...rs.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
return max * (DESIRED_CARD_HEIGHT + CARD_GAP);
}; };
const pageHeight = calcHeight(page); const label = labelFor(anim ? anim.toPage : page);
const animFromHeight = anim ? calcHeight(anim.fromPage) : pageHeight;
const animToHeight = anim ? calcHeight(anim.toPage) : pageHeight;
const visibleRounds = mainRounds.slice(page, page + 2); const pageG = pageGeometry(page);
const fromRounds = anim ? mainRounds.slice(anim.fromPage, anim.fromPage + 2) : visibleRounds; const animFromG = anim ? pageGeometry(anim.fromPage) : pageG;
const toRounds = anim ? mainRounds.slice(anim.toPage, anim.toPage + 2) : visibleRounds; const animToG = anim ? pageGeometry(anim.toPage) : pageG;
let leftRounds: string[]; let leftPage: number;
let rightRounds: string[] = []; let rightPage: number | null = null;
let leftHeight: number; let leftG = pageG;
let rightHeight = 0; let rightG = pageG;
let settlingTransition = false; let settlingTransition = false;
if (anim?.phase === "sliding") { if (anim?.phase === "sliding") {
leftRounds = anim.dir === "right" ? fromRounds : toRounds; leftPage = anim.dir === "right" ? anim.fromPage : anim.toPage;
rightRounds = anim.dir === "right" ? toRounds : fromRounds; rightPage = anim.dir === "right" ? anim.toPage : anim.fromPage;
leftHeight = anim.dir === "right" ? animFromHeight : animToHeight; leftG = anim.dir === "right" ? animFromG : animToG;
rightHeight = anim.dir === "right" ? animToHeight : animFromHeight; rightG = anim.dir === "right" ? animToG : animFromG;
} else if (anim?.phase === "settling") { } else if (anim?.phase === "settling") {
leftRounds = toRounds; leftPage = anim.toPage;
leftHeight = animToHeight; leftG = animToG;
settlingTransition = true; settlingTransition = true;
} else { } else {
leftRounds = visibleRounds; leftPage = page;
leftHeight = pageHeight;
} }
const containerMinHeight = anim?.phase === "settling" ? animToHeight : animFromHeight; const containerMinHeight =
anim?.phase === "settling" ? animToG.bracketHeight : animFromG.bracketHeight;
const initialX = anim?.phase === "sliding" && anim.dir === "left" ? -SLOT_WIDTH : 0; const initialX = anim?.phase === "sliding" && anim.dir === "left" ? -SLOT_WIDTH : 0;
return ( return (
@ -109,7 +119,7 @@ export function BracketTreePaginated({
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => navigate(page + 1)} onClick={() => navigate(page + 1)}
disabled={page + 2 >= mainRounds.length || !!anim} disabled={page >= lastPage || !!anim}
className="h-7 w-7 shrink-0" className="h-7 w-7 shrink-0"
aria-label="Next rounds" aria-label="Next rounds"
> >
@ -129,22 +139,24 @@ export function BracketTreePaginated({
> >
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}> <div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
<TreeColumns <TreeColumns
visibleRounds={leftRounds} geometry={leftG}
matchesByRound={matchesByRound} columnRange={[leftPage, leftPage + 1]}
ownershipMap={ownershipMap} ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds} userParticipantIds={userParticipantIds}
bracketHeight={leftHeight} feeders={feeders}
template={template}
transitionDuration={settlingTransition ? 500 : undefined} transitionDuration={settlingTransition ? 500 : undefined}
/> />
</div> </div>
{anim?.phase === "sliding" && ( {anim?.phase === "sliding" && rightPage !== null && (
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}> <div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
<TreeColumns <TreeColumns
visibleRounds={rightRounds} geometry={rightG}
matchesByRound={matchesByRound} columnRange={[rightPage, rightPage + 1]}
ownershipMap={ownershipMap} ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds} userParticipantIds={userParticipantIds}
bracketHeight={rightHeight} feeders={feeders}
template={template}
/> />
</div> </div>
)} )}
@ -164,6 +176,8 @@ export function BracketTreePaginated({
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)} slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
ownershipMap={ownershipMap} ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds} userParticipantIds={userParticipantIds}
feeders={feeders}
template={template}
/> />
</div> </div>
)} )}

View file

@ -1,5 +1,13 @@
import { avatarColor } from "~/lib/avatar-colors"; import { avatarColor } from "~/lib/avatar-colors";
import { BRACKT_GRADIENT } from "~/lib/brand"; import { BRACKT_GRADIENT } from "~/lib/brand";
import {
computeGroupLayout,
describeSlotSource,
matchKey,
type BracketLayout,
type FeederMap,
} from "~/lib/bracket-layout";
import type { BracketTemplate } from "~/lib/bracket-templates";
export interface BracketMatch { export interface BracketMatch {
id: string; id: string;
@ -46,6 +54,8 @@ function formatScore(score: string | null): string | null {
interface ParticipantRowProps { interface ParticipantRowProps {
name: string | null; name: string | null;
/** What fills this slot when it's still empty, e.g. "Winner of Winners SF 2". */
feedLabel?: string | null;
isTbd: boolean; isTbd: boolean;
isWinner: boolean; isWinner: boolean;
isLoser: boolean; isLoser: boolean;
@ -60,6 +70,7 @@ interface ParticipantRowProps {
function ParticipantRow({ function ParticipantRow({
name, name,
feedLabel,
isTbd, isTbd,
isWinner, isWinner,
isLoser, isLoser,
@ -114,7 +125,7 @@ function ParticipantRow({
.filter(Boolean) .filter(Boolean)
.join(" ")} .join(" ")}
> >
{name ?? "TBD"} {name ?? feedLabel ?? "TBD"}
</span> </span>
{/* Owner name below participant name */} {/* Owner name below participant name */}
@ -150,6 +161,8 @@ interface BracketMatchSlotProps {
slotHeight: number; slotHeight: number;
ownershipMap: Map<string, BracketOwnership>; ownershipMap: Map<string, BracketOwnership>;
userParticipantIds: Set<string>; userParticipantIds: Set<string>;
feeders?: FeederMap;
template?: BracketTemplate;
} }
export function BracketMatchSlot({ export function BracketMatchSlot({
@ -157,6 +170,8 @@ export function BracketMatchSlot({
slotHeight, slotHeight,
ownershipMap, ownershipMap,
userParticipantIds, userParticipantIds,
feeders,
template,
}: BracketMatchSlotProps) { }: BracketMatchSlotProps) {
const rowHeight = slotHeight / 2; const rowHeight = slotHeight / 2;
const showText = rowHeight >= 10; const showText = rowHeight >= 10;
@ -187,6 +202,13 @@ export function BracketMatchSlot({
const INSET = Math.max(1, Math.min(2, Math.floor(slotHeight / 20))); const INSET = Math.max(1, Math.min(2, Math.floor(slotHeight / 20)));
// An empty slot reads better as "Loser of Winners SF 2" than "TBD" — especially for
// the feeds that cross between the winners and elimination brackets, which render as
// separate trees and so can never be joined by a line.
const slotSources = feeders?.get(matchKey(match.round, match.matchNumber));
const feed1 = describeSlotSource(slotSources?.[0], template);
const feed2 = describeSlotSource(slotSources?.[1], template);
return ( return (
<div className="relative overflow-hidden" style={{ height: slotHeight }}> <div className="relative overflow-hidden" style={{ height: slotHeight }}>
{/* Corona layer — left-2 matches card borderRadius to prevent gradient bleed at left corners */} {/* Corona layer — left-2 matches card borderRadius to prevent gradient bleed at left corners */}
@ -208,6 +230,7 @@ export function BracketMatchSlot({
> >
<ParticipantRow <ParticipantRow
name={match.participant1?.name ?? null} name={match.participant1?.name ?? null}
feedLabel={feed1}
isTbd={isTbd1} isTbd={isTbd1}
isWinner={p1IsWinner} isWinner={p1IsWinner}
isLoser={p1IsLoser} isLoser={p1IsLoser}
@ -221,6 +244,7 @@ export function BracketMatchSlot({
/> />
<ParticipantRow <ParticipantRow
name={match.participant2?.name ?? null} name={match.participant2?.name ?? null}
feedLabel={feed2}
isTbd={isTbd2} isTbd={isTbd2}
isWinner={p2IsWinner} isWinner={p2IsWinner}
isLoser={p2IsLoser} isLoser={p2IsLoser}
@ -237,54 +261,45 @@ export function BracketMatchSlot({
); );
} }
// ─── Per-pair connector column ──────────────────────────────────────────────── // ─── Connector column ─────────────────────────────────────────────────────────
interface ConnectorColumnProps { interface ConnectorColumnProps {
currentMatches: BracketMatch[]; /** Edges crossing this gutter, in slot units. */
nextMatches: BracketMatch[]; edges: { fromCenter: number; toCenter: number }[];
rowHeight: number;
offset: number;
bracketHeight: number; bracketHeight: number;
} }
function ConnectorColumn({ currentMatches, nextMatches, bracketHeight }: ConnectorColumnProps) { /**
* Draws the feeder edges crossing one gutter. Because the layout assigns columns by
* depth from the final, every edge spans exactly one gutter so a card that enters the
* bracket late is drawn in the column where it actually plays, and there is never an
* edge to route across a skipped column.
*/
function ConnectorColumn({ edges, rowHeight, offset, bracketHeight }: ConnectorColumnProps) {
const mid = CONNECTOR_WIDTH / 2; const mid = CONNECTOR_WIDTH / 2;
// Merge the two edges feeding one card into a single elbow, so a pair reads as one
// bracket join rather than two overlapping lines.
const byTarget = new Map<number, number[]>();
for (const { fromCenter, toCenter } of edges) {
const sources = byTarget.get(toCenter) ?? [];
sources.push(fromCenter);
byTarget.set(toCenter, sources);
}
const paths: string[] = []; const paths: string[] = [];
for (const [toCenter, sources] of byTarget) {
const currentSlotH = bracketHeight / Math.max(currentMatches.length, 1); const destY = toCenter * rowHeight - offset;
const nextSlotH = bracketHeight / Math.max(nextMatches.length, 1); const ys = sources.map((c) => c * rowHeight - offset).toSorted((a, b) => a - b);
if (ys.length === 1) {
// Use halving U-shapes only when prev > 1 (avoids false-positive 1→1 side branches like 3PG→Finals) paths.push(`M 0 ${ys[0]} H ${mid} V ${destY} H ${CONNECTOR_WIDTH}`);
if (nextMatches.length === Math.ceil(currentMatches.length / 2) && currentMatches.length > 1) { continue;
// Standard single-elimination halving: U-shape connectors
for (let k = 0; k < nextMatches.length; k++) {
const topY = (2 * k) * currentSlotH + currentSlotH / 2;
const midY = k * nextSlotH + nextSlotH / 2;
const botIdx = 2 * k + 1;
if (botIdx < currentMatches.length) {
const botY = botIdx * currentSlotH + currentSlotH / 2;
paths.push(`M 0 ${topY} H ${mid} V ${botY} H 0`);
paths.push(`M ${mid} ${midY} H ${CONNECTOR_WIDTH}`);
} else {
paths.push(`M 0 ${topY} H ${CONNECTOR_WIDTH}`);
} }
} paths.push(`M 0 ${ys[0]} H ${mid} V ${ys[ys.length - 1]} H 0`);
} else { for (const y of ys.slice(1, -1)) paths.push(`M 0 ${y} H ${mid}`);
// Non-standard (byes, play-ins, etc.): trace winners by participantId paths.push(`M ${mid} ${destY} H ${CONNECTOR_WIDTH}`);
const winnerToIdx = new Map<string, number>();
currentMatches.forEach((m, idx) => {
if (m.winnerId) winnerToIdx.set(m.winnerId, idx);
});
nextMatches.forEach((nextMatch, nextIdx) => {
const destY = nextIdx * nextSlotH + nextSlotH / 2;
for (const pId of [nextMatch.participant1Id, nextMatch.participant2Id]) {
if (!pId) continue;
const srcIdx = winnerToIdx.get(pId);
if (srcIdx === undefined) continue;
const srcY = srcIdx * currentSlotH + currentSlotH / 2;
paths.push(`M 0 ${srcY} H ${mid} V ${destY} H ${CONNECTOR_WIDTH}`);
}
});
} }
return ( return (
@ -310,52 +325,131 @@ function ConnectorColumn({ currentMatches, nextMatches, bracketHeight }: Connect
// ─── Tree columns (shared by full + paginated) ─────────────────────────────── // ─── Tree columns (shared by full + paginated) ───────────────────────────────
export interface BracketGeometry {
layout: BracketLayout<BracketMatch>;
/** Height of one leaf row. */
rowHeight: number;
/** Height of the card area, excluding the round labels. */
bracketHeight: number;
/** Narrowest the columns and gutters can be drawn without overlapping. */
minWidth: number;
/** Pixels trimmed off the top, non-zero only for a cropped column window. */
offset: number;
}
/**
* Lay out a group's matches from the feeder graph and derive its pixel geometry.
*
* Height comes from the number of leaf rows rather than the largest round, so a bracket
* whose widest column isn't its first still gets the room it needs.
*/
export function bracketGeometry(
visibleRounds: string[],
matchesByRound: Map<string, BracketMatch[]>,
feeders: FeederMap | undefined,
templateRoundOrder: string[]
): BracketGeometry {
const layout = computeGroupLayout(
visibleRounds,
matchesByRound,
feeders ?? new Map(),
templateRoundOrder
);
const rowHeight = DESIRED_CARD_HEIGHT + CARD_GAP;
const columnCount = Math.max(layout.columns.length, 1);
return {
layout,
rowHeight,
bracketHeight: Math.max(layout.leafCount, 1) * rowHeight,
minWidth: columnCount * COLUMN_WIDTH + (columnCount - 1) * CONNECTOR_WIDTH,
offset: 0,
};
}
/**
* Crop a layout to a window of columns, as the mobile pager does.
*
* Card positions are absolute within the whole bracket, so showing a slice of columns
* means trimming the empty space above them rather than re-flowing otherwise a later
* page would render its two columns stranded at the bottom of a full-height bracket.
*/
export function windowGeometry(
geometry: BracketGeometry,
firstColumn: number,
lastColumn: number
): BracketGeometry {
const centers = geometry.layout.columns
.slice(firstColumn, lastColumn + 1)
.flatMap((c) => c.matches.map((m) => m.center));
if (centers.length === 0) return geometry;
const min = Math.min(...centers);
const max = Math.max(...centers);
return {
...geometry,
bracketHeight: (max - min + 1) * geometry.rowHeight,
offset: (min - 0.5) * geometry.rowHeight,
};
}
interface TreeColumnsProps { interface TreeColumnsProps {
visibleRounds: string[]; geometry: BracketGeometry;
matchesByRound: Map<string, BracketMatch[]>;
ownershipMap: Map<string, BracketOwnership>; ownershipMap: Map<string, BracketOwnership>;
userParticipantIds: Set<string>; userParticipantIds: Set<string>;
bracketHeight: number;
transitionDuration?: number; transitionDuration?: number;
feeders?: FeederMap;
template?: BracketTemplate;
/** Restrict rendering to a window of columns (used by the mobile pager). */
columnRange?: [number, number];
} }
export function TreeColumns({ export function TreeColumns({
visibleRounds, geometry,
matchesByRound,
ownershipMap, ownershipMap,
userParticipantIds, userParticipantIds,
bracketHeight,
transitionDuration, transitionDuration,
feeders,
template,
columnRange,
}: TreeColumnsProps) { }: TreeColumnsProps) {
const tr = transitionDuration ? `${transitionDuration}ms ease` : undefined; const tr = transitionDuration ? `${transitionDuration}ms ease` : undefined;
return ( const { layout, rowHeight, bracketHeight, offset } = geometry;
<div style={{ display: "flex", width: "100%", height: bracketHeight + LABEL_HEIGHT, transition: tr ? `height ${tr}` : undefined }}>
{visibleRounds.map((round, ri) => { const [firstColumn, lastColumn] = columnRange ?? [0, layout.columns.length - 1];
const roundMatches = matchesByRound.get(round) ?? []; const visible = layout.columns.slice(firstColumn, lastColumn + 1);
const slotHeight = bracketHeight / Math.max(roundMatches.length, 1);
const cardHeight = Math.min(slotHeight - CARD_GAP, MAX_CARD_HEIGHT); // Cards keep a fixed height regardless of how many share a column — stretching a
const cardTop = (slotHeight - cardHeight) / 2; // lone final to fill its column is what made it tower over the rest of the bracket.
const nextRound = ri < visibleRounds.length - 1 ? visibleRounds[ri + 1] : null; const cardHeight = Math.min(
const nextMatches = nextRound ? (matchesByRound.get(nextRound) ?? []) : []; Math.max(rowHeight - CARD_GAP, 1),
MAX_CARD_HEIGHT
);
return ( return (
<div key={round} style={{ display: "contents" }}> <div style={{ display: "flex", width: "100%", height: bracketHeight + LABEL_HEIGHT, transition: tr ? `height ${tr}` : undefined }}>
{visible.map((column, vi) => {
const ci = firstColumn + vi;
const gutterEdges = layout.edges.filter((e) => e.fromColumn === ci);
return (
<div key={column.label + ci} style={{ display: "contents" }}>
{/* Round column */} {/* Round column */}
<div style={{ flex: "1 1 0", minWidth: COLUMN_WIDTH, position: "relative" }}> <div style={{ flex: "1 1 0", minWidth: COLUMN_WIDTH, position: "relative" }}>
<div <div
className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground truncate text-center" className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground truncate text-center"
style={{ height: LABEL_HEIGHT, display: "flex", alignItems: "center", justifyContent: "center" }} style={{ height: LABEL_HEIGHT, display: "flex", alignItems: "center", justifyContent: "center" }}
> >
{round} {column.label}
</div> </div>
<div style={{ position: "relative", height: bracketHeight, transition: tr ? `height ${tr}` : undefined }}> <div style={{ position: "relative", height: bracketHeight, transition: tr ? `height ${tr}` : undefined }}>
{roundMatches.map((match, matchIdx) => ( {column.matches.map(({ match, center }) => (
<div <div
key={match.id} key={match.id}
data-match-id={match.id}
style={{ style={{
position: "absolute", position: "absolute",
top: matchIdx * slotHeight + cardTop, top: center * rowHeight - offset - cardHeight / 2,
left: 0, left: 0,
right: 0, right: 0,
height: cardHeight, height: cardHeight,
@ -367,6 +461,8 @@ export function TreeColumns({
slotHeight={cardHeight} slotHeight={cardHeight}
ownershipMap={ownershipMap} ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds} userParticipantIds={userParticipantIds}
feeders={feeders}
template={template}
/> />
</div> </div>
))} ))}
@ -374,10 +470,11 @@ export function TreeColumns({
</div> </div>
{/* Connector between this column and the next */} {/* Connector between this column and the next */}
{nextRound && ( {vi < visible.length - 1 && (
<ConnectorColumn <ConnectorColumn
currentMatches={roundMatches} edges={gutterEdges}
nextMatches={nextMatches} rowHeight={rowHeight}
offset={offset}
bracketHeight={bracketHeight} bracketHeight={bracketHeight}
/> />
)} )}
@ -396,6 +493,8 @@ interface BracketTreeViewProps {
ownershipMap: Map<string, BracketOwnership>; ownershipMap: Map<string, BracketOwnership>;
userParticipantIds: Set<string>; userParticipantIds: Set<string>;
thirdPlaceRound?: string; thirdPlaceRound?: string;
feeders?: FeederMap;
template?: BracketTemplate;
} }
export function BracketTreeView({ export function BracketTreeView({
@ -404,13 +503,19 @@ export function BracketTreeView({
ownershipMap, ownershipMap,
userParticipantIds, userParticipantIds,
thirdPlaceRound, thirdPlaceRound,
feeders,
template,
}: BracketTreeViewProps) { }: BracketTreeViewProps) {
const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds; const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds;
const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined; const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined;
const maxMatches = Math.max(...mainRounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1); const geometry = bracketGeometry(
const bracketHeight = maxMatches * (DESIRED_CARD_HEIGHT + CARD_GAP); mainRounds,
const minWidth = mainRounds.length * COLUMN_WIDTH + Math.max(0, mainRounds.length - 1) * CONNECTOR_WIDTH; matchesByRound,
feeders,
template?.rounds.map((r) => r.name) ?? mainRounds
);
const { bracketHeight, minWidth } = geometry;
return ( return (
<div <div
@ -419,11 +524,11 @@ export function BracketTreeView({
> >
<div style={{ minWidth }}> <div style={{ minWidth }}>
<TreeColumns <TreeColumns
visibleRounds={mainRounds} geometry={geometry}
matchesByRound={matchesByRound}
ownershipMap={ownershipMap} ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds} userParticipantIds={userParticipantIds}
bracketHeight={bracketHeight} feeders={feeders}
template={template}
/> />
{thirdPlaceMatch && ( {thirdPlaceMatch && (
<div style={{ display: "flex", paddingTop: 20 }}> <div style={{ display: "flex", paddingTop: 20 }}>
@ -441,6 +546,8 @@ export function BracketTreeView({
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)} slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
ownershipMap={ownershipMap} ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds} userParticipantIds={userParticipantIds}
feeders={feeders}
template={template}
/> />
</div> </div>
</div> </div>

View file

@ -1,5 +1,11 @@
import type { ConferenceGroup } from "~/lib/bracket-templates"; import type { BracketTemplate, ConferenceGroup } from "~/lib/bracket-templates";
import { TreeColumns, type BracketMatch, type BracketOwnership } from "./BracketTreeView"; import type { FeederMap } from "~/lib/bracket-layout";
import {
TreeColumns,
bracketGeometry,
type BracketMatch,
type BracketOwnership,
} from "./BracketTreeView";
import { BracketTreePaginated } from "./BracketTreePaginated"; import { BracketTreePaginated } from "./BracketTreePaginated";
interface NbaBracketLayoutProps { interface NbaBracketLayoutProps {
@ -10,11 +16,10 @@ interface NbaBracketLayoutProps {
userParticipantIds: Set<string>; userParticipantIds: Set<string>;
conferenceGroups: ConferenceGroup[]; conferenceGroups: ConferenceGroup[];
scoringRoundIdx: number; scoringRoundIdx: number;
feeders?: FeederMap;
template?: BracketTemplate;
} }
const DESIRED_CARD_HEIGHT = 112;
const CARD_GAP = 14;
function splitMatchesByConference( function splitMatchesByConference(
matchesByRound: Map<string, BracketMatch[]>, matchesByRound: Map<string, BracketMatch[]>,
group: ConferenceGroup group: ConferenceGroup
@ -28,11 +33,6 @@ function splitMatchesByConference(
return result; return result;
} }
function bracketHeight(matchesByRound: Map<string, BracketMatch[]>, rounds: string[]): number {
const max = Math.max(...rounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
return max * (DESIRED_CARD_HEIGHT + CARD_GAP);
}
export function NbaBracketLayout({ export function NbaBracketLayout({
rounds, rounds,
matchesByRound, matchesByRound,
@ -40,7 +40,10 @@ export function NbaBracketLayout({
userParticipantIds, userParticipantIds,
conferenceGroups, conferenceGroups,
scoringRoundIdx, scoringRoundIdx,
feeders,
template,
}: NbaBracketLayoutProps) { }: NbaBracketLayoutProps) {
const roundOrder = template?.rounds.map((r) => r.name) ?? rounds;
// Rounds that belong to any conference group // Rounds that belong to any conference group
const conferenceRoundSet = new Set( const conferenceRoundSet = new Set(
conferenceGroups.flatMap((g) => Object.keys(g.roundMatchNumbers)) conferenceGroups.flatMap((g) => Object.keys(g.roundMatchNumbers))
@ -57,7 +60,7 @@ export function NbaBracketLayout({
const sharedMatches = new Map( const sharedMatches = new Map(
sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []]) sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []])
); );
const sharedHeight = bracketHeight(sharedMatches, sharedRounds); const sharedGeometry = bracketGeometry(sharedRounds, sharedMatches, feeders, roundOrder);
return ( return (
<> <>
@ -66,7 +69,7 @@ export function NbaBracketLayout({
{conferenceGroups.map((group, gi) => { {conferenceGroups.map((group, gi) => {
const confRounds = conferenceRounds[gi]; const confRounds = conferenceRounds[gi];
const confMatches = splitMatchesByConference(matchesByRound, group); const confMatches = splitMatchesByConference(matchesByRound, group);
const height = bracketHeight(confMatches, confRounds); const geometry = bracketGeometry(confRounds, confMatches, feeders, roundOrder);
return ( return (
<div key={group.name}> <div key={group.name}>
@ -74,11 +77,11 @@ export function NbaBracketLayout({
{group.name} {group.name}
</p> </p>
<TreeColumns <TreeColumns
visibleRounds={confRounds} geometry={geometry}
matchesByRound={confMatches}
ownershipMap={ownershipMap} ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds} userParticipantIds={userParticipantIds}
bracketHeight={height} feeders={feeders}
template={template}
/> />
</div> </div>
); );
@ -87,11 +90,11 @@ export function NbaBracketLayout({
{sharedRounds.length > 0 && ( {sharedRounds.length > 0 && (
<div> <div>
<TreeColumns <TreeColumns
visibleRounds={sharedRounds} geometry={sharedGeometry}
matchesByRound={sharedMatches}
ownershipMap={ownershipMap} ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds} userParticipantIds={userParticipantIds}
bracketHeight={sharedHeight} feeders={feeders}
template={template}
/> />
</div> </div>
)} )}
@ -105,6 +108,8 @@ export function NbaBracketLayout({
ownershipMap={ownershipMap} ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds} userParticipantIds={userParticipantIds}
firstScoringRoundIdx={scoringRoundIdx} firstScoringRoundIdx={scoringRoundIdx}
feeders={feeders}
template={template}
/> />
</div> </div>
</> </>

View file

@ -13,7 +13,8 @@ import { GradientIcon } from "~/components/ui/GradientIcon";
import { RankingsRow } from "./RankingsRow"; import { RankingsRow } from "./RankingsRow";
import { BracketTreeView, type BracketMatch, type BracketOwnership } from "./BracketTreeView"; import { BracketTreeView, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
import { BracketTreePaginated } from "./BracketTreePaginated"; import { BracketTreePaginated } from "./BracketTreePaginated";
import { getBracketTemplate } from "~/lib/bracket-templates"; import { getBracketTemplate, type BracketTemplate } from "~/lib/bracket-templates";
import { buildFeederMap } from "~/lib/bracket-layout";
import { NbaBracketLayout } from "./NbaBracketLayout"; import { NbaBracketLayout } from "./NbaBracketLayout";
import { TabbedBracketLayout } from "./TabbedBracketLayout"; import { TabbedBracketLayout } from "./TabbedBracketLayout";
@ -76,43 +77,6 @@ export function groupMatchesByRound(matches: Match[]): Map<string, Match[]> {
return byRound; return byRound;
} }
/**
* For a standard single-elimination bracket, slot p1 of match N in round R
* comes from match (2N-1) in the previous round, and slot p2 from match 2N.
*/
export function buildFeederMap(
matchesByRound: Map<string, Match[]>,
orderedRounds: string[]
): Map<string, { round: string; matchNumber: number }> {
const feederMap = new Map<string, { round: string; matchNumber: number }>();
for (let ri = 1; ri < orderedRounds.length; ri++) {
const currentRound = orderedRounds[ri];
const prevRound = orderedRounds[ri - 1];
const prevMatchNums = new Set(
(matchesByRound.get(prevRound) || []).map((m) => m.matchNumber)
);
for (const match of matchesByRound.get(currentRound) || []) {
const p1Src = 2 * (match.matchNumber - 1) + 1;
const p2Src = 2 * (match.matchNumber - 1) + 2;
if (prevMatchNums.has(p1Src)) {
feederMap.set(`${currentRound}:${match.matchNumber}:p1`, {
round: prevRound,
matchNumber: p1Src,
});
}
if (prevMatchNums.has(p2Src)) {
feederMap.set(`${currentRound}:${match.matchNumber}:p2`, {
round: prevRound,
matchNumber: p2Src,
});
}
}
}
return feederMap;
}
interface EliminatedEntry { interface EliminatedEntry {
participant: Participant; participant: Participant;
score: string | null; score: string | null;
@ -174,6 +138,182 @@ export function computeEliminatedByRound(
return result; 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.
*
* A consolation round must be TERMINAL its winner plays no further game, which is
* what lets its result split two exact positions. `loserFeedsInto` alone is not
* enough: a double-elimination bracket (llws_20) uses it on every winners-bracket
* round to route losers into the elimination bracket, and those targets are ordinary
* rounds that feed onward. Picking the first `loserFeedsInto` there would mistake
* "Elimination Round 1" for a third-place game and corrupt the final rankings.
*
* Exported for unit testing.
*/
export function findConsolationRound(
template: BracketTemplate | undefined
): ConsolationRound | undefined {
const isTerminal = (roundName: string) =>
template?.rounds.find((r) => r.name === roundName)?.feedsInto === null;
const feeder = template?.rounds.find(
(r) => r.loserFeedsInto && isTerminal(r.loserFeedsInto)
);
if (!feeder?.loserFeedsInto) return undefined;
return { round: feeder.loserFeedsInto, feederRound: feeder.name };
}
/**
* Round names whose losers are placed by some LATER round rather than finishing where
* they lost i.e. double-elimination winners-bracket rounds, whose losers drop into
* the elimination bracket.
*
* The consolation feeder is deliberately excluded: its losers do finish at that tier
* (the consolation game splits their two positions), so it still consumes them.
*
* Exported for unit testing.
*/
export function roundsWithLosersPlacedLater(
template: BracketTemplate | undefined,
consolation: ConsolationRound | undefined
): Set<string> {
return new Set(
(template?.rounds ?? [])
.filter((r) => r.loserFeedsInto && r.name !== consolation?.feederRound)
.map((r) => r.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>,
/** See roundsWithLosersPlacedLater. Empty for single-elimination brackets. */
losersPlacedLater: Set<string> = new Set()
): 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;
// A round normally consumes one position per match — its losers finish here,
// whether or not the games have been played yet (four semifinalists occupy 14
// regardless). But in a double-elimination bracket a winners-bracket loss places
// nobody: the loser drops into the elimination bracket and is ranked by whatever
// knocks them out later. Those rounds must consume nothing, or every position
// below inflates (a 20-team llws_20 bracket would end at "T23").
if (losersPlacedLater.has(roundName)) continue;
nextRank += matchesByRound.get(roundName)?.length ?? 0;
}
return rankedEntries;
}
/** Find the index of the first round that has scoring matches. */ /** Find the index of the first round that has scoring matches. */
function firstScoringRoundIdx(matchesByRound: Map<string, Match[]>, rounds: string[]): number { function firstScoringRoundIdx(matchesByRound: Map<string, Match[]>, rounds: string[]): number {
for (let i = 0; i < rounds.length; i++) { for (let i = 0; i < rounds.length; i++) {
@ -215,13 +355,13 @@ export function PlayoffBracket({
const matchesByRound = groupMatchesByRound(matches); const matchesByRound = groupMatchesByRound(matches);
const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds); const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds);
const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined; const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined;
// What fills each slot, used for both card placement and naming empty slots.
const feeders = buildFeederMap(template);
const thirdPlaceRound = template?.rounds const consolation = findConsolationRound(template);
.find((r) => template.rounds.some((other) => other.loserFeedsInto === r.name)) const thirdPlaceRound = consolation?.round;
?.name;
// Build elimination rankings // Build elimination rankings
const losersByRound = new Map<string, Array<{ participant: Participant; score: string | null; ownership: TeamOwnership | null }>>();
let bracketWinner: Participant | null = null; let bracketWinner: Participant | null = null;
const lastRound = rounds[rounds.length - 1]; const lastRound = rounds[rounds.length - 1];
@ -230,43 +370,20 @@ export function PlayoffBracket({
: null; : null;
if (finalMatch?.winner) bracketWinner = finalMatch.winner; 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>(); const allBracketParticipantIds = new Set<string>();
for (const match of matches) { for (const match of matches) {
if (match.participant1Id) allBracketParticipantIds.add(match.participant1Id); if (match.participant1Id) allBracketParticipantIds.add(match.participant1Id);
if (match.participant2Id) allBracketParticipantIds.add(match.participant2Id); if (match.participant2Id) allBracketParticipantIds.add(match.participant2Id);
} }
const rankedEntries: EliminatedEntry[] = [];
let nextRank = 2; const rankedEntries = computeRankedEntries(
for (let ri = rounds.length - 1; ri >= 0; ri--) { matches,
const roundName = rounds[ri]; rounds,
const roundLosers = losersByRound.get(roundName) || []; matchesByRound,
const totalMatchesInRound = matchesByRound.get(roundName)?.length ?? 0; consolation,
if (roundLosers.length > 0) { ownershipMap,
const rankLabel = `T${nextRank}`; roundsWithLosersPlacedLater(template, consolation)
for (const loser of roundLosers) { );
rankedEntries.push({ ...loser, rankLabel });
}
}
nextRank += totalMatchesInRound;
}
const rankedParticipantIds = new Set(rankedEntries.map((e) => e.participant.id)); const rankedParticipantIds = new Set(rankedEntries.map((e) => e.participant.id));
if (bracketWinner) rankedParticipantIds.add(bracketWinner.id); if (bracketWinner) rankedParticipantIds.add(bracketWinner.id);
@ -284,7 +401,14 @@ export function PlayoffBracket({
const activeParticipants = [...allBracketParticipantIds] const activeParticipants = [...allBracketParticipantIds]
.filter((id) => !rankedParticipantIds.has(id)) .filter((id) => !rankedParticipantIds.has(id))
.map((id) => participantMap.get(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) => const isDraftedOrScoring = (participantId: string) =>
ownershipMap.has(participantId) || (pointsMap.get(participantId) ?? 0) > 0; ownershipMap.has(participantId) || (pointsMap.get(participantId) ?? 0) > 0;
@ -320,6 +444,8 @@ export function PlayoffBracket({
userParticipantIds={userParticipantSet} userParticipantIds={userParticipantSet}
phases={template.phases} phases={template.phases}
scoringRoundIdx={scoringRoundIdx} scoringRoundIdx={scoringRoundIdx}
feeders={feeders}
template={template}
/> />
) : template?.conferenceGroups ? ( ) : template?.conferenceGroups ? (
<NbaBracketLayout <NbaBracketLayout
@ -330,6 +456,8 @@ export function PlayoffBracket({
userParticipantIds={userParticipantSet} userParticipantIds={userParticipantSet}
conferenceGroups={template.conferenceGroups} conferenceGroups={template.conferenceGroups}
scoringRoundIdx={scoringRoundIdx} scoringRoundIdx={scoringRoundIdx}
feeders={feeders}
template={template}
/> />
) : ( ) : (
<> <>
@ -341,6 +469,8 @@ export function PlayoffBracket({
ownershipMap={ownershipMap as Map<string, BracketOwnership>} ownershipMap={ownershipMap as Map<string, BracketOwnership>}
userParticipantIds={userParticipantSet} userParticipantIds={userParticipantSet}
thirdPlaceRound={thirdPlaceRound} thirdPlaceRound={thirdPlaceRound}
feeders={feeders}
template={template}
/> />
</div> </div>
@ -353,6 +483,8 @@ export function PlayoffBracket({
userParticipantIds={userParticipantSet} userParticipantIds={userParticipantSet}
firstScoringRoundIdx={scoringRoundIdx} firstScoringRoundIdx={scoringRoundIdx}
thirdPlaceRound={thirdPlaceRound} thirdPlaceRound={thirdPlaceRound}
feeders={feeders}
template={template}
/> />
</div> </div>
</> </>

View file

@ -55,7 +55,9 @@ interface QualifyingPointsStandingsProps {
function formatQP(raw: string): string { function formatQP(raw: string): string {
const n = parseFloat(raw); const n = parseFloat(raw);
if (isNaN(n)) return "—"; 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({ export function QualifyingPointsStandings({

View file

@ -1,8 +1,18 @@
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import type { BracketPhase, ConferenceGroup } from "~/lib/bracket-templates"; import type { BracketPhase, BracketTemplate, ConferenceGroup } from "~/lib/bracket-templates";
import { TreeColumns, BracketMatchSlot, type BracketMatch, type BracketOwnership } from "./BracketTreeView"; import type { FeederMap } from "~/lib/bracket-layout";
import {
TreeColumns,
BracketMatchSlot,
bracketGeometry,
type BracketMatch,
type BracketOwnership,
} from "./BracketTreeView";
import { BracketTreePaginated } from "./BracketTreePaginated"; import { BracketTreePaginated } from "./BracketTreePaginated";
/** Card height for the play-in columns, which lay themselves out rather than via TreeColumns. */
const CARD_H = 112;
interface TabbedBracketLayoutProps { interface TabbedBracketLayoutProps {
rounds: string[]; rounds: string[];
matchesByRound: Map<string, BracketMatch[]>; matchesByRound: Map<string, BracketMatch[]>;
@ -10,11 +20,10 @@ interface TabbedBracketLayoutProps {
userParticipantIds: Set<string>; userParticipantIds: Set<string>;
phases: BracketPhase[]; phases: BracketPhase[];
scoringRoundIdx: number; scoringRoundIdx: number;
feeders?: FeederMap;
template?: BracketTemplate;
} }
const CARD_H = 112;
const CARD_GAP = 14;
function groupMatches( function groupMatches(
matchesByRound: Map<string, BracketMatch[]>, matchesByRound: Map<string, BracketMatch[]>,
group: ConferenceGroup group: ConferenceGroup
@ -29,11 +38,6 @@ function groupMatches(
return out; return out;
} }
function phaseHeight(matchesByRound: Map<string, BracketMatch[]>, rounds: string[]): number {
const max = Math.max(...rounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
return max * (CARD_H + CARD_GAP);
}
// ─── Play-In Layout ─────────────────────────────────────────────────────────── // ─── Play-In Layout ───────────────────────────────────────────────────────────
interface PlayInColumnProps { interface PlayInColumnProps {
@ -141,7 +145,10 @@ export function TabbedBracketLayout({
userParticipantIds, userParticipantIds,
phases, phases,
scoringRoundIdx, scoringRoundIdx,
feeders,
template,
}: TabbedBracketLayoutProps) { }: TabbedBracketLayoutProps) {
const roundOrder = template?.rounds.map((r) => r.name) ?? rounds;
return ( return (
<div className="space-y-10"> <div className="space-y-10">
{phases.map((phase) => { {phases.map((phase) => {
@ -152,7 +159,22 @@ export function TabbedBracketLayout({
const simpleRounds = phase.groups ? [] : (phase.rounds ?? []).filter((r) => rounds.includes(r)); const simpleRounds = phase.groups ? [] : (phase.rounds ?? []).filter((r) => rounds.includes(r));
const phaseRounds = phase.groups ? [...groupRounds, ...sharedRounds] : simpleRounds; const phaseRounds = phase.groups ? [...groupRounds, ...sharedRounds] : simpleRounds;
const phaseMatchesByRound = new Map(phaseRounds.map((r) => [r, matchesByRound.get(r) ?? []])); // Restrict each round to the match numbers this phase's groups actually claim.
// Rounds can be shared across phases (LLWS runs U.S. and International through
// the same rounds), so without this the mobile view would merge both sides into
// one column. No-op where a phase's groups already cover every match in the
// round (NCAA regions, NBA conferences) and for sharedRounds, which have no
// group filter.
const phaseMatchesByRound = new Map(
phaseRounds.map((r) => {
const all = matchesByRound.get(r) ?? [];
if (!phase.groups || sharedRounds.includes(r)) return [r, all] as const;
const allowed = new Set(
phase.groups.flatMap((g) => g.roundMatchNumbers[r] ?? [])
);
return [r, allowed.size > 0 ? all.filter((m) => allowed.has(m.matchNumber)) : all] as const;
})
);
const sharedMatchesByRound = new Map(sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []])); const sharedMatchesByRound = new Map(sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []]));
const phaseFirstScoringIdx = phaseRounds.findIndex((r) => rounds.indexOf(r) >= scoringRoundIdx); const phaseFirstScoringIdx = phaseRounds.findIndex((r) => rounds.indexOf(r) >= scoringRoundIdx);
@ -179,50 +201,87 @@ export function TabbedBracketLayout({
{phase.groups.map((group) => { {phase.groups.map((group) => {
const gMatches = groupMatches(matchesByRound, group); const gMatches = groupMatches(matchesByRound, group);
const gRounds = groupRounds.filter((r) => group.roundMatchNumbers[r] !== undefined); const gRounds = groupRounds.filter((r) => group.roundMatchNumbers[r] !== undefined);
const geometry = bracketGeometry(gRounds, gMatches, feeders, roundOrder);
return ( return (
<div key={group.name}> <div key={group.name}>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2"> <p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
{group.name} {group.name}
</p> </p>
<div className="w-full overflow-x-auto">
<div style={{ minWidth: geometry.minWidth }}>
<TreeColumns <TreeColumns
visibleRounds={gRounds} geometry={geometry}
matchesByRound={gMatches}
ownershipMap={ownershipMap} ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds} userParticipantIds={userParticipantIds}
bracketHeight={phaseHeight(gMatches, gRounds)} feeders={feeders}
template={template}
/> />
</div> </div>
</div>
</div>
); );
})} })}
{sharedRounds.length > 0 && ( {sharedRounds.length > 0 && (
<TreeColumns <TreeColumns
visibleRounds={sharedRounds} geometry={bracketGeometry(sharedRounds, sharedMatchesByRound, feeders, roundOrder)}
matchesByRound={sharedMatchesByRound}
ownershipMap={ownershipMap} ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds} userParticipantIds={userParticipantIds}
bracketHeight={phaseHeight(sharedMatchesByRound, sharedRounds)} feeders={feeders}
template={template}
/> />
)} )}
</div> </div>
) : ( ) : (
<TreeColumns <TreeColumns
visibleRounds={phaseRounds} geometry={bracketGeometry(phaseRounds, phaseMatchesByRound, feeders, roundOrder)}
matchesByRound={phaseMatchesByRound}
ownershipMap={ownershipMap} ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds} userParticipantIds={userParticipantIds}
bracketHeight={phaseHeight(phaseMatchesByRound, phaseRounds)} feeders={feeders}
template={template}
/> />
)} )}
</div> </div>
{/* Mobile */} {/* Mobile paged one group at a time, matching the desktop split. Paging a
<div className="md:hidden"> whole phase would merge the winners and elimination brackets into one
tree, and a double-elimination phase is a DAG rather than a tree: the
same game feeds forward and sideways, so its column placement would be
arbitrary. */}
<div className="md:hidden space-y-6">
{phase.layout === "play-in" ? ( {phase.layout === "play-in" ? (
<PlayInLayout <PlayInLayout
matchesByRound={phaseMatchesByRound} matchesByRound={phaseMatchesByRound}
ownershipMap={ownershipMap} ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds} userParticipantIds={userParticipantIds}
/> />
) : phase.groups ? (
<>
{phase.groups.map((group) => (
<div key={group.name}>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
{group.name}
</p>
<BracketTreePaginated
rounds={groupRounds.filter((r) => group.roundMatchNumbers[r] !== undefined)}
matchesByRound={groupMatches(matchesByRound, group)}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
feeders={feeders}
template={template}
/>
</div>
))}
{sharedRounds.length > 0 && (
<BracketTreePaginated
rounds={sharedRounds}
matchesByRound={sharedMatchesByRound}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
feeders={feeders}
template={template}
/>
)}
</>
) : ( ) : (
<BracketTreePaginated <BracketTreePaginated
rounds={phaseRounds} rounds={phaseRounds}
@ -230,6 +289,8 @@ export function TabbedBracketLayout({
ownershipMap={ownershipMap} ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds} userParticipantIds={userParticipantIds}
firstScoringRoundIdx={phaseFirstScoringIdx >= 0 ? phaseFirstScoringIdx : undefined} firstScoringRoundIdx={phaseFirstScoringIdx >= 0 ? phaseFirstScoringIdx : undefined}
feeders={feeders}
template={template}
/> />
)} )}
</div> </div>

View file

@ -0,0 +1,116 @@
import { describe, it, expect } from "vitest";
import { render, within } from "@testing-library/react";
import { NbaBracketLayout } from "../NbaBracketLayout";
import { buildFeederMap } from "~/lib/bracket-layout";
import type { BracketTemplate } from "~/lib/bracket-templates";
import type { BracketMatch } from "../BracketTreeView";
/**
* NbaBracketLayout renders a desktop view and a mobile pager side by side, hidden from
* each other by Tailwind breakpoints. Both need `feeders` and `template` without them
* bracketGeometry falls back to index-derived positions and an unplayed slot reads "TBD"
* where the feeder graph would name the game it is waiting on.
*/
const TEMPLATE: BracketTemplate = {
id: "test_conf_4",
name: "Two-conference test bracket",
totalTeams: 4,
scoringStartsAtRound: "Final",
rounds: [
{ name: "Semis", matchCount: 2, feedsInto: "Final", isScoring: false },
{ name: "Final", matchCount: 1, feedsInto: null, isScoring: true },
],
conferenceGroups: [
{ name: "East", roundMatchNumbers: { Semis: [1] } },
{ name: "West", roundMatchNumbers: { Semis: [2] } },
],
};
const ROUNDS = ["Semis", "Final"];
function match(
round: string,
matchNumber: number,
overrides: Partial<BracketMatch> = {}
): BracketMatch {
return {
id: `${round}-${matchNumber}`,
round,
matchNumber,
participant1Id: null,
participant2Id: null,
winnerId: null,
loserId: null,
isComplete: false,
participant1Score: null,
participant2Score: null,
...overrides,
} as BracketMatch;
}
/** Semis are played; the Final's two slots are still empty. */
const MATCHES_BY_ROUND = new Map<string, BracketMatch[]>([
[
"Semis",
[
match("Semis", 1, { participant1Id: "p1", participant2Id: "p2" }),
match("Semis", 2, { participant1Id: "p3", participant2Id: "p4" }),
],
],
["Final", [match("Final", 1)]],
]);
function renderLayout(withGraph: boolean) {
const { container } = render(
<NbaBracketLayout
matches={[...MATCHES_BY_ROUND.values()].flat()}
rounds={ROUNDS}
matchesByRound={MATCHES_BY_ROUND}
ownershipMap={new Map()}
userParticipantIds={new Set()}
conferenceGroups={TEMPLATE.conferenceGroups ?? []}
scoringRoundIdx={1}
feeders={withGraph ? buildFeederMap(TEMPLATE) : undefined}
template={withGraph ? TEMPLATE : undefined}
/>
);
// Both panes render in jsdom — media queries are class-based, not applied — so scope
// each assertion to the pane it is about.
const mobile = container.querySelector<HTMLElement>(".md\\:hidden");
const desktop = container.querySelector<HTMLElement>(".md\\:flex");
if (!mobile || !desktop) throw new Error("Expected both a mobile and a desktop pane");
return { mobile, desktop };
}
describe("NbaBracketLayout", () => {
it("names the feeding game in the mobile pager", () => {
// Only slots filled by advancement get a label; a directly seeded slot with no
// participant still reads "TBD", which is why this asserts on the Final's slots.
const { mobile } = renderLayout(true);
expect(within(mobile).getAllByText(/Winner of/).length).toBe(2);
});
it("shows the mobile pager the same slot labels as the desktop view", () => {
const { mobile, desktop } = renderLayout(true);
const labels = (pane: HTMLElement) =>
within(pane)
.getAllByText(/Winner of/)
.map((el) => el.textContent)
.toSorted();
expect(labels(mobile)).toEqual(labels(desktop));
});
it("falls back to TBD when the feeder graph is unavailable", () => {
// Guards the assertions above: without feeders/template there is nothing to name a
// slot with, which is exactly the state the mobile pane was stuck in.
const { mobile } = renderLayout(false);
expect(within(mobile).queryByText(/Winner of/)).toBeNull();
expect(within(mobile).getAllByText("TBD").length).toBeGreaterThan(0);
});
});

View file

@ -1,5 +1,16 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { buildFeederMap, groupMatchesByRound, computeEliminatedByRound } from "../PlayoffBracket"; import { render, screen, within } from "@testing-library/react";
import {
PlayoffBracket,
groupMatchesByRound,
computeEliminatedByRound,
computeRankedEntries,
findConsolationRound,
roundsWithLosersPlacedLater,
type Match,
} from "../PlayoffBracket";
import { getBracketTemplate } from "~/lib/bracket-templates";
import { resolveLLWSAdvancement } from "~/models/playoff-match";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
@ -52,88 +63,72 @@ describe("groupMatchesByRound", () => {
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// buildFeederMap // Rendered LLWS bracket — geometry and empty-slot labels
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe("buildFeederMap", () => { describe("PlayoffBracket — rendered LLWS bracket", () => {
it("returns an empty map when there is only one round", () => { const LLWS_ROUNDS = (getBracketTemplate("llws_20")?.rounds ?? []).map((r) => r.name);
const matches = [makeMatch("Finals", 1)];
const map = buildFeederMap(groupMatchesByRound(matches), ["Finals"]); /** Every LLWS match, all unplayed, so each slot shows what will fill it. */
expect(map.size).toBe(0); function emptyLlwsMatches(): Match[] {
const template = getBracketTemplate("llws_20");
const matches: Match[] = [];
for (const round of template?.rounds ?? []) {
for (let n = 1; n <= round.matchCount; n++) {
matches.push({
...makeMatch(round.name, n, { participant1Id: null, participant2Id: null }),
participant1: null,
participant2: null,
});
}
}
return matches;
}
it("names empty slots after the game that feeds them", () => {
render(
<PlayoffBracket
matches={emptyLlwsMatches()}
rounds={LLWS_ROUNDS}
bracketTemplateId="llws_20"
/>
);
// A winners-bracket loss drops into the elimination bracket — an edge that spans
// two separately rendered trees, so the label is the only way to show it.
expect(screen.getAllByText("Loser of Winners SF 1").length).toBeGreaterThan(0);
expect(screen.getAllByText("Winner of Opening 1").length).toBeGreaterThan(0);
}); });
it("maps SF slots to the correct QF matches for an 8-team bracket", () => { it("still shows TBD for a directly seeded slot", () => {
const rounds = ["Quarterfinals", "Semifinals", "Finals"]; render(
const matches = [ <PlayoffBracket
makeMatch("Quarterfinals", 1), matches={emptyLlwsMatches()}
makeMatch("Quarterfinals", 2), rounds={LLWS_ROUNDS}
makeMatch("Quarterfinals", 3), bracketTemplateId="llws_20"
makeMatch("Quarterfinals", 4), />
makeMatch("Semifinals", 1), );
makeMatch("Semifinals", 2),
makeMatch("Finals", 1),
];
const map = buildFeederMap(groupMatchesByRound(matches), rounds); // The opening round is seeded, not fed, so it has nothing to name.
expect(screen.getAllByText("TBD").length).toBeGreaterThan(0);
// SF Match 1, slot p1 ← QF Match 1
expect(map.get("Semifinals:1:p1")).toEqual({ round: "Quarterfinals", matchNumber: 1 });
// SF Match 1, slot p2 ← QF Match 2
expect(map.get("Semifinals:1:p2")).toEqual({ round: "Quarterfinals", matchNumber: 2 });
// SF Match 2, slot p1 ← QF Match 3
expect(map.get("Semifinals:2:p1")).toEqual({ round: "Quarterfinals", matchNumber: 3 });
// SF Match 2, slot p2 ← QF Match 4
expect(map.get("Semifinals:2:p2")).toEqual({ round: "Quarterfinals", matchNumber: 4 });
}); });
it("maps Finals slots to the correct SF matches", () => { it("gives every card the same height, including a lone final", () => {
const rounds = ["Quarterfinals", "Semifinals", "Finals"]; const { container } = render(
const matches = [ <PlayoffBracket
makeMatch("Quarterfinals", 1), matches={emptyLlwsMatches()}
makeMatch("Quarterfinals", 2), rounds={LLWS_ROUNDS}
makeMatch("Quarterfinals", 3), bracketTemplateId="llws_20"
makeMatch("Quarterfinals", 4), />
makeMatch("Semifinals", 1), );
makeMatch("Semifinals", 2),
makeMatch("Finals", 1),
];
const map = buildFeederMap(groupMatchesByRound(matches), rounds); const heights = new Set(
[...container.querySelectorAll<HTMLElement>("[data-match-id]")].map(
expect(map.get("Finals:1:p1")).toEqual({ round: "Semifinals", matchNumber: 1 }); (el) => el.style.height
expect(map.get("Finals:1:p2")).toEqual({ round: "Semifinals", matchNumber: 2 }); )
}); );
// Previously a one-match column stretched its card to fill the bracket height.
it("does not add an entry when the source match does not exist in the previous round", () => { expect(heights.size).toBe(1);
const rounds = ["Quarterfinals", "Finals"];
const matches = [
makeMatch("Quarterfinals", 1),
makeMatch("Quarterfinals", 2),
makeMatch("Finals", 1),
];
const map = buildFeederMap(groupMatchesByRound(matches), rounds);
expect(map.get("Finals:1:p1")).toEqual({ round: "Quarterfinals", matchNumber: 1 });
expect(map.get("Finals:1:p2")).toEqual({ round: "Quarterfinals", matchNumber: 2 });
expect(map.has("Finals:2:p1")).toBe(false);
});
it("handles a 16-team bracket correctly for Round of 16 → Quarterfinals", () => {
const rounds = ["Round of 16", "Quarterfinals", "Semifinals", "Finals"];
const matches = [
...[1, 2, 3, 4, 5, 6, 7, 8].map((n) => makeMatch("Round of 16", n)),
...[1, 2, 3, 4].map((n) => makeMatch("Quarterfinals", n)),
...[1, 2].map((n) => makeMatch("Semifinals", n)),
makeMatch("Finals", 1),
];
const map = buildFeederMap(groupMatchesByRound(matches), rounds);
expect(map.get("Quarterfinals:1:p1")).toEqual({ round: "Round of 16", matchNumber: 1 });
expect(map.get("Quarterfinals:1:p2")).toEqual({ round: "Round of 16", matchNumber: 2 });
expect(map.get("Quarterfinals:4:p1")).toEqual({ round: "Round of 16", matchNumber: 7 });
expect(map.get("Quarterfinals:4:p2")).toEqual({ round: "Round of 16", matchNumber: 8 });
}); });
}); });
@ -291,3 +286,526 @@ 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;
}
// ---------------------------------------------------------------------------
// llws_20 — double elimination, where a winners-bracket loss places nobody
// ---------------------------------------------------------------------------
/** Stable participant id for an llws_20 bracket slot. */
function llwsTeam(i: number): string {
return `t${String(i).padStart(2, "0")}`;
}
/**
* Play a full 20-team LLWS tournament, always advancing the lower-numbered
* participant id so the outcome is deterministic, and return every match.
* Routing comes from the real advancement map rather than being hand-listed.
*/
function llwsMatches(): Match[] {
const template = getBracketTemplate("llws_20");
if (!template) throw new Error("llws_20 template missing");
// round → matchNumber → [p1, p2]
const slots = new Map<string, Map<number, [string | null, string | null]>>();
for (const round of template.rounds) {
const byNumber = new Map<number, [string | null, string | null]>();
for (let n = 1; n <= round.matchCount; n++) byNumber.set(n, [null, null]);
slots.set(round.name, byNumber);
}
const put = (round: string, n: number, slot: 0 | 1, id: string) => {
const pair = slots.get(round)?.get(n);
if (pair) pair[slot] = id;
};
// Seed the Opening Round and the four byes, mirroring generateLLWS20Bracket.
for (const [base, roundBase] of [[0, 1], [10, 5]] as const) {
for (let local = 0; local < 4; local++) {
put("Opening Round", roundBase + local, 0, llwsTeam(base + local * 2));
put("Opening Round", roundBase + local, 1, llwsTeam(base + local * 2 + 1));
}
}
put("Winners Round 2", 1, 0, llwsTeam(8));
put("Winners Round 2", 2, 0, llwsTeam(9));
put("Winners Round 2", 3, 0, llwsTeam(18));
put("Winners Round 2", 4, 0, llwsTeam(19));
const matches: Match[] = [];
for (const round of template.rounds) {
for (let n = 1; n <= round.matchCount; n++) {
const [p1, p2] = slots.get(round.name)?.get(n) ?? [null, null];
if (!p1 || !p2) throw new Error(`${round.name} #${n} was not filled`);
// Deterministic: the lower id always wins.
const winnerId = p1 < p2 ? p1 : p2;
const loserId = p1 < p2 ? p2 : p1;
matches.push(
makeRankedMatch(round.name, n, winnerId, loserId, {
winnerSlot: winnerId === p1 ? 1 : 2,
})
);
const { winner, loser } = resolveLLWSAdvancement(round.name, n);
if (winner) put(winner.round, winner.matchNumber, winner.slot === "participant1Id" ? 0 : 1, winnerId);
if (loser) put(loser.round, loser.matchNumber, loser.slot === "participant1Id" ? 0 : 1, loserId);
}
}
return matches;
}
describe("computeRankedEntries — llws_20 double elimination", () => {
const template = getBracketTemplate("llws_20");
const rounds = template?.rounds.map((r) => r.name) ?? [];
function rankLlws() {
const matches = llwsMatches();
const consolation = findConsolationRound(template);
return computeRankedEntries(
matches,
rounds,
groupMatchesByRound(matches),
consolation,
new Map(),
roundsWithLosersPlacedLater(template, consolation)
);
}
it("ranks all 19 non-champions exactly once", () => {
const entries = rankLlws();
expect(entries).toHaveLength(19);
expect(new Set(entries.map((e) => e.participant.id)).size).toBe(19);
});
it("gives the top 8 the positions the scoring tiers depend on", () => {
const entries = rankLlws();
const labels = entries.map((e) => e.rankLabel);
// 2nd (World Championship loser), then 3rd and 4th decided by the consolation
// game, then the two 56 and two 78 tier teams.
expect(labels[0]).toBe("T2");
expect(labels.filter((l) => l === "3")).toHaveLength(1);
expect(labels.filter((l) => l === "4")).toHaveLength(1);
expect(labels.filter((l) => l === "T5")).toHaveLength(2);
expect(labels.filter((l) => l === "T7")).toHaveLength(2);
});
it("does not inflate positions below the top 8", () => {
// Winners-bracket losses place nobody — those teams are ranked by the
// elimination-bracket game that actually knocks them out. If the winners
// rounds consumed positions, the last tier would read T23 in a 20-team field.
const entries = rankLlws();
const labels = entries.map((e) => e.rankLabel);
expect(labels.filter((l) => l === "T9")).toHaveLength(4);
expect(labels.filter((l) => l === "T13")).toHaveLength(4);
expect(labels.filter((l) => l === "T17")).toHaveLength(4);
// 1 champion (not in the list) + 19 ranked = the full 20-team field.
expect(labels.some((l) => Number(l.replace("T", "")) > 17)).toBe(false);
});
it("never ranks a winners-bracket loser at the round they first lost", () => {
const entries = rankLlws();
// t00 wins every game it plays (lowest id), so take a team that loses in the
// winners bracket but survives: the Opening Round M1 loser, t01.
const t01 = entries.find((e) => e.participant.id === "t01");
expect(t01).toBeDefined();
// Losing the opening game must not park them in the bottom tier — they got a
// second life in the elimination bracket.
expect(t01?.rankLabel).not.toBe("T17");
});
});
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();
});
it("ignores double-elimination loser routing and finds the real consolation game", () => {
// llws_20 sets loserFeedsInto on every winners-bracket round to route losers
// into the elimination bracket. Only the Bracket Championship feeds a terminal
// round; taking the first loserFeedsInto instead would mistake "Elimination
// Round 1" for a third-place game and corrupt the final rankings.
expect(findConsolationRound(getBracketTemplate("llws_20"))).toEqual({
round: "Consolation Third Place",
feederRound: "Bracket Championship",
});
});
});
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,4 +1,5 @@
import { format, parseISO } from "date-fns"; import { format } from "date-fns";
import { useHasMounted } from "~/hooks/useHasMounted";
import { Link } from "react-router"; import { Link } from "react-router";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
@ -9,6 +10,7 @@ interface ScheduleEvent {
name: string; name: string;
eventDate: string | null; eventDate: string | null;
eventStartsAt?: Date | string | null; eventStartsAt?: Date | string | null;
earliestGameTime?: string | null;
eventType: string; eventType: string;
isComplete: boolean; isComplete: boolean;
completedAt?: Date | string | null; completedAt?: Date | string | null;
@ -25,28 +27,13 @@ interface EventScheduleProps {
function formatEventDate(dateStr: string | null | undefined): string { function formatEventDate(dateStr: string | null | undefined): string {
if (!dateStr) return "Date TBD"; if (!dateStr) return "Date TBD";
try { try {
// eventDate is stored as a date string "YYYY-MM-DD" const [year, month, day] = dateStr.split("-").map(Number);
return format(parseISO(dateStr), "MMM d, yyyy"); return format(new Date(year, month - 1, day), "MMM d, yyyy");
} catch { } catch {
return dateStr; return dateStr;
} }
} }
/**
* Returns the display date for an event.
* Prefers eventStartsAt (a full UTC timestamp correct local date) over
* eventDate (the UTC calendar date, which is off by one day for late-night
* events in UTC-negative timezones like PDT).
*/
function getDisplayDate(event: Pick<ScheduleEvent, "eventDate" | "eventStartsAt">): string {
if (event.eventStartsAt) {
const d = new Date(event.eventStartsAt);
if (!isNaN(d.getTime())) {
return format(d, "MMM d, yyyy");
}
}
return formatEventDate(event.eventDate);
}
function getEventTypeLabel(eventType: string): string { function getEventTypeLabel(eventType: string): string {
switch (eventType) { switch (eventType) {
@ -64,6 +51,19 @@ function getEventTypeLabel(eventType: string): string {
} }
export function EventSchedule({ upcomingEvents, recentEvents, leagueId, sportsSeasonId }: EventScheduleProps) { export function EventSchedule({ upcomingEvents, recentEvents, leagueId, sportsSeasonId }: EventScheduleProps) {
const isMounted = useHasMounted();
function getDisplayDate(event: Pick<ScheduleEvent, "eventDate" | "eventStartsAt" | "earliestGameTime">): string {
if (isMounted) {
const ts = event.earliestGameTime ?? (event.eventStartsAt as string | null | undefined);
if (ts) {
const d = new Date(ts);
if (!isNaN(d.getTime())) return format(d, "MMM d, yyyy");
}
}
return formatEventDate(event.eventDate);
}
const hasUpcoming = upcomingEvents.length > 0; const hasUpcoming = upcomingEvents.length > 0;
const hasRecent = recentEvents.length > 0; const hasRecent = recentEvents.length > 0;
@ -112,12 +112,14 @@ export function EventSchedule({ upcomingEvents, recentEvents, leagueId, sportsSe
</p> </p>
<p className="text-xs text-muted-foreground flex items-center gap-1 mt-0.5"> <p className="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
<Calendar className="h-3 w-3" /> <Calendar className="h-3 w-3" />
<span suppressHydrationWarning>{getDisplayDate(event)}</span> <span>{getDisplayDate(event)}</span>
{event.eventStartsAt && ( {isMounted && (() => {
<span suppressHydrationWarning> const ts = event.earliestGameTime ?? (event.eventStartsAt as string | null | undefined);
· {format(new Date(event.eventStartsAt), "h:mm a")} if (!ts) return null;
</span> const d = new Date(ts);
)} if (isNaN(d.getTime())) return null;
return <span>· {d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}</span>;
})()}
</p> </p>
</div> </div>
{event.eventType === "schedule_event" ? ( {event.eventType === "schedule_event" ? (
@ -178,7 +180,7 @@ export function EventSchedule({ upcomingEvents, recentEvents, leagueId, sportsSe
</p> </p>
<p className="text-xs text-muted-foreground flex items-center gap-1 mt-0.5"> <p className="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
<Calendar className="h-3 w-3" /> <Calendar className="h-3 w-3" />
<span suppressHydrationWarning>{getDisplayDate(event)}</span> <span>{getDisplayDate(event)}</span>
</p> </p>
</div> </div>
{event.eventType !== "schedule_event" && ( {event.eventType !== "schedule_event" && (

View file

@ -1,4 +1,6 @@
import { useState, useEffect } from "react";
import type { GroupStandingsRow } from "~/models/group-stage-match"; import type { GroupStandingsRow } from "~/models/group-stage-match";
import { utcIsoToLocalDateTime } from "~/lib/date-utils";
import { TeamOwnerBadge } from "~/components/ui/team-owner-badge"; import { TeamOwnerBadge } from "~/components/ui/team-owner-badge";
interface GroupMatch { interface GroupMatch {
@ -38,6 +40,23 @@ function MatchResult({ match }: { match: GroupMatch }) {
const p1 = match.participant1?.name ?? "?"; const p1 = match.participant1?.name ?? "?";
const p2 = match.participant2?.name ?? "?"; const p2 = match.participant2?.name ?? "?";
// SSR renders UTC time (stable); useEffect replaces with the user's local time after hydration.
const [kickoffDisplay, setKickoffDisplay] = useState(() =>
match.scheduledAt
? new Date(match.scheduledAt).toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
timeZone: "UTC",
})
: null
);
useEffect(() => {
if (!match.scheduledAt) return;
setKickoffDisplay(
new Date(match.scheduledAt).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })
);
}, [match.scheduledAt]);
if (match.isComplete && match.participant1Score !== null && match.participant2Score !== null) { if (match.isComplete && match.participant1Score !== null && match.participant2Score !== null) {
const s1 = match.participant1Score; const s1 = match.participant1Score;
const s2 = match.participant2Score; const s2 = match.participant2Score;
@ -61,7 +80,6 @@ function MatchResult({ match }: { match: GroupMatch }) {
); );
} }
const kickoff = match.scheduledAt ? new Date(match.scheduledAt) : null;
return ( return (
<div className="flex flex-col gap-0"> <div className="flex flex-col gap-0">
<div className="flex items-center gap-1 text-xs text-muted-foreground"> <div className="flex items-center gap-1 text-xs text-muted-foreground">
@ -69,9 +87,9 @@ function MatchResult({ match }: { match: GroupMatch }) {
<span className="shrink-0 text-muted-foreground/60">vs</span> <span className="shrink-0 text-muted-foreground/60">vs</span>
<span className="truncate max-w-[80px]">{p2}</span> <span className="truncate max-w-[80px]">{p2}</span>
</div> </div>
{kickoff && ( {kickoffDisplay && (
<span className="text-[10px] text-muted-foreground/50"> <span className="text-[10px] text-muted-foreground/50">
{kickoff.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })} {kickoffDisplay}
</span> </span>
)} )}
</div> </div>
@ -83,6 +101,12 @@ export function GroupStageStandings({
ownershipMap, ownershipMap,
showEmpty = false, showEmpty = false,
}: GroupStageStandingsProps) { }: 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 const visibleGroups = showEmpty
? groups ? groups
: groups.filter((g) => g.matches.some((m) => m.isComplete) || g.standings.length > 0); : groups.filter((g) => g.matches.some((m) => m.isComplete) || g.standings.length > 0);
@ -93,7 +117,7 @@ export function GroupStageStandings({
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-lg font-semibold">Group Stage</h3> <h3 className="text-lg font-semibold">Group Stage</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{groups.map((group) => ( {visibleGroups.map((group) => (
<div <div
key={group.groupName} key={group.groupName}
className="rounded-lg border bg-card text-card-foreground shadow-sm overflow-hidden" className="rounded-lg border bg-card text-card-foreground shadow-sm overflow-hidden"
@ -169,26 +193,46 @@ export function GroupStageStandings({
if (!b.scheduledAt) return -1; if (!b.scheduledAt) return -1;
return new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime(); return new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime();
}); });
// 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[]>(); const byDay = new Map<string, GroupMatch[]>();
for (const m of sorted) { for (const m of sorted) {
const key = m.scheduledAt const key = m.scheduledAt
? new Date(m.scheduledAt).toLocaleDateString("en-US", { month: "short", day: "numeric" }) ? (mounted
? utcIsoToLocalDateTime(m.scheduledAt).slice(0, 10)
: m.scheduledAt.slice(0, 10))
: "TBD"; : "TBD";
if (!byDay.has(key)) byDay.set(key, []); if (!byDay.has(key)) byDay.set(key, []);
byDay.get(key)?.push(m); byDay.get(key)?.push(m);
} }
return ( return (
<div className="px-4 pb-3 space-y-0.5 border-t pt-2"> <div className="px-4 pb-3 space-y-0.5 border-t pt-2">
{[...byDay.entries()].map(([day, matches]) => ( {[...byDay.entries()].map(([key, matches]) => {
<div key={day} className="space-y-0.5"> // 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"
: 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"> <p className="text-[10px] text-muted-foreground/70 uppercase tracking-wide pt-1">
{day} {dayLabel}
</p> </p>
{matches.map((m) => ( {matches.map((m) => (
<MatchResult key={m.id} match={m} /> <MatchResult key={m.id} match={m} />
))} ))}
</div> </div>
))} );
})}
</div> </div>
); );
})()} })()}

View file

@ -1,6 +1,7 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { Calendar, ChevronRight, Users } from "lucide-react"; import { Calendar, ChevronRight, Users } from "lucide-react";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useHasMounted } from "~/hooks/useHasMounted";
import { Link } from "react-router"; import { Link } from "react-router";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { import {
@ -58,9 +59,10 @@ function ParticipantList({ participants }: { participants: Array<{ id: string; n
} }
function EventRow({ event, showLeague }: { event: CalendarPanelEvent; showLeague: boolean }) { function EventRow({ event, showLeague }: { event: CalendarPanelEvent; showLeague: boolean }) {
const isMounted = useHasMounted();
// Prefer game-level scheduledAt over event-level eventDate for display // Prefer game-level scheduledAt over event-level eventDate for display
const gameDate = event.earliestGameTime ? new Date(event.earliestGameTime) : null; const gameDate = event.earliestGameTime ? new Date(event.earliestGameTime) : null;
const dateStr = gameDate const dateStr = isMounted && gameDate
? format(gameDate, "MMM d") ? format(gameDate, "MMM d")
: formatEventDate(event.eventDate); : formatEventDate(event.eventDate);
const participantCount = event.relevantParticipants.length; const participantCount = event.relevantParticipants.length;
@ -73,14 +75,11 @@ function EventRow({ event, showLeague }: { event: CalendarPanelEvent; showLeague
<div className="flex items-start gap-3 py-2.5 border-b last:border-0 group"> <div className="flex items-start gap-3 py-2.5 border-b last:border-0 group">
{/* Date pill */} {/* Date pill */}
<div className="w-14 shrink-0 text-center"> <div className="w-14 shrink-0 text-center">
<span className="text-xs font-semibold text-electric" suppressHydrationWarning> <span className="text-xs font-semibold text-electric">
{dateStr ?? "TBD"} {dateStr ?? "TBD"}
</span> </span>
{gameDate && ( {isMounted && gameDate && (
<p <p className="text-xs text-muted-foreground leading-tight">
className="text-xs text-muted-foreground leading-tight"
suppressHydrationWarning
>
{gameDate.toLocaleTimeString(undefined, { {gameDate.toLocaleTimeString(undefined, {
hour: "numeric", hour: "numeric",
minute: "2-digit", minute: "2-digit",

View file

@ -1,7 +1,15 @@
import { useState } from "react"; import { useState } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { ArrowUp, ArrowDown, ArrowUpDown } from "lucide-react"; import { ArrowUp, ArrowDown, ArrowUpDown } from "lucide-react";
import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card"; 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 { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "~/components/ui/table";
import { Badge } from "~/components/ui/badge"; 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 * Display detailed team score breakdown with all drafted participants
* Phase 4.3: Team breakdown pages * 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); const allPicks = sortPicks(breakdown.picks, sortColumn, sortDirection);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* 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> <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> <p className="text-muted-foreground mt-1">Team Score Breakdown</p>
</div> </div>
<div className="text-right"> <div className="text-left sm:text-right">
<div className="text-4xl font-bold text-primary"> <div className="text-4xl font-bold text-primary">
{breakdown.actualPoints.toFixed(2)} {breakdown.actualPoints.toFixed(2)}
</div> </div>
@ -164,8 +226,8 @@ export function TeamScoreBreakdown({
</div> </div>
</div> </div>
{/* All picks — single flat table */} {/* All picks — desktop table */}
<Card> <Card className="hidden md:block">
<CardContent className="pt-4"> <CardContent className="pt-4">
<Table> <Table>
<TableHeader> <TableHeader>
@ -198,9 +260,7 @@ export function TeamScoreBreakdown({
{allPicks.map((pick) => ( {allPicks.map((pick) => (
<TableRow key={pick.pickNumber}> <TableRow key={pick.pickNumber}>
<TableCell className="text-muted-foreground"> <TableCell className="text-muted-foreground">
{numTeams > 0 {pickLabel(pick, numTeams)}
? `${pick.round}.${String(pick.pickNumber - (pick.round - 1) * numTeams).padStart(2, "0")}`
: `#${pick.pickNumber}`}
<span className="text-xs ml-1">(#{pick.pickNumber})</span> <span className="text-xs ml-1">(#{pick.pickNumber})</span>
</TableCell> </TableCell>
<TableCell> <TableCell>
@ -215,31 +275,10 @@ export function TeamScoreBreakdown({
{pick.participant.name} {pick.participant.name}
</TableCell> </TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
{pick.isComplete && !pick.isPartialScore ? ( <PositionCell pick={pick} />
(pick.finalPosition ?? 0) === 0 ? (
<Badge variant="secondary">Did Not Score</Badge>
) : (
<PlacementBadge position={pick.finalPosition ?? 0} />
)
) : (
<Badge variant="outline">Pending</Badge>
)}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
{pick.isComplete && !pick.isPartialScore ? ( <PointsValue pick={pick} />
<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>
)}
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
@ -248,6 +287,70 @@ export function TeamScoreBreakdown({
</CardContent> </CardContent>
</Card> </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 */} {/* Navigation */}
<div className="flex justify-between pt-4"> <div className="flex justify-between pt-4">
<Link <Link

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; 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 userEvent from "@testing-library/user-event";
import { BrowserRouter } from "react-router"; import { BrowserRouter } from "react-router";
import { TeamScoreBreakdown } from "../TeamScoreBreakdown"; import { TeamScoreBreakdown } from "../TeamScoreBreakdown";
@ -160,9 +160,10 @@ describe("TeamScoreBreakdown", () => {
/> />
); );
expect(screen.getByText("1.01")).toBeInTheDocument(); // Pick labels render in both the desktop table and mobile cards.
expect(screen.getByText("1.02")).toBeInTheDocument(); expect(screen.getAllByText("1.01").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("2.01")).toBeInTheDocument(); expect(screen.getAllByText("1.02").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("2.01").length).toBeGreaterThanOrEqual(1);
}); });
it("should show overall pick number in parentheses", () => { it("should show overall pick number in parentheses", () => {
@ -176,9 +177,9 @@ describe("TeamScoreBreakdown", () => {
/> />
); );
expect(screen.getByText("(#1)")).toBeInTheDocument(); expect(screen.getAllByText("(#1)").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("(#2)")).toBeInTheDocument(); expect(screen.getAllByText("(#2)").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("(#3)")).toBeInTheDocument(); expect(screen.getAllByText("(#3)").length).toBeGreaterThanOrEqual(1);
}); });
it("should fall back to #pickNumber when numTeams is 0", () => { it("should fall back to #pickNumber when numTeams is 0", () => {
@ -192,9 +193,9 @@ describe("TeamScoreBreakdown", () => {
/> />
); );
expect(screen.getByText("#1")).toBeInTheDocument(); expect(screen.getAllByText("#1").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("#2")).toBeInTheDocument(); expect(screen.getAllByText("#2").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("#3")).toBeInTheDocument(); expect(screen.getAllByText("#3").length).toBeGreaterThanOrEqual(1);
}); });
}); });
@ -210,9 +211,10 @@ describe("TeamScoreBreakdown", () => {
/> />
); );
expect(screen.getByText("Team A")).toBeInTheDocument(); const table = within(screen.getByRole("table"));
expect(screen.getByText("Driver B")).toBeInTheDocument(); expect(table.getByText("Team A")).toBeInTheDocument();
expect(screen.getByText("Team C")).toBeInTheDocument(); expect(table.getByText("Driver B")).toBeInTheDocument();
expect(table.getByText("Team C")).toBeInTheDocument();
}); });
it("should show sport names as links to sport season pages", () => { 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.length).toBeGreaterThanOrEqual(1);
expect(nflLinks[0]).toHaveAttribute( expect(nflLinks[0]).toHaveAttribute(
"href", "href",
`/leagues/${mockLeagueId}/sports-seasons/ss-nfl` `/leagues/${mockLeagueId}/sports-seasons/ss-nfl`
); );
const f1Link = screen.getByRole("link", { name: "F1" }); const f1Link = table.getByRole("link", { name: "F1" });
expect(f1Link).toHaveAttribute( expect(f1Link).toHaveAttribute(
"href", "href",
`/leagues/${mockLeagueId}/sports-seasons/ss-f1` `/leagues/${mockLeagueId}/sports-seasons/ss-f1`
@ -251,8 +254,9 @@ describe("TeamScoreBreakdown", () => {
/> />
); );
expect(screen.getByText("1st")).toBeInTheDocument(); const table = within(screen.getByRole("table"));
expect(screen.getByText("3rd")).toBeInTheDocument(); expect(table.getByText("1st")).toBeInTheDocument();
expect(table.getByText("3rd")).toBeInTheDocument();
}); });
it("should show Pending badge for incomplete participants", () => { 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", () => { 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", () => { 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", () => { 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 // Incomplete participant shows 0.00 (actual) and 25.00 (EV) in the row
expect(screen.getByText("0.00")).toBeInTheDocument(); const table = within(screen.getByRole("table"));
expect(screen.getByText("25.00")).toBeInTheDocument(); expect(table.getByText("0.00")).toBeInTheDocument();
expect(table.getByText("25.00")).toBeInTheDocument();
}); });
it("should display 0.00 for completed picks with zero points", () => { 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", () => { describe("Without Standing Data", () => {
it("should render without standing prop", () => { it("should render without standing prop", () => {
renderWithRouter( renderWithRouter(

View file

@ -31,7 +31,7 @@ export function AccountSection({ email, linkedAccounts }: Props) {
setLinkingDiscord(true); setLinkingDiscord(true);
setLinkError(null); setLinkError(null);
try { try {
await authClient.linkSocial({ provider: "discord", callbackURL: "/settings?section=account" }); await authClient.linkSocial({ provider: "discord", callbackURL: "/settings/account" });
} catch { } catch {
setLinkError("Failed to connect Discord. Please try again."); setLinkError("Failed to connect Discord. Please try again.");
setLinkingDiscord(false); 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 { Switch } from "~/components/ui/switch";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
@ -14,10 +14,9 @@ type Props = {
discordPingEnabled: boolean; discordPingEnabled: boolean;
hasDiscordLinked: boolean; hasDiscordLinked: boolean;
draftEmailNotificationsEnabled: boolean; draftEmailNotificationsEnabled: boolean;
onNavigateToAccount: () => void;
}; };
export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, draftEmailNotificationsEnabled, onNavigateToAccount }: Props) { export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, draftEmailNotificationsEnabled }: Props) {
const discordFetcher = useFetcher(); const discordFetcher = useFetcher();
const emailFetcher = useFetcher(); const emailFetcher = useFetcher();
@ -87,13 +86,12 @@ export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, dra
{!hasDiscordLinked ? ( {!hasDiscordLinked ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Link your Discord account in{" "} Link your Discord account in{" "}
<button <Link
type="button" to="/settings/account"
onClick={onNavigateToAccount}
className="text-primary underline-offset-4 hover:underline" className="text-primary underline-offset-4 hover:underline"
> >
Account settings Account settings
</button>{" "} </Link>{" "}
to enable Discord pings for league notifications. to enable Discord pings for league notifications.
</p> </p>
) : ( ) : (

View file

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

View file

@ -1,18 +1,26 @@
import * as Sentry from "@sentry/react-router"; import * as Sentry from "@sentry/react-router";
import { PassThrough } from "node:stream"; import { PassThrough } from "node:stream";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
import { shouldReportServerError } from "~/lib/error-reporting";
import type { AppLoadContext, EntryContext } from "react-router"; import type { AppLoadContext, EntryContext, HandleErrorFunction } from "react-router";
import { createReadableStreamFromReadable } from "@react-router/node"; import { createReadableStreamFromReadable } from "@react-router/node";
import { ServerRouter } from "react-router"; import { ServerRouter } from "react-router";
import { isbot } from "isbot"; import { isbot } from "isbot";
import type { RenderToPipeableStreamOptions } from "react-dom/server"; import type { RenderToPipeableStreamOptions } from "react-dom/server";
import { renderToPipeableStream } from "react-dom/server"; import { renderToPipeableStream } from "react-dom/server";
export const handleError = Sentry.createSentryHandleError({ const sentryHandleError = Sentry.createSentryHandleError({
logErrors: true, logErrors: true,
}); });
export const handleError: HandleErrorFunction = (error, args) => {
// Unrecognised URLs and methods are bot scans, not bugs. Skipping early also
// keeps them out of the `logErrors` console output; morgan still logs the request.
if (!shouldReportServerError(error, args.request)) return;
return sentryHandleError(error, args);
};
export const streamTimeout = 5_000; export const streamTimeout = 5_000;
async function handleRequest( async function handleRequest(

View file

@ -0,0 +1,7 @@
import { useSyncExternalStore } from "react";
const subscribe = () => () => {};
export function useHasMounted(): boolean {
return useSyncExternalStore(subscribe, () => true, () => false);
}

View file

@ -0,0 +1,117 @@
/**
* The AFL Wildcard winners are re-seeded into the Elimination Finals by ladder position
* (5th draws the lower-ranked winner, 6th the higher-ranked one) rather than crossing
* over from a fixed Wildcard match. These tests pin that mapping for every combination
* of results, and for either order of entry.
*/
import { describe, it, expect } from "vitest";
import {
resolveAflWildcardPlacements,
AFL_WILDCARD_DRAW,
AFL_ELIMINATION_HOSTS,
type AflWildcardResult,
} from "../afl-wildcard-reseed";
/** Both Wildcard games decided, addressed by the seed that won each. */
function bothDecided(match1Winner: 7 | 10, match2Winner: 8 | 9): AflWildcardResult[] {
return [
{ matchNumber: 1, winnerSlot: match1Winner === 7 ? 1 : 2 },
{ matchNumber: 2, winnerSlot: match2Winner === 8 ? 1 : 2 },
];
}
/** Elimination Finals match number each winning seed was sent to. */
function slotsBySeed(results: AflWildcardResult[]): Record<number, number> {
return Object.fromEntries(
resolveAflWildcardPlacements(results).map((p) => [p.seed, p.eliminationMatchNumber])
);
}
describe("AFL Wildcard draw constants", () => {
it("draws 7v10 and 8v9", () => {
expect(AFL_WILDCARD_DRAW[1]).toEqual([7, 10]);
expect(AFL_WILDCARD_DRAW[2]).toEqual([8, 9]);
});
it("hosts the Elimination Finals with seeds 5 and 6", () => {
expect(AFL_ELIMINATION_HOSTS[1]).toBe(5);
expect(AFL_ELIMINATION_HOSTS[2]).toBe(6);
});
});
describe("resolveAflWildcardPlacements", () => {
it("sends the higher-ranked winner to 6th and the lower to 5th (7 and 8 win)", () => {
expect(slotsBySeed(bothDecided(7, 8))).toEqual({ 7: 2, 8: 1 });
});
it("re-seeds when the lower seed wins the 7v10 game (10 and 8 win)", () => {
// The bug this replaces sent the 7v10 winner to 6th regardless, pairing 5th with
// 8th and handing 6th the weakest survivor.
expect(slotsBySeed(bothDecided(10, 8))).toEqual({ 8: 2, 10: 1 });
});
it("re-seeds when the lower seed wins the 8v9 game (7 and 9 win)", () => {
expect(slotsBySeed(bothDecided(7, 9))).toEqual({ 7: 2, 9: 1 });
});
it("re-seeds when both lower seeds win (10 and 9 win)", () => {
expect(slotsBySeed(bothDecided(10, 9))).toEqual({ 9: 2, 10: 1 });
});
it("places the 7v10 winner alone, since its rank is settled either way", () => {
// 7th outranks both possible 8v9 winners; 10th is outranked by both.
expect(slotsBySeed([
{ matchNumber: 1, winnerSlot: 1 },
{ matchNumber: 2, winnerSlot: null },
])).toEqual({ 7: 2 });
expect(slotsBySeed([
{ matchNumber: 1, winnerSlot: 2 },
{ matchNumber: 2, winnerSlot: null },
])).toEqual({ 10: 1 });
});
it("holds an 8v9 winner back until the 7v10 game is decided", () => {
// 8th and 9th both sit between 7th and 10th, so either slot is still possible.
expect(slotsBySeed([
{ matchNumber: 1, winnerSlot: null },
{ matchNumber: 2, winnerSlot: 1 },
])).toEqual({});
expect(slotsBySeed([
{ matchNumber: 1, winnerSlot: null },
{ matchNumber: 2, winnerSlot: 2 },
])).toEqual({});
});
it("places nothing while both games are undecided", () => {
expect(resolveAflWildcardPlacements([
{ matchNumber: 1, winnerSlot: null },
{ matchNumber: 2, winnerSlot: null },
])).toEqual([]);
});
it("gives the same answer whichever result is entered first", () => {
for (const m1 of [7, 10] as const) {
for (const m2 of [8, 9] as const) {
const final = slotsBySeed(bothDecided(m1, m2));
// Whatever a single result places must survive the second result unchanged.
const m1First = slotsBySeed([
{ matchNumber: 1, winnerSlot: m1 === 7 ? 1 : 2 },
{ matchNumber: 2, winnerSlot: null },
]);
for (const [seed, slot] of Object.entries(m1First)) {
expect(final[Number(seed)]).toBe(slot);
}
}
}
});
it("rejects a match number outside the Wildcard draw", () => {
expect(() => resolveAflWildcardPlacements([{ matchNumber: 3, winnerSlot: 1 }])).toThrow(
/Unknown AFL Wildcard Round match number 3/
);
});
});

View file

@ -0,0 +1,533 @@
/**
* Bracket layout tests.
*
* The load-bearing assertions check the LLWS geometry against the official 2026 LLBWS
* bracket, in the PDF's own game numbers. A bracket "lines up" when each card sits level
* with the game that feeds it, so these tests assert column membership, top-to-bottom
* order, and vertical alignment not just that a layout was produced.
*/
import { describe, it, expect } from "vitest";
import {
LLWS_20,
SIMPLE_16,
NFL_14,
BRACKET_TEMPLATES,
getBracketTemplate,
type BracketTemplate,
type ConferenceGroup,
} from "~/lib/bracket-templates";
import {
buildFeederMap,
computeGroupLayout,
describeSlotSource,
matchKey,
type SlotSource,
} from "~/lib/bracket-layout";
import { GAME_TO_MATCH, MATCH_TO_GAME } from "~/test/fixtures/llws-bracket";
interface TestMatch {
round: string;
matchNumber: number;
/** Only the fallback reads these, to trace edges through an unrecognised shape. */
winnerId?: string | null;
participant1Id?: string | null;
participant2Id?: string | null;
}
/** Every match a template defines, as the renderer would receive them. */
function allMatches(template: BracketTemplate): Map<string, TestMatch[]> {
const byRound = new Map<string, TestMatch[]>();
for (const round of template.rounds) {
byRound.set(
round.name,
Array.from({ length: round.matchCount }, (_, i) => ({
round: round.name,
matchNumber: i + 1,
}))
);
}
return byRound;
}
/** The matches of one phase group, filtered the way TabbedBracketLayout filters them. */
function groupMatches(group: ConferenceGroup): Map<string, TestMatch[]> {
const byRound = new Map<string, TestMatch[]>();
for (const [round, nums] of Object.entries(group.roundMatchNumbers)) {
byRound.set(
round,
nums.map((matchNumber) => ({ round, matchNumber }))
);
}
return byRound;
}
function findGroup(name: string): ConferenceGroup {
for (const phase of LLWS_20.phases ?? []) {
for (const group of phase.groups ?? []) {
if (group.name === name) return group;
}
}
throw new Error(`No LLWS group named ${name}`);
}
/** Lay out one LLWS group and describe it in PDF game numbers. */
function layOutLLWSGroup(name: string) {
const group = findGroup(name);
const byRound = groupMatches(group);
const roundOrder = LLWS_20.rounds.map((r) => r.name);
const rounds = roundOrder.filter((r) => byRound.has(r));
const layout = computeGroupLayout(rounds, byRound, buildFeederMap(LLWS_20), roundOrder);
const game = (m: TestMatch) => {
const n = MATCH_TO_GAME.get(`${m.round}#${m.matchNumber}`);
if (n === undefined) throw new Error(`No PDF game for ${m.round} #${m.matchNumber}`);
return n;
};
return {
layout,
labels: layout.columns.map((c) => c.label),
/** Column contents, top to bottom, as PDF game numbers. */
columns: layout.columns.map((c) => c.matches.map((m) => game(m.match))),
/** Vertical centre of a game's card, in leaf-row units. */
centerOf(gameNumber: number): number {
const target = GAME_TO_MATCH[gameNumber];
for (const column of layout.columns) {
for (const { match, center } of column.matches) {
if (match.round === target.round && match.matchNumber === target.matchNumber) {
return center;
}
}
}
throw new Error(`G${gameNumber} is not in this group`);
},
};
}
describe("computeGroupLayout — LLWS winners brackets", () => {
// The International side is the one in the reported screenshot. Under the old index
// math, G5 and G7 were stranded in the first column: they skip Winners Round 2 and go
// straight to the semifinals, so nothing in column two lined up with them.
it("puts the International winners bracket in the printed bracket's columns", () => {
const { columns } = layOutLLWSGroup("International Winner's Bracket");
expect(columns).toEqual([
[1, 3],
[5, 9, 11, 7],
[18, 20],
[29],
]);
});
it("mirrors that layout on the U.S. side", () => {
const { columns } = layOutLLWSGroup("U.S. Winner's Bracket");
expect(columns).toEqual([
[2, 4],
[6, 10, 12, 8],
[17, 19],
[30],
]);
});
it("names a mixed column for the latest round it holds", () => {
// Column two holds two Opening Round games (G5, G7) alongside Winners Round 2.
const { labels } = layOutLLWSGroup("International Winner's Bracket");
expect(labels).toEqual([
"Opening Round",
"Winners Round 2",
"Winners Semifinals",
"Winners Final",
]);
});
it("levels each card with the game that feeds it", () => {
const { centerOf } = layOutLLWSGroup("International Winner's Bracket");
// G1's winner fills a slot of G9, so the two sit at the same height.
expect(centerOf(1)).toBe(centerOf(9));
expect(centerOf(3)).toBe(centerOf(11));
// G18 = W5 v W9, so it sits midway between them.
expect(centerOf(18)).toBe((centerOf(5) + centerOf(9)) / 2);
expect(centerOf(20)).toBe((centerOf(11) + centerOf(7)) / 2);
expect(centerOf(29)).toBe((centerOf(18) + centerOf(20)) / 2);
});
it("draws an edge for every in-group feed, played or not", () => {
const { layout } = layOutLLWSGroup("International Winner's Bracket");
// G9←G1, G11←G3, G18←{G5,G9}, G20←{G11,G7}, G29←{G18,G20}: 8 in-group edges.
expect(layout.edges).toHaveLength(8);
// Every edge crosses exactly one gutter, which is what makes them drawable.
for (const edge of layout.edges) {
expect(edge.fromColumn).toBeGreaterThanOrEqual(0);
expect(edge.fromColumn).toBeLessThan(layout.columns.length - 1);
}
});
});
describe("computeGroupLayout — LLWS elimination brackets", () => {
it("orders Elimination Round 3 the way the printed bracket does", () => {
// G31 = W27 v W25, so the later game is printed on top — the reverse of match
// number order, which is how the old index-based sort got it wrong.
const { columns } = layOutLLWSGroup("International Elimination Bracket");
expect(columns).toEqual([
[13, 15],
[21, 23],
[27, 25],
[31],
[33],
]);
});
it("orders the U.S. elimination bracket the same way", () => {
const { columns } = layOutLLWSGroup("U.S. Elimination Bracket");
expect(columns).toEqual([
[14, 16],
[22, 24],
[28, 26],
[32],
[34],
]);
});
it("ignores feeds arriving from the winners bracket", () => {
// G21 = L9 v W13. L9 is in the winners bracket group, so only W13 is an edge here.
const { layout, centerOf } = layOutLLWSGroup("International Elimination Bracket");
expect(centerOf(21)).toBe(centerOf(13));
expect(layout.edges).toHaveLength(7);
});
});
describe("buildFeederMap", () => {
it("routes LLWS winners and losers to the slots the printed bracket shows", () => {
const feeders = buildFeederMap(LLWS_20);
// G18 = W5 v W9.
const g18 = GAME_TO_MATCH[18];
expect(feeders.get(matchKey(g18.round, g18.matchNumber))).toEqual([
{ kind: "match", ref: GAME_TO_MATCH[5], result: "winner" },
{ kind: "match", ref: GAME_TO_MATCH[9], result: "winner" },
]);
// G13 = L3 v L5 — a winners-bracket loss drops into the elimination bracket.
const g13 = GAME_TO_MATCH[13];
expect(feeders.get(matchKey(g13.round, g13.matchNumber))).toEqual([
{ kind: "match", ref: GAME_TO_MATCH[3], result: "loser" },
{ kind: "match", ref: GAME_TO_MATCH[5], result: "loser" },
]);
});
it("marks directly seeded slots as seeds", () => {
const feeders = buildFeederMap(LLWS_20);
// G9 = a bye team v W1: slot one is seeded, slot two is fed.
const g9 = GAME_TO_MATCH[9];
const [p1, p2] = feeders.get(matchKey(g9.round, g9.matchNumber)) ?? [];
expect(p1).toEqual({ kind: "seed" });
expect(p2).toEqual({ kind: "match", ref: GAME_TO_MATCH[1], result: "winner" });
});
it("applies the standard halving rule to other templates", () => {
const feeders = buildFeederMap(SIMPLE_16);
expect(feeders.get(matchKey("Quarterfinals", 1))).toEqual([
{ kind: "match", ref: { round: "Round of 16", matchNumber: 1 }, result: "winner" },
{ kind: "match", ref: { round: "Round of 16", matchNumber: 2 }, result: "winner" },
]);
expect(feeders.get(matchKey("Quarterfinals", 4))).toEqual([
{ kind: "match", ref: { round: "Round of 16", matchNumber: 7 }, result: "winner" },
{ kind: "match", ref: { round: "Round of 16", matchNumber: 8 }, result: "winner" },
]);
// The first round is seeded, not fed.
expect(feeders.get(matchKey("Round of 16", 1))).toEqual([
{ kind: "seed" },
{ kind: "seed" },
]);
});
it("returns an empty map without a template", () => {
expect(buildFeederMap(undefined).size).toBe(0);
});
});
describe("computeGroupLayout — standard brackets are unchanged", () => {
it("halves a 16-team bracket evenly, first round in seeded order", () => {
const byRound = allMatches(SIMPLE_16);
const roundOrder = SIMPLE_16.rounds.map((r) => r.name);
const layout = computeGroupLayout(
roundOrder,
byRound,
buildFeederMap(SIMPLE_16),
roundOrder
);
expect(layout.leafCount).toBe(8);
expect(layout.columns.map((c) => c.label)).toEqual(roundOrder);
expect(layout.columns.map((c) => c.matches.map((m) => m.match.matchNumber))).toEqual([
[1, 2, 3, 4, 5, 6, 7, 8],
[1, 2, 3, 4],
[1, 2],
[1],
]);
// Evenly spread, exactly as the previous index math placed them.
expect(layout.columns[0].matches.map((m) => m.center)).toEqual([
0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5,
]);
expect(layout.columns[3].matches[0].center).toBe(4);
});
it("handles byes, placing a seeded team level with the round it enters", () => {
// The NFL bracket's top seeds skip the wild card round.
const byRound = allMatches(NFL_14);
const roundOrder = NFL_14.rounds.map((r) => r.name);
const layout = computeGroupLayout(roundOrder, byRound, buildFeederMap(NFL_14), roundOrder);
expect(layout.columns.length).toBeGreaterThan(0);
for (const column of layout.columns) {
expect(column.matches.length).toBeGreaterThan(0);
}
});
it("falls back to even spreading when a group has no single root", () => {
// Two finals and no way to join them — the shape can't resolve to one tree.
const byRound = new Map<string, TestMatch[]>([
["Semifinals", [{ round: "Semifinals", matchNumber: 1 }]],
[
"Finals",
[
{ round: "Finals", matchNumber: 1 },
{ round: "Finals", matchNumber: 2 },
],
],
]);
const layout = computeGroupLayout(
["Semifinals", "Finals"],
byRound,
new Map(),
["Semifinals", "Finals"]
);
expect(layout.columns.map((c) => c.label)).toEqual(["Semifinals", "Finals"]);
expect(layout.edges).toEqual([]);
expect(layout.columns[0].matches[0].center).toBe(1);
expect(layout.columns[1].matches.map((m) => m.center)).toEqual([0.5, 1.5]);
});
it("returns nothing for an empty group", () => {
const layout = computeGroupLayout([], new Map(), new Map(), []);
expect(layout).toEqual({ columns: [], leafCount: 0, edges: [] });
});
});
describe("buildFeederMap — templates with routing of their own", () => {
// The halving rule describes advanceWinnerTemplate, not every bracket. Inventing it
// where it doesn't hold draws confident, wrong connectors and mislabels slots, which
// is worse than drawing nothing.
it("follows feedsInto rather than the order rounds are listed in", () => {
// AFL's Wildcard Round feeds the Elimination Finals, skipping the round printed
// next to it, so array order would fabricate the whole chain.
const afl = BRACKET_TEMPLATES.afl_10;
const feeders = buildFeederMap(afl);
const fed = [...feeders.entries()].filter(([, pair]) =>
pair.some((s) => s.kind === "match")
);
// Only Preliminary Finals → Grand Final actually halves.
expect(fed.map(([key]) => key)).toEqual(["Grand Final#1"]);
});
it("leaves a bye round's slots seeded rather than inventing feeds", () => {
// CFP's First Round (4) feeds the Quarterfinals (4) — the top seeds have byes.
const feeders = buildFeederMap(BRACKET_TEMPLATES.cfp_12);
expect(feeders.get(matchKey("Quarterfinals", 1))).toEqual([
{ kind: "seed" },
{ kind: "seed" },
]);
});
it("leaves the First Four out of the Round of 64", () => {
// advanceFirstFourWinner puts each winner in a specific seed slot, not games 1-2.
const feeders = buildFeederMap(BRACKET_TEMPLATES.ncaa_68);
expect(feeders.get(matchKey("Round of 64", 1))).toEqual([
{ kind: "seed" },
{ kind: "seed" },
]);
});
it("leaves the NBA play-in alone, where a loser feeds forward", () => {
// Play-In Round 2 pairs the 7v8 loser with the 9v10 winner, so the round sizes
// halve but the winners-only rule still doesn't describe it.
const feeders = buildFeederMap(BRACKET_TEMPLATES.nba_20);
expect(feeders.get(matchKey("Play-In Round 2", 1))).toEqual([
{ kind: "seed" },
{ kind: "seed" },
]);
});
it("does not route the FIFA final through the third place game", () => {
// Third Place Game sits between Semifinals and Finals in round order, so array
// order made it the Finals' feeder and left the Finals' second slot empty.
const feeders = buildFeederMap(BRACKET_TEMPLATES.fifa_48);
expect(feeders.get(matchKey("Finals", 1))).toEqual([
{ kind: "match", ref: { round: "Semifinals", matchNumber: 1 }, result: "winner" },
{ kind: "match", ref: { round: "Semifinals", matchNumber: 2 }, result: "winner" },
]);
});
});
describe("computeGroupLayout — every template still draws connectors", () => {
/** Lay a whole template out the way BracketTreeView would. */
function layOut(template: BracketTemplate) {
const byRound = allMatches(template);
const order = template.rounds.map((r) => r.name);
// BracketTreeView renders a third place game outside the tree.
const rounds = order.filter((r) => r !== "Third Place Game");
return computeGroupLayout(rounds, byRound, buildFeederMap(template), order);
}
// A gutter joining a column to one exactly half its size is a plain bracket join and
// must always be drawn. Where the sizes don't halve — a bye round, a play-in, the
// First Four — the routing is bespoke and nothing is drawn until the games decide it,
// which is what these brackets did before.
it.each(Object.keys(BRACKET_TEMPLATES).filter((id) => id !== "llws_20"))(
"%s draws every gutter that halves",
(id) => {
const layout = layOut(BRACKET_TEMPLATES[id]);
const gutters = new Set(layout.edges.map((e) => e.fromColumn));
let halvingGutters = 0;
for (let ci = 0; ci < layout.columns.length - 1; ci++) {
const from = layout.columns[ci].matches.length;
const to = layout.columns[ci + 1].matches.length;
if (from !== to * 2) continue;
halvingGutters += 1;
expect(gutters).toContain(ci);
}
// Every template has at least one, so a template that lost all its lines fails.
expect(halvingGutters).toBeGreaterThan(0);
}
);
it("keeps the FIFA bracket a single tree once the third place game is set aside", () => {
const layout = layOut(BRACKET_TEMPLATES.fifa_48);
expect(layout.columns.map((c) => c.label)).toEqual([
"Round of 32",
"Round of 16",
"Quarterfinals",
"Semifinals",
"Finals",
]);
expect(layout.edges).toHaveLength(30);
});
// llws_20 is excluded above because both sides in one group is genuinely not a tree;
// it renders per side, which the tests further up cover.
});
describe("computeGroupLayout — fallback keeps the old connectors", () => {
const rounds = ["Quarterfinals", "Semifinals", "Finals"];
const byRound = new Map<string, TestMatch[]>([
["Quarterfinals", [1, 2, 3, 4].map((n) => ({ round: "Quarterfinals", matchNumber: n }))],
["Semifinals", [1, 2].map((n) => ({ round: "Semifinals", matchNumber: n }))],
["Finals", [{ round: "Finals", matchNumber: 1 }]],
]);
it("infers halving edges when there is no feeder map at all", () => {
// A bracket with no template id, which SportSeasonDisplay renders.
const layout = computeGroupLayout(rounds, byRound, new Map(), rounds);
expect(layout.edges).toHaveLength(6);
// Quarterfinals 1 and 2 both join Semifinal 1.
const intoFirstSemi = layout.edges.filter((e) => e.toCenter === 1);
expect(intoFirstSemi.map((e) => e.fromCenter)).toEqual([0.5, 1.5]);
});
it("traces played winners when the shape is not a halving", () => {
const irregular = new Map<string, TestMatch[]>([
[
"Wildcard",
[
{ round: "Wildcard", matchNumber: 1, winnerId: "a" },
{ round: "Wildcard", matchNumber: 2, winnerId: "b" },
],
],
[
"Semifinals",
[
{ round: "Semifinals", matchNumber: 1, participant1Id: "seeded", participant2Id: "b" },
{ round: "Semifinals", matchNumber: 2, participant1Id: "seeded2", participant2Id: "a" },
],
],
]);
const layout = computeGroupLayout(
["Wildcard", "Semifinals"],
irregular,
new Map(),
["Wildcard", "Semifinals"]
);
// b won Wildcard 2 (centre 1.5) and plays Semifinal 1 (centre 0.5) — a crossing
// edge that only the actual result can reveal.
expect(layout.edges).toContainEqual({ fromColumn: 0, fromCenter: 1.5, toCenter: 0.5 });
expect(layout.edges).toContainEqual({ fromColumn: 0, fromCenter: 0.5, toCenter: 1.5 });
});
});
describe("describeSlotSource", () => {
const feeders = buildFeederMap(LLWS_20);
const sourcesFor = (game: number): [SlotSource, SlotSource] => {
const m = GAME_TO_MATCH[game];
const pair = feeders.get(matchKey(m.round, m.matchNumber));
if (!pair) throw new Error(`No feeders for G${game}`);
return pair;
};
it("names a winner feed", () => {
// G18 = W5 v W9; G5 is Opening Round match 3 on the International side.
expect(describeSlotSource(sourcesFor(18)[0], LLWS_20)).toBe("Winner of Opening 3");
});
it("names a loser feed, which is the one no line can show", () => {
// G21 = L9 v W13; G9 is Winners Round 2 match 1 on the International side.
expect(describeSlotSource(sourcesFor(21)[0], LLWS_20)).toBe("Loser of Winners R2 1");
// G25 = L18 v W23; G18 is International semifinal 1.
expect(describeSlotSource(sourcesFor(25)[0], LLWS_20)).toBe("Loser of Winners SF 1");
});
it("uses side-local numbers, as the printed bracket does", () => {
// G27 = L20 v W21. G20 is Winners Semifinals match 4 globally, but International
// semifinal 2 — the number the printed bracket uses.
expect(describeSlotSource(sourcesFor(27)[0], LLWS_20)).toBe("Loser of Winners SF 2");
});
it("names the side where each side plays only one such game", () => {
// G37 = L36 v L35: both feeds are Bracket Championship losers, one per side, so a
// number would say nothing and the side is the only thing that tells them apart.
const [p1, p2] = sourcesFor(37);
expect(describeSlotSource(p1, LLWS_20)).toBe("Loser of U.S. Bracket Final");
expect(describeSlotSource(p2, LLWS_20)).toBe("Loser of Intl Bracket Final");
// Same rule inside a side bracket: G34 = L30 v W32.
const [elimP1, elimP2] = sourcesFor(34);
expect(describeSlotSource(elimP1, LLWS_20)).toBe("Loser of U.S. Winners Final");
expect(describeSlotSource(elimP2, LLWS_20)).toBe("Winner of U.S. Elim R4");
});
it("drops both number and side for the shared final games", () => {
// The two sides meet here, so there is only one of each game in the whole bracket.
const wc = describeSlotSource(
{ kind: "match", ref: GAME_TO_MATCH[38], result: "winner" },
LLWS_20
);
expect(wc).toBe("Winner of World Championship");
});
it("returns null for a seeded slot so the caller can render TBD", () => {
expect(describeSlotSource({ kind: "seed" }, LLWS_20)).toBeNull();
expect(describeSlotSource(undefined, LLWS_20)).toBeNull();
});
it("uses plain round names for non-LLWS templates", () => {
const template = getBracketTemplate("simple_16");
const source: SlotSource = {
kind: "match",
ref: { round: "Quarterfinals", matchNumber: 3 },
result: "winner",
};
expect(describeSlotSource(source, template)).toBe("Winner of Quarterfinals 3");
});
});

View file

@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { format, parseISO } from "date-fns"; import { format, parseISO } from "date-fns";
import { localDateTimeToUtcIso, toEventSortKey } from "../date-utils"; import { localDateTimeToUtcIso, toEventSortKey, utcIsoToLocalDateTime } from "../date-utils";
describe("localDateTimeToUtcIso", () => { describe("localDateTimeToUtcIso", () => {
it("returns null for empty string", () => { 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", () => { describe("toEventSortKey", () => {
it("prefers earliestGameTime over eventDate", () => { it("prefers earliestGameTime over eventDate", () => {
const key = toEventSortKey({ eventDate: "2026-03-01", earliestGameTime: "2026-03-17T10:45:00.000Z" }); const key = toEventSortKey({ eventDate: "2026-03-01", earliestGameTime: "2026-03-17T10:45:00.000Z" });

View file

@ -0,0 +1,230 @@
import { describe, it, expect } from "vitest";
import { createStaticHandler } from "react-router";
import { shouldReportServerError } from "../error-reporting";
const ORIGIN = "https://brackt.com";
/** Shaped like the ErrorResponse React Router hands to `handleError`. */
function routeError(
status: number,
internal: boolean,
statusText = "Not Found",
) {
return {
status,
statusText,
internal,
data: `Error: No route matches URL "/blog/wp/v2/posts/999999"`,
};
}
function request(path: string, referer?: string, method = "GET") {
return new Request(`${ORIGIN}${path}`, {
method,
headers: referer ? { referer } : {},
});
}
describe("shouldReportServerError", () => {
it("drops a router 404 for a scanner hitting a URL cold", () => {
expect(
shouldReportServerError(
routeError(404, true),
request("/blog/wp/v2/posts/999999"),
),
).toBe(false);
});
it("drops a router 404 linked from another site", () => {
expect(
shouldReportServerError(
routeError(404, true),
request("/blog/", "https://evil.example/"),
),
).toBe(false);
});
it("reports a router 404 linked from one of our own pages", () => {
expect(
shouldReportServerError(
routeError(404, true),
request("/leagues/gone", `${ORIGIN}/leagues`),
),
).toBe(true);
});
it("drops the 405 from a POST to a route with no action", () => {
expect(
shouldReportServerError(
routeError(405, true, "Method Not Allowed"),
request("/", undefined, "POST"),
),
).toBe(false);
});
it("reports a 404 the app threw deliberately", () => {
expect(
shouldReportServerError(
routeError(404, false),
request("/leagues/missing"),
),
).toBe(true);
});
it("reports a 403 the app threw from an ownership check", () => {
expect(
shouldReportServerError(
routeError(403, false, "Forbidden"),
request("/admin/sports"),
),
).toBe(true);
});
it("reports a router-internal 500", () => {
expect(
shouldReportServerError(
routeError(500, true, "Internal Server Error"),
request("/leagues"),
),
).toBe(true);
});
it("reports a plain exception", () => {
expect(
shouldReportServerError(new Error("boom"), request("/leagues")),
).toBe(true);
});
it("reports anything that is not a route error response", () => {
expect(shouldReportServerError("just a string", request("/leagues"))).toBe(
true,
);
expect(shouldReportServerError(null, request("/leagues"))).toBe(true);
});
it("drops a router 404 whose referer header is not a URL", () => {
expect(
shouldReportServerError(
routeError(404, true),
request("/blog/", "not a url"),
),
).toBe(false);
});
});
/**
* The unit tests above use hand-written error objects. These drive real requests
* through React Router so the suite fails if the shape it throws ever changes.
*/
describe("shouldReportServerError against real React Router errors", () => {
const handler = createStaticHandler([
{
id: "root",
path: "/",
children: [{ id: "home", index: true, loader: () => null }],
},
]);
async function errorFor(req: Request) {
const ctx = await handler.query(req);
if (ctx instanceof Response) return null;
return Object.values(ctx.errors ?? {})[0] ?? null;
}
it('drops the 404 for an unmatched URL (No route matches URL "...")', async () => {
const req = request("/blog/wp/v2/posts/999999");
const error = await errorFor(req);
expect(error).toMatchObject({ status: 404, internal: true });
expect(shouldReportServerError(error, req)).toBe(false);
});
it("reports the same 404 when it came from a link on our own site", async () => {
const req = request("/nope", `${ORIGIN}/leagues`);
expect(shouldReportServerError(await errorFor(req), req)).toBe(true);
});
it("drops the 405 from a POST to a route with no action", async () => {
const req = request("/", undefined, "POST");
const error = await errorFor(req);
expect(error).toMatchObject({ status: 405, internal: true });
expect(shouldReportServerError(error, req)).toBe(false);
});
});
describe("static asset 404s", () => {
it("drops a stale hashed bundle even with a same-host referer", () => {
// Every deploy leaves clients requesting the previous build's assets.
const req = request("/assets/index-OLDHASH.js", `${ORIGIN}/leagues`);
expect(shouldReportServerError(routeError(404, true), req)).toBe(false);
});
it.each([
"/assets/app-x1.css",
"/fonts/inter.woff2",
"/images/logo.png",
"/favicon.ico",
])("drops a 404 for %s", (path) => {
expect(
shouldReportServerError(
routeError(404, true),
request(path, `${ORIGIN}/`),
),
).toBe(false);
});
it("still follows the referer rule for a non-asset path containing a dot", () => {
expect(
shouldReportServerError(
routeError(404, true),
request("/leagues/v1.2", `${ORIGIN}/leagues`),
),
).toBe(true);
expect(
shouldReportServerError(routeError(404, true), request("/leagues/v1.2")),
).toBe(false);
});
});
describe("React Router internal statuses that are not 404/405", () => {
it("reports an internal 400 (route is missing a loader)", () => {
expect(
shouldReportServerError(
routeError(400, true, "Bad Request"),
request("/leagues"),
),
).toBe(true);
});
it("reports an internal 403 (route does not match URL)", () => {
expect(
shouldReportServerError(
routeError(403, true, "Forbidden"),
request("/leagues"),
),
).toBe(true);
});
});
describe("production shape: TLS terminated upstream", () => {
it("reports a 404 linked from our own site when the proxy strips https", () => {
// Express builds request.url from req.protocol, which is `http` inside the
// container. Real browsers send an https referer. Comparing full origins
// would never match, silencing every broken internal link.
const req = new Request("http://brackt.com/leagues/gone", {
headers: { referer: "https://brackt.com/leagues" },
});
expect(shouldReportServerError(routeError(404, true), req)).toBe(true);
});
it("still drops a cold scanner hit under that same shape", () => {
const req = new Request("http://brackt.com/blog/wp/v2/posts/999999");
expect(shouldReportServerError(routeError(404, true), req)).toBe(false);
});
it("still drops a 404 linked from another site under that same shape", () => {
const req = new Request("http://brackt.com/nope", {
headers: { referer: "https://evil.example/" },
});
expect(shouldReportServerError(routeError(404, true), req)).toBe(false);
});
});

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"; const name = "oklahoma city thunder";
expect(normalizeTeamName(name)).toBe(name); 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", () => { describe("findMatchingTeamName", () => {
@ -38,6 +43,12 @@ describe("findMatchingTeamName", () => {
expect(findMatchingTeamName("Oklahoma City", participants)).toBe("Oklahoma City Thunder"); 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", () => { it("returns null for unmatched name", () => {
expect(findMatchingTeamName("Phoenix Suns", participants)).toBeNull(); expect(findMatchingTeamName("Phoenix Suns", participants)).toBeNull();
}); });

View file

@ -0,0 +1,99 @@
/**
* AFL Wildcard Round Elimination Finals re-seeding.
*
* The Wildcard Round is drawn 7 v 10 and 8 v 9, and its two winners fill the open slots
* in the Elimination Finals opposite the 5th and 6th seeds. Those slots are NOT a fixed
* crossover: the winners are re-seeded by ladder position, exactly as the classic final
* eight pairs 5 v 8 and 6 v 7 the higher seed of the two hosts meets the lower-ranked
* winner. So 5th plays whichever winner finished further down the ladder and 6th plays
* the other, whichever Wildcard game each came out of.
*
* Worked example: 10th beats 7th and 9th beats 8th. A fixed crossover would send the
* 7v10 winner (10th) to 6th and the 8v9 winner (9th) to 5th handing the higher host
* the better opponent. Re-seeded, 5th plays 10th and 6th plays 9th.
*/
/** Seeds drawn into each Wildcard Round match, in [participant1, participant2] order. */
export const AFL_WILDCARD_DRAW: Readonly<Record<number, readonly [number, number]>> = {
1: [7, 10],
2: [8, 9],
};
/** Seed hosting each Elimination Finals match (its participant1 slot). */
export const AFL_ELIMINATION_HOSTS: Readonly<Record<number, number>> = {
1: 5,
2: 6,
};
export interface AflWildcardResult {
matchNumber: number;
/** Slot the winner occupied, or null while the match is still to be played. */
winnerSlot: 1 | 2 | null;
}
export interface AflWildcardPlacement {
wildcardMatchNumber: number;
/** Seed of the Wildcard winner being placed. */
seed: number;
eliminationMatchNumber: number;
}
/**
* Decide which Elimination Final each decided Wildcard winner belongs in.
*
* A winner is only placed once its destination is settled whichever way the other
* Wildcard game falls, so results can be entered in either order:
* - 7th winning match 1 outranks both possible match 2 winners always meets 6th.
* - 10th winning match 1 is outranked by both always meets 5th.
* - A match 2 winner (8th or 9th) sits between them, so it is held back until match 1
* is decided rather than being placed and then moved.
*
* Undecided winners are simply omitted; the caller fills the slots it is handed and
* leaves the rest TBD.
*/
export function resolveAflWildcardPlacements(
results: readonly AflWildcardResult[]
): AflWildcardPlacement[] {
const entries = results.map((result) => {
const draw = AFL_WILDCARD_DRAW[result.matchNumber];
if (!draw) {
throw new Error(`Unknown AFL Wildcard Round match number ${result.matchNumber}`);
}
return {
matchNumber: result.matchNumber,
seed: result.winnerSlot === null ? null : draw[result.winnerSlot - 1],
// Every seed the match could still send through — one entry once it is decided.
possibleSeeds: result.winnerSlot === null ? [...draw] : [draw[result.winnerSlot - 1]],
};
});
// Best-ranked winner takes the weakest host, so order the hosts worst seed first.
const hostsWorstFirst = Object.keys(AFL_ELIMINATION_HOSTS)
.map(Number)
.toSorted((a, b) => AFL_ELIMINATION_HOSTS[b] - AFL_ELIMINATION_HOSTS[a]);
const placements: AflWildcardPlacement[] = [];
for (const entry of entries) {
const seed = entry.seed;
if (seed === null) continue;
const others = entries.filter((other) => other !== entry);
const outranks = (other: (typeof entries)[number]) => other.possibleSeeds.every((s) => s < seed);
const outrankedBy = (other: (typeof entries)[number]) => other.possibleSeeds.every((s) => s > seed);
// This winner's rank is only knowable while every other one sits wholly above or
// wholly below it — an undecided game straddling this seed leaves it unplaceable.
if (!others.every((other) => outranks(other) || outrankedBy(other))) continue;
const rank = others.filter(outranks).length;
const eliminationMatchNumber = hostsWorstFirst[rank];
if (eliminationMatchNumber === undefined) {
throw new Error(`No Elimination Finals slot for AFL Wildcard winner ranked ${rank + 1}`);
}
placements.push({ wildcardMatchNumber: entry.matchNumber, seed, eliminationMatchNumber });
}
return placements;
}

420
app/lib/bracket-layout.ts Normal file
View file

@ -0,0 +1,420 @@
/**
* Bracket geometry, derived from the real feeder graph.
*
* The renderer used to place cards by index within a round match i at
* `i * (height / roundSize)` and drew connectors assuming matches 2k and 2k+1 feed
* match k. That holds only when each round is an exact halving of the previous one.
*
* The LLWS winners bracket is not a halving: two of the four Opening Round games skip
* Winners Round 2 entirely and go straight to the semifinals (see LLWS_ADVANCEMENT).
* Under index math those games get pulled to the bottom of column one with nothing
* above them in column two, and the connectors confidently join the wrong pairs.
*
* So lay out from the graph instead:
* column = depth from the group's final, counted backwards
* vertical order = the parent's slot order (participant1 above participant2)
* connectors = actual feeder edges
*
* Counting columns back from the final is what makes a printed bracket line up: a team
* entering late sits in the column where it actually plays, not the column its round
* name suggests. For the LLWS International side this reproduces the official bracket
* exactly, including putting the Australia/Mexico game alongside Winners Round 2.
*
* Pure no React, no DB so the geometry can be asserted against the printed bracket
* in tests.
*/
import {
llwsSideAndLocal,
type BracketTemplate,
} from "~/lib/bracket-templates";
import { resolveLLWSAdvancement } from "~/lib/llws-bracket";
// ── Feeder graph ──────────────────────────────────────────────────────────────
export interface MatchRef {
round: string;
matchNumber: number;
}
/** What fills one participant slot of a match. */
export type SlotSource =
| { kind: "match"; ref: MatchRef; result: "winner" | "loser" }
| { kind: "seed" };
/** Keyed by `${round}#${matchNumber}`; the pair is [participant1, participant2]. */
export type FeederMap = Map<string, [SlotSource, SlotSource]>;
const SEED: SlotSource = { kind: "seed" };
/**
* `template.id:roundName` for transitions routed by a dedicated advancement function
* rather than advanceWinnerTemplate's ceil(n/2) rule, and whose round sizes happen to
* halve so the check in buildFeederMap can't rule them out on shape alone.
*
* The NBA play-in is the case: Play-In Round 2 pairs the 7v8 *loser* with the 9v10
* winner (advanceNBAPlayInWinner), which no winners-only halving describes.
*/
const BESPOKE_TRANSITIONS = new Set(["nba_20:Play-In Round 1"]);
export function matchKey(round: string, matchNumber: number): string {
return `${round}#${matchNumber}`;
}
/**
* Invert a template's advancement rules into "what fills each slot".
*
* `llws_20` has an explicit, hand-verified routing table with deliberate cross-overs, so
* it is inverted from that. Everything else follows the standard rule: slot p1 of match N
* is the winner of match 2N-1 in the previous round, slot p2 the winner of match 2N.
*/
export function buildFeederMap(template: BracketTemplate | undefined): FeederMap {
const feeders: FeederMap = new Map();
if (!template) return feeders;
const slots = (key: string): [SlotSource, SlotSource] => {
let pair = feeders.get(key);
if (!pair) {
pair = [SEED, SEED];
feeders.set(key, pair);
}
return pair;
};
// Seed every match in the template so unfed slots read as directly seeded.
for (const round of template.rounds) {
for (let n = 1; n <= round.matchCount; n++) slots(matchKey(round.name, n));
}
if (template.id === "llws_20") {
for (const round of template.rounds) {
for (let n = 1; n <= round.matchCount; n++) {
const { winner, loser } = resolveLLWSAdvancement(round.name, n);
const ref: MatchRef = { round: round.name, matchNumber: n };
for (const [destination, result] of [
[winner, "winner"],
[loser, "loser"],
] as const) {
if (!destination) continue;
const pair = slots(matchKey(destination.round, destination.matchNumber));
pair[destination.slot === "participant1Id" ? 0 : 1] = { kind: "match", ref, result };
}
}
}
return feeders;
}
// Follow each round's declared `feedsInto` rather than array order — AFL's Wildcard
// Round feeds the Elimination Finals, skipping the round printed next to it.
for (const prev of template.rounds) {
if (!prev.feedsInto) continue;
const round = template.rounds.find((r) => r.name === prev.feedsInto);
if (!round) continue;
// advanceWinnerTemplate sends match n to ceil(n/2) in the next round, slot by
// parity. That describes the bracket only where the round halves exactly; a
// play-in, a bye round, or a First Four routes by rules of its own, and inventing
// a halving there would draw connectors and slot labels that are simply wrong.
// Leaving those edges out drops the group to computeGroupLayout's fallback, which
// is the geometry these brackets already had.
if (prev.matchCount !== round.matchCount * 2) continue;
if (BESPOKE_TRANSITIONS.has(`${template.id}:${prev.name}`)) continue;
for (let n = 1; n <= round.matchCount; n++) {
const pair = slots(matchKey(round.name, n));
pair[0] = {
kind: "match",
ref: { round: prev.name, matchNumber: 2 * n - 1 },
result: "winner",
};
pair[1] = {
kind: "match",
ref: { round: prev.name, matchNumber: 2 * n },
result: "winner",
};
}
}
return feeders;
}
// ── Slot labels ───────────────────────────────────────────────────────────────
/**
* Round names as they read inside a card, where there is room for about twenty
* characters. Anything not listed keeps its full name.
*/
const SHORT_ROUND_NAMES: Record<string, string> = {
"Opening Round": "Opening",
"Winners Round 2": "Winners R2",
"Winners Semifinals": "Winners SF",
"Winners Final": "Winners Final",
"Elimination Round 1": "Elim R1",
"Elimination Round 2": "Elim R2",
"Elimination Round 3": "Elim R3",
"Elimination Round 4": "Elim R4",
"Elimination Final": "Elim Final",
"Bracket Championship": "Bracket Final",
};
/**
* How an empty slot should read: "Winner of Winners SF 2" rather than "TBD".
*
* Returns null for a directly seeded slot, which the caller renders as "TBD".
*
* The cross-bracket feeds matter most here a winners-bracket loser dropping into the
* elimination bracket is a real edge that no line can show, because the two sides render
* as separate trees.
*/
export function describeSlotSource(
source: SlotSource | undefined,
template: BracketTemplate | undefined
): string | null {
if (!source || source.kind !== "match") return null;
const { round, matchNumber } = source.ref;
const name = SHORT_ROUND_NAMES[round] ?? round;
const verb = source.result === "winner" ? "Winner" : "Loser";
const roundMatchCount = template?.rounds.find((r) => r.name === round)?.matchCount ?? 0;
if (template?.id !== "llws_20") {
return `${verb} of ${name}${roundMatchCount <= 1 ? "" : ` ${matchNumber}`}`;
}
// LLWS numbers matches globally across both sides, so semifinal 4 is International
// semifinal 2. Name it the way the printed bracket does — by side-local number, or by
// side where each side plays only one such game and the number would say nothing.
const { side, localMatch } = llwsSideAndLocal(round, matchNumber);
const isShared = round === "Consolation Third Place" || round === "World Championship";
const perSideCount = isShared ? roundMatchCount : roundMatchCount / 2;
if (perSideCount > 1) return `${verb} of ${name} ${localMatch}`;
if (isShared) return `${verb} of ${name}`;
return `${verb} of ${side === 0 ? "U.S." : "Intl"} ${name}`;
}
// ── Layout ────────────────────────────────────────────────────────────────────
export interface LaidOutMatch<M> {
match: M;
/** Centre of the card, in slot units (1 unit = one leaf row). */
center: number;
}
export interface LayoutColumn<M> {
label: string;
matches: LaidOutMatch<M>[];
}
export interface BracketLayout<M> {
columns: LayoutColumn<M>[];
/** Number of leaf rows; multiply by row height for the pixel height of the bracket. */
leafCount: number;
/** Edges to draw, as (column index of the source, source centre, target centre). */
edges: { fromColumn: number; fromCenter: number; toCenter: number }[];
}
interface PositionedMatch {
round: string;
matchNumber: number;
/** Only read by the fallback, to trace edges through an unrecognised shape. */
winnerId?: string | null;
participant1Id?: string | null;
participant2Id?: string | null;
}
/**
* Lay out one rendered group a winners bracket, an elimination bracket, a region.
*
* `matchesByRound` should already be filtered to the group; cross-group feeds are
* dropped, matching the printed bracket, which labels those slots rather than drawing
* lines to another tree.
*
* Falls back to the previous index-based geometry when the group has no single root
* (disjoint or unrecognised shapes), so no existing template can regress to a blank
* column.
*/
export function computeGroupLayout<M extends PositionedMatch>(
visibleRounds: string[],
matchesByRound: Map<string, M[]>,
feeders: FeederMap,
templateRoundOrder: string[]
): BracketLayout<M> {
const nodes = new Map<string, M>();
const roundOf = new Map<string, string>();
for (const round of visibleRounds) {
for (const match of matchesByRound.get(round) ?? []) {
const key = matchKey(match.round, match.matchNumber);
nodes.set(key, match);
roundOf.set(key, round);
}
}
if (nodes.size === 0) return { columns: [], leafCount: 0, edges: [] };
// In-group children, in slot order. A slot fed from outside the group has no card
// here, so it contributes no edge.
const childrenOf = new Map<string, string[]>();
const hasParent = new Set<string>();
for (const key of nodes.keys()) {
const pair = feeders.get(key);
const kids: string[] = [];
for (const source of pair ?? []) {
if (source.kind !== "match") continue;
const childKey = matchKey(source.ref.round, source.ref.matchNumber);
if (!nodes.has(childKey) || kids.includes(childKey)) continue;
kids.push(childKey);
hasParent.add(childKey);
}
childrenOf.set(key, kids);
}
const roots = [...nodes.keys()].filter((k) => !hasParent.has(k));
if (roots.length !== 1) {
return fallbackLayout(visibleRounds, matchesByRound);
}
const [root] = roots;
// Depth from the root, then flip so leaves are column 0 and the final is last.
//
// Take the longest path, not the first one found: in a double-elimination bracket a
// match feeds two places (its winner forward, its loser into the elimination side), so
// the graph is a DAG and a node can be reached at several depths. The longest path is
// the one that leaves room for every game on the way.
const depth = new Map<string, number>();
const assignDepth = (key: string, d: number) => {
const known = depth.get(key);
if (known !== undefined && known >= d) return;
depth.set(key, d);
for (const child of childrenOf.get(key) ?? []) assignDepth(child, d + 1);
};
assignDepth(root, 0);
if (depth.size !== nodes.size) {
return fallbackLayout(visibleRounds, matchesByRound);
}
const maxDepth = Math.max(...depth.values());
const columnOf = (key: string) => maxDepth - (depth.get(key) ?? 0);
// Vertical order comes from a depth-first walk in slot order: participant1's feeder
// sits above participant2's. This is why the elimination bracket's later game ends up
// on top, as the printed bracket has it.
const center = new Map<string, number>();
let leafCount = 0;
const place = (key: string): number => {
const already = center.get(key);
if (already !== undefined) return already;
const kids = childrenOf.get(key) ?? [];
if (kids.length === 0) {
const y = leafCount + 0.5;
leafCount += 1;
center.set(key, y);
return y;
}
const kidCenters = kids.map(place);
const y = kidCenters.reduce((sum, c) => sum + c, 0) / kidCenters.length;
center.set(key, y);
return y;
};
place(root);
const columns: LayoutColumn<M>[] = Array.from({ length: maxDepth + 1 }, () => ({
label: "",
matches: [],
}));
for (const [key, match] of nodes) {
columns[columnOf(key)].matches.push({ match, center: center.get(key) ?? 0 });
}
for (const column of columns) {
column.matches.sort((a, b) => a.center - b.center);
}
// A column can mix rounds — the LLWS second column holds two Opening Round games
// alongside Winners Round 2. Name it for the latest round it contains, which is how
// the printed bracket labels that column.
for (let ci = 0; ci < columns.length; ci++) {
const rounds = columns[ci].matches.map((m) => m.match.round);
columns[ci].label = rounds.reduce((latest, r) =>
templateRoundOrder.indexOf(r) > templateRoundOrder.indexOf(latest) ? r : latest
);
}
// Connectors live in the single gutter between adjacent columns, so only edges that
// span exactly one gutter can be drawn. In a tree every edge does; in the DAG case a
// feed can reach further back, and a line that stopped short would be worse than none.
const edges: BracketLayout<M>["edges"] = [];
for (const [key] of nodes) {
const toCenter = center.get(key) ?? 0;
for (const child of childrenOf.get(key) ?? []) {
const fromColumn = columnOf(child);
if (fromColumn !== columnOf(key) - 1) continue;
edges.push({ fromColumn, fromCenter: center.get(child) ?? 0, toCenter });
}
}
return { columns, leafCount, edges };
}
/**
* The previous behaviour, kept for groups whose shape can't be resolved into a single
* tree: one column per round, matches spread evenly over it, and edges inferred from the
* round sizes. Brackets with bespoke routing (AFL, CFP byes, a bracket with no template)
* land here, so it has to keep drawing what they drew before rather than nothing.
*/
function fallbackLayout<M extends PositionedMatch>(
visibleRounds: string[],
matchesByRound: Map<string, M[]>
): BracketLayout<M> {
const leafCount = Math.max(
...visibleRounds.map((r) => matchesByRound.get(r)?.length ?? 0),
1
);
const centersFor = (matches: M[]) => {
const span = leafCount / Math.max(matches.length, 1);
return matches.map((_, i) => (i + 0.5) * span);
};
const columns = visibleRounds.map((round) => {
const matches = matchesByRound.get(round) ?? [];
const centers = centersFor(matches);
return {
label: round,
matches: matches.map((match, i) => ({ match, center: centers[i] })),
};
});
const edges: BracketLayout<M>["edges"] = [];
for (let ci = 0; ci < columns.length - 1; ci++) {
const from = columns[ci].matches;
const to = columns[ci + 1].matches;
if (to.length === Math.ceil(from.length / 2) && from.length > 1) {
// A halving: matches 2k and 2k+1 feed match k.
for (let k = 0; k < to.length; k++) {
for (const idx of [2 * k, 2 * k + 1]) {
if (idx >= from.length) continue;
edges.push({
fromColumn: ci,
fromCenter: from[idx].center,
toCenter: to[k].center,
});
}
}
continue;
}
// Otherwise the only thing that can be known is where a winner actually went, so
// nothing is drawn until the games are played.
const winnerToCenter = new Map<string, number>();
for (const { match, center } of from) {
if (match.winnerId) winnerToCenter.set(match.winnerId, center);
}
for (const { match, center } of to) {
for (const id of [match.participant1Id, match.participant2Id]) {
const fromCenter = id ? winnerToCenter.get(id) : undefined;
if (fromCenter === undefined) continue;
edges.push({ fromColumn: ci, fromCenter, toCenter: center });
}
}
}
return { columns, leafCount, edges };
}

View file

@ -19,6 +19,32 @@ export interface BracketRound {
* When set, the loser of each match in this round is placed into the target round. * When set, the loser of each match in this round is placed into the target round.
*/ */
loserFeedsInto?: string | null; loserFeedsInto?: string | null;
/**
* Floor position banked by the WINNER of a *non-scoring* round.
*
* Omit for the default behavior: winners entering the first scoring round bank a
* T5T8 floor (position 5), everyone else banks nothing. Set an explicit number when
* that default is wrong in a double-elimination losers bracket a win can guarantee
* a worse finish than 5th (llws_20 "Elimination Round 3" 7). Set null to bank no
* floor even though the next round scores.
*
* Has no effect on scoring rounds, which use RoundScoringConfig.winnerFloor instead.
*/
nonScoringWinnerFloor?: number | null;
/**
* Floor position every team is guaranteed simply by being *seeded into* this
* round when the bracket is generated before a single match is played.
*
* Omit (the default) for rounds where entering guarantees nothing: a team that
* loses its first match earns 0. Set a number when the bracket structure locks
* in a scoring tier on entry e.g. afl_10's Qualifying Finals, where the loser
* still gets a Semi-Final and so cannot finish worse than the 5th-6th tier.
*
* Only teams actually assigned to a match slot at generation receive this floor;
* TBD slots filled later by advancing winners get their floor from the round they
* won (nonScoringWinnerFloor / RoundScoringConfig.winnerFloor) instead.
*/
entryFloor?: number | null;
} }
export interface GroupStageConfig { export interface GroupStageConfig {
@ -455,6 +481,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) * NCAA March Madness (68 teams)
* First Four (play-in) Round of 64 Round of 32 Sweet Sixteen Elite Eight Final Four Championship * First Four (play-in) Round of 64 Round of 32 Sweet Sixteen Elite Eight Final Four Championship
@ -652,7 +703,8 @@ export const NFL_14: BracketTemplate = {
* - Wildcard Round: 7v10, 8v9 (losers eliminated with 0 points) * - Wildcard Round: 7v10, 8v9 (losers eliminated with 0 points)
* - Week 1 Finals: * - Week 1 Finals:
* - Qualifying Finals: 1v4, 2v3 (losers get second chance) * - Qualifying Finals: 1v4, 2v3 (losers get second chance)
* - Elimination Finals: 5v8(wildcard winner), 6v7(wildcard winner) (losers share 7th-8th) * - Elimination Finals: the two Wildcard winners are re-seeded by ladder position, so
* 5th hosts the lower-ranked winner and 6th the higher-ranked one (losers share 7th-8th)
* - Week 2: Semi-Finals (QF losers vs EF winners, losers share 5th-6th) * - Week 2: Semi-Finals (QF losers vs EF winners, losers share 5th-6th)
* - Week 3: Preliminary Finals (QF winners vs SF winners, losers share 3rd-4th) * - Week 3: Preliminary Finals (QF winners vs SF winners, losers share 3rd-4th)
* - Week 4: Grand Final (1st vs 2nd) * - Week 4: Grand Final (1st vs 2nd)
@ -670,18 +722,32 @@ export const AFL_10: BracketTemplate = {
matchCount: 2, matchCount: 2,
feedsInto: "Elimination Finals", feedsInto: "Elimination Finals",
isScoring: false, // Losers get 0 points (9th-10th) isScoring: false, // Losers get 0 points (9th-10th)
// A Wildcard win only buys an Elimination Final; losing that is the 7th-8th
// tier, so the winner banks 7 — not the generic "entering a scoring round
// means top-8" default of 5, which would over-award them a 5th-6th floor.
nonScoringWinnerFloor: 7,
}, },
{ {
name: "Qualifying Finals", name: "Qualifying Finals",
matchCount: 2, matchCount: 2,
feedsInto: "Preliminary Finals", // Winners get bye feedsInto: "Preliminary Finals", // Winners get bye
isScoring: false, // Losers get second chance (go to Semi-Finals) isScoring: false, // Losers get second chance (go to Semi-Finals)
// Seeds 1-4 have the double chance from the moment the bracket is drawn:
// lose the QF, lose the Semi-Final, and you still finish in the 5th-6th tier.
entryFloor: 5,
// Winning the QF is a bye straight to a Preliminary Final; losing that is the
// 3rd-4th tier, so the winner's floor is 3 rather than the generic default of 5.
nonScoringWinnerFloor: 3,
}, },
{ {
name: "Elimination Finals", name: "Elimination Finals",
matchCount: 2, matchCount: 2,
feedsInto: "Semi-Finals", feedsInto: "Semi-Finals",
isScoring: true, // Losers share 7th-8th isScoring: true, // Losers share 7th-8th
// Seeds 5-6 are seeded straight into this round, so the 7th-8th tier is
// locked in for them at generation. (The other slot is a TBD Wildcard winner,
// who banks the same floor by winning the Wildcard Round.)
entryFloor: 7,
}, },
{ {
name: "Semi-Finals", name: "Semi-Finals",
@ -909,6 +975,258 @@ export const NBA_20: BracketTemplate = {
], ],
}; };
// ── LLWS 20 ───────────────────────────────────────────────────────────────────
/** Side-local match numbers → global match numbers, per round shape. */
const LLWS_OPENING_OFFSET = 4; // Opening Round: US M14, Intl M58
const LLWS_PAIR_OFFSET = 2; // 4-match rounds: US M12, Intl M34
const LLWS_SOLO_OFFSET = 1; // 2-match rounds: US M1, Intl M2
/** Rounds with 4 matches (2 per side). Opening Round has 8; the rest have 2. */
export const LLWS_FOUR_MATCH_ROUNDS = new Set([
"Winners Round 2",
"Elimination Round 1",
"Winners Semifinals",
"Elimination Round 2",
"Elimination Round 3",
]);
/**
* Returns the global match number for a side-local match in an LLWS round.
* side 0 = United States, side 1 = International.
*/
export function llwsMatchNumber(round: string, side: 0 | 1, localMatch: number): number {
const offset =
round === "Opening Round"
? LLWS_OPENING_OFFSET
: LLWS_FOUR_MATCH_ROUNDS.has(round)
? LLWS_PAIR_OFFSET
: LLWS_SOLO_OFFSET;
return localMatch + side * offset;
}
/**
* Inverse of llwsMatchNumber: global match number { side, localMatch }.
*/
export function llwsSideAndLocal(
round: string,
matchNumber: number
): { side: 0 | 1; localMatch: number } {
const offset =
round === "Opening Round"
? LLWS_OPENING_OFFSET
: LLWS_FOUR_MATCH_ROUNDS.has(round)
? LLWS_PAIR_OFFSET
: LLWS_SOLO_OFFSET;
const side: 0 | 1 = matchNumber > offset ? 1 : 0;
return { side, localMatch: matchNumber - side * offset };
}
/**
* Little League Baseball World Series (20 teams, 2025+ double-elimination format)
*
* Two independent 10-team double-elimination brackets United States and
* International each producing a side champion, then a World Championship game and
* a Consolation Third Place game between the two side runners-up.
*
* Rounds are shared across both sides: U.S. matches take the low match numbers and
* International the high ones (see llwsMatchNumber). The phases/groups config splits
* them back apart for display.
*
* A loss in the winners bracket is NOT an elimination it drops the team into the
* elimination bracket at a specific slot (see advanceLLWSWinner in models/playoff-match).
* A loss in the elimination bracket is final.
*
* There is deliberately NO "if necessary" game: the winners-bracket champion is out if
* it loses the Bracket Championship, dropping to the Consolation game rather than
* forcing a rematch. This is the official LLWS modified double-elimination format.
*
* Placement tiers (only 8 teams score the field is exactly 8 when Elim R4 begins):
* 1st / 2nd World Championship
* 3rd / 4th Consolation Third Place (real game, so positions are distinct)
* 5th / 6th Elimination Final losers
* 7th / 8th Elimination Round 4 losers
* 0 pts the 12 teams eliminated in Elimination Rounds 13
*
* Participant array layout (20 slots):
* [07] U.S. Opening Round teams, two per game (M1..M4)
* [8, 9] U.S. bye teams, entering Winners Round 2 M1 / M2 at participant1
* [1017] International Opening Round teams, two per game (M5..M8)
* [18,19] International bye teams, entering Winners Round 2 M3 / M4 at participant1
*/
export const LLWS_20: BracketTemplate = {
id: "llws_20",
name: "Little League World Series (20 teams)",
totalTeams: 20,
scoringStartsAtRound: "Winners Final",
// Ordered by the real schedule so non-phased views read chronologically.
rounds: [
{
name: "Opening Round",
matchCount: 8,
feedsInto: "Winners Round 2",
isScoring: false,
loserFeedsInto: "Elimination Round 1",
nonScoringWinnerFloor: null, // 16 teams still alive — nothing guaranteed
},
{
name: "Winners Round 2",
matchCount: 4,
feedsInto: "Winners Semifinals",
isScoring: false,
loserFeedsInto: "Elimination Round 2",
nonScoringWinnerFloor: null,
},
{
name: "Elimination Round 1",
matchCount: 4,
feedsInto: "Elimination Round 2",
isScoring: false, // losers finish 13th16th
nonScoringWinnerFloor: null,
},
{
name: "Winners Semifinals",
matchCount: 4,
feedsInto: "Winners Final",
isScoring: false,
loserFeedsInto: "Elimination Round 3",
// Reaching the Winners Final guarantees at worst 5th (lose it, then lose the
// Elimination Final). Same value as the engine default, stated explicitly.
nonScoringWinnerFloor: 5,
},
{
name: "Elimination Round 2",
matchCount: 4,
feedsInto: "Elimination Round 3",
isScoring: false, // losers finish 11th12th
nonScoringWinnerFloor: null,
},
{
name: "Elimination Round 3",
matchCount: 4,
feedsInto: "Elimination Round 4",
isScoring: false, // losers finish 9th10th
// Winners reach Elimination Round 4, where a loss is 7th — not 5th.
nonScoringWinnerFloor: 7,
},
{
name: "Winners Final",
matchCount: 2,
feedsInto: "Bracket Championship",
isScoring: true, // loser drops to the Elimination Final (provisional 5th)
loserFeedsInto: "Elimination Final",
},
{
name: "Elimination Round 4",
matchCount: 2,
feedsInto: "Elimination Final",
isScoring: true, // losers share 7th8th
},
{
name: "Elimination Final",
matchCount: 2,
feedsInto: "Bracket Championship",
isScoring: true, // losers share 5th6th
},
{
name: "Bracket Championship",
matchCount: 2,
feedsInto: "World Championship",
isScoring: true, // loser drops to the Consolation game (provisional 4th)
loserFeedsInto: "Consolation Third Place",
},
{
name: "Consolation Third Place",
matchCount: 1,
feedsInto: null,
isScoring: true, // winner 3rd, loser 4th
},
{
name: "World Championship",
matchCount: 1,
feedsInto: null,
isScoring: true, // winner 1st, loser 2nd
},
],
// Region assignments rotate year to year (which region draws the bye changes), so
// these are positional slot labels rather than region names. Kept short — the admin
// form renders them in a narrow fixed-width column alongside each participant picker.
participantLabels: [
"US G1 Home", "US G1 Away",
"US G2 Home", "US G2 Away",
"US G3 Home", "US G3 Away",
"US G4 Home", "US G4 Away",
"US Bye 1", "US Bye 2",
"Intl G1 Home", "Intl G1 Away",
"Intl G2 Home", "Intl G2 Away",
"Intl G3 Home", "Intl G3 Away",
"Intl G4 Home", "Intl G4 Away",
"Intl Bye 1", "Intl Bye 2",
],
phases: [
{
name: "United States",
groups: [
{
name: "U.S. Winner's Bracket",
roundMatchNumbers: {
"Opening Round": [1, 2, 3, 4],
"Winners Round 2": [1, 2],
"Winners Semifinals": [1, 2],
"Winners Final": [1],
},
},
{
name: "U.S. Elimination Bracket",
roundMatchNumbers: {
"Elimination Round 1": [1, 2],
"Elimination Round 2": [1, 2],
"Elimination Round 3": [1, 2],
"Elimination Round 4": [1],
"Elimination Final": [1],
},
},
{
name: "U.S. Championship",
roundMatchNumbers: { "Bracket Championship": [1] },
},
],
},
{
name: "International",
groups: [
{
name: "International Winner's Bracket",
roundMatchNumbers: {
"Opening Round": [5, 6, 7, 8],
"Winners Round 2": [3, 4],
"Winners Semifinals": [3, 4],
"Winners Final": [2],
},
},
{
name: "International Elimination Bracket",
roundMatchNumbers: {
"Elimination Round 1": [3, 4],
"Elimination Round 2": [3, 4],
"Elimination Round 3": [3, 4],
"Elimination Round 4": [2],
"Elimination Final": [2],
},
},
{
name: "International Championship",
roundMatchNumbers: { "Bracket Championship": [2] },
},
],
},
{
name: "Championship",
rounds: ["Consolation Third Place", "World Championship"],
},
],
};
/** /**
* All available bracket templates * All available bracket templates
*/ */
@ -923,8 +1241,10 @@ export const BRACKET_TEMPLATES: Record<string, BracketTemplate> = {
afl_10: AFL_10, afl_10: AFL_10,
fifa_48: FIFA_48, fifa_48: FIFA_48,
darts_128: DARTS_128, darts_128: DARTS_128,
tennis_128: TENNIS_128,
cfp_12: CFP_12, cfp_12: CFP_12,
nba_20: NBA_20, nba_20: NBA_20,
llws_20: LLWS_20,
}; };
/** /**
@ -980,6 +1300,18 @@ export function getScoringRoundType(
const round = template.rounds.find((r) => r.name === roundName); const round = template.rounds.find((r) => r.name === roundName);
if (!round || !round.isScoring) return null; if (!round || !round.isScoring) return null;
// Special handling for LLWS double elimination: match counts don't identify the
// tier (Elimination Round 4 and the Elimination Final both have 2 matches), and
// the Winners Final eliminates nobody.
if (template.id === "llws_20") {
if (roundName === "Elimination Round 4") return "quarterfinals"; // losers share 7-8th
if (roundName === "Elimination Final") return "quarterfinals"; // losers share 5-6th
if (roundName === "Bracket Championship") return "semifinals"; // losers play for 3-4th
if (roundName === "Consolation Third Place") return "semifinals"; // finalizes 3rd/4th
if (roundName === "World Championship") return "finals"; // 1st and 2nd
return null; // Winners Final: loser drops to the elimination bracket, nobody is out
}
// Special handling for AFL finals // Special handling for AFL finals
if (template.id === "afl_10") { if (template.id === "afl_10") {
if (roundName === "Elimination Finals") return "quarterfinals"; // Losers share 7-8th if (roundName === "Elimination Finals") return "quarterfinals"; // Losers share 7-8th

View file

@ -16,7 +16,9 @@
* clear the field. Always write the result to a separate hidden input, not back to * clear the field. Always write the result to a separate hidden input, not back to
* the datetime-local input itself. * the datetime-local input itself.
*/ */
import { format, parseISO } from "date-fns"; 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"). * Format a YYYY-MM-DD date string for display (e.g. "Apr 9").
@ -25,7 +27,8 @@ import { format, parseISO } from "date-fns";
export function formatEventDate(dateStr: string | null | undefined): string | null { export function formatEventDate(dateStr: string | null | undefined): string | null {
if (!dateStr) return null; if (!dateStr) return null;
try { try {
return format(parseISO(dateStr), "MMM d"); const [year, month, day] = dateStr.split("-").map(Number);
return format(new Date(year, month - 1, day), "MMM d");
} catch { } catch {
return null; return null;
} }
@ -56,3 +59,28 @@ export function localDateTimeToUtcIso(value: string | null | undefined): string
if (isNaN(date.getTime())) return null; if (isNaN(date.getTime())) return null;
return date.toISOString(); 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,87 @@
/**
* Decides which server-side errors are worth sending to Sentry.
*
* Automated scanners probe for CMS paths that have never existed here
* (`/blog/wp/v2/posts/999999`, `/wp-login.php`, a bare `POST /`). React Router
* throws for each one a 404 when no route matches, a 405 when a route has no
* `action` and every throw reaches `handleError` in `app/entry.server.tsx`.
* Reporting those burns the Sentry quota without ever describing a real bug.
*/
import { isRouteErrorResponse } from "react-router";
/**
* Statuses React Router uses to say "nothing here matched this request":
* 404 when no route matches the URL, 405 when the route has no `action` or the
* method is invalid. Its other internal statuses (400 "did not provide a
* `loader`", 403 "Route does not match URL") describe a misconfigured route
* rather than an unrecognised request, so those keep reporting.
*/
const UNMATCHED_REQUEST_STATUSES = new Set([404, 405]);
/**
* Static assets 404 in bulk for reasons that are never actionable: scanners
* guessing filenames, and clients running stale HTML that still references the
* previous deploy's hashed bundles.
*/
const ASSET_EXT_RE =
/\.(css|js|mjs|map|png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|eot)$/i;
/** React Router stamps `internal: true` on the errors it generates itself. */
function isInternalRouterError(error: unknown): boolean {
return (error as { internal?: unknown }).internal === true;
}
/**
* True when the request was linked from a page on this same site.
*
* Compares host rather than origin on purpose. Production terminates TLS
* upstream and serves plain HTTP in the container, so `request.url` which
* `@react-router/express` builds from `req.protocol` says `http` while the
* browser sends an `https` referer. Comparing full origins would therefore
* never match in production. (`app/routes/leagues/$leagueId.server.ts` works
* around the same mismatch for invite URLs.) Protocol tells us nothing about
* whether the link was ours; host does.
*/
function hasSameHostReferer(request: Request): boolean {
const referer = request.headers.get("referer");
if (!referer) return false;
try {
return new URL(referer).host === new URL(request.url).host;
} catch {
// Scanners send garbage in this header; a referer we can't parse isn't ours.
return false;
}
}
/**
* Whether `error` should be reported to Sentry.
*
* Drops the 404s and 405s React Router generated for a request that matched
* nothing. Everything else is reported: real exceptions, 5xx, React Router's
* other internal statuses, and responses the app threw deliberately
* (`internal: false`), so a 403 from an ownership check still shows up.
*
* The exception is a request carrying a same-host `Referer`: a 404 reached from
* one of our own pages is a broken internal link, not a scanner, and stays
* visible in Sentry. Asset paths are excluded from that exception a stale
* client requesting last deploy's bundle sends a same-host referer too, and
* would otherwise spike Sentry on every release.
*/
export function shouldReportServerError(
error: unknown,
request: Request,
): boolean {
if (!isRouteErrorResponse(error)) return true;
if (!isInternalRouterError(error)) return true;
if (!UNMATCHED_REQUEST_STATUSES.has(error.status)) return true;
let pathname: string;
try {
pathname = new URL(request.url).pathname;
} catch {
pathname = "";
}
if (ASSET_EXT_RE.test(pathname)) return false;
return hasSameHostReferer(request);
}

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",
};

194
app/lib/llws-bracket.ts Normal file
View file

@ -0,0 +1,194 @@
/**
* LLWS 20-team double-elimination routing the pure half of the bracket.
*
* Lives in lib/ rather than models/ because the renderer needs it: models/playoff-match
* pulls in the database context and drizzle, which must not reach the browser bundle.
* models/playoff-match re-exports everything here, so server-side callers are unchanged.
*/
import { llwsMatchNumber, llwsSideAndLocal } from "~/lib/bracket-templates";
/**
* Where one participant goes after an LLWS match: a round, a side-local match number,
* and which slot to fill. `null` means eliminated (or, for winners, no further game).
*/
interface LLWSDestination {
round: string;
localMatch: number;
slot: "participant1Id" | "participant2Id";
}
/**
* LLWS advancement map, in SIDE-LOCAL match numbers.
*
* Keyed by round, then by the local match number of the completed game. Each entry
* says where the winner goes and where the loser goes (null = eliminated).
*
* Verified game-by-game against the official 2026 LLBWS bracket. Note the deliberate
* cross-overs the elimination bracket does NOT feed straight across:
* Elim R1: L(Opening m2) v L(Opening m3) and L(Opening m1) v L(Opening m4)
* Elim R3: L(Semi m1) v W(Elim R2 m2) and L(Semi m2) v W(Elim R2 m1)
* Elim R4: W(Elim R3 m1) v W(Elim R3 m2)
*
* A loss in the winners bracket routes into the elimination bracket rather than
* eliminating the team; a loss in the elimination bracket is final.
*/
const LLWS_ADVANCEMENT: Record<
string,
Record<number, { winner: LLWSDestination | null; loser: LLWSDestination | null }>
> = {
"Opening Round": {
1: {
winner: { round: "Winners Round 2", localMatch: 1, slot: "participant2Id" },
loser: { round: "Elimination Round 1", localMatch: 2, slot: "participant1Id" },
},
2: {
winner: { round: "Winners Round 2", localMatch: 2, slot: "participant2Id" },
loser: { round: "Elimination Round 1", localMatch: 1, slot: "participant1Id" },
},
3: {
winner: { round: "Winners Semifinals", localMatch: 1, slot: "participant1Id" },
loser: { round: "Elimination Round 1", localMatch: 1, slot: "participant2Id" },
},
4: {
winner: { round: "Winners Semifinals", localMatch: 2, slot: "participant2Id" },
loser: { round: "Elimination Round 1", localMatch: 2, slot: "participant2Id" },
},
},
"Winners Round 2": {
1: {
winner: { round: "Winners Semifinals", localMatch: 1, slot: "participant2Id" },
loser: { round: "Elimination Round 2", localMatch: 1, slot: "participant1Id" },
},
2: {
winner: { round: "Winners Semifinals", localMatch: 2, slot: "participant1Id" },
loser: { round: "Elimination Round 2", localMatch: 2, slot: "participant1Id" },
},
},
"Winners Semifinals": {
1: {
winner: { round: "Winners Final", localMatch: 1, slot: "participant1Id" },
loser: { round: "Elimination Round 3", localMatch: 1, slot: "participant1Id" },
},
2: {
winner: { round: "Winners Final", localMatch: 1, slot: "participant2Id" },
loser: { round: "Elimination Round 3", localMatch: 2, slot: "participant1Id" },
},
},
"Winners Final": {
1: {
winner: { round: "Bracket Championship", localMatch: 1, slot: "participant1Id" },
// A winners-bracket final loss is not an elimination — it drops to the
// Elimination Final for a second chance at the side championship.
loser: { round: "Elimination Final", localMatch: 1, slot: "participant1Id" },
},
},
"Elimination Round 1": {
1: {
winner: { round: "Elimination Round 2", localMatch: 1, slot: "participant2Id" },
loser: null,
},
2: {
winner: { round: "Elimination Round 2", localMatch: 2, slot: "participant2Id" },
loser: null,
},
},
"Elimination Round 2": {
// Cross-over: R2 m1's winner meets the OTHER semifinal loser.
1: {
winner: { round: "Elimination Round 3", localMatch: 2, slot: "participant2Id" },
loser: null,
},
2: {
winner: { round: "Elimination Round 3", localMatch: 1, slot: "participant2Id" },
loser: null,
},
},
"Elimination Round 3": {
// The later game (m2) is printed on top: G32 = W28 v W26, G31 = W27 v W25.
1: {
winner: { round: "Elimination Round 4", localMatch: 1, slot: "participant2Id" },
loser: null,
},
2: {
winner: { round: "Elimination Round 4", localMatch: 1, slot: "participant1Id" },
loser: null,
},
},
"Elimination Round 4": {
1: {
winner: { round: "Elimination Final", localMatch: 1, slot: "participant2Id" },
loser: null,
},
},
"Elimination Final": {
1: {
winner: { round: "Bracket Championship", localMatch: 1, slot: "participant2Id" },
loser: null,
},
},
};
/** Rounds whose losers drop into the elimination bracket instead of going out. */
export const LLWS_LOSER_ADVANCES_ROUNDS = new Set([
"Opening Round",
"Winners Round 2",
"Winners Semifinals",
]);
/** A resolved LLWS destination, in global (not side-local) match numbers. */
export interface LLWSResolvedDestination {
round: string;
matchNumber: number;
slot: "participant1Id" | "participant2Id";
}
/**
* Resolve where the winner and loser of a completed LLWS match go, in global match
* numbers. `null` means that participant has no further game (eliminated, or the
* tournament is over for them).
*
* Pure no DB access so the whole 38-game routing can be verified against the
* official bracket in tests. advanceLLWSWinner is a thin writer on top of this.
*/
export function resolveLLWSAdvancement(
round: string,
matchNumber: number
): { winner: LLWSResolvedDestination | null; loser: LLWSResolvedDestination | null } {
// Terminal rounds — nobody advances.
if (round === "Consolation Third Place" || round === "World Championship") {
return { winner: null, loser: null };
}
// Bracket Championship is the crossover: the winner goes to the World Championship
// and the loser to the Consolation game. The side fixes the slot in both (U.S. takes
// participant1, International participant2), so the two sides can't collide.
if (round === "Bracket Championship") {
const { side } = llwsSideAndLocal("Bracket Championship", matchNumber);
const slot: "participant1Id" | "participant2Id" =
side === 0 ? "participant1Id" : "participant2Id";
return {
winner: { round: "World Championship", matchNumber: 1, slot },
loser: { round: "Consolation Third Place", matchNumber: 1, slot },
};
}
const roundMap = LLWS_ADVANCEMENT[round];
if (!roundMap) {
throw new Error(`Round '${round}' is not part of the LLWS bracket`);
}
const { side, localMatch } = llwsSideAndLocal(round, matchNumber);
const routes = roundMap[localMatch];
if (!routes) {
throw new Error(`No LLWS advancement defined for ${round} match ${matchNumber}`);
}
// Winner and loser stay on their own side, so the same side offset applies to both.
const toGlobal = (d: LLWSDestination | null): LLWSResolvedDestination | null =>
d === null
? null
: { round: d.round, matchNumber: llwsMatchNumber(d.round, side, d.localMatch), slot: d.slot };
return { winner: toGlobal(routes.winner), loser: toGlobal(routes.loser) };
}

View file

@ -1,9 +1,15 @@
/** /**
* Normalize a team name for fuzzy matching across data sources. * Normalize a team/participant name for fuzzy matching across data sources.
* Lowercases, trims, and collapses whitespace. * Folds accents (so "Stéfanos Tsitsipás" matches a plain "Stefanos Tsitsipas"),
* lowercases, trims, and collapses whitespace.
*/ */
export function normalizeTeamName(name: string): string { 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

@ -7,7 +7,8 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { AFL_10, getScoringRoundType } from "~/lib/bracket-templates"; import { AFL_10, getScoringRoundType } from "~/lib/bracket-templates";
import { calculateFantasyPoints, calculateAveragedPoints, type ScoringRules } from "../scoring-rules"; import { calculateFantasyPoints, calculateAveragedPoints, calculateBracketPoints, type ScoringRules } from "../scoring-rules";
import { getBracketEntryFloor } from "../scoring-calculator";
const DEFAULT_SCORING: ScoringRules = { const DEFAULT_SCORING: ScoringRules = {
pointsFor1st: 100, pointsFor1st: 100,
@ -206,3 +207,69 @@ describe("AFL Finals System - Phase 3.3", () => {
}); });
}); });
}); });
describe("AFL guaranteed floors from seeding (afl_10)", () => {
const byName = (name: string) => AFL_10.rounds.find((r) => r.name === name);
describe("getBracketEntryFloor — banked the moment the bracket is set", () => {
it("gives seeds 1-4 the 5th-6th tier: the double chance is locked in at seeding", () => {
// Worst case for a top-4 seed is lose the Qualifying Final, then lose the
// Semi-Final — which is the 5th-6th tier. They can never finish below it.
expect(getBracketEntryFloor("Qualifying Finals", "afl_10")).toBe(5);
expect(calculateBracketPoints(5, DEFAULT_SCORING, "afl_10")).toBe(25);
});
it("gives seeds 5-6 the 7th-8th tier: they are seeded straight into a scoring round", () => {
expect(getBracketEntryFloor("Elimination Finals", "afl_10")).toBe(7);
expect(calculateBracketPoints(7, DEFAULT_SCORING, "afl_10")).toBe(15);
});
it("gives seeds 7-10 nothing: a Wildcard loss is worth 0", () => {
expect(getBracketEntryFloor("Wildcard Round", "afl_10")).toBeNull();
});
it("returns null when the template is unknown or missing", () => {
expect(getBracketEntryFloor("Qualifying Finals", null)).toBeNull();
expect(getBracketEntryFloor("Qualifying Finals", "not_a_template")).toBeNull();
});
it("does not hand out floors for TBD rounds nobody is seeded into yet", () => {
// These rounds do carry a loser tier, but every slot is empty at generation,
// so applyBracketEntryFloors has no participant to write against.
expect(byName("Semi-Finals")?.entryFloor).toBeUndefined();
expect(byName("Preliminary Finals")?.entryFloor).toBeUndefined();
});
});
describe("nonScoringWinnerFloor — the generic top-8 default is wrong for both AFL non-scoring rounds", () => {
it("Qualifying Finals winners bank 3, not 5 — the bye means a Prelim loss is 3rd-4th", () => {
expect(byName("Qualifying Finals")?.nonScoringWinnerFloor).toBe(3);
});
it("Wildcard winners bank 7, not 5 — winning only buys an Elimination Final", () => {
expect(byName("Wildcard Round")?.nonScoringWinnerFloor).toBe(7);
});
});
describe("floors only ever improve along every AFL path", () => {
const pts = (position: number) => calculateBracketPoints(position, DEFAULT_SCORING, "afl_10");
it("top-4 seed: entry 5 → QF win 3 → PF win 2 → GF win 1", () => {
expect(pts(5)).toBeLessThan(pts(3));
expect(pts(3)).toBeLessThan(pts(2));
expect(pts(2)).toBeLessThan(pts(1));
});
it("top-4 seed losing the QF holds the entry floor, then finalizes at 5th-6th", () => {
// QF losers advance to the Semi-Final, so nothing is written at the QF —
// the entry floor of 5 carries them until the Semi-Final resolves.
const entryFloor = getBracketEntryFloor("Qualifying Finals", "afl_10");
expect(entryFloor).toBe(5);
expect(pts(entryFloor ?? 0)).toBe(25); // unchanged by the loss
});
it("seeds 5-6 and Wildcard winners share a 7th-8th floor, below the top-4's", () => {
expect(pts(7)).toBeLessThan(pts(5));
});
});
});

View file

@ -0,0 +1,273 @@
/**
* Advancing an AFL Elimination Finals winner into the Semi-Finals.
*
* Unlike the Wildcard Round, this pathway is fixed: Elimination Final n feeds Semi-Final
* n. The crossover comes a round later, at Semi-Finals Preliminary Finals, so that a
* Qualifying Final loser cannot meet the side that just beat it.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { AFL_10 } from "~/lib/bracket-templates";
interface MatchRow {
id: string;
scoringEventId: string;
round: string;
matchNumber: number;
participant1Id: string | null;
participant2Id: string | null;
isComplete: boolean;
winnerId: string | null;
loserId: string | null;
}
let rows: MatchRow[] = [];
/**
* The literal values drizzle put in a where clause (`eq(col, value)`), which is all this
* mock needs to tell one lookup from another there is no query engine behind it.
*/
function whereValues(node: unknown, depth = 0): string[] {
if (!node || depth > 10) return [];
if (Array.isArray(node)) return node.flatMap((child) => whereValues(child, depth + 1));
if (typeof node !== "object") return [];
const obj = node as Record<string, unknown>;
const own = typeof obj.value === "string" ? [obj.value] : [];
return [...own, ...whereValues(obj.queryChunks, depth + 1)];
}
const db = {
query: {
playoffMatches: {
findFirst: vi.fn(({ where }: { where: unknown }) => {
const values = whereValues(where);
return Promise.resolve(rows.find((r) => values.includes(r.id)));
}),
findMany: vi.fn(({ where }: { where: unknown }) => {
const values = whereValues(where);
return Promise.resolve(
rows
.filter((r) => values.includes(r.scoringEventId) && values.includes(r.round))
.toSorted((a, b) => a.matchNumber - b.matchNumber)
);
}),
},
},
update: vi.fn(() => ({
set: (data: Partial<MatchRow>) => {
const applyTo = (where: unknown) => {
const values = whereValues(where);
const target = rows.find((r) => values.includes(r.id));
if (target) Object.assign(target, data);
return target;
};
// Advancement writes through the query builder with and without .returning().
return {
where: (where: unknown) => {
const applied = Promise.resolve([applyTo(where)]);
return Object.assign(applied, { returning: () => applied });
},
};
},
})),
// No rollback: the tests assert the writes that were attempted, in order.
transaction: vi.fn((fn: (tx: typeof db) => Promise<unknown>) => fn(db)),
};
vi.mock("~/database/context", () => ({ database: () => db }));
const { advanceWinnerTemplate, reseedAflSemiFinals } = await import("../playoff-match");
const EVENT = "event-1";
/**
* The real 2026 finals, which is what surfaced the crossover bug. Ladder: 1 Fremantle,
* 2 Sydney, 3 Brisbane, 4 Hawthorn, 5 Geelong, 6 Adelaide, 7 Melbourne, 8 Bulldogs,
* 9 Collingwood, 10 Carlton. Carlton (10th) and the Bulldogs (8th) came through the
* Wildcard Round, so 5th hosts Carlton and 6th hosts the Bulldogs.
*/
const FREO = "fremantle";
const SYDNEY = "sydney";
const BRISBANE = "brisbane";
const HAWTHORN = "hawthorn";
const GEELONG = "geelong";
const ADELAIDE = "adelaide";
const BULLDOGS = "bulldogs";
const CARLTON = "carlton";
/** An afl_10 bracket with week one played: Freo and Brisbane lost their Qualifying Finals. */
function bracket(): MatchRow[] {
const base = { scoringEventId: EVENT, isComplete: false, winnerId: null, loserId: null };
return [
{ ...base, id: "qf1", round: "Qualifying Finals", matchNumber: 1, participant1Id: FREO, participant2Id: HAWTHORN, isComplete: true, winnerId: HAWTHORN, loserId: FREO },
{ ...base, id: "qf2", round: "Qualifying Finals", matchNumber: 2, participant1Id: SYDNEY, participant2Id: BRISBANE, isComplete: true, winnerId: SYDNEY, loserId: BRISBANE },
{ ...base, id: "ef1", round: "Elimination Finals", matchNumber: 1, participant1Id: GEELONG, participant2Id: CARLTON },
{ ...base, id: "ef2", round: "Elimination Finals", matchNumber: 2, participant1Id: ADELAIDE, participant2Id: BULLDOGS },
// Filled by the Qualifying Final losers, as advancement already does.
{ ...base, id: "sf1", round: "Semi-Finals", matchNumber: 1, participant1Id: FREO, participant2Id: null },
{ ...base, id: "sf2", round: "Semi-Finals", matchNumber: 2, participant1Id: BRISBANE, participant2Id: null },
{ ...base, id: "pf1", round: "Preliminary Finals", matchNumber: 1, participant1Id: HAWTHORN, participant2Id: null },
{ ...base, id: "pf2", round: "Preliminary Finals", matchNumber: 2, participant1Id: SYDNEY, participant2Id: null },
];
}
function row(id: string): MatchRow {
const found = rows.find((r) => r.id === id);
if (!found) throw new Error(`No such match ${id}`);
return found;
}
/** Record a result the way setMatchWinner does, then advance it. */
async function win(id: string, winnerId: string) {
const match = row(id);
match.winnerId = winnerId;
match.loserId = match.participant1Id === winnerId ? match.participant2Id : match.participant1Id;
match.isComplete = true;
await advanceWinnerTemplate(id, winnerId, AFL_10);
}
const pairing = () => ({
sf1: [row("sf1").participant1Id, row("sf1").participant2Id],
sf2: [row("sf2").participant1Id, row("sf2").participant2Id],
});
beforeEach(() => {
rows = bracket();
vi.clearAllMocks();
});
describe("Elimination Finals → Semi-Finals advancement", () => {
it("feeds Elimination Final 1 into Semi-Final 1", async () => {
await win("ef1", GEELONG);
expect(row("sf1").participant2Id).toBe(GEELONG);
expect(row("sf2").participant2Id).toBeNull();
});
it("feeds Elimination Final 2 into Semi-Final 2", async () => {
await win("ef2", ADELAIDE);
expect(row("sf2").participant2Id).toBe(ADELAIDE);
expect(row("sf1").participant2Id).toBeNull();
});
it("draws the real 2026 Semi-Finals: Freo v Geelong and Brisbane v Adelaide", async () => {
await win("ef1", GEELONG);
await win("ef2", ADELAIDE);
expect(pairing()).toEqual({
sf1: [FREO, GEELONG],
sf2: [BRISBANE, ADELAIDE],
});
});
it("draws the same Semi-Finals whichever order the results are entered", async () => {
await win("ef2", ADELAIDE);
await win("ef1", GEELONG);
expect(pairing()).toEqual({
sf1: [FREO, GEELONG],
sf2: [BRISBANE, ADELAIDE],
});
});
it("keeps the Preliminary Finals crossover so a QF loser dodges the side that beat it", async () => {
await win("ef1", GEELONG);
await win("ef2", ADELAIDE);
// Freo (lost QF1 to Hawthorn) wins its semi, so it must land in Sydney's Prelim.
await win("sf1", FREO);
expect(row("pf2").participant2Id).toBe(FREO);
expect(row("pf1").participant2Id).toBeNull();
});
it("pulls the beaten team back out when an Elimination Final result is corrected", async () => {
await win("ef1", GEELONG);
expect(row("sf1").participant2Id).toBe(GEELONG);
await win("ef1", CARLTON);
expect(row("sf1").participant2Id).toBe(CARLTON);
expect(row("sf2").participant2Id).toBeNull();
});
});
describe("reseedAflSemiFinals", () => {
it("repairs a bracket left crossed by the old fixed crossover", async () => {
// What advancement wrote before the fix: EF1 winner into SF2, EF2 winner into SF1.
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
Object.assign(row("ef2"), { isComplete: true, winnerId: ADELAIDE, loserId: BULLDOGS });
row("sf1").participant2Id = ADELAIDE;
row("sf2").participant2Id = GEELONG;
const reseed = await reseedAflSemiFinals(EVENT);
expect(pairing()).toEqual({
sf1: [FREO, GEELONG],
sf2: [BRISBANE, ADELAIDE],
});
expect(reseed.vacated.toSorted()).toEqual([1, 2]);
expect(reseed.filled.toSorted((a, b) => a.matchNumber - b.matchNumber)).toEqual([
{ matchNumber: 1, participantId: GEELONG },
{ matchNumber: 2, participantId: ADELAIDE },
]);
});
it("writes nothing when the pairings are already right", async () => {
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
Object.assign(row("ef2"), { isComplete: true, winnerId: ADELAIDE, loserId: BULLDOGS });
row("sf1").participant2Id = GEELONG;
row("sf2").participant2Id = ADELAIDE;
const reseed = await reseedAflSemiFinals(EVENT);
expect(reseed).toEqual({ vacated: [], filled: [] });
expect(db.update).not.toHaveBeenCalled();
});
it("leaves an undecided Elimination Final's slot TBD", async () => {
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
await reseedAflSemiFinals(EVENT);
expect(row("sf1").participant2Id).toBe(GEELONG);
expect(row("sf2").participant2Id).toBeNull();
});
it("refuses a slot held by someone who never played an Elimination Final", async () => {
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
row("sf1").participant2Id = SYDNEY;
await expect(reseedAflSemiFinals(EVENT)).rejects.toThrow("SF 1 participant2 already filled");
});
it("refuses to move a qualifier out of a Semi-Final that has been played", async () => {
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
Object.assign(row("sf1"), {
participant2Id: ADELAIDE,
isComplete: true,
winnerId: FREO,
loserId: ADELAIDE,
});
await expect(reseedAflSemiFinals(EVENT)).rejects.toThrow(
"Semi-Finals match 1 already has a recorded result"
);
});
it("rejects an Elimination Final winner who is not one of its participants", async () => {
Object.assign(row("ef1"), { isComplete: true, winnerId: SYDNEY, loserId: CARLTON });
await expect(reseedAflSemiFinals(EVENT)).rejects.toThrow(
"Elimination Finals match 1 winner is not one of its participants"
);
});
it("throws on an event with no Semi-Finals to re-seed", async () => {
rows = rows.filter((r) => r.round !== "Semi-Finals");
await expect(reseedAflSemiFinals(EVENT)).rejects.toThrow(
"no AFL Elimination Finals / Semi-Finals matches to re-seed"
);
});
});

View file

@ -0,0 +1,271 @@
/**
* Advancing an AFL Wildcard Round winner into the Elimination Finals.
*
* The two winners are re-seeded by ladder position 5th hosts the lower-ranked winner,
* 6th the higher-ranked one so the destination is not a fixed crossover from a given
* Wildcard match, and results can be recorded in either order.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { AFL_10 } from "~/lib/bracket-templates";
interface MatchRow {
id: string;
scoringEventId: string;
round: string;
matchNumber: number;
participant1Id: string | null;
participant2Id: string | null;
isComplete: boolean;
winnerId: string | null;
loserId: string | null;
}
let rows: MatchRow[] = [];
/**
* The literal values drizzle put in a where clause (`eq(col, value)`), which is all this
* mock needs to tell one lookup from another there is no query engine behind it.
*/
function whereValues(node: unknown, depth = 0): string[] {
if (!node || depth > 10) return [];
if (Array.isArray(node)) return node.flatMap((child) => whereValues(child, depth + 1));
if (typeof node !== "object") return [];
const obj = node as Record<string, unknown>;
const own = typeof obj.value === "string" ? [obj.value] : [];
return [...own, ...whereValues(obj.queryChunks, depth + 1)];
}
const db = {
query: {
playoffMatches: {
findFirst: vi.fn(({ where }: { where: unknown }) => {
const values = whereValues(where);
return Promise.resolve(rows.find((r) => values.includes(r.id)));
}),
findMany: vi.fn(({ where }: { where: unknown }) => {
const values = whereValues(where);
return Promise.resolve(
rows
.filter((r) => values.includes(r.scoringEventId) && values.includes(r.round))
.toSorted((a, b) => a.matchNumber - b.matchNumber)
);
}),
},
},
update: vi.fn(() => ({
set: (data: Partial<MatchRow>) => {
const applyTo = (where: unknown) => {
const values = whereValues(where);
const target = rows.find((r) => values.includes(r.id));
if (target) Object.assign(target, data);
return target;
};
// Advancement writes through the query builder with and without .returning().
return {
where: (where: unknown) => {
const applied = Promise.resolve([applyTo(where)]);
return Object.assign(applied, { returning: () => applied });
},
};
},
})),
// No rollback: the tests assert the writes that were attempted, in order.
transaction: vi.fn((fn: (tx: typeof db) => Promise<unknown>) => fn(db)),
};
vi.mock("~/database/context", () => ({ database: () => db }));
const { advanceWinnerTemplate, reseedAflEliminationFinals } = await import("../playoff-match");
const EVENT = "event-1";
/** Ladder seed n → participant id. */
const seed = (n: number) => `seed-${n}`;
/** A freshly generated afl_10 Wildcard Round (7v10, 8v9) and Elimination Finals (5, 6). */
function bracket(): MatchRow[] {
const base = { scoringEventId: EVENT, isComplete: false, winnerId: null, loserId: null };
return [
{ ...base, id: "wc1", round: "Wildcard Round", matchNumber: 1, participant1Id: seed(7), participant2Id: seed(10) },
{ ...base, id: "wc2", round: "Wildcard Round", matchNumber: 2, participant1Id: seed(8), participant2Id: seed(9) },
{ ...base, id: "ef1", round: "Elimination Finals", matchNumber: 1, participant1Id: seed(5), participant2Id: null },
{ ...base, id: "ef2", round: "Elimination Finals", matchNumber: 2, participant1Id: seed(6), participant2Id: null },
];
}
function row(id: string): MatchRow {
const found = rows.find((r) => r.id === id);
if (!found) throw new Error(`No such match ${id}`);
return found;
}
/** Record a Wildcard result the way setMatchWinner does, then advance it. */
async function winWildcard(id: string, winnerId: string) {
const match = row(id);
match.winnerId = winnerId;
match.loserId = match.participant1Id === winnerId ? match.participant2Id : match.participant1Id;
match.isComplete = true;
await advanceWinnerTemplate(id, winnerId, AFL_10);
}
describe("AFL Wildcard Round advancement", () => {
beforeEach(() => {
rows = bracket();
});
it("sends 5th the lower-ranked winner and 6th the higher-ranked one", async () => {
await winWildcard("wc1", seed(7));
await winWildcard("wc2", seed(8));
expect(row("ef1").participant2Id).toBe(seed(8));
expect(row("ef2").participant2Id).toBe(seed(7));
});
it("re-seeds when the lower seed wins through", async () => {
// The reported bug: 10th beating 7th used to be crossed straight to 6th, leaving
// 5th with the better survivor.
await winWildcard("wc1", seed(10));
await winWildcard("wc2", seed(8));
expect(row("ef1").participant2Id).toBe(seed(10));
expect(row("ef2").participant2Id).toBe(seed(8));
});
it("re-seeds a 9th-placed winner above a 10th-placed one", async () => {
await winWildcard("wc1", seed(10));
await winWildcard("wc2", seed(9));
expect(row("ef1").participant2Id).toBe(seed(10));
expect(row("ef2").participant2Id).toBe(seed(9));
});
it("places the same pairings whichever result is entered first", async () => {
await winWildcard("wc2", seed(8));
await winWildcard("wc1", seed(10));
expect(row("ef1").participant2Id).toBe(seed(10));
expect(row("ef2").participant2Id).toBe(seed(8));
});
it("places the 7v10 winner immediately, since its slot is settled either way", async () => {
await winWildcard("wc1", seed(7));
expect(row("ef2").participant2Id).toBe(seed(7));
expect(row("ef1").participant2Id).toBeNull();
});
it("holds an 8v9 winner back until the 7v10 game is decided", async () => {
// 8th and 9th sit between 7th and 10th, so placing one now could need undoing.
await winWildcard("wc2", seed(8));
expect(row("ef1").participant2Id).toBeNull();
expect(row("ef2").participant2Id).toBeNull();
});
it("does not disturb a winner it already placed", async () => {
await winWildcard("wc1", seed(7));
await winWildcard("wc2", seed(9));
expect(row("ef2").participant2Id).toBe(seed(7));
expect(row("ef1").participant2Id).toBe(seed(9));
});
it("refuses to overwrite a slot already holding someone else", async () => {
row("ef1").participant2Id = "stranger";
await expect(winWildcard("wc1", seed(10))).rejects.toThrow(/already filled/);
expect(row("ef1").participant2Id).toBe("stranger");
});
it("moves the winner when a recorded Wildcard result is corrected", async () => {
await winWildcard("wc1", seed(7));
expect(row("ef2").participant2Id).toBe(seed(7));
// The result was wrong: 10th won. 7th must not be left alive in the other slot.
await winWildcard("wc1", seed(10));
expect(row("ef1").participant2Id).toBe(seed(10));
expect(row("ef2").participant2Id).toBeNull();
});
it("re-seeds a pairing left behind by the old fixed crossover", async () => {
// Pre-fix state: the 7v10 winner was crossed to 6th whatever its ladder position.
row("wc1").winnerId = seed(10);
row("wc1").loserId = seed(7);
row("wc1").isComplete = true;
row("ef2").participant2Id = seed(10);
await winWildcard("wc2", seed(8));
expect(row("ef1").participant2Id).toBe(seed(10));
expect(row("ef2").participant2Id).toBe(seed(8));
});
it("swaps both winners when re-resolving an already-placed pair", async () => {
row("wc1").winnerId = seed(10);
row("wc1").loserId = seed(7);
row("wc1").isComplete = true;
row("ef2").participant2Id = seed(10);
row("ef1").participant2Id = seed(8);
await winWildcard("wc2", seed(8));
expect(row("ef1").participant2Id).toBe(seed(10));
expect(row("ef2").participant2Id).toBe(seed(8));
});
it("refuses to re-seed an Elimination Final that has already been played", async () => {
await winWildcard("wc1", seed(7));
Object.assign(row("ef2"), { isComplete: true, winnerId: seed(6), loserId: seed(7) });
await expect(winWildcard("wc1", seed(10))).rejects.toThrow(/already has a recorded result/);
expect(row("ef2").participant2Id).toBe(seed(7));
});
it("repairs an already-advanced bracket from the recorded results alone", async () => {
// What scripts/fix-afl-wildcard-reseed.ts does: no new result, just the rows a
// bracket advanced under the old fixed crossover left behind.
Object.assign(row("wc1"), { isComplete: true, winnerId: seed(10), loserId: seed(7) });
Object.assign(row("wc2"), { isComplete: true, winnerId: seed(8), loserId: seed(9) });
row("ef2").participant2Id = seed(10);
row("ef1").participant2Id = seed(8);
const reseed = await reseedAflEliminationFinals(EVENT);
expect(row("ef1").participant2Id).toBe(seed(10));
expect(row("ef2").participant2Id).toBe(seed(8));
expect(reseed.vacated.toSorted()).toEqual([1, 2]);
expect(reseed.filled.toSorted((a, b) => a.matchNumber - b.matchNumber)).toEqual([
{ matchNumber: 1, participantId: seed(10) },
{ matchNumber: 2, participantId: seed(8) },
]);
});
it("reports no change when a repair run finds the pairings correct", async () => {
await winWildcard("wc1", seed(7));
await winWildcard("wc2", seed(8));
db.transaction.mockClear();
const reseed = await reseedAflEliminationFinals(EVENT);
expect(reseed).toEqual({ vacated: [], filled: [] });
expect(db.transaction).not.toHaveBeenCalled();
});
it("rejects an event with no AFL bracket rather than reporting nothing to do", async () => {
await expect(reseedAflEliminationFinals("no-such-event")).rejects.toThrow(/no AFL Wildcard/);
});
it("leaves the bracket alone when the pairings are already right", async () => {
await winWildcard("wc1", seed(7));
await winWildcard("wc2", seed(8));
db.transaction.mockClear();
await winWildcard("wc2", seed(8));
expect(db.transaction).not.toHaveBeenCalled();
expect(row("ef1").participant2Id).toBe(seed(8));
expect(row("ef2").participant2Id).toBe(seed(7));
});
});

View file

@ -0,0 +1,243 @@
/**
* Entry-floor scoring: points a bracket guarantees at seeding time.
*
* Some seedings lock in a scoring tier before a single match is played. The AFL
* finals are the clearest case: a top-4 seed has the double chance, so losing the
* Qualifying Final still leaves them a Semi-Final, and losing that is the 5th-6th
* tier. Those teams must not sit on 0 fantasy points until their first game.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
interface MatchRow {
round: string;
participant1Id: string | null;
participant2Id: string | null;
}
/**
* Minimal db mock. applyBracketEntryFloors calls, in order:
* 1. db.query.scoringEvents.findFirst the event (for template + sportsSeasonId)
* 2. db.query.playoffMatches.findMany the bracket's match slots
* 3. upsertParticipantResult per floored participant findFirst + insert/update
*/
function makeDb(
event: { bracketTemplateId: string | null; sportsSeasonId: string } | null,
matches: MatchRow[],
existingByParticipant: Record<string, { id: string; finalPosition: number; isPartialScore: boolean }> = {}
) {
const existingRows = Object.entries(existingByParticipant).map(([participantId, row]) => ({
participantId,
finalPosition: row.finalPosition,
}));
const insertedRows: Array<Record<string, unknown>> = [];
const updatedRows: Array<Record<string, unknown>> = [];
return {
db: {
insert: vi.fn().mockReturnValue({
values: vi.fn().mockImplementation((values: Record<string, unknown>) => {
insertedRows.push(values);
return Promise.resolve();
}),
}),
update: vi.fn().mockReturnValue({
set: vi.fn().mockImplementation((values: Record<string, unknown>) => {
updatedRows.push(values);
return { where: vi.fn().mockResolvedValue(undefined) };
}),
}),
query: {
scoringEvents: { findFirst: vi.fn().mockResolvedValue(event) },
playoffMatches: { findMany: vi.fn().mockResolvedValue(matches) },
seasonParticipantResults: {
// The pre-pass that stops a floor from downgrading an existing placement.
findMany: vi.fn().mockResolvedValue(existingRows),
findFirst: vi.fn().mockImplementation((args: { where?: unknown }) => {
// Resolve by scanning the seeded map — the mock has no real query engine,
// so tests that need an existing row use a single-participant bracket.
void args;
const only = Object.values(existingByParticipant)[0];
return Promise.resolve(only);
}),
},
},
} as never,
insertedRows,
updatedRows,
};
}
import { applyBracketEntryFloors, getBracketEntryFloor } from "../scoring-calculator";
/** The AFL bracket exactly as generateAFL10Bracket writes it: later rounds are TBD. */
const AFL_BRACKET: MatchRow[] = [
{ round: "Wildcard Round", participant1Id: "seed7", participant2Id: "seed10" },
{ round: "Wildcard Round", participant1Id: "seed8", participant2Id: "seed9" },
{ round: "Qualifying Finals", participant1Id: "seed1", participant2Id: "seed4" },
{ round: "Qualifying Finals", participant1Id: "seed2", participant2Id: "seed3" },
{ round: "Elimination Finals", participant1Id: "seed5", participant2Id: null },
{ round: "Elimination Finals", participant1Id: "seed6", participant2Id: null },
{ round: "Semi-Finals", participant1Id: null, participant2Id: null },
{ round: "Semi-Finals", participant1Id: null, participant2Id: null },
{ round: "Preliminary Finals", participant1Id: null, participant2Id: null },
{ round: "Preliminary Finals", participant1Id: null, participant2Id: null },
{ round: "Grand Final", participant1Id: null, participant2Id: null },
];
describe("applyBracketEntryFloors", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("afl_10", () => {
it("banks 5 for the top 4 and 7 for seeds 5-6, and nothing for the wildcard teams", async () => {
const { db, insertedRows } = makeDb(
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
AFL_BRACKET
);
const applied = await applyBracketEntryFloors("event-1", db);
expect(applied).toBe(6);
const floors = Object.fromEntries(
insertedRows.map((r) => [r.participantId as string, r.finalPosition as number])
);
expect(floors).toEqual({
seed1: 5, seed2: 5, seed3: 5, seed4: 5, // double chance → 5th-6th tier
seed5: 7, seed6: 7, // seeded into the Elimination Finals
});
// Seeds 7-10 lose the Wildcard Round for 0, so nothing is guaranteed yet.
expect(floors).not.toHaveProperty("seed7");
expect(floors).not.toHaveProperty("seed10");
});
it("writes every floor as provisional so real results supersede it", async () => {
const { db, insertedRows } = makeDb(
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
AFL_BRACKET
);
await applyBracketEntryFloors("event-1", db);
expect(insertedRows.every((r) => r.isPartialScore === true)).toBe(true);
expect(insertedRows.every((r) => r.sportsSeasonId === "ss-1")).toBe(true);
});
it("leaves TBD slots alone — a Semi-Final nobody has reached grants nothing", async () => {
const { db, insertedRows } = makeDb(
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
[{ round: "Semi-Finals", participant1Id: null, participant2Id: null }]
);
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
expect(insertedRows).toHaveLength(0);
});
it("never downgrades a better placement — a finalist regenerating stays a finalist", async () => {
// clear-bracket → generate-bracket mid-tournament must not knock a team sitting
// on a 2nd-place floor back down to their 5th-6th seeding floor.
const { db, insertedRows, updatedRows } = makeDb(
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
[{ round: "Qualifying Finals", participant1Id: "seed1", participant2Id: null }],
{ seed1: { id: "row-1", finalPosition: 2, isPartialScore: true } }
);
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
expect(insertedRows).toHaveLength(0);
expect(updatedRows).toHaveLength(0);
});
it("treats position 0 as eliminated, not as a better placement", async () => {
// A 0 means "missed the bracket". Re-seeding a team into the bracket must still
// give them their floor rather than reading 0 as an unbeatable placement.
const { db, updatedRows } = makeDb(
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
[{ round: "Qualifying Finals", participant1Id: "seed1", participant2Id: null }],
{ seed1: { id: "row-1", finalPosition: 0, isPartialScore: true } }
);
expect(await applyBracketEntryFloors("event-1", db)).toBe(1);
expect(updatedRows).toHaveLength(1);
expect(updatedRows[0]).toMatchObject({ finalPosition: 5, isPartialScore: true });
});
it("does not un-finalize a participant who already has a real result", async () => {
// upsertParticipantResult's never-un-finalize guard: a finalized row must not be
// dragged back to a provisional floor when the bracket is regenerated.
const { db, insertedRows, updatedRows } = makeDb(
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
[{ round: "Qualifying Finals", participant1Id: "seed1", participant2Id: null }],
{ seed1: { id: "row-1", finalPosition: 1, isPartialScore: false } }
);
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
expect(insertedRows).toHaveLength(0);
expect(updatedRows).toHaveLength(0);
});
});
describe("other brackets", () => {
it("is a no-op for an event with no bracket template", async () => {
const { db, insertedRows } = makeDb(
{ bracketTemplateId: null, sportsSeasonId: "ss-1" },
AFL_BRACKET
);
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
expect(insertedRows).toHaveLength(0);
});
it("is a no-op when the event does not exist", async () => {
const { db } = makeDb(null, AFL_BRACKET);
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
});
it("grants nothing to an NBA bracket: every seeded round is non-scoring", async () => {
const { db, insertedRows } = makeDb(
{ bracketTemplateId: "nba_20", sportsSeasonId: "ss-1" },
[
{ round: "Play-In Round 1", participant1Id: "e7", participant2Id: "e8" },
{ round: "First Round", participant1Id: "e1", participant2Id: null },
]
);
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
expect(insertedRows).toHaveLength(0);
});
it("grants the T5-8 tier to a simple_8 field: every entrant is already in a scoring round", async () => {
const { db, insertedRows } = makeDb(
{ bracketTemplateId: "simple_8", sportsSeasonId: "ss-1" },
[{ round: "Quarterfinals", participant1Id: "a", participant2Id: "b" }]
);
expect(await applyBracketEntryFloors("event-1", db)).toBe(2);
expect(insertedRows.map((r) => r.finalPosition)).toEqual([5, 5]);
});
});
});
describe("getBracketEntryFloor", () => {
it("prefers a round's explicit entryFloor over its loser position", () => {
// Qualifying Finals is non-scoring, so only the explicit entryFloor makes it pay.
expect(getBracketEntryFloor("Qualifying Finals", "afl_10")).toBe(5);
});
it("falls back to a scoring round's own loser position", () => {
expect(getBracketEntryFloor("Quarterfinals", "simple_8")).toBe(5);
expect(getBracketEntryFloor("Semifinals", "simple_8")).toBe(3);
});
it("returns null for non-scoring rounds with no explicit floor", () => {
expect(getBracketEntryFloor("Wildcard Round", "afl_10")).toBeNull();
expect(getBracketEntryFloor("First Round", "nba_20")).toBeNull();
expect(getBracketEntryFloor("Round of 64", "ncaa_68")).toBeNull();
});
it("returns null for unknown rounds and templates", () => {
expect(getBracketEntryFloor("Not A Round", "afl_10")).toBeNull();
expect(getBracketEntryFloor("Quarterfinals", "not_a_template")).toBeNull();
expect(getBracketEntryFloor("Quarterfinals", null)).toBeNull();
});
});

View file

@ -0,0 +1,92 @@
import { describe, expect, it, vi } from "vitest";
import {
getBracketTemplateIdForSportsSeason,
getBracketTemplateIdsForSportsSeasons,
} from "../bracket-template";
/**
* A sports season can own several scoring events a bracket plus schedule events, or a
* re-created bracket alongside a stale one. Resolving the template from an arbitrary row
* is not harmless: calculateBracketPoints falls back to the flat 5th8th average when the
* template id is null, so losing "llws_20" makes a team locked into 5th6th and one
* locked into 7th8th both score 20.
*/
/**
* Minimal db stub. Applies the same filter and ordering the real query does, so the
* assertions exercise the helper's row-picking rather than re-stating the query.
*/
function makeDb(
rows: Array<{ sportsSeasonId: string; bracketTemplateId: string | null; createdAt: Date }>
) {
const findMany = vi.fn(async () =>
rows
.filter((row) => row.bracketTemplateId !== null)
.toSorted((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
);
return { db: { query: { scoringEvents: { findMany } } } as any, findMany };
}
describe("getBracketTemplateIdForSportsSeason", () => {
it("ignores a non-bracket event and returns the bracket event's template", async () => {
const { db } = makeDb([
{ sportsSeasonId: "ss1", bracketTemplateId: null, createdAt: new Date("2026-08-01") },
{ sportsSeasonId: "ss1", bracketTemplateId: "llws_20", createdAt: new Date("2026-07-01") },
]);
await expect(getBracketTemplateIdForSportsSeason("ss1", db)).resolves.toBe("llws_20");
});
it("takes the most recent bracket event when a stale one is still around", async () => {
const { db } = makeDb([
{ sportsSeasonId: "ss1", bracketTemplateId: "simple_16", createdAt: new Date("2026-06-01") },
{ sportsSeasonId: "ss1", bracketTemplateId: "llws_20", createdAt: new Date("2026-08-01") },
]);
await expect(getBracketTemplateIdForSportsSeason("ss1", db)).resolves.toBe("llws_20");
});
it("returns null when the season has no bracket event", async () => {
const { db } = makeDb([
{ sportsSeasonId: "ss1", bracketTemplateId: null, createdAt: new Date("2026-08-01") },
]);
await expect(getBracketTemplateIdForSportsSeason("ss1", db)).resolves.toBeNull();
});
});
describe("getBracketTemplateIdsForSportsSeasons", () => {
it("resolves each season independently in one query", async () => {
const { db, findMany } = makeDb([
{ sportsSeasonId: "ss1", bracketTemplateId: "llws_20", createdAt: new Date("2026-08-01") },
{ sportsSeasonId: "ss2", bracketTemplateId: "afl_10", createdAt: new Date("2026-08-02") },
{ sportsSeasonId: "ss3", bracketTemplateId: null, createdAt: new Date("2026-08-03") },
]);
const resolved = await getBracketTemplateIdsForSportsSeasons(["ss1", "ss2", "ss3"], db);
expect(resolved.get("ss1")).toBe("llws_20");
expect(resolved.get("ss2")).toBe("afl_10");
expect(resolved.get("ss3")).toBeNull();
expect(findMany).toHaveBeenCalledTimes(1);
});
it("gives every requested season an entry so callers can cache the miss", async () => {
const { db } = makeDb([]);
const resolved = await getBracketTemplateIdsForSportsSeasons(["ss1", "ss2"], db);
expect([...resolved.entries()]).toEqual([
["ss1", null],
["ss2", null],
]);
});
it("does not query at all for an empty season list", async () => {
const { db, findMany } = makeDb([]);
await expect(getBracketTemplateIdsForSportsSeasons([], db)).resolves.toEqual(new Map());
expect(findMany).not.toHaveBeenCalled();
});
});

View file

@ -1,6 +1,17 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { computeStage3ExitQP } from "../cs2-major-stage";
import { calculateSplitQualifyingPoints } from "../qualifying-points"; // 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> { function makeStage3QPConfig(): Map<number, number> {
const config = new 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 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,481 @@
/**
* LLWS 20-Team Double-Elimination Bracket Tests
*
* Verifies the llws_20 template against the official 2026 LLBWS bracket
* (Williamsport, Aug 1930). The PDF numbers its games 138; those numbers appear
* throughout as `G<n>` so the routing can be checked against the printed bracket.
*
* The critical property under test is the double-elimination loser routing: a loss in
* the winners bracket drops a team into the elimination bracket at a specific slot,
* while a loss in the elimination bracket is final.
*/
import { describe, it, expect, vi } from "vitest";
import {
LLWS_20,
getScoringRoundType,
llwsMatchNumber,
llwsSideAndLocal,
} from "~/lib/bracket-templates";
import {
doesLoserAdvance,
generateBracketFromTemplate,
resolveLLWSAdvancement,
} from "../playoff-match";
import {
calculateBracketPoints,
calculateAveragedPoints,
type ScoringRules,
} from "../scoring-rules";
import {
GAME_TO_MATCH,
EXPECTED_SLOTS,
gameNumberFor,
required,
destinationGame,
} from "~/test/fixtures/llws-bracket";
// generateBracketFromTemplate's only DB touch for llws_20 is the bulk insert, so a
// minimal stub is enough to capture the generated rows.
const insertedRows: Record<string, unknown>[] = [];
vi.mock("~/database/context", () => ({
database: () => ({
insert: () => ({
values: (rows: Record<string, unknown>[]) => ({
returning: async () => {
insertedRows.push(...rows);
return rows;
},
}),
}),
}),
}));
const DEFAULT_SCORING: ScoringRules = {
pointsFor1st: 100,
pointsFor2nd: 70,
pointsFor3rd: 50,
pointsFor4th: 40,
pointsFor5th: 25,
pointsFor6th: 20,
pointsFor7th: 15,
pointsFor8th: 10,
};
describe("LLWS 20 Bracket Template", () => {
describe("Template structure", () => {
it("has correct identity and size", () => {
expect(LLWS_20.id).toBe("llws_20");
expect(LLWS_20.totalTeams).toBe(20);
expect(LLWS_20.scoringStartsAtRound).toBe("Winners Final");
});
it("has 12 rounds totalling 38 matches", () => {
expect(LLWS_20.rounds).toHaveLength(12);
const total = LLWS_20.rounds.reduce((sum, r) => sum + r.matchCount, 0);
expect(total).toBe(38);
});
it("has the expected match count per round", () => {
const counts = Object.fromEntries(
LLWS_20.rounds.map((r) => [r.name, r.matchCount])
);
expect(counts).toEqual({
"Opening Round": 8,
"Winners Round 2": 4,
"Elimination Round 1": 4,
"Winners Semifinals": 4,
"Elimination Round 2": 4,
"Elimination Round 3": 4,
"Winners Final": 2,
"Elimination Round 4": 2,
"Elimination Final": 2,
"Bracket Championship": 2,
"Consolation Third Place": 1,
"World Championship": 1,
});
});
it("marks exactly the point-awarding rounds as scoring", () => {
const scoring = LLWS_20.rounds.filter((r) => r.isScoring).map((r) => r.name);
expect(scoring).toEqual([
"Winners Final",
"Elimination Round 4",
"Elimination Final",
"Bracket Championship",
"Consolation Third Place",
"World Championship",
]);
});
it("lists rounds in chronological order", () => {
// Elimination Round 1 (Aug 22) is played before Winners Semifinals (Aug 23).
const names = LLWS_20.rounds.map((r) => r.name);
expect(names.indexOf("Elimination Round 1")).toBeLessThan(
names.indexOf("Winners Semifinals")
);
expect(names.indexOf("Winners Final")).toBeLessThan(
names.indexOf("Elimination Final")
);
});
it("gives elimination-bracket winners a floor matching their real worst case", () => {
const byName = (n: string) => LLWS_20.rounds.find((r) => r.name === n);
// Winning Elim R3 only guarantees 7th (a loss in Elim R4 is the 78 tier),
// so the engine's default floor of 5 would overstate it.
expect(byName("Elimination Round 3")?.nonScoringWinnerFloor).toBe(7);
// Reaching the Winners Final guarantees 5th at worst.
expect(byName("Winners Semifinals")?.nonScoringWinnerFloor).toBe(5);
// Nothing is guaranteed earlier than that.
expect(byName("Opening Round")?.nonScoringWinnerFloor).toBeNull();
expect(byName("Winners Round 2")?.nonScoringWinnerFloor).toBeNull();
expect(byName("Elimination Round 1")?.nonScoringWinnerFloor).toBeNull();
expect(byName("Elimination Round 2")?.nonScoringWinnerFloor).toBeNull();
});
it("has 20 participant labels", () => {
expect(LLWS_20.participantLabels).toHaveLength(20);
});
it("splits display into U.S., International and Championship phases", () => {
expect(LLWS_20.phases?.map((p) => p.name)).toEqual([
"United States",
"International",
"Championship",
]);
});
it("assigns every match to exactly one phase group", () => {
const claimed = new Map<string, number>();
for (const phase of LLWS_20.phases ?? []) {
for (const group of phase.groups ?? []) {
for (const [round, numbers] of Object.entries(group.roundMatchNumbers)) {
for (const n of numbers) {
const key = `${round}#${n}`;
claimed.set(key, (claimed.get(key) ?? 0) + 1);
}
}
}
}
// Every per-side match claimed exactly once (36 games; the 2 finals live in
// the Championship phase's plain round list, not in a group).
expect(claimed.size).toBe(36);
expect([...claimed.values()].every((c) => c === 1)).toBe(true);
});
});
describe("Bracket generation", () => {
const PARTICIPANTS = Array.from({ length: 20 }, (_, i) => `team-${i}`);
async function generate() {
insertedRows.length = 0;
await generateBracketFromTemplate("event-1", "llws_20", PARTICIPANTS);
return insertedRows.map((r) => ({
round: r.round as string,
matchNumber: r.matchNumber as number,
participant1Id: (r.participant1Id ?? null) as string | null,
participant2Id: (r.participant2Id ?? null) as string | null,
isScoring: r.isScoring as boolean,
}));
}
it("creates all 38 matches", async () => {
const rows = await generate();
expect(rows).toHaveLength(38);
});
it("creates the right number of matches per round", async () => {
const rows = await generate();
for (const round of LLWS_20.rounds) {
expect(
rows.filter((r) => r.round === round.name),
`${round.name} match count`
).toHaveLength(round.matchCount);
}
});
it("numbers matches 1..n within each round", async () => {
const rows = await generate();
for (const round of LLWS_20.rounds) {
const numbers = rows
.filter((r) => r.round === round.name)
.map((r) => r.matchNumber)
.toSorted((a, b) => a - b);
expect(numbers).toEqual(
Array.from({ length: round.matchCount }, (_, i) => i + 1)
);
}
});
it("seeds the Opening Round two teams at a time, U.S. then International", async () => {
const rows = await generate();
const opening = rows
.filter((r) => r.round === "Opening Round")
.toSorted((a, b) => a.matchNumber - b.matchNumber);
// U.S. slots 07 fill matches 14; International slots 1017 fill matches 58.
expect(opening.map((m) => [m.participant1Id, m.participant2Id])).toEqual([
["team-0", "team-1"],
["team-2", "team-3"],
["team-4", "team-5"],
["team-6", "team-7"],
["team-10", "team-11"],
["team-12", "team-13"],
["team-14", "team-15"],
["team-16", "team-17"],
]);
});
it("seats the four bye teams in Winners Round 2 awaiting an opponent", async () => {
const rows = await generate();
const wr2 = rows
.filter((r) => r.round === "Winners Round 2")
.toSorted((a, b) => a.matchNumber - b.matchNumber);
expect(wr2.map((m) => [m.participant1Id, m.participant2Id])).toEqual([
["team-8", null],
["team-9", null],
["team-18", null],
["team-19", null],
]);
});
it("uses each participant exactly once and leaves every other slot empty", async () => {
const rows = await generate();
const seeded = rows
.flatMap((r) => [r.participant1Id, r.participant2Id])
.filter((id): id is string => id !== null);
expect(seeded).toHaveLength(20);
expect(new Set(seeded).size).toBe(20);
expect(new Set(seeded)).toEqual(new Set(PARTICIPANTS));
});
it("stamps isScoring from the template", async () => {
const rows = await generate();
for (const round of LLWS_20.rounds) {
for (const row of rows.filter((r) => r.round === round.name)) {
expect(row.isScoring, `${round.name} #${row.matchNumber}`).toBe(round.isScoring);
}
}
});
it("rejects a participant count other than 20", async () => {
await expect(
generateBracketFromTemplate("event-1", "llws_20", PARTICIPANTS.slice(0, 19))
).rejects.toThrow(/requires 20 participants/);
});
});
describe("Side / match-number mapping", () => {
it("round-trips every match number through side-local form", () => {
for (const round of LLWS_20.rounds) {
if (round.matchCount === 1) continue; // shared finals have no side
for (let n = 1; n <= round.matchCount; n++) {
const { side, localMatch } = llwsSideAndLocal(round.name, n);
expect(llwsMatchNumber(round.name, side, localMatch)).toBe(n);
}
}
});
it("puts U.S. matches in the low half and International in the high half", () => {
expect(llwsSideAndLocal("Opening Round", 4).side).toBe(0);
expect(llwsSideAndLocal("Opening Round", 5).side).toBe(1);
expect(llwsSideAndLocal("Winners Semifinals", 2).side).toBe(0);
expect(llwsSideAndLocal("Winners Semifinals", 3).side).toBe(1);
expect(llwsSideAndLocal("Winners Final", 1).side).toBe(0);
expect(llwsSideAndLocal("Winners Final", 2).side).toBe(1);
});
});
describe("Advancement matches the official bracket", () => {
/**
* Replay the whole tournament through resolveLLWSAdvancement and record which
* feed label ends up in each slot, then compare against the printed bracket.
*/
const actualSlots: Record<number, [string | null, string | null]> = {};
for (const game of Object.keys(EXPECTED_SLOTS)) {
actualSlots[Number(game)] = [null, null];
}
for (const [gameStr, { round, matchNumber }] of Object.entries(GAME_TO_MATCH)) {
const game = Number(gameStr);
const { winner, loser } = resolveLLWSAdvancement(round, matchNumber);
for (const [dest, label] of [
[winner, `W${game}`],
[loser, `L${game}`],
] as const) {
if (!dest) continue;
const targetGame = gameNumberFor(dest.round, dest.matchNumber);
const slotIndex = dest.slot === "participant1Id" ? 0 : 1;
actualSlots[targetGame][slotIndex] = label;
}
}
it.each(Object.keys(EXPECTED_SLOTS).map(Number).toSorted((a, b) => a - b))(
"Game %i has the printed participants",
(game) => {
expect(actualSlots[game]).toEqual(EXPECTED_SLOTS[game]);
}
);
it("fills every slot in the bracket exactly once", () => {
// 38 games × 2 slots = 76. 20 are seeded directly (16 opening teams + 4 byes),
// leaving 56 to be filled by advancement.
const filled = Object.values(actualSlots)
.flat()
.filter((s) => s !== null).length;
expect(filled).toBe(56);
});
});
describe("Double-elimination loser routing", () => {
it("routes every winners-bracket loser into the elimination bracket", () => {
const winnersRounds = [
"Opening Round",
"Winners Round 2",
"Winners Semifinals",
"Winners Final",
];
for (const roundName of winnersRounds) {
const round = LLWS_20.rounds.find((r) => r.name === roundName);
if (!round) throw new Error(`missing round ${roundName}`);
for (let n = 1; n <= round.matchCount; n++) {
const { loser } = resolveLLWSAdvancement(roundName, n);
expect(loser, `${roundName} #${n} loser should advance`).not.toBeNull();
expect(loser?.round.startsWith("Elimination")).toBe(true);
}
}
});
it("eliminates every elimination-bracket loser", () => {
const elimRounds = [
"Elimination Round 1",
"Elimination Round 2",
"Elimination Round 3",
"Elimination Round 4",
"Elimination Final",
];
for (const roundName of elimRounds) {
const round = LLWS_20.rounds.find((r) => r.name === roundName);
if (!round) throw new Error(`missing round ${roundName}`);
for (let n = 1; n <= round.matchCount; n++) {
const { loser } = resolveLLWSAdvancement(roundName, n);
expect(loser, `${roundName} #${n} loser should be out`).toBeNull();
}
}
});
it("keeps the winners-bracket final loser alive via the Elimination Final", () => {
// G30 (U.S. Winners Final) loser → G34, not out. This is the defining
// double-elimination behavior: a first loss never eliminates.
const { winner, loser } = resolveLLWSAdvancement("Winners Final", 1);
expect(destinationGame(loser)).toBe(34);
expect(destinationGame(winner)).toBe(36);
});
it("sends the side-championship loser to the consolation game, not out", () => {
// No "if necessary" rematch: the winners-bracket champion that loses G36 is
// done in the bracket, but still plays G37 for 3rd/4th.
const us = resolveLLWSAdvancement("Bracket Championship", 1);
expect(destinationGame(us.winner)).toBe(38);
expect(destinationGame(us.loser)).toBe(37);
expect(required(us.winner).slot).toBe("participant1Id");
expect(required(us.loser).slot).toBe("participant1Id");
const intl = resolveLLWSAdvancement("Bracket Championship", 2);
expect(required(intl.winner).slot).toBe("participant2Id");
expect(required(intl.loser).slot).toBe("participant2Id");
});
it("flags winners-bracket losers as advancing so they are not marked eliminated", () => {
// doesLoserAdvance is what stops the scoring engine writing a 0-point
// elimination (and announcing a knockout) for a team that is still alive.
// Winners Final and Bracket Championship are scoring rounds and are covered
// by loserIsPartial instead, so they are deliberately not listed here.
for (const round of ["Opening Round", "Winners Round 2", "Winners Semifinals"]) {
expect(doesLoserAdvance(round, 1, "llws_20"), round).toBe(true);
}
for (const round of [
"Elimination Round 1",
"Elimination Round 2",
"Elimination Round 3",
"Elimination Round 4",
"Elimination Final",
]) {
expect(doesLoserAdvance(round, 1, "llws_20"), round).toBe(false);
}
});
it("does not apply LLWS loser routing to other templates", () => {
expect(doesLoserAdvance("Opening Round", 1, "ncaa_68")).toBe(false);
expect(doesLoserAdvance("Winners Semifinals", 1, "")).toBe(false);
});
it("advances nobody out of the two final games", () => {
for (const round of ["Consolation Third Place", "World Championship"]) {
expect(resolveLLWSAdvancement(round, 1)).toEqual({ winner: null, loser: null });
}
});
it("never crosses a team between the U.S. and International sides", () => {
for (const round of LLWS_20.rounds) {
if (round.name === "Bracket Championship") continue; // the crossover point
if (round.matchCount === 1) continue;
for (let n = 1; n <= round.matchCount; n++) {
const { side } = llwsSideAndLocal(round.name, n);
const { winner, loser } = resolveLLWSAdvancement(round.name, n);
for (const dest of [winner, loser]) {
if (!dest) continue;
const destRound = LLWS_20.rounds.find((r) => r.name === dest.round);
if (!destRound || destRound.matchCount === 1) continue;
expect(llwsSideAndLocal(dest.round, dest.matchNumber).side).toBe(side);
}
}
}
});
});
describe("Placement tiers", () => {
it("classifies scoring rounds correctly", () => {
expect(getScoringRoundType("Elimination Round 4", LLWS_20)).toBe("quarterfinals");
expect(getScoringRoundType("Elimination Final", LLWS_20)).toBe("quarterfinals");
expect(getScoringRoundType("Bracket Championship", LLWS_20)).toBe("semifinals");
expect(getScoringRoundType("World Championship", LLWS_20)).toBe("finals");
// Nobody is eliminated in the Winners Final — the loser drops to the
// elimination bracket — so it has no placement tier.
expect(getScoringRoundType("Winners Final", LLWS_20)).toBeNull();
});
it("pays 3rd and 4th distinctly (there is a real consolation game)", () => {
expect(calculateBracketPoints(3, DEFAULT_SCORING, "llws_20")).toBe(50);
expect(calculateBracketPoints(4, DEFAULT_SCORING, "llws_20")).toBe(40);
});
it("splits 58 into two two-team tiers", () => {
const upper = calculateAveragedPoints([5, 6], DEFAULT_SCORING); // (25+20)/2
const lower = calculateAveragedPoints([7, 8], DEFAULT_SCORING); // (15+10)/2
expect(calculateBracketPoints(5, DEFAULT_SCORING, "llws_20")).toBe(upper);
expect(calculateBracketPoints(6, DEFAULT_SCORING, "llws_20")).toBe(upper);
expect(calculateBracketPoints(7, DEFAULT_SCORING, "llws_20")).toBe(lower);
expect(calculateBracketPoints(8, DEFAULT_SCORING, "llws_20")).toBe(lower);
// Surviving Elimination Round 4 is worth more than losing it.
expect(upper).toBeGreaterThan(lower);
});
it("awards nothing below 8th", () => {
// The 12 teams knocked out in Elimination Rounds 13 finish 9th20th.
expect(calculateBracketPoints(9, DEFAULT_SCORING, "llws_20")).toBe(0);
expect(calculateBracketPoints(0, DEFAULT_SCORING, "llws_20")).toBe(0);
});
it("has exactly 8 teams alive when the first scoring elimination game is played", () => {
// Elimination Round 4 is the 7th8th tier, so the field must be 8 at that point:
// per side the Winners Final winner, the Winners Final loser, and the two
// Elimination Round 3 winners.
const eliminatedBeforeElimR4 =
(LLWS_20.rounds.find((r) => r.name === "Elimination Round 1")?.matchCount ?? 0) +
(LLWS_20.rounds.find((r) => r.name === "Elimination Round 2")?.matchCount ?? 0) +
(LLWS_20.rounds.find((r) => r.name === "Elimination Round 3")?.matchCount ?? 0);
expect(eliminatedBeforeElimR4).toBe(12);
expect(LLWS_20.totalTeams - eliminatedBeforeElimR4).toBe(8);
});
});
});

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

@ -190,12 +190,26 @@ describe("processMatchResult", () => {
}); });
}); });
it("AFL Wildcard Round: loser=0, winner gets T5 floor (feeds into Elimination Finals = scoring)", async () => { it("AFL Wildcard Round: loser=0, winner gets T7 floor (a Wildcard win only buys an Elimination Final)", async () => {
// The generic "entering a scoring round ⇒ top-8" default would bank 5 here,
// over-awarding the 5th-6th tier to a team whose next loss is the 7th-8th tier.
const { db, insertedRows } = makeDb(); const { db, insertedRows } = makeDb();
await processMatchResult({ ...BASE, bracketTemplateId: "afl_10", round: "Wildcard Round", isScoring: false }, db); await processMatchResult({ ...BASE, bracketTemplateId: "afl_10", round: "Wildcard Round", isScoring: false }, db);
expect(insertedRows).toHaveLength(2); expect(insertedRows).toHaveLength(2);
expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 0, isPartialScore: false }); expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 0, isPartialScore: false });
expect(insertedRows[1]).toMatchObject({ participantId: "winner-1", finalPosition: 5, isPartialScore: true }); expect(insertedRows[1]).toMatchObject({ participantId: "winner-1", finalPosition: 7, isPartialScore: true });
});
it("AFL Qualifying Finals: winner gets T3 floor (bye to a Preliminary Final), loser holds their entry floor", async () => {
const { db, insertedRows } = makeDb();
await processMatchResult(
{ ...BASE, bracketTemplateId: "afl_10", round: "Qualifying Finals", isScoring: false, loserAdvances: true },
db
);
// Only the winner is written: the loser still has a Semi-Final, so their
// seeding-derived floor of 5 stands untouched.
expect(insertedRows).toHaveLength(1);
expect(insertedRows[0]).toMatchObject({ participantId: "winner-1", finalPosition: 3, isPartialScore: true });
}); });
describe("NBA Play-In loserAdvances=true (7v8 game)", () => { describe("NBA Play-In loserAdvances=true (7v8 game)", () => {
@ -305,6 +319,22 @@ describe("processMatchResult", () => {
expect(updateProbabilitiesAfterResult).toHaveBeenCalledWith("ss-1", true); expect(updateProbabilitiesAfterResult).toHaveBeenCalledWith("ss-1", true);
}); });
it("skips only the probability refresh when asked, still announcing", async () => {
// For a caller scoring several matches in a loop: the refresh is season-wide and, for a
// bracket-aware sport, a full Monte Carlo run, so it belongs once after the loop rather
// than once per match. Standings and the announcement still happen per match.
const { db } = makeDb();
await processMatchResult(
{ ...BASE, round: "Quarterfinals", isScoring: true, skipProbabilities: true },
db
);
expect(updateProbabilitiesAfterResult).not.toHaveBeenCalled();
// recalculateAffectedLeagues still ran: it is the only thing that reads seasonSports.
expect(db.query.seasonSports.findMany).toHaveBeenCalled();
});
it("does not throw even if probability update fails", async () => { it("does not throw even if probability update fails", async () => {
(updateProbabilitiesAfterResult as ReturnType<typeof vi.fn>).mockRejectedValueOnce( (updateProbabilitiesAfterResult as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error("network error") new Error("network error")
@ -399,6 +429,7 @@ describe("isLoserNotifiable", () => {
}); });
}); });
describe("processPlayoffEvent - NBA Play-In Round 1 loserAdvances fix", () => { describe("processPlayoffEvent - NBA Play-In Round 1 loserAdvances fix", () => {
function makePlayInDb(matches: object[]) { function makePlayInDb(matches: object[]) {
const insertedRows: Array<{ participantId: string; finalPosition: number; isPartialScore: boolean }> = []; 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 { import {
calculateSplitQualifyingPoints, calculateSplitQualifyingPoints,
DEFAULT_QP_VALUES, DEFAULT_QP_VALUES,
hasProcessedQualifyingPlacement, diffChangedQualifyingPoints,
} from "../qualifying-points"; } 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("Qualifying Points Configuration", () => {
describe("DEFAULT_QP_VALUES", () => { describe("DEFAULT_QP_VALUES", () => {
it("should have 16 placement 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) 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", () => { 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", () => { 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 setCalls: unknown[] = [];
const updateChain = { const updateChain = {
set: (values: unknown) => { set: (values: unknown) => {
@ -217,6 +231,8 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
id: "event-1", id: "event-1",
sportsSeasonId: "sports-season-1", sportsSeasonId: "sports-season-1",
isQualifyingEvent: true, isQualifyingEvent: true,
tournamentId,
bracketTemplateId,
sportsSeason: { majorsCompleted: 1 }, 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, update: () => updateChain,
} as any; } 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 () => { it("does not increment majorsCompleted when reprocessing a placed zero-QP result", async () => {
const { db, setCalls } = makeProcessQPMockDb([ const { db, setCalls } = makeProcessQPMockDb([
{ {

View file

@ -15,7 +15,10 @@ vi.mock("../qualifying-points", async (importOriginal) => {
import { deleteScoringEvent } from "../scoring-event"; import { deleteScoringEvent } from "../scoring-event";
describe("deleteScoringEvent", () => { 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 setCalls: unknown[] = [];
const deleteChain = { where: vi.fn().mockResolvedValue(undefined) }; const deleteChain = { where: vi.fn().mockResolvedValue(undefined) };
const updateChain = { 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)), transaction: vi.fn(async (callback) => callback(db)),
delete: vi.fn(() => deleteChain), delete: vi.fn(() => deleteChain),
@ -57,10 +54,125 @@ describe("deleteScoringEvent", () => {
await deleteScoringEvent("event-1", db); await deleteScoringEvent("event-1", db);
expect(setCalls).toEqual( expect(setCalls).not.toEqual(
expect.arrayContaining([ 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"; import { describe, it, expect, vi, beforeEach } from "vitest";
const mockFindMany = vi.fn(); 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 = { const mockDb = {
query: { query: {
scoringEvents: { findMany: mockFindMany }, scoringEvents: { findMany: mockFindMany, findFirst: mockFindFirst },
}, },
update: mockUpdate,
}; };
vi.mock("~/database/context", () => ({ vi.mock("~/database/context", () => ({
@ -14,8 +26,10 @@ vi.mock("~/database/context", () => ({
vi.mock("~/database/schema", () => ({ vi.mock("~/database/schema", () => ({
scoringEvents: { scoringEvents: {
id: "se.id",
sportsSeasonId: "se.sports_season_id", sportsSeasonId: "se.sports_season_id",
tournamentId: "se.tournament_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 }), eq: (col: unknown, val: unknown) => ({ type: "eq", col, val }),
and: (...args: unknown[]) => ({ type: "and", args }), and: (...args: unknown[]) => ({ type: "and", args }),
isNotNull: (col: unknown) => ({ type: "isNotNull", col }), isNotNull: (col: unknown) => ({ type: "isNotNull", col }),
asc: (col: unknown) => ({ type: "asc", col }),
})); }));
import { import {
getTournamentsBySportsSeason, getTournamentsBySportsSeason,
getSportsSeasonsByTournament, getSportsSeasonsByTournament,
ensurePrimaryEvent,
setPrimaryEvent,
} from "../scoring-event"; } from "../scoring-event";
const SEASON_ID = "ss-1"; const SEASON_ID = "ss-1";
@ -39,6 +56,7 @@ const mockTournamentB = { id: TOURNAMENT_ID_B, name: "US Open", year: 2026 };
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
updateCalls.length = 0;
}); });
describe("getTournamentsBySportsSeason", () => { 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

@ -0,0 +1,192 @@
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
import { countSeasonRaces, hasRaceRun } from "../season-races";
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
const NOW = new Date("2026-08-17T12:00:00.000Z");
const TODAY = "2026-08-17";
interface EventRow {
eventType: string;
isComplete: boolean;
eventDate: string | null;
eventStartsAt: Date | null;
}
function makeEvent(overrides: Partial<EventRow> = {}): EventRow {
return {
eventType: "schedule_event",
isComplete: false,
eventDate: null,
eventStartsAt: null,
...overrides,
};
}
async function mockEvents(events: EventRow[]) {
const { database } = await import("~/database/context");
(database as unknown as MockInstance).mockReturnValue({
query: {
scoringEvents: {
findMany: vi.fn().mockResolvedValue(events),
},
},
});
}
beforeEach(async () => {
await mockEvents([]);
});
describe("hasRaceRun", () => {
it("trusts isComplete when an admin has set it", () => {
expect(
hasRaceRun(
{ isComplete: true, eventDate: "2026-12-31", eventStartsAt: null },
NOW,
TODAY
)
).toBe(true);
});
it("prefers eventStartsAt over eventDate", () => {
// Started yesterday and long finished, even though eventDate is unset.
expect(
hasRaceRun(
{
isComplete: false,
eventDate: null,
eventStartsAt: new Date("2026-08-16T18:00:00.000Z"),
},
NOW,
TODAY
)
).toBe(true);
expect(
hasRaceRun(
{
isComplete: false,
eventDate: TODAY,
eventStartsAt: new Date("2026-08-17T18:00:00.000Z"),
},
NOW,
TODAY
)
).toBe(false);
});
it("does not call a race run the moment it goes green", () => {
// Declaring the finale finished at the green flag would publish the
// pre-race leader as champion at 100%, from standings without that race.
const greenFlag = new Date(NOW.getTime() - 30 * 60 * 1000);
expect(
hasRaceRun({ isComplete: false, eventDate: TODAY, eventStartsAt: greenFlag }, NOW, TODAY)
).toBe(false);
});
it("counts a race run once it has had time to finish", () => {
const greenFlag = new Date(NOW.getTime() - 7 * 60 * 60 * 1000);
expect(
hasRaceRun({ isComplete: false, eventDate: TODAY, eventStartsAt: greenFlag }, NOW, TODAY)
).toBe(true);
});
it("still honours isComplete for a race that just started", () => {
const greenFlag = new Date(NOW.getTime() - 30 * 60 * 1000);
expect(
hasRaceRun({ isComplete: true, eventDate: TODAY, eventStartsAt: greenFlag }, NOW, TODAY)
).toBe(true);
});
it("treats a past date as run even when nobody marked it complete", () => {
expect(
hasRaceRun(
{ isComplete: false, eventDate: "2026-08-16", eventStartsAt: null },
NOW,
TODAY
)
).toBe(true);
});
it("treats a race happening today as still upcoming", () => {
expect(
hasRaceRun({ isComplete: false, eventDate: TODAY, eventStartsAt: null }, NOW, TODAY)
).toBe(false);
});
it("treats an undated row as upcoming", () => {
expect(
hasRaceRun({ isComplete: false, eventDate: null, eventStartsAt: null }, NOW, TODAY)
).toBe(false);
});
});
describe("countSeasonRaces", () => {
it("counts schedule_event rows as races", async () => {
// This is the whole bug: a season_standings calendar is stored as
// schedule_event rows, and the simulator used to skip them.
await mockEvents([
makeEvent({ eventDate: "2026-03-01" }),
makeEvent({ eventDate: "2026-04-01" }),
makeEvent({ eventDate: "2026-09-01" }),
]);
expect(await countSeasonRaces("s1", NOW)).toEqual({
completed: 2,
remaining: 1,
total: 3,
});
});
it("excludes the final_standings scoring row", async () => {
await mockEvents([
makeEvent({ eventDate: "2026-03-01" }),
makeEvent({ eventType: "final_standings", eventDate: "2026-11-01" }),
]);
expect(await countSeasonRaces("s1", NOW)).toEqual({
completed: 1,
remaining: 0,
total: 1,
});
});
it("counts other event types too, whichever type the admin used", async () => {
await mockEvents([
makeEvent({ eventType: "major_tournament", eventDate: "2026-03-01" }),
makeEvent({ eventType: "playoff_game", eventDate: "2026-09-01" }),
]);
expect(await countSeasonRaces("s1", NOW)).toEqual({
completed: 1,
remaining: 1,
total: 2,
});
});
it("returns zeroes when the season has no events", async () => {
expect(await countSeasonRaces("s1", NOW)).toEqual({
completed: 0,
remaining: 0,
total: 0,
});
});
it("counts a realistic late-season IndyCar calendar", async () => {
const calendar = [
...Array.from({ length: 15 }, (_, i) =>
makeEvent({ eventDate: `2026-0${((i % 6) + 3)}-0${(i % 9) + 1}` })
),
// The next race goes green in a few hours — still remaining.
makeEvent({ eventStartsAt: new Date("2026-08-17T18:00:00.000Z") }),
makeEvent({ eventStartsAt: new Date("2026-08-30T18:00:00.000Z") }),
makeEvent({ eventType: "final_standings" }),
];
await mockEvents(calendar);
expect(await countSeasonRaces("s1", NOW)).toEqual({
completed: 15,
remaining: 2,
total: 17,
});
});
});

View file

@ -71,4 +71,90 @@ describe("simulator input model", () => {
expect(byParticipant.get("generated-rating")?.rating).toBeNull(); expect(byParticipant.get("generated-rating")?.rating).toBeNull();
expect(byParticipant.get("legacy-direct-rating")?.rating).toBe(27.5); 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();
});
it("hides an Elo flagged as projection-derived so the projection is re-derived", async () => {
// This is what stops a stale Elo from winning the baseEloPriority race. A row
// carrying projectedWins and a projectedWins method flag must surface with a
// null sourceElo, so resolveSourceElos falls through to the projection rather
// than reusing an Elo that was itself derived from an older projection.
mockDb.query.seasonParticipants.findMany.mockResolvedValue([
{ id: "projected" },
{ id: "hand-entered" },
]);
mockDb.query.seasonParticipantSimulatorInputs.findMany.mockResolvedValue([
{
participantId: "projected",
sourceOdds: null,
sourceElo: 1561,
worldRanking: null,
rating: null,
projectedWins: "95.00",
projectedTablePoints: null,
seed: null,
region: null,
metadata: { sourceEloMethod: "projectedWins" },
},
{
participantId: "hand-entered",
sourceOdds: null,
sourceElo: 1561,
worldRanking: null,
rating: null,
projectedWins: "95.00",
projectedTablePoints: null,
seed: null,
region: null,
metadata: {},
},
]);
mockDb.query.seasonParticipantExpectedValues.findMany.mockResolvedValue([]);
const inputs = await getParticipantSimulatorInputs("season-1");
const byParticipant = new Map(inputs.map((input) => [input.participantId, input]));
expect(byParticipant.get("projected")?.sourceElo).toBeNull();
expect(byParticipant.get("projected")?.projectedWins).toBe(95);
// No flag means the admin entered that Elo themselves — it is trusted as direct.
expect(byParticipant.get("hand-entered")?.sourceElo).toBe(1561);
});
}); });

View file

@ -27,7 +27,11 @@ vi.mock("drizzle-orm", () => ({
asc: (col: unknown) => ({ type: "asc", col }), asc: (col: unknown) => ({ type: "asc", col }),
})); }));
import { findAllSportsSeasons, findDraftableSportsSeasons } from "../sports-season"; import {
findAllSportsSeasons,
findDraftableSportsSeasons,
findDraftScheduleForHorizon,
} from "../sports-season";
import { database } from "~/database/context"; import { database } from "~/database/context";
const today = new Date().toISOString().slice(0, 10); 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

@ -80,6 +80,9 @@ function makeDb(
}, },
scoringEvents: { scoringEvents: {
findFirst: vi.fn().mockResolvedValue({ bracketTemplateId: null }), findFirst: vi.fn().mockResolvedValue({ bracketTemplateId: null }),
// getBracketTemplateIdsForSportsSeasons filters to events that carry a
// template, so "no bracket template" is an empty result, not a null row.
findMany: vi.fn().mockResolvedValue([]),
}, },
seasonParticipantResults: { seasonParticipantResults: {
findMany: vi.fn().mockResolvedValue(seasonResults), findMany: vi.fn().mockResolvedValue(seasonResults),

View file

@ -11,6 +11,7 @@ import {
findTournamentBySportNameYear, findTournamentBySportNameYear,
upsertTournament, upsertTournament,
updateTournamentStatus, updateTournamentStatus,
deleteTournament,
} from "../tournament"; } from "../tournament";
import { database } from "~/database/context"; import { database } from "~/database/context";
@ -172,3 +173,26 @@ describe("updateTournamentStatus", () => {
expect(result.status).toBe("in_progress"); 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

@ -0,0 +1,66 @@
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { and, desc, inArray, isNotNull } from "drizzle-orm";
/**
* Resolve which bracket template a sports season's placements should be scored against.
*
* A sports season can own several scoring events a bracket plus schedule events, or a
* re-created bracket alongside a stale one and only some of them carry a
* bracketTemplateId. Picking an arbitrary row is not harmless: calculateBracketPoints
* falls back to the standard single 5th8th tier when the template id is null, which
* silently collapses the two-tier templates (llws_20, afl_10) so a team locked into
* 5th6th and one locked into 7th8th both score the flat 58 average. The 3rd/4th
* distinction that llws_20 and fifa_48 have goes the same way.
*
* So: only events that actually carry a template are considered, most recent first
* matching the "a re-created event wins over a stale one" rule the LLWS simulator uses
* when it picks its bracket event.
*
* Every requested season gets an entry, null when it has no bracket event, so callers
* can cache the negative result too.
*/
export async function getBracketTemplateIdsForSportsSeasons(
sportsSeasonIds: string[],
providedDb?: ReturnType<typeof database>
): Promise<Map<string, string | null>> {
const resolved = new Map<string, string | null>(
sportsSeasonIds.map((id) => [id, null])
);
if (sportsSeasonIds.length === 0) return resolved;
const db = providedDb || database();
const events = await db.query.scoringEvents.findMany({
where: and(
inArray(schema.scoringEvents.sportsSeasonId, sportsSeasonIds),
isNotNull(schema.scoringEvents.bracketTemplateId)
),
columns: { sportsSeasonId: true, bracketTemplateId: true },
// createdAt can tie when a bracket is generated in the same transaction as a
// sibling event, so id breaks the tie and keeps the choice deterministic.
orderBy: [desc(schema.scoringEvents.createdAt), desc(schema.scoringEvents.id)],
});
for (const event of events) {
// Ordered newest-first, so the first row seen for a season is the one to keep.
// The isNotNull filter means bracketTemplateId is set, but a mocked or partial row
// could still carry null — skip those rather than caching a null as a real answer.
if (resolved.get(event.sportsSeasonId) === null && event.bracketTemplateId) {
resolved.set(event.sportsSeasonId, event.bracketTemplateId);
}
}
return resolved;
}
/**
* Single-season form of getBracketTemplateIdsForSportsSeasons.
*/
export async function getBracketTemplateIdForSportsSeason(
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
): Promise<string | null> {
const resolved = await getBracketTemplateIdsForSportsSeasons([sportsSeasonId], providedDb);
return resolved.get(sportsSeasonId) ?? null;
}

View file

@ -15,7 +15,10 @@
import { database } from "~/database/context"; import { database } from "~/database/context";
import { cs2MajorStageResults, seasonParticipants, eventResults } from "~/database/schema"; import { cs2MajorStageResults, seasonParticipants, eventResults } from "~/database/schema";
import { eq, and, sql } from "drizzle-orm"; 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 { export interface Cs2StageResult {
id: string; id: string;
@ -126,14 +129,57 @@ export async function upsertCs2StageAssignments(
* Called when the admin resets the event setup. * Called when the admin resets the event setup.
*/ */
export async function clearCs2StageAssignments( export async function clearCs2StageAssignments(
scoringEventId: string scoringEventId: string,
providedDb?: ReturnType<typeof database>
): Promise<void> { ): Promise<void> {
const db = database(); const db = providedDb || database();
await db await db
.delete(cs2MajorStageResults) .delete(cs2MajorStageResults)
.where(eq(cs2MajorStageResults.scoringEventId, scoringEventId)); .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. * Mark teams as eliminated from a specific stage.
* Each elimination entry specifies which stage the team was eliminated at * Each elimination entry specifies which stage the team was eliminated at
@ -394,34 +440,30 @@ export async function assignCs2EliminationQP(
} }
} }
const now = new Date(); // 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.
await Promise.all( const existingRows = await db.query.eventResults.findMany({
[...resultsByParticipant.entries()].map(([seasonParticipantId, { qp, placement }]) => where: eq(eventResults.scoringEventId, scoringEventId),
db });
.insert(eventResults) const existingQPBySPId = new Map<string, number>(
.values({ existingRows
scoringEventId, .filter((r) => r.qualifyingPointsAwarded !== null)
seasonParticipantId, .map((r) => [r.seasonParticipantId, parseFloat(r.qualifyingPointsAwarded as string)])
qualifyingPointsAwarded: qp.toString(),
placement,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: [eventResults.scoringEventId, eventResults.seasonParticipantId],
set: {
qualifyingPointsAwarded: qp.toString(),
placement,
updatedAt: now,
},
})
)
); );
await Promise.all( await writeEventResultsQP(scoringEventId, sportsSeasonId, resultsByParticipant, db);
[...resultsByParticipant.keys()].map((seasonParticipantId) =>
recalculateParticipantQP(seasonParticipantId, sportsSeasonId) 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

@ -7,6 +7,7 @@ import {
calculateBracketPoints, calculateBracketPoints,
calculateSharedPlacementPoints, calculateSharedPlacementPoints,
} from "./scoring-rules"; } from "./scoring-rules";
import { getBracketTemplateIdsForSportsSeasons } from "./bracket-template";
export async function createDraftPick(data: { export async function createDraftPick(data: {
seasonId: string; seasonId: string;
@ -175,18 +176,10 @@ export async function getDraftedParticipantsWithPoints(
} }
// Batch-fetch bracket template IDs (one per sports season) // Batch-fetch bracket template IDs (one per sports season)
const bracketTemplateMap = new Map<string, string | null>(); const bracketTemplateMap =
if (bracketSeasonIds.size > 0) { bracketSeasonIds.size > 0
const events = await db.query.scoringEvents.findMany({ ? await getBracketTemplateIdsForSportsSeasons([...bracketSeasonIds], db)
where: inArray(schema.scoringEvents.sportsSeasonId, [...bracketSeasonIds]), : new Map<string, string | null>();
columns: { sportsSeasonId: true, bracketTemplateId: true },
});
for (const ev of events) {
if (!bracketTemplateMap.has(ev.sportsSeasonId)) {
bracketTemplateMap.set(ev.sportsSeasonId, ev.bracketTemplateId ?? null);
}
}
}
// Batch-fetch QP totals for qualifying_points participants // Batch-fetch QP totals for qualifying_points participants
const qpMap = new Map<string, number>(); // participantId → totalQP const qpMap = new Map<string, number>(); // participantId → totalQP

View file

@ -6,11 +6,11 @@
*/ */
import { database } from "~/database/context"; import { database } from "~/database/context";
import { seasonParticipantExpectedValues, seasonParticipants, seasonParticipantSimulatorInputs } from "~/database/schema"; import { seasonParticipantExpectedValues, seasonParticipants } from "~/database/schema";
import { eq, and, count, inArray, sql } from "drizzle-orm"; import { eq, and, count, sql } from "drizzle-orm";
import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator"; import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator";
import { calculateEV, normalizeProbabilities, calculateReplacementLevel, calculateVORP } 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"; export type ProbabilitySource = "manual" | "futures_odds" | "elo_simulation" | "performance_model";
@ -366,95 +366,6 @@ export async function batchUpsertParticipantEVs(
return results; 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. * Persist raw Elo ratings (and optional world rankings) for a batch of seasonParticipants.
* Used by Elo-based simulators like snooker_bracket and darts_bracket. * Used by Elo-based simulators like snooker_bracket and darts_bracket.

View file

@ -1,4 +1,4 @@
import { eq, and } from "drizzle-orm"; import { eq, and, inArray } from "drizzle-orm";
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
@ -95,14 +95,42 @@ export async function deleteParticipantResult(id: string): Promise<void> {
} }
export async function deleteParticipantResultsBySportsSeasonId( export async function deleteParticipantResultsBySportsSeasonId(
sportsSeasonId: string sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
): Promise<void> { ): Promise<void> {
const db = database(); const db = providedDb || database();
await db await db
.delete(schema.seasonParticipantResults) .delete(schema.seasonParticipantResults)
.where(eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId)); .where(eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId));
} }
/**
* Delete the results of specific participants within one sports season.
*
* The season-wide delete above is too blunt for a single bracket: results are keyed by
* sports season, not by event, so wiping the season takes every other event's placements
* with it. Scoping to the participants a bracket actually holds lets reprocess-bracket
* rebuild that bracket from scratch while leaving the rest of the season alone.
*
* No-ops on an empty id list `inArray` with no values is not a valid SQL predicate.
*/
export async function deleteParticipantResultsForParticipants(
sportsSeasonId: string,
participantIds: string[],
providedDb?: ReturnType<typeof database>
): Promise<void> {
if (participantIds.length === 0) return;
const db = providedDb || database();
await db
.delete(schema.seasonParticipantResults)
.where(
and(
eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId),
inArray(schema.seasonParticipantResults.participantId, participantIds)
)
);
}
/** /**
* Set result for a participant in a sports season * Set result for a participant in a sports season
* Points are calculated on-demand based on each fantasy league's scoring rules * Points are calculated on-demand based on each fantasy league's scoring rules

View file

@ -6,8 +6,19 @@ import {
getBracketTemplate, getBracketTemplate,
buildNCAA68SlotMap, buildNCAA68SlotMap,
matchIndexForSeedSlot, matchIndexForSeedSlot,
llwsMatchNumber,
llwsSideAndLocal,
STANDARD_BRACKET_SEEDING, STANDARD_BRACKET_SEEDING,
} from "~/lib/bracket-templates"; } from "~/lib/bracket-templates";
import {
LLWS_LOSER_ADVANCES_ROUNDS,
resolveLLWSAdvancement,
type LLWSResolvedDestination,
} from "~/lib/llws-bracket";
import {
resolveAflWildcardPlacements,
type AflWildcardResult,
} from "~/lib/afl-wildcard-reseed";
export type PlayoffMatch = typeof schema.playoffMatches.$inferSelect; export type PlayoffMatch = typeof schema.playoffMatches.$inferSelect;
export type NewPlayoffMatch = typeof schema.playoffMatches.$inferInsert; export type NewPlayoffMatch = typeof schema.playoffMatches.$inferInsert;
@ -142,6 +153,108 @@ export async function setMatchWinner(
return match; 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 * Generate a standard single elimination bracket structure
*/ */
@ -365,6 +478,11 @@ export async function generateBracketFromTemplate(
return await generateNBA20Bracket(eventId, template, participantIds); return await generateNBA20Bracket(eventId, template, participantIds);
} }
// LLWS 20 requires special handling for its two double-elimination brackets
if (templateId === "llws_20") {
return await generateLLWS20Bracket(eventId, template, participantIds);
}
const matches: NewPlayoffMatch[] = []; const matches: NewPlayoffMatch[] = [];
// Generate matches for each round in the template // Generate matches for each round in the template
@ -628,9 +746,11 @@ async function generateNFL14Bracket(
* Structure: * Structure:
* - Wildcard Round: 7v10, 8v9 * - Wildcard Round: 7v10, 8v9
* - Qualifying Finals: 1v4, 2v3 (winners get bye to Preliminary Finals, losers to Semi-Finals) * - Qualifying Finals: 1v4, 2v3 (winners get bye to Preliminary Finals, losers to Semi-Finals)
* - Elimination Finals: 5v8, 6v7 (where 7 and 8 are wildcard winners) * - Elimination Finals: 5 and 6 host the two Wildcard winners, re-seeded by ladder
* - Semi-Finals: QF losers vs EF winners * position 5th draws the lower-ranked winner, 6th the higher-ranked one
* - Preliminary Finals: QF winners vs SF winners * - Semi-Finals: SF1 = QF1 loser v EF1 winner, SF2 = QF2 loser v EF2 winner
* - Preliminary Finals: PF1 = QF1 winner v SF2 winner, PF2 = QF2 winner v SF1 winner
* (the crossover keeps a QF loser away from the side that just beat it)
* - Grand Final: PF winners * - Grand Final: PF winners
*/ */
async function generateAFL10Bracket( async function generateAFL10Bracket(
@ -682,14 +802,16 @@ async function generateAFL10Bracket(
}); });
} }
// Elimination Finals: 5th vs TBD (wildcard winner), 6th vs TBD (wildcard winner) // Elimination Finals: 5th and 6th host the two Wildcard winners. Which winner lands
// where is decided by ladder position once both games are played (see
// resolveAflWildcardPlacements), not by a fixed crossover from a Wildcard match.
const eliminationSeeding = [ const eliminationSeeding = [
{ higher: 4, wildcard: 2 }, // #5 (index 4) vs Wildcard Match 2 winner { higher: 4, opponent: "lower-ranked WC winner" }, // #5 (index 4)
{ higher: 5, wildcard: 1 }, // #6 (index 5) vs Wildcard Match 1 winner { higher: 5, opponent: "higher-ranked WC winner" }, // #6 (index 5)
]; ];
for (let i = 0; i < eliminationSeeding.length; i++) { for (let i = 0; i < eliminationSeeding.length; i++) {
const { higher, wildcard } = eliminationSeeding[i]; const { higher, opponent } = eliminationSeeding[i];
matches.push({ matches.push({
scoringEventId: eventId, scoringEventId: eventId,
round: "Elimination Finals", round: "Elimination Finals",
@ -699,11 +821,11 @@ async function generateAFL10Bracket(
isComplete: false, isComplete: false,
isScoring: true, // Losers share 7th-8th isScoring: true, // Losers share 7th-8th
templateRound: "Elimination Finals", templateRound: "Elimination Finals",
seedInfo: participantIds ? `${higher + 1} vs WC${wildcard}` : null, seedInfo: participantIds ? `${higher + 1} vs ${opponent}` : null,
}); });
} }
// Semi-Finals: QF losers vs EF winners (TBD vs TBD) // Semi-Finals: SF n = QF n loser vs EF n winner (TBD vs TBD)
for (let i = 0; i < 2; i++) { for (let i = 0; i < 2; i++) {
matches.push({ matches.push({
scoringEventId: eventId, scoringEventId: eventId,
@ -749,15 +871,257 @@ async function generateAFL10Bracket(
return await createManyPlayoffMatches(matches); return await createManyPlayoffMatches(matches);
} }
/** What a re-seed changed, by Elimination Finals match number. */
export interface AflEliminationReseed {
vacated: number[];
filled: Array<{ matchNumber: number; participantId: string }>;
}
/**
* Put the decided Wildcard winners in the Elimination Finals they belong in.
*
* The two winners are re-seeded by ladder position 5th meets the lower-ranked one and
* 6th the higher-ranked one rather than crossing over from a fixed Wildcard match. That
* destination depends on both games, so this reconciles both slots against the results
* recorded so far every time it runs: it places a winner whose slot only became certain
* once the other game was decided, and moves one that an earlier (or corrected) result,
* or a bracket advanced before this rule existed, put in the other slot.
*
* `pending` supplies a result that may not be in the database yet the row read back
* while advancing a match can predate the winner being written to it.
*
* Idempotent: pairings that are already right do no writes.
*/
export async function reseedAflEliminationFinals(
eventId: string,
pending?: { matchId: string; winnerId: string }
): Promise<AflEliminationReseed> {
const [wcMatches, efMatches] = await Promise.all([
findPlayoffMatchesByEventIdAndRound(eventId, "Wildcard Round"),
findPlayoffMatchesByEventIdAndRound(eventId, "Elimination Finals"),
]);
// Nothing to reconcile against is a bad event id or a broken bracket, not a no-op.
if (wcMatches.length === 0 || efMatches.length === 0) {
throw new Error(
`Event ${eventId} has no AFL Wildcard Round / Elimination Finals matches to re-seed`
);
}
const winnerByMatchNumber = new Map<number, string>();
for (const wc of wcMatches) {
const decidedWinner =
pending && wc.id === pending.matchId ? pending.winnerId : wc.isComplete ? wc.winnerId : null;
if (decidedWinner) winnerByMatchNumber.set(wc.matchNumber, decidedWinner);
}
const results: AflWildcardResult[] = wcMatches.map((wc) => {
const decidedWinner = winnerByMatchNumber.get(wc.matchNumber) ?? null;
if (decidedWinner === null) return { matchNumber: wc.matchNumber, winnerSlot: null };
if (decidedWinner === wc.participant1Id) return { matchNumber: wc.matchNumber, winnerSlot: 1 };
if (decidedWinner === wc.participant2Id) return { matchNumber: wc.matchNumber, winnerSlot: 2 };
throw new Error(
`Wildcard Round match ${wc.matchNumber} winner is not one of its participants`
);
});
const wanted = new Map<number, string>();
for (const placement of resolveAflWildcardPlacements(results)) {
const placedWinner = winnerByMatchNumber.get(placement.wildcardMatchNumber);
if (placedWinner) wanted.set(placement.eliminationMatchNumber, placedWinner);
}
// Only these teams can legitimately be moved between the two Elimination Finals;
// anyone else in a slot came from somewhere this function knows nothing about.
const wildcardParticipants = new Set<string>();
for (const wc of wcMatches) {
if (wc.participant1Id) wildcardParticipants.add(wc.participant1Id);
if (wc.participant2Id) wildcardParticipants.add(wc.participant2Id);
}
const slotsToClear: Array<{ id: string; matchNumber: number }> = [];
const slotsToFill: Array<{ id: string; matchNumber: number; participantId: string }> = [];
for (const efMatch of efMatches) {
const occupant = efMatch.participant2Id;
const belongsHere = wanted.get(efMatch.matchNumber) ?? null;
if (occupant === belongsHere) continue;
if (occupant !== null && !wildcardParticipants.has(occupant)) {
throw new Error(`EF ${efMatch.matchNumber} participant2 already filled`);
}
// Re-seeding a game that has already been played would rewrite who contested a
// recorded result. Surface that (this message is not one callers swallow) rather
// than quietly corrupting the bracket.
if (occupant !== null && (efMatch.isComplete || efMatch.winnerId)) {
throw new Error(
`Elimination Finals match ${efMatch.matchNumber} already has a recorded result, ` +
`so its Wildcard qualifier cannot be re-seeded — clear and regenerate the bracket`
);
}
if (occupant !== null) slotsToClear.push({ id: efMatch.id, matchNumber: efMatch.matchNumber });
if (belongsHere !== null) {
slotsToFill.push({ id: efMatch.id, matchNumber: efMatch.matchNumber, participantId: belongsHere });
}
}
const reseed: AflEliminationReseed = {
vacated: slotsToClear.map((slot) => slot.matchNumber),
filled: slotsToFill.map(({ matchNumber, participantId }) => ({ matchNumber, participantId })),
};
if (slotsToClear.length === 0 && slotsToFill.length === 0) return reseed;
// One transaction, vacating before filling: a half-applied re-seed would leave the
// same team in both Elimination Finals.
const db = database();
await db.transaction(async (tx) => {
const now = new Date();
for (const slot of slotsToClear) {
await tx
.update(schema.playoffMatches)
.set({ participant2Id: null, updatedAt: now })
.where(eq(schema.playoffMatches.id, slot.id));
}
for (const slot of slotsToFill) {
await tx
.update(schema.playoffMatches)
.set({ participant2Id: slot.participantId, updatedAt: now })
.where(eq(schema.playoffMatches.id, slot.id));
}
});
return reseed;
}
/** What a Semi-Finals re-seed changed, by Semi-Finals match number. */
export interface AflSemiFinalReseed {
vacated: number[];
filled: Array<{ matchNumber: number; participantId: string }>;
}
/**
* Put the decided Elimination Final winners in the Semi-Finals they belong in.
*
* Unlike the Wildcard Round, this pathway is fixed: Elimination Final n feeds Semi-Final
* n, so SF1 is the QF1 loser against the EF1 winner and SF2 the QF2 loser against the EF2
* winner. The crossover in this system comes a round later, at Semi-Final Preliminary
* Final, so that a Qualifying Final loser cannot meet the side that just beat it.
*
* Brackets advanced before this was fixed crossed the two winners the EF1 winner went
* to SF2 and the EF2 winner to SF1 which is why this reconciles both slots against the
* results recorded so far rather than writing the one it was called for: a winner sitting
* in the wrong Semi-Final is vacated, and a corrected Elimination Final result pulls the
* beaten team back out instead of leaving it alive.
*
* `pending` supplies a result that may not be in the database yet the row read back
* while advancing a match can predate the winner being written to it.
*
* Idempotent: pairings that are already right do no writes.
*/
export async function reseedAflSemiFinals(
eventId: string,
pending?: { matchId: string; winnerId: string }
): Promise<AflSemiFinalReseed> {
const [efMatches, sfMatches] = await Promise.all([
findPlayoffMatchesByEventIdAndRound(eventId, "Elimination Finals"),
findPlayoffMatchesByEventIdAndRound(eventId, "Semi-Finals"),
]);
// Nothing to reconcile against is a bad event id or a broken bracket, not a no-op.
if (efMatches.length === 0 || sfMatches.length === 0) {
throw new Error(
`Event ${eventId} has no AFL Elimination Finals / Semi-Finals matches to re-seed`
);
}
// Elimination Final n feeds Semi-Final n, so a decided winner's destination never
// depends on the other game.
const wanted = new Map<number, string>();
for (const ef of efMatches) {
const decidedWinner =
pending && ef.id === pending.matchId ? pending.winnerId : ef.isComplete ? ef.winnerId : null;
if (!decidedWinner) continue;
if (decidedWinner !== ef.participant1Id && decidedWinner !== ef.participant2Id) {
throw new Error(
`Elimination Finals match ${ef.matchNumber} winner is not one of its participants`
);
}
wanted.set(ef.matchNumber, decidedWinner);
}
// Only these teams can legitimately be moved between the two Semi-Finals; anyone else
// in a slot came from somewhere this function knows nothing about.
const eliminationParticipants = new Set<string>();
for (const ef of efMatches) {
if (ef.participant1Id) eliminationParticipants.add(ef.participant1Id);
if (ef.participant2Id) eliminationParticipants.add(ef.participant2Id);
}
const slotsToClear: Array<{ id: string; matchNumber: number }> = [];
const slotsToFill: Array<{ id: string; matchNumber: number; participantId: string }> = [];
for (const sfMatch of sfMatches) {
const occupant = sfMatch.participant2Id;
const belongsHere = wanted.get(sfMatch.matchNumber) ?? null;
if (occupant === belongsHere) continue;
if (occupant !== null && !eliminationParticipants.has(occupant)) {
throw new Error(`SF ${sfMatch.matchNumber} participant2 already filled`);
}
// Re-seeding a game that has already been played would rewrite who contested a
// recorded result. Surface that (this message is not one callers swallow) rather
// than quietly corrupting the bracket.
if (occupant !== null && (sfMatch.isComplete || sfMatch.winnerId)) {
throw new Error(
`Semi-Finals match ${sfMatch.matchNumber} already has a recorded result, ` +
`so its Elimination Finals qualifier cannot be re-seeded — clear and regenerate the bracket`
);
}
if (occupant !== null) slotsToClear.push({ id: sfMatch.id, matchNumber: sfMatch.matchNumber });
if (belongsHere !== null) {
slotsToFill.push({ id: sfMatch.id, matchNumber: sfMatch.matchNumber, participantId: belongsHere });
}
}
const reseed: AflSemiFinalReseed = {
vacated: slotsToClear.map((slot) => slot.matchNumber),
filled: slotsToFill.map(({ matchNumber, participantId }) => ({ matchNumber, participantId })),
};
if (slotsToClear.length === 0 && slotsToFill.length === 0) return reseed;
// One transaction, vacating before filling: a half-applied re-seed would leave the
// same team in both Semi-Finals.
const db = database();
await db.transaction(async (tx) => {
const now = new Date();
for (const slot of slotsToClear) {
await tx
.update(schema.playoffMatches)
.set({ participant2Id: null, updatedAt: now })
.where(eq(schema.playoffMatches.id, slot.id));
}
for (const slot of slotsToFill) {
await tx
.update(schema.playoffMatches)
.set({ participant2Id: slot.participantId, updatedAt: now })
.where(eq(schema.playoffMatches.id, slot.id));
}
});
return reseed;
}
/** /**
* AFL-specific advancement logic for the complex double-chance system * AFL-specific advancement logic for the complex double-chance system
* Phase 3.3: Handles both winners and losers advancing to different rounds * Phase 3.3: Handles both winners and losers advancing to different rounds
* *
* Advancement rules: * Advancement rules:
* - Wildcard Round: Winner Elimination Finals * - Wildcard Round: Winner Elimination Finals (re-seeded by ladder position)
* - Qualifying Finals: Winner Preliminary Finals, Loser Semi-Finals * - Qualifying Finals: Winner Preliminary Finals, Loser Semi-Finals (QF n PF n, SF n)
* - Elimination Finals: Winner Semi-Finals * - Elimination Finals: Winner Semi-Finals (EF n SF n, a fixed pathway)
* - Semi-Finals: Winner Preliminary Finals * - Semi-Finals: Winner Preliminary Finals (SF n crosses over: SF1 PF2, SF2 PF1)
* - Preliminary Finals: Winner Grand Final * - Preliminary Finals: Winner Grand Final
*/ */
async function advanceAFLWinner( async function advanceAFLWinner(
@ -767,18 +1131,10 @@ async function advanceAFLWinner(
): Promise<void> { ): Promise<void> {
const eventId = match.scoringEventId; const eventId = match.scoringEventId;
// Wildcard Round: Winner advances to Elimination Finals // Wildcard Round: winners are re-seeded into the Elimination Finals by ladder
// position, so every result re-resolves both slots.
if (match.round === "Wildcard Round") { if (match.round === "Wildcard Round") {
// Wildcard Match 1 winner → EF Match 2, participant2Id await reseedAflEliminationFinals(eventId, { matchId: match.id, winnerId });
// Wildcard Match 2 winner → EF Match 1, participant2Id
const efMatchNumber = match.matchNumber === 1 ? 2 : 1;
const efMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Elimination Finals");
const efMatch = efMatches.find((m) => m.matchNumber === efMatchNumber);
if (!efMatch) throw new Error(`Elimination Finals match ${efMatchNumber} not found`);
if (efMatch.participant2Id) throw new Error(`EF ${efMatchNumber} participant2 already filled`);
await updatePlayoffMatch(efMatch.id, { participant2Id: winnerId });
return; return;
} }
@ -806,18 +1162,11 @@ async function advanceAFLWinner(
return; return;
} }
// Elimination Finals: Winner → Semi-Finals // Elimination Finals: Winner → Semi-Finals. EF n feeds SF n — the crossover in this
// system is a round later, at Semi-Finals → Preliminary Finals. Reconcile both slots so
// a corrected result moves the qualifier instead of leaving the beaten team alive.
if (match.round === "Elimination Finals") { if (match.round === "Elimination Finals") {
// EF Match 1 winner → SF2 participant2 await reseedAflSemiFinals(eventId, { matchId: match.id, winnerId });
// EF Match 2 winner → SF1 participant2
const sfMatchNumber = match.matchNumber === 1 ? 2 : 1;
const sfMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Semi-Finals");
const sfMatch = sfMatches.find((m) => m.matchNumber === sfMatchNumber);
if (!sfMatch) throw new Error(`Semi-Finals match ${sfMatchNumber} not found`);
if (sfMatch.participant2Id) throw new Error(`SF ${sfMatchNumber} participant2 already filled`);
await updatePlayoffMatch(sfMatch.id, { participant2Id: winnerId });
return; return;
} }
@ -878,6 +1227,15 @@ export async function advanceWinnerTemplate(
return await advanceNBAPlayInWinner(match, winnerId, loserId); return await advanceNBAPlayInWinner(match, winnerId, loserId);
} }
// Special handling for LLWS 20 double elimination: winners-bracket losers route
// into the elimination bracket instead of being knocked out.
if (template.id === "llws_20") {
const loserId =
match.participant1Id === winnerId ? match.participant2Id : match.participant1Id;
if (!loserId) throw new Error("Cannot determine loser for LLWS advancement");
return await advanceLLWSWinner(match, winnerId, loserId);
}
// Special handling for AFL 10 double-chance system // Special handling for AFL 10 double-chance system
// Phase 3.3: AFL has complex winner/loser advancement rules // Phase 3.3: AFL has complex winner/loser advancement rules
if (template.id === "afl_10") { if (template.id === "afl_10") {
@ -1325,6 +1683,12 @@ export function doesLoserAdvance(
if (templateId === "afl_10" && round === "Qualifying Finals") { if (templateId === "afl_10" && round === "Qualifying Finals") {
return true; return true;
} }
// LLWS winners bracket: a loss drops the team into the elimination bracket, so it
// must not be recorded as an elimination. (Winners Final and Bracket Championship
// are scoring rounds and are handled via loserIsPartial instead.)
if (templateId === "llws_20" && LLWS_LOSER_ADVANCES_ROUNDS.has(round)) {
return true;
}
return false; return false;
} }
@ -1436,3 +1800,140 @@ async function advanceNBAPlayInWinner(
throw new Error(`Unknown Play-In Round 2 match number: ${match.matchNumber}`); throw new Error(`Unknown Play-In Round 2 match number: ${match.matchNumber}`);
} }
} }
// ── LLWS 20 (double elimination) ──────────────────────────────────────────────
// The routing table itself is pure and lives in lib/ so the renderer can import it
// without pulling the database context into the browser bundle. Re-exported here so
// existing server-side callers and tests keep their import path.
export { LLWS_LOSER_ADVANCES_ROUNDS, resolveLLWSAdvancement, type LLWSResolvedDestination };
/**
* Generate the 20-team LLWS double-elimination bracket (38 matches).
*
* Only the Opening Round and the four bye slots receive participants up front;
* everything else is filled by advanceLLWSWinner as games complete.
*
* Participant array layout (see LLWS_20 in lib/bracket-templates):
* [07] U.S. Opening Round teams, two per game
* [8, 9] U.S. bye teams Winners Round 2 M1 / M2 participant1
* [1017] International Opening Round teams, two per game
* [18,19] International bye teams Winners Round 2 M3 / M4 participant1
*/
async function generateLLWS20Bracket(
eventId: string,
template: BracketTemplate,
participantIds?: string[]
): Promise<PlayoffMatch[]> {
const matches: NewPlayoffMatch[] = [];
const p = (idx: number): string | null =>
participantIds ? (participantIds[idx] ?? null) : null;
const sides = [
{ side: 0 as const, label: "U.S.", openingBase: 0, byeBase: 8 },
{ side: 1 as const, label: "Intl", openingBase: 10, byeBase: 18 },
];
// ── Opening Round: 4 games per side, both slots seeded ──────────────────────
for (const { side, label, openingBase } of sides) {
for (let local = 1; local <= 4; local++) {
matches.push({
scoringEventId: eventId,
round: "Opening Round",
matchNumber: llwsMatchNumber("Opening Round", side, local),
participant1Id: p(openingBase + (local - 1) * 2),
participant2Id: p(openingBase + (local - 1) * 2 + 1),
isComplete: false,
isScoring: false,
templateRound: "Opening Round",
seedInfo: `${label} Opening ${local}`,
});
}
}
// ── Winners Round 2: bye team at participant1, Opening winner at participant2 ─
for (const { side, label, byeBase } of sides) {
for (let local = 1; local <= 2; local++) {
matches.push({
scoringEventId: eventId,
round: "Winners Round 2",
matchNumber: llwsMatchNumber("Winners Round 2", side, local),
participant1Id: p(byeBase + (local - 1)),
participant2Id: null, // Opening Round winner
isComplete: false,
isScoring: false,
templateRound: "Winners Round 2",
seedInfo: `${label} Bye ${local} vs Opening ${local} winner`,
});
}
}
// ── Every remaining round starts empty ──────────────────────────────────────
const remaining = template.rounds.filter(
(r) => r.name !== "Opening Round" && r.name !== "Winners Round 2"
);
for (const round of remaining) {
for (let i = 1; i <= round.matchCount; i++) {
// Championship/Consolation are single shared games; everything else is per-side.
const perSide = round.matchCount > 1;
const label = perSide
? llwsSideAndLocal(round.name, i).side === 0
? "U.S."
: "Intl"
: null;
matches.push({
scoringEventId: eventId,
round: round.name,
matchNumber: i,
participant1Id: null,
participant2Id: null,
isComplete: false,
isScoring: round.isScoring,
templateRound: round.name,
seedInfo: label ? `${label} ${round.name}` : null,
});
}
}
return await createManyPlayoffMatches(matches);
}
/**
* LLWS advancement: routes the winner forward and, in the winners bracket, routes the
* loser into the elimination bracket rather than eliminating them.
*
* All routing decisions live in resolveLLWSAdvancement; this function only writes.
*/
async function advanceLLWSWinner(
match: PlayoffMatch,
winnerId: string,
loserId: string
): Promise<void> {
const eventId = match.scoringEventId;
const { winner, loser } = resolveLLWSAdvancement(match.round, match.matchNumber);
// Winner and loser can land in different rounds, so resolve each independently.
const moves: Array<{ destination: LLWSResolvedDestination; participantId: string }> = [];
if (winner) moves.push({ destination: winner, participantId: winnerId });
if (loser) moves.push({ destination: loser, participantId: loserId });
for (const { destination, participantId } of moves) {
const targetMatches = await findPlayoffMatchesByEventIdAndRound(
eventId,
destination.round
);
const target = targetMatches.find((m) => m.matchNumber === destination.matchNumber);
if (!target) {
throw new Error(
`Next match not found: round=${destination.round}, matchNumber=${destination.matchNumber}`
);
}
if (target[destination.slot]) {
throw new Error(
`Next match ${destination.slot} is already filled ` +
`(round=${destination.round}, matchNumber=${destination.matchNumber})`
);
}
await updatePlayoffMatch(target.id, { [destination.slot]: participantId });
}
}

View file

@ -33,6 +33,33 @@ export function roundQualifyingPoints(points: number): number {
return Math.round((points + Number.EPSILON) * 100) / 100; 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( export function calculateSplitQualifyingPoints(
placement: number, placement: number,
tieCount: number, tieCount: number,
@ -48,21 +75,6 @@ export function calculateSplitQualifyingPoints(
return roundQualifyingPoints(totalQP / tieCount); 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 * Initialize default qualifying point configuration for a sports season
*/ */
@ -283,6 +295,57 @@ export async function recalculateParticipantQP(
return { totalQP, eventsScored }; 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 * Recalculate eventsScored for a participant by counting unique events with QP awarded
* @deprecated Use recalculateParticipantQP instead for more accurate recalculation * @deprecated Use recalculateParticipantQP instead for more accurate recalculation

View file

@ -9,20 +9,23 @@ import {
} from "./scoring-rules"; } from "./scoring-rules";
import { getSeasonResults } from "./participant-season-result"; import { getSeasonResults } from "./participant-season-result";
import { updateProbabilitiesAfterResult } from "~/services/probability-updater"; import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
import { sendStandingsUpdateNotification, type ScoredMatch } from "~/services/discord"; import { sendStandingsUpdateNotification, type ScoredMatch, type EliminatedTeam } from "~/services/discord";
import { BRACKET_TEMPLATES } from "~/lib/bracket-templates"; 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 { doesLoserAdvance, findPlayoffMatchesByEventId } from "~/models/playoff-match";
import { getUserDisplayName } from "~/models/user"; import { getUserDisplayName } from "~/models/user";
import { findDiscordIdsByUserIds } from "~/models/account"; import { findDiscordIdsByUserIds } from "~/models/account";
import { createDailySnapshot } from "~/models/standings"; import { createDailySnapshot } from "~/models/standings";
import { getBracketTemplateIdForSportsSeason } from "~/models/bracket-template";
import { recordMatchScoreEvents } from "~/models/team-score-events"; import { recordMatchScoreEvents } from "~/models/team-score-events";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
import { getEventResults } from "./event-result"; import { getEventResults } from "./event-result";
import { import {
calculateSplitQualifyingPoints, calculateSplitQualifyingPoints,
diffChangedQualifyingPoints,
getQPConfig, getQPConfig,
hasProcessedQualifyingPlacement,
recalculateParticipantQP, recalculateParticipantQP,
writeEventResultsQP,
getQPStandings, getQPStandings,
updateFinalRankings, updateFinalRankings,
} from "./qualifying-points"; } from "./qualifying-points";
@ -111,31 +114,169 @@ const TEMPLATE_ROUND_CONFIG: Record<string, Record<string, RoundScoringConfig>>
// 3rd place game finalizes both positions distinctly. // 3rd place game finalizes both positions distinctly.
"Third Place Game": { loserPosition: 4, loserIsPartial: false, winnerFloor: null, winnerPosition: 3 }, "Third Place Game": { loserPosition: 4, loserIsPartial: false, winnerFloor: null, winnerPosition: 3 },
}, },
llws_20: {
// Winners Final loser drops to the Elimination Final, so 5th is provisional —
// winning that game lifts them back to a 4th-place floor.
"Winners Final": { loserPosition: 5, loserIsPartial: true, winnerFloor: 4 },
// Elimination Round 4 losers are the 7th8th tier (8 teams alive at this point).
"Elimination Round 4": { loserPosition: 7, loserIsPartial: false, winnerFloor: 5 },
// Elimination Final losers are the 5th6th tier; the winner reaches the side
// championship, where the worst case is 4th (lose it, then lose the consolation).
"Elimination Final": { loserPosition: 5, loserIsPartial: false, winnerFloor: 4 },
// Side championship loser still has the consolation game — provisional 4th.
"Bracket Championship": { loserPosition: 4, loserIsPartial: true, winnerFloor: 2 },
// Consolation finalizes 3rd and 4th distinctly.
"Consolation Third Place": { loserPosition: 4, loserIsPartial: false, winnerFloor: null, winnerPosition: 3 },
"World Championship": { loserPosition: 2, loserIsPartial: false, winnerFloor: null },
},
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 },
},
}; };
/** /**
* Returns true if a non-scoring round's winners are entering the first scoring round * Returns the floor position that winners of a NON-scoring round should bank, or null
* (i.e., they've guaranteed a top-8 fantasy placement and should receive a T5T8 floor). * to bank nothing.
* *
* For multi-round pre-bracket sequences like NCAA (Round of 64 Round of 32 * Default: winners entering the first scoring round have guaranteed a top-8 fantasy
* Sweet Sixteen Elite Eight), only Sweet Sixteen winners are entering the scoring * placement and receive a T5T8 floor (5); everyone else gets nothing yet. For
* bracket Round of 64 and Round of 32 winners should not receive any floor yet. * multi-round pre-bracket sequences like NCAA (Round of 64 Round of 32 Sweet
* Sixteen Elite Eight), only Sweet Sixteen winners are entering the scoring bracket.
* *
* Falls back to true when template/round info is unavailable to preserve legacy behavior. * A round may override this with `nonScoringWinnerFloor` when the default is wrong
* in a double-elimination losers bracket a win can guarantee a worse finish than 5th
* (llws_20 "Elimination Round 3" 7), or nothing at all.
*
* Falls back to 5 when template/round info is unavailable, preserving legacy behavior.
*/ */
function doesNonScoringRoundFeedIntoScoringRound( function nonScoringWinnerFloorFor(
round: string, round: string,
bracketTemplateId: string | null | undefined bracketTemplateId: string | null | undefined
): boolean { ): number | null {
if (!bracketTemplateId) return true; // Legacy: preserve old behavior if (!bracketTemplateId) return 5; // Legacy: preserve old behavior
const template = BRACKET_TEMPLATES[bracketTemplateId]; const template = BRACKET_TEMPLATES[bracketTemplateId];
if (!template) return true; // Unknown template: preserve old behavior if (!template) return 5; // Unknown template: preserve old behavior
const currentRound = template.rounds.find((r) => r.name === round); const currentRound = template.rounds.find((r) => r.name === round);
if (!currentRound) return true; // Unknown round: preserve old behavior if (!currentRound) return 5; // Unknown round: preserve old behavior
// Explicit per-round override wins, including an explicit null (bank nothing).
if (currentRound.nonScoringWinnerFloor !== undefined) {
return currentRound.nonScoringWinnerFloor;
}
const nextRoundName = currentRound.feedsInto; const nextRoundName = currentRound.feedsInto;
if (!nextRoundName) return false; // No next round (shouldn't happen for non-scoring) if (!nextRoundName) return null; // No next round (shouldn't happen for non-scoring)
const nextRound = template.rounds.find((r) => r.name === nextRoundName); const nextRound = template.rounds.find((r) => r.name === nextRoundName);
return nextRound?.isScoring === true; return nextRound?.isScoring === true ? 5 : null;
}
/**
* Returns the floor position a participant banks purely by being *seeded into* the
* given round when the bracket is generated, or null when entry guarantees nothing.
*
* Two sources, in order:
* 1. The template round's explicit `entryFloor` (e.g. afl_10 "Qualifying Finals" 5:
* seeds 1-4 have the double chance, so the 5th-6th tier is locked in on day one).
* 2. Otherwise a scoring round's own loser position being drawn into a round whose
* losers score means the worst case is that round's loser tier.
*
* Non-scoring rounds with no explicit `entryFloor` return null: losing your first game
* there is worth 0, so there is nothing to bank yet.
*/
export function getBracketEntryFloor(
round: string,
bracketTemplateId: string | null | undefined
): number | null {
const template = bracketTemplateId ? BRACKET_TEMPLATES[bracketTemplateId] : undefined;
const templateRound = template?.rounds.find((r) => r.name === round);
if (templateRound?.entryFloor !== undefined) return templateRound.entryFloor;
if (!templateRound?.isScoring) return null;
return getRoundConfig(round, bracketTemplateId)?.loserPosition ?? null;
}
/**
* Write the provisional entry floors for a freshly generated (or reprocessed) bracket.
*
* A seeded bracket can guarantee points before anyone plays: an AFL top-4 seed cannot
* finish below the 5th-6th tier because a Qualifying Final loss still leaves them a
* Semi-Final. Without this, those teams sit on 0 fantasy points until their first game
* resolves, which understates every roster holding them.
*
* Only participants already assigned to a match slot are touched, and every write is
* provisional (isPartialScore=true) so it is superseded the moment a real result lands.
*
* Floors never go backwards. A participant already sitting on an equal or better
* placement is skipped, so regenerating a bracket mid-tournament (clear-bracket
* generate-bracket) cannot knock a finalist back down to their seeding floor. Combined
* with upsertParticipantResult's never-un-finalize guard, re-running over the same
* bracket is a no-op.
*
* Returns the number of participants whose floor this call actually raised.
*/
export async function applyBracketEntryFloors(
eventId: string,
providedDb?: ReturnType<typeof database>
): Promise<number> {
const db = providedDb || database();
const event = await db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.id, eventId),
});
if (!event?.bracketTemplateId) return 0;
const matches = await db.query.playoffMatches.findMany({
where: eq(schema.playoffMatches.scoringEventId, eventId),
});
// Highest (best) floor wins when a participant somehow appears in more than one
// round's slots — a lower position number is a better guarantee.
const floorByParticipant = new Map<string, number>();
for (const match of matches) {
const floor = getBracketEntryFloor(match.round, event.bracketTemplateId);
if (floor === null) continue;
for (const participantId of [match.participant1Id, match.participant2Id]) {
if (!participantId) continue;
const existing = floorByParticipant.get(participantId);
if (existing === undefined || floor < existing) {
floorByParticipant.set(participantId, floor);
}
}
}
// Existing placements, so a floor is only ever written when it improves on what
// the participant already has. Position 0 means eliminated / missed the bracket —
// not a better placement — so it never blocks a floor.
const existingRows = await db.query.seasonParticipantResults.findMany({
where: eq(schema.seasonParticipantResults.sportsSeasonId, event.sportsSeasonId),
columns: { participantId: true, finalPosition: true },
});
const existingPosition = new Map(
existingRows
.filter((r) => r.finalPosition !== null && r.finalPosition > 0)
.map((r) => [r.participantId, r.finalPosition as number])
);
let applied = 0;
for (const [participantId, floor] of floorByParticipant) {
const current = existingPosition.get(participantId);
if (current !== undefined && current <= floor) continue; // already as good or better
const oldFloor = await upsertParticipantResult(
participantId,
event.sportsSeasonId,
floor,
db,
true // provisional: replaced as soon as the participant wins or is eliminated
);
if (oldFloor !== null) applied++;
}
return applied;
} }
/** /**
@ -143,7 +284,7 @@ function doesNonScoringRoundFeedIntoScoringRound(
* overrides before falling back to the standard ROUND_CONFIG. * overrides before falling back to the standard ROUND_CONFIG.
* Returns null for unrecognized rounds. * Returns null for unrecognized rounds.
*/ */
function getRoundConfig( export function getRoundConfig(
round: string, round: string,
bracketTemplateId?: string | null bracketTemplateId?: string | null
): RoundScoringConfig | null { ): RoundScoringConfig | null {
@ -269,19 +410,18 @@ export async function processPlayoffEvent(
} }
if (!isScoring) { if (!isScoring) {
// Non-scoring (pre-bracket) round: losers are permanently eliminated (0 pts). // Non-scoring round: losers are permanently eliminated (0 pts) unless they
// Winners only bank a provisional T5T8 floor if they're entering the first // advance (double-elimination winners-bracket losers). Winners bank a
// scoring round (i.e., guaranteed top-8). For multi-round pre-bracket sequences // provisional floor only when this round guarantees them one — see
// like NCAA (R64 → R32 → Sweet 16 → Elite Eight), only Sweet 16 winners should // nonScoringWinnerFloorFor for how that is derived per template.
// receive floor points — R64 and R32 winners are not yet guaranteed top-8. const winnerFloor = nonScoringWinnerFloorFor(round, event.bracketTemplateId);
const awardFloor = doesNonScoringRoundFeedIntoScoringRound(round, event.bracketTemplateId);
for (const match of matches) { for (const match of matches) {
const loserAdvances = doesLoserAdvance(round, match.matchNumber, event.bracketTemplateId ?? ""); const loserAdvances = doesLoserAdvance(round, match.matchNumber, event.bracketTemplateId ?? "");
if (match.loserId && !loserAdvances) { if (match.loserId && !loserAdvances) {
await upsertParticipantResult(match.loserId, event.sportsSeasonId, 0, db); await upsertParticipantResult(match.loserId, event.sportsSeasonId, 0, db);
} }
if (match.winnerId && awardFloor) { if (match.winnerId && winnerFloor !== null) {
await upsertParticipantResult(match.winnerId, event.sportsSeasonId, 5, db, true); await upsertParticipantResult(match.winnerId, event.sportsSeasonId, winnerFloor, db, true);
} }
} }
} else { } else {
@ -341,7 +481,7 @@ export async function processPlayoffEvent(
// Progressive floor scoring: assign guaranteed minimum points to winners. // Progressive floor scoring: assign guaranteed minimum points to winners.
// For Finals (winnerFloor=null) getGuaranteedMinimumPosition returns null — the // For Finals (winnerFloor=null) getGuaranteedMinimumPosition returns null — the
// winner is already finalized as 1st above. For non-scoring rounds it also // winner is already finalized as 1st above. For non-scoring rounds it also
// returns null (winners were given floor 5 inline above). // returns null; those winners were given their floor inline above.
const guaranteedMinimum = getGuaranteedMinimumPosition( const guaranteedMinimum = getGuaranteedMinimumPosition(
round, round,
event.bracketTemplateId, event.bracketTemplateId,
@ -407,6 +547,19 @@ export async function processMatchResult(
/** When set, Discord notification only shows this match (not all completed matches for the event). */ /** When set, Discord notification only shows this match (not all completed matches for the event). */
matchId?: string; matchId?: string;
skipSideEffects?: boolean; skipSideEffects?: boolean;
/**
* Skip only the probability refresh, still recalculating standings and announcing.
*
* For a caller scoring several matches in a loop: the refresh is season-wide and
* idempotent, so running it per match repeats the whole thing needlessly and for a
* bracket-aware sport that now means a full Monte Carlo run each time. Set this in the
* loop and call updateProbabilitiesAfterResult once when it finishes. Per-match
* announcements then project from the previous probabilities until that final call.
*
* Distinct from skipSideEffects, which also suppresses the standings recalculation and
* the announcement.
*/
skipProbabilities?: boolean;
/** /**
* When true, the loser of this non-scoring round advances to another match * When true, the loser of this non-scoring round advances to another match
* (e.g. NBA Play-In Round 1 7v8 loser Play-In Round 2) and must NOT be * (e.g. NBA Play-In Round 1 7v8 loser Play-In Round 2) and must NOT be
@ -417,7 +570,7 @@ export async function processMatchResult(
providedDb?: ReturnType<typeof database> providedDb?: ReturnType<typeof database>
): Promise<void> { ): Promise<void> {
const db = providedDb || database(); const db = providedDb || database();
const { round, winnerId, loserId, isScoring, sportsSeasonId, bracketTemplateId, eventId, eventName, matchId, skipSideEffects, loserAdvances } = params; const { round, winnerId, loserId, isScoring, sportsSeasonId, bracketTemplateId, eventId, eventName, matchId, skipSideEffects, skipProbabilities, loserAdvances } = params;
if (!isScoring) { if (!isScoring) {
// Non-scoring (pre-bracket) round: loser permanently eliminated (0 pts), // Non-scoring (pre-bracket) round: loser permanently eliminated (0 pts),
@ -429,8 +582,9 @@ export async function processMatchResult(
if (!loserAdvances) { if (!loserAdvances) {
await upsertParticipantResult(loserId, sportsSeasonId, 0, db); await upsertParticipantResult(loserId, sportsSeasonId, 0, db);
} }
if (doesNonScoringRoundFeedIntoScoringRound(round, bracketTemplateId)) { const nonScoringFloor = nonScoringWinnerFloorFor(round, bracketTemplateId);
await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true); if (nonScoringFloor !== null) {
await upsertParticipantResult(winnerId, sportsSeasonId, nonScoringFloor, db, true);
} }
// Non-scoring round wins are not surfaced in the Recent Scores feed. // Non-scoring round wins are not surfaced in the Recent Scores feed.
} else { } else {
@ -496,6 +650,7 @@ export async function processMatchResult(
: undefined; : undefined;
// Update probabilities first so the standings recalc reads fresh EVs and // Update probabilities first so the standings recalc reads fresh EVs and
// projected points reflect the new result. // projected points reflect the new result.
if (!skipProbabilities) {
try { try {
await updateProbabilitiesAfterResult(sportsSeasonId, true); await updateProbabilitiesAfterResult(sportsSeasonId, true);
} catch (error) { } catch (error) {
@ -504,6 +659,7 @@ export async function processMatchResult(
error error
); );
} }
}
await recalculateAffectedLeagues(sportsSeasonId, db, sideEffectOptions); await recalculateAffectedLeagues(sportsSeasonId, db, sideEffectOptions);
} }
} }
@ -612,6 +768,7 @@ export function isLoserNotifiable(
return scoreChanged || finalizedLoserIds.has(loserId); return scoreChanged || finalizedLoserIds.has(loserId);
} }
export function getGuaranteedMinimumPosition( export function getGuaranteedMinimumPosition(
round: string, round: string,
bracketTemplateId: string | null | undefined, bracketTemplateId: string | null | undefined,
@ -623,13 +780,260 @@ export function getGuaranteedMinimumPosition(
return config.winnerFloor; // null for Finals; actual floor for all other rounds 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. * Process a qualifying event completion and update QP totals.
* Ties in QP are handled by sharing placements (averaged points). * Ties in QP are handled by sharing placements (averaged points).
*/ */
export async function processQualifyingEvent( export async function processQualifyingEvent(
eventId: string, 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> { ): Promise<void> {
const db = providedDb || database(); const db = providedDb || database();
@ -652,9 +1056,40 @@ export async function processQualifyingEvent(
// Get all event results for this qualifying event // Get all event results for this qualifying event
const results = await getEventResults(eventId, db); const results = await getEventResults(eventId, db);
// Check if this was already processed (for majorsCompleted counter) // Snapshot awarded QP before reprocessing so the Discord notification below can
const wasAlreadyProcessed = hasProcessedQualifyingPlacement(results); // 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,
}));
// 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;
}
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. // Clear stale QP before reprocessing so removed/null placements do not keep old awards.
for (const result of results) { for (const result of results) {
await db await db
@ -671,6 +1106,28 @@ export async function processQualifyingEvent(
qpConfig.map((config) => [config.placement, parseFloat(config.points)]) 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 // Group results by placement to handle ties
const placementGroups = new Map<number, typeof results>(); const placementGroups = new Map<number, typeof results>();
for (const result of results) { for (const result of results) {
@ -683,7 +1140,7 @@ export async function processQualifyingEvent(
// Process each placement group and update event_results with QP awarded // Process each placement group and update event_results with QP awarded
for (const [placement, group] of placementGroups) { for (const [placement, group] of placementGroups) {
const tieCount = group.length; const tieCount = canonicalTieCountByPlacement?.get(placement) ?? group.length;
const qpPerParticipant = calculateSplitQualifyingPoints( const qpPerParticipant = calculateSplitQualifyingPoints(
placement, placement,
@ -702,6 +1159,7 @@ export async function processQualifyingEvent(
.where(eq(schema.eventResults.id, result.id)); .where(eq(schema.eventResults.id, result.id));
} }
} }
}
// Recalculate totals for all participants from scratch // Recalculate totals for all participants from scratch
// This is the source of truth - sums all their event_results // This is the source of truth - sums all their event_results
@ -710,21 +1168,46 @@ export async function processQualifyingEvent(
await recalculateParticipantQP(participantId, event.sportsSeasonId, db); await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
} }
// Increment majorsCompleted counter (only if this is the first time processing) // NOTE: majorsCompleted is NOT a stored counter. It is derived on read via
if (!wasAlreadyProcessed) { // getMajorsCompleted() (count of completed qualifying events). Incrementing here
const sportsSeason = event.sportsSeason; // per fan-out sync over-counted it past totalMajors ("11 of 4"), so the write was
await db // removed. See app/models/scoring-event.ts:getMajorsCompleted.
.update(schema.sportsSeasons)
.set({
majorsCompleted: (sportsSeason.majorsCompleted || 0) + 1,
updatedAt: new Date(),
})
.where(eq(schema.sportsSeasons.id, event.sportsSeasonId));
}
logger.log( logger.log(
`[ScoringCalculator] Processed qualifying event ${eventId}: awarded QP to ${results.length} participants` `[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);
}
}
} }
/** /**
@ -998,11 +1481,7 @@ export async function calculateTeamScore(
if (bracketTemplateCache.has(sportsSeasonId)) { if (bracketTemplateCache.has(sportsSeasonId)) {
return bracketTemplateCache.get(sportsSeasonId) ?? null; return bracketTemplateCache.get(sportsSeasonId) ?? null;
} }
const event = await db.query.scoringEvents.findFirst({ const templateId = await getBracketTemplateIdForSportsSeason(sportsSeasonId, db);
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
columns: { bracketTemplateId: true },
});
const templateId = event?.bracketTemplateId ?? null;
bracketTemplateCache.set(sportsSeasonId, templateId); bracketTemplateCache.set(sportsSeasonId, templateId);
return templateId; return templateId;
} }
@ -1111,11 +1590,7 @@ export async function calculateTeamProjectedScore(
if (bracketTemplateCache.has(sportsSeasonId)) { if (bracketTemplateCache.has(sportsSeasonId)) {
return bracketTemplateCache.get(sportsSeasonId) ?? null; return bracketTemplateCache.get(sportsSeasonId) ?? null;
} }
const event = await db.query.scoringEvents.findFirst({ const templateId = await getBracketTemplateIdForSportsSeason(sportsSeasonId, db);
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
columns: { bracketTemplateId: true },
});
const templateId = event?.bracketTemplateId ?? null;
bracketTemplateCache.set(sportsSeasonId, templateId); bracketTemplateCache.set(sportsSeasonId, templateId);
return templateId; return templateId;
} }
@ -1425,7 +1900,7 @@ export async function recalculateStandings(
export async function recalculateAffectedLeagues( export async function recalculateAffectedLeagues(
sportsSeasonId: string, sportsSeasonId: string,
providedDb?: ReturnType<typeof database>, 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> { ): Promise<void> {
const db = providedDb || database(); const db = providedDb || database();
@ -1494,6 +1969,18 @@ export async function recalculateAffectedLeagues(
for (const r of loserResults) finalizedLoserIds.add(r.participantId); 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 // Recalculate each affected season
for (const seasonId of seasonIds) { for (const seasonId of seasonIds) {
// Capture standings before recalculation for delta calculation // Capture standings before recalculation for delta calculation
@ -1568,20 +2055,33 @@ export async function recalculateAffectedLeagues(
afterStandings.map((s) => [s.teamId, s.team.ownerId]) 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. // Build scored matches for the notification.
// Winners only appear if their team's score changed (they earned points this round). // A match is worth announcing when:
// Losers appear if their team's score changed OR they were definitively eliminated // • the winner is owned by a manager AND earned points this round, OR
// (finalPosition set, non-partial) — the latter catches 0-pt eliminations that // • the loser is owned by a manager (eliminated or scored).
// produce no score delta. Losers who advance to another match (loserAdvances=true, // A winning team that merely advanced through a non-scoring round (e.g. R32 → R16
// e.g. NBA 7v8 → PIR2) have no finalized result and no score change, so they're // in the World Cup) does not qualify on its own — their owner hasn't earned anything yet.
// correctly suppressed. // 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; let scoredMatches: ScoredMatch[] | undefined;
if (allCompletedMatches.length > 0) { 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( const relevant = allCompletedMatches.filter(
(m) => (m) =>
(m.winnerId && draftedIds.has(m.winnerId)) || (m.winnerId && draftedIds.has(m.winnerId)) ||
@ -1589,10 +2089,6 @@ export async function recalculateAffectedLeagues(
); );
if (relevant.length > 0) { if (relevant.length > 0) {
const teamIdByParticipantId = new Map(
draftPicks.map((p) => [p.participantId, p.teamId])
);
const lookupOwner = (participantId: string | null | undefined) => { const lookupOwner = (participantId: string | null | undefined) => {
if (!participantId) return undefined; if (!participantId) return undefined;
const teamId = teamIdByParticipantId.get(participantId); const teamId = teamIdByParticipantId.get(participantId);
@ -1609,23 +2105,52 @@ export async function recalculateAffectedLeagues(
const winnerOwnerId = lookupOwner(m.winnerId); const winnerOwnerId = lookupOwner(m.winnerId);
const loserOwnerId = lookupOwner(m.loserId); const loserOwnerId = lookupOwner(m.loserId);
const showLoser = isLoserNotifiable(m.loserId, loserTeamId, changedTeamIds, finalizedLoserIds); const showLoser = isLoserNotifiable(m.loserId, loserTeamId, changedTeamIds, finalizedLoserIds);
return { return { m, winnerScoreChanged, showLoser, winnerOwnerId, loserOwnerId };
winnerName: m.winnerName ?? "", })
loserName: m.loserName ?? "", .filter((x) => (x.winnerScoreChanged && !!x.winnerOwnerId) || (x.showLoser && !!x.loserOwnerId))
winnerUsername: winnerScoreChanged && winnerOwnerId ? usernameByUserId.get(winnerOwnerId) : undefined, .map((x) => ({
loserUsername: showLoser && loserOwnerId ? usernameByUserId.get(loserOwnerId) : undefined, winnerName: x.m.winnerName ?? "",
winnerDiscordUserId: winnerScoreChanged && winnerOwnerId ? discordIdByUserId.get(winnerOwnerId) : undefined, loserName: x.m.loserName ?? "",
loserDiscordUserId: showLoser && loserOwnerId ? discordIdByUserId.get(loserOwnerId) : undefined, 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( const hasScoredMatchesToShow = scoredMatches?.some(
(m) => m.winnerUsername !== undefined || m.loserUsername !== undefined (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) => ({ const standings = afterStandings.map((s) => ({
teamId: s.teamId, teamId: s.teamId,
@ -1646,6 +2171,7 @@ export async function recalculateAffectedLeagues(
sportName, sportName,
eventName: options?.eventName, eventName: options?.eventName,
scoredMatches, scoredMatches,
eliminatedTeams,
}); });
} catch (err) { } catch (err) {
// Log but don't fail the scoring pipeline if Discord is unreachable // 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 { eq, and, desc, asc, gte, lte, or, inArray, isNotNull, sql } from "drizzle-orm";
import type { BracketRegion } from "~/lib/bracket-templates"; import type { BracketRegion } from "~/lib/bracket-templates";
import { recalculateAffectedLeagues } from "./scoring-calculator"; import { recalculateAffectedLeagues } from "./scoring-calculator";
import { hasProcessedQualifyingPlacement, recalculateParticipantQP } from "./qualifying-points"; import { recalculateParticipantQP } from "./qualifying-points";
import { findParticipantNamesByIds } from "./season-participant"; import { findParticipantNamesByIds } from "./season-participant";
import { deleteTournament } from "./tournament";
export type EventType = "playoff_game" | "major_tournament" | "final_standings" | "schedule_event"; import type { EventType } from "./scoring-event-types";
export { type EventType, getEventTypeLabel } from "./scoring-event-types";
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;
}
}
export interface CreateScoringEventData { export interface CreateScoringEventData {
sportsSeasonId: string; sportsSeasonId: string;
@ -42,6 +33,8 @@ export interface UpdateScoringEventData {
scoringStartsAtRound?: string; scoringStartsAtRound?: string;
/** Per-event region config for NCAA-style brackets (overrides template defaults) */ /** Per-event region config for NCAA-style brackets (overrides template defaults) */
bracketRegionConfig?: BracketRegion[] | null; 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 * Update a scoring event
*/ */
@ -173,6 +193,7 @@ export async function updateScoringEvent(
if (data.bracketTemplateId !== undefined) updateData.bracketTemplateId = data.bracketTemplateId; if (data.bracketTemplateId !== undefined) updateData.bracketTemplateId = data.bracketTemplateId;
if (data.scoringStartsAtRound !== undefined) updateData.scoringStartsAtRound = data.scoringStartsAtRound; if (data.scoringStartsAtRound !== undefined) updateData.scoringStartsAtRound = data.scoringStartsAtRound;
if (data.bracketRegionConfig !== undefined) updateData.bracketRegionConfig = data.bracketRegionConfig; if (data.bracketRegionConfig !== undefined) updateData.bracketRegionConfig = data.bracketRegionConfig;
if (data.externalSourceKey !== undefined) updateData.externalSourceKey = data.externalSourceKey;
const [updated] = await db const [updated] = await db
.update(schema.scoringEvents) .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 * sports season (placements are stored there with no FK back to the event) and
* recalculates league standings. * 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( export async function deleteScoringEvent(
eventId: string, eventId: string,
providedDb?: ReturnType<typeof database> providedDb?: ReturnType<typeof database>,
) { opts?: DeleteScoringEventOptions
): Promise<DeleteScoringEventResult> {
const db = providedDb || database(); const db = providedDb || database();
const event = await db.query.scoringEvents.findFirst({ const event = await db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.id, eventId), 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 // For qualifying events: capture affected participant IDs before the cascade deletes
// eventResults (which is how we know who had QP awarded from this event). // eventResults (which is how we know who had QP awarded from this event).
let affectedParticipantIds: string[] = []; let affectedParticipantIds: string[] = [];
let wasQPProcessed = false;
if (event.isQualifyingEvent) { if (event.isQualifyingEvent) {
const results = await db.query.eventResults.findMany({ const results = await db.query.eventResults.findMany({
where: eq(schema.eventResults.scoringEventId, eventId), where: eq(schema.eventResults.scoringEventId, eventId),
}); });
affectedParticipantIds = results.map((r) => r.seasonParticipantId); affectedParticipantIds = results.map((r) => r.seasonParticipantId);
wasQPProcessed = hasProcessedQualifyingPlacement(results);
} }
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
@ -252,21 +300,49 @@ export async function deleteScoringEvent(
await recalculateParticipantQP(participantId, event.sportsSeasonId, db); await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
} }
// Decrement majorsCompleted if this event had already been processed // majorsCompleted is derived on read (getMajorsCompleted), so deleting the event
if (wasQPProcessed) { // — which removes it from the completed-qualifying-event count — self-corrects the
const sportsSeason = await db.query.sportsSeasons.findFirst({ // number. No stored counter to decrement.
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));
}
}
await recalculateAffectedLeagues(event.sportsSeasonId, db); 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,
};
} }
/** /**
@ -310,10 +386,13 @@ export async function getUpcomingScoringEvents(
if (events.length === 0) return []; if (events.length === 0) return [];
// Find earliest upcoming game time per event from bracket match games. // Find earliest upcoming game time per event from both bracket match games
// and group stage matches (FIFA World Cup style).
const eventIds = events.map((e) => e.id); const eventIds = events.map((e) => e.id);
const now = new Date(); const now = new Date();
const gameTimeRows = await db
const [gameTimeRows, groupStageTimeRows] = await Promise.all([
db
.select({ .select({
eventId: schema.scoringEvents.id, eventId: schema.scoringEvents.id,
scheduledAt: schema.playoffMatchGames.scheduledAt, scheduledAt: schema.playoffMatchGames.scheduledAt,
@ -333,10 +412,28 @@ export async function getUpcomingScoringEvents(
isNotNull(schema.playoffMatchGames.scheduledAt), isNotNull(schema.playoffMatchGames.scheduledAt),
gte(schema.playoffMatchGames.scheduledAt, now) gte(schema.playoffMatchGames.scheduledAt, now)
) )
); ),
db
.select({
eventId: schema.tournamentGroups.scoringEventId,
scheduledAt: schema.groupStageMatches.scheduledAt,
})
.from(schema.groupStageMatches)
.innerJoin(
schema.tournamentGroups,
eq(schema.groupStageMatches.tournamentGroupId, schema.tournamentGroups.id)
)
.where(
and(
inArray(schema.tournamentGroups.scoringEventId, eventIds),
isNotNull(schema.groupStageMatches.scheduledAt),
gte(schema.groupStageMatches.scheduledAt, now)
)
),
]);
const earliestGameById = new Map<string, Date>(); const earliestGameById = new Map<string, Date>();
for (const row of gameTimeRows) { for (const row of [...gameTimeRows, ...groupStageTimeRows]) {
if (!row.scheduledAt) continue; if (!row.scheduledAt) continue;
const prev = earliestGameById.get(row.eventId); const prev = earliestGameById.get(row.eventId);
if (!prev || row.scheduledAt < prev) { if (!prev || row.scheduledAt < prev) {
@ -353,7 +450,15 @@ export async function getUpcomingScoringEvents(
return candidates.length > 0 ? Math.min(...candidates) : Infinity; return candidates.length > 0 ? Math.min(...candidates) : Infinity;
}; };
return events.toSorted((a, b) => getEffectiveMs(a) - getEffectiveMs(b)).slice(0, limit); return events
.toSorted((a, b) => getEffectiveMs(a) - getEffectiveMs(b))
.slice(0, limit)
.map((e) => ({
...e,
earliestGameTime: earliestGameById.get(e.id)?.toISOString()
?? e.eventStartsAt?.toISOString()
?? null,
}));
} }
/** /**
@ -1038,3 +1143,112 @@ export async function getSportsSeasonsByTournament(tournamentId: string) {
return true; 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

@ -134,14 +134,21 @@ export function calculateSharedPlacementPoints(
* AFL is different: it has TWO distinct tiers in the 58 zone: * AFL is different: it has TWO distinct tiers in the 58 zone:
* - T5-T6: Semi-Finals losers (positions 5 and 6) avg([5,6]) * - T5-T6: Semi-Finals losers (positions 5 and 6) avg([5,6])
* - T7-T8: Elimination Finals losers (positions 7 and 8) avg([7,8]) * - T7-T8: Elimination Finals losers (positions 7 and 8) avg([7,8])
*
* LLWS has the same shape from its two elimination brackets:
* - T5-T6: Elimination Final losers (one per side) avg([5,6])
* - T7-T8: Elimination Round 4 losers (one per side) avg([7,8])
*/ */
const SPLIT_5678_TEMPLATE_IDS = new Set(["afl_10"]); const SPLIT_5678_TEMPLATE_IDS = new Set(["afl_10", "llws_20"]);
/** /**
* Brackets with a real 3rd place game, meaning positions 3 and 4 are distinct * Brackets with a real 3rd place game, meaning positions 3 and 4 are distinct
* (not averaged). Standard brackets average them because both SF losers tie. * (not averaged). Standard brackets average them because both SF losers tie.
*
* llws_20's Consolation Third Place game decides 3rd and 4th head-to-head between
* the two side runners-up.
*/ */
const DISTINCT_34_TEMPLATE_IDS = new Set(["fifa_48"]); const DISTINCT_34_TEMPLATE_IDS = new Set(["fifa_48", "llws_20"]);
/** /**
* Calculate fantasy points for a bracket placement, averaging tied positions. * Calculate fantasy points for a bracket placement, averaging tied positions.

View file

@ -0,0 +1,99 @@
/**
* Race-calendar state for season-standings sports (F1, IndyCar).
*
* Kept in its own leaf module rather than in `scoring-event.ts` so that
* `simulator.ts` can read it: `scoring-event.ts` pulls in `scoring-calculator`,
* which reaches `participant-expected-value` and back into `simulator`. This
* file imports nothing but the database.
*/
import { eq } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
export interface SeasonRaceCounts {
completed: number;
remaining: number;
total: number;
}
/**
* How long after the green flag a race is assumed to have finished.
*
* `event_starts_at` is a start time, so treating it as "already run" would
* declare the season over the moment the finale goes green and the simulator
* would publish the pre-race leader as champion at 100%, from standings that do
* not yet include the race being run. No race in these series comes close to
* six hours, and the standings feed updates within hours of a finish.
*/
const RACE_DURATION_MS = 6 * 60 * 60 * 1000;
/**
* Has this race already been run?
*
* `is_complete` wins when an admin has set it, but a racing calendar is stored
* as "Non-Scoring" rows that nobody ever marks complete, so the date is the real
* signal. Mirrors the Upcoming / Results Pending badge on the admin events page.
* A race happening today is still upcoming, and a row with no date at all counts
* as upcoming.
*
* @param today `now` as a `YYYY-MM-DD` string, to compare against the date-only
* `event_date` column.
*/
export function hasRaceRun(
event: {
isComplete: boolean;
eventDate: string | null;
eventStartsAt: Date | string | null;
},
now: Date,
today: string
): boolean {
if (event.isComplete) return true;
if (event.eventStartsAt) {
return new Date(event.eventStartsAt).getTime() + RACE_DURATION_MS < now.getTime();
}
if (event.eventDate) return event.eventDate < today;
return false;
}
/**
* Count the races on a season-standings calendar (F1, IndyCar).
*
* `event_type` has no race value, so a racing calendar is stored as
* `schedule_event` rows the admin default for the `season_standings` scoring
* pattern. The only other row such a season carries is the single
* `final_standings` event that assigns fantasy placements once the championship
* is settled. A race is therefore "every event except `final_standings`", not
* "every event except `schedule_event`" getting that backwards leaves the
* simulator with zero remaining races and no idea the season is in progress.
*/
export async function countSeasonRaces(
sportsSeasonId: string,
now: Date = new Date(),
providedDb?: ReturnType<typeof database>
): Promise<SeasonRaceCounts> {
const db = providedDb || database();
const events = await db.query.scoringEvents.findMany({
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
columns: {
eventType: true,
isComplete: true,
eventDate: true,
eventStartsAt: true,
},
});
const today = now.toISOString().split("T")[0];
let completed = 0;
let remaining = 0;
for (const event of events) {
if (event.eventType === "final_standings") continue;
if (hasRaceRun(event, now, today)) completed++;
else remaining++;
}
return { completed, remaining, total: completed + remaining };
}

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 { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { import {
@ -8,12 +8,17 @@ import {
type SimulatorManifestProfile, type SimulatorManifestProfile,
} from "~/services/simulations/manifest"; } from "~/services/simulations/manifest";
import { import {
getSimulatorInputPolicy,
ratingRequirementLabel, ratingRequirementLabel,
resolveRatings, resolveRatings,
resolveSourceElos, resolveSourceElos,
sourceEloRequirementLabel, sourceEloRequirementLabel,
} from "~/services/simulations/input-policy"; } from "~/services/simulations/input-policy";
import { SIMULATOR_TYPES, type SimulatorType } from "~/services/simulations/registry"; import { SIMULATOR_TYPES, type SimulatorType } from "~/services/simulations/registry";
import { countSeasonRaces } from "~/models/season-races";
/** Simulator types driven by a race calendar plus championship standings. */
const RACE_CALENDAR_SIMULATORS: SimulatorType[] = ["f1_standings", "indycar_standings"];
export interface SimulatorProfile extends SimulatorManifestProfile { export interface SimulatorProfile extends SimulatorManifestProfile {
isActive: boolean; isActive: boolean;
@ -82,6 +87,12 @@ export interface SportsSeasonSimulatorSummary {
participantInputCount: number; participantInputCount: number;
lastSimulatedDate: string | null; lastSimulatedDate: string | null;
readiness: SimulatorReadiness; 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 { function parseDecimal(value: string | null | undefined): number | null {
@ -125,8 +136,8 @@ function isFallbackMethod(method: string): boolean {
return method === "fallbackElo" || method === "fallbackRating" || method === "averageKnown" || method === "worstKnownMinus"; return method === "fallbackElo" || method === "fallbackRating" || method === "averageKnown" || method === "worstKnownMinus";
} }
const GENERATED_RATING_METHODS = ["sourceOdds", "fallbackRating", "averageKnown", "worstKnownMinus"] as const; const GENERATED_RATING_METHODS = ["sourceOdds", "blend", "fallbackRating", "averageKnown", "worstKnownMinus"] as const;
const GENERATED_SOURCE_ELO_METHODS = ["projectedWins", "projectedTablePoints", "sourceOdds", "fallbackElo", "averageKnown", "worstKnownMinus"] as const; const GENERATED_SOURCE_ELO_METHODS = ["projectedWins", "projectedTablePoints", "sourceOdds", "blend", "fallbackElo", "averageKnown", "worstKnownMinus"] as const;
function isGeneratedRatingMethod(method: unknown): boolean { function isGeneratedRatingMethod(method: unknown): boolean {
return typeof method === "string" && GENERATED_RATING_METHODS.includes(method as (typeof GENERATED_RATING_METHODS)[number]); return typeof method === "string" && GENERATED_RATING_METHODS.includes(method as (typeof GENERATED_RATING_METHODS)[number]);
@ -300,112 +311,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( export async function batchUpsertParticipantSimulatorInputs(
inputs: UpsertParticipantSimulatorInput[] inputs: UpsertParticipantSimulatorInput[]
): Promise<void> { ): Promise<void> {
@ -438,16 +343,44 @@ export async function batchUpsertParticipantSimulatorInputs(
schema.seasonParticipantSimulatorInputs.participantId, schema.seasonParticipantSimulatorInputs.participantId,
schema.seasonParticipantSimulatorInputs.sportsSeasonId, 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: { set: {
sourceOdds: sql`excluded.source_odds`, sourceOdds: sql`COALESCE(excluded.source_odds, ${schema.seasonParticipantSimulatorInputs.sourceOdds})`,
sourceElo: sql`excluded.source_elo`, sourceElo: sql`COALESCE(excluded.source_elo, ${schema.seasonParticipantSimulatorInputs.sourceElo})`,
worldRanking: sql`excluded.world_ranking`, worldRanking: sql`COALESCE(excluded.world_ranking, ${schema.seasonParticipantSimulatorInputs.worldRanking})`,
rating: sql`excluded.rating`, rating: sql`COALESCE(excluded.rating, ${schema.seasonParticipantSimulatorInputs.rating})`,
projectedWins: sql`excluded.projected_wins`, projectedWins: sql`COALESCE(excluded.projected_wins, ${schema.seasonParticipantSimulatorInputs.projectedWins})`,
projectedTablePoints: sql`excluded.projected_table_points`, projectedTablePoints: sql`COALESCE(excluded.projected_table_points, ${schema.seasonParticipantSimulatorInputs.projectedTablePoints})`,
seed: sql`excluded.seed`, seed: sql`COALESCE(excluded.seed, ${schema.seasonParticipantSimulatorInputs.seed})`,
region: sql`excluded.region`, region: sql`COALESCE(excluded.region, ${schema.seasonParticipantSimulatorInputs.region})`,
metadata: sql`excluded.metadata`, // Metadata carries the method flags (sourceEloMethod/ratingMethod) that
// tell readers whether the stored Elo/rating is generated vs. a trusted
// direct value. Two rules apply, and both always apply — they are not
// alternatives:
//
// 1. 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).
// 2. Merge any metadata the caller supplied over the result
// (prepareSimulatorInputsForRun and the projection importers set the
// correct flags) — a merge rather than a replace so a caller that
// only needs to stamp one method flag does not wipe unrelated keys.
//
// Ordering matters: strip first, then merge, so a caller stamping one flag
// still gets the other column's stale flag cleared. Running these as
// exclusive CASE branches instead would mean a bulk row carrying both a
// direct `rating` and a `projectedWins` (which stamps sourceEloMethod)
// silently kept a stale ratingMethod, hiding the rating it just set.
metadata: sql`(
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)
) || COALESCE(excluded.metadata, '{}'::jsonb)`,
updatedAt: sql`excluded.updated_at`, updatedAt: sql`excluded.updated_at`,
}, },
}); });
@ -569,6 +502,19 @@ export async function validateSimulatorReadiness(
} }
} }
if (RACE_CALENDAR_SIMULATORS.includes(config.simulatorType)) {
// Without a calendar the simulator cannot tell how many races are left, so
// it falls back to futures odds and ignores the championship standings
// entirely. A warning, not a blocker — a season drafted before the schedule
// is published still needs to run.
const races = await countSeasonRaces(sportsSeasonId);
if (races.total === 0) {
warnings.push(
"No race calendar found for this season. Add the schedule on the events page — until then the simulation uses futures odds only and ignores championship standings."
);
}
}
if (config.profile.setupSections.includes("regularStandings")) { if (config.profile.setupSections.includes("regularStandings")) {
warnings.push("Regular-season standings may be needed for in-season accuracy."); warnings.push("Regular-season standings may be needed for in-season accuracy.");
} }
@ -663,6 +609,23 @@ export async function listSportsSeasonSimulatorSummaries(): Promise<SportsSeason
: []; : [];
const lastSnapshotBySeason = new Map(snapshotRows.map((row) => [row.sportsSeasonId, row.lastSimulatedDate])); 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). // Each season calls getSimulatorProfile + validateSimulatorReadiness (~4 queries each).
// Acceptable for an admin-only page; revisit if season counts grow past ~50. // Acceptable for an admin-only page; revisit if season counts grow past ~50.
const summaries = await Promise.all( const summaries = await Promise.all(
@ -670,6 +633,8 @@ export async function listSportsSeasonSimulatorSummaries(): Promise<SportsSeason
const simulatorType = (season.simulatorConfig?.simulatorType ?? season.sport.simulatorType) as SimulatorType; const simulatorType = (season.simulatorConfig?.simulatorType ?? season.sport.simulatorType) as SimulatorType;
const profile = await getSimulatorProfile(simulatorType); const profile = await getSimulatorProfile(simulatorType);
const readiness = await validateSimulatorReadiness(season.id); const readiness = await validateSimulatorReadiness(season.id);
const mergedConfig = { ...profile.defaultConfig, ...season.simulatorConfig?.config };
const policy = getSimulatorInputPolicy(mergedConfig);
return { return {
sportsSeasonId: season.id, sportsSeasonId: season.id,
seasonName: season.name, seasonName: season.name,
@ -687,6 +652,9 @@ export async function listSportsSeasonSimulatorSummaries(): Promise<SportsSeason
participantInputCount: readiness.participantInputCount, participantInputCount: readiness.participantInputCount,
lastSimulatedDate: lastSnapshotBySeason.get(season.id) ?? null, lastSimulatedDate: lastSnapshotBySeason.get(season.id) ?? null,
readiness, 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( export async function updateSportsSeason(
id: string, id: string,
data: Partial<NewSportsSeason> data: Partial<NewSportsSeason>

View file

@ -5,6 +5,7 @@ import type { TeamStanding, TeamStandingSnapshot, TeamStandingWithChange } from
import { calculateBracketPoints, calculateFantasyPoints } from "~/models/scoring-rules"; import { calculateBracketPoints, calculateFantasyPoints } from "~/models/scoring-rules";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
import { getParticipantEV } from "./participant-expected-value"; import { getParticipantEV } from "./participant-expected-value";
import { getBracketTemplateIdForSportsSeason } from "./bracket-template";
import { calculateEV } from "~/services/ev-calculator"; import { calculateEV } from "~/services/ev-calculator";
// Re-export types from shared types file // Re-export types from shared types file
@ -163,11 +164,7 @@ export async function getTeamScoreBreakdown(
if (bracketTemplateCache.has(sportsSeasonId)) { if (bracketTemplateCache.has(sportsSeasonId)) {
return bracketTemplateCache.get(sportsSeasonId) ?? null; return bracketTemplateCache.get(sportsSeasonId) ?? null;
} }
const event = await db.query.scoringEvents.findFirst({ const templateId = await getBracketTemplateIdForSportsSeason(sportsSeasonId, db);
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
columns: { bracketTemplateId: true },
});
const templateId = event?.bracketTemplateId ?? null;
bracketTemplateCache.set(sportsSeasonId, templateId); bracketTemplateCache.set(sportsSeasonId, templateId);
return templateId; return templateId;
} }

View file

@ -136,3 +136,17 @@ export async function updateTournamentStatus(
.returning(); .returning();
return tournament; 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("forgot-password", "routes/forgot-password.tsx"),
route("reset-password", "routes/reset-password.tsx"), route("reset-password", "routes/reset-password.tsx"),
route("onboarding", "routes/onboarding.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("user-profile", "routes/user-profile-redirect.tsx"),
route("how-to-play", "routes/how-to-play.tsx"), route("how-to-play", "routes/how-to-play.tsx"),
route("rules", "routes/rules.tsx"), route("rules", "routes/rules.tsx"),
@ -96,6 +96,7 @@ export default [
route("simulators", "routes/admin.simulators.tsx"), route("simulators", "routes/admin.simulators.tsx"),
route("sports/new", "routes/admin.sports.new.tsx"), route("sports/new", "routes/admin.sports.new.tsx"),
route("sports/:id", "routes/admin.sports.$id.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", "routes/admin.sports-seasons.tsx"),
route("sports-seasons/new", "routes/admin.sports-seasons.new.tsx"), route("sports-seasons/new", "routes/admin.sports-seasons.new.tsx"),
route("sports-seasons/:id", "routes/admin.sports-seasons.$id.tsx"), route("sports-seasons/:id", "routes/admin.sports-seasons.$id.tsx"),

View file

@ -0,0 +1,64 @@
import { describe, expect, it, vi } from "vitest";
/**
* The Expected Values admin page renders EV from the stored probability columns.
*
* It used to carry its own hardcoded scoring table (100/70/45/45/20/20/20/20), which
* flattened positions 58 to 20 points each. For a standard single-elimination bracket
* that was invisible all four quarterfinal losers share one tier worth
* avg(25,25,15,15) = 20 anyway but for the templates that split 58 into two tiers
* (llws_20, afl_10) it reported a team locked into 5th6th and a team locked into
* 7th8th as the same 20 points. These pin it to the shared DEFAULT_SCORING_RULES.
*/
vi.mock("../admin.sports-seasons.$id.expected-values.server", () => ({
loader: vi.fn(),
}));
import { evFromProbs } from "../admin.sports-seasons.$id.expected-values";
const ZERO = {
probFirst: "0", probSecond: "0", probThird: "0", probFourth: "0",
probFifth: "0", probSixth: "0", probSeventh: "0", probEighth: "0",
};
describe("evFromProbs", () => {
it("gives a team locked into the 5th6th tier 25 points, not 20", () => {
expect(evFromProbs({ ...ZERO, probFifth: "0.5", probSixth: "0.5" })).toBe(25);
});
it("gives a team locked into the 7th8th tier 15 points, not 20", () => {
expect(evFromProbs({ ...ZERO, probSeventh: "0.5", probEighth: "0.5" })).toBe(15);
});
it("still gives a single 5th8th tier (4 QF losers) 20 points", () => {
const ev = evFromProbs({
...ZERO,
probFifth: "0.25", probSixth: "0.25", probSeventh: "0.25", probEighth: "0.25",
});
expect(ev).toBe(20);
});
it("keeps 3rd and 4th distinct rather than a flat 45 each", () => {
expect(evFromProbs({ ...ZERO, probThird: "1" })).toBe(50);
expect(evFromProbs({ ...ZERO, probFourth: "1" })).toBe(40);
});
it("preserves the 340 total-EV invariant across a full set of unit columns", () => {
const perPosition = [
evFromProbs({ ...ZERO, probFirst: "1" }),
evFromProbs({ ...ZERO, probSecond: "1" }),
evFromProbs({ ...ZERO, probThird: "1" }),
evFromProbs({ ...ZERO, probFourth: "1" }),
evFromProbs({ ...ZERO, probFifth: "1" }),
evFromProbs({ ...ZERO, probSixth: "1" }),
evFromProbs({ ...ZERO, probSeventh: "1" }),
evFromProbs({ ...ZERO, probEighth: "1" }),
];
expect(perPosition.reduce((sum, ev) => sum + ev, 0)).toBe(340);
});
it("returns 0 for a participant with no probability mass", () => {
expect(evFromProbs(ZERO)).toBe(0);
});
});

View file

@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import {
parseBaseEloPriorityChoice,
projectionMethodMetadata,
resolvedInputMethodLabel,
} from "../admin.sports-seasons.$id.simulator.helpers";
import { DEFAULT_BASE_ELO_PRIORITY } from "~/services/simulations/input-policy";
describe("projectionMethodMetadata", () => {
it("flags a row that supplies projected wins and no Elo", () => {
expect(projectionMethodMetadata(undefined, 95, undefined)).toEqual({
sourceEloMethod: "projectedWins",
});
});
it("flags a row that supplies projected table points and no Elo", () => {
expect(projectionMethodMetadata(undefined, undefined, 76.5)).toEqual({
sourceEloMethod: "projectedTablePoints",
});
});
it("leaves metadata alone when the row supplies an explicit Elo", () => {
// An explicit Elo is a direct entry and must stay trusted, even alongside a
// projection — the upsert then clears any stale generated flag.
expect(projectionMethodMetadata(1600, 95, undefined)).toBeUndefined();
});
it("leaves metadata alone for a row with neither", () => {
expect(projectionMethodMetadata(undefined, undefined, undefined)).toBeUndefined();
});
it("prefers wins over table points when a row somehow carries both", () => {
expect(projectionMethodMetadata(undefined, 95, 76.5)).toEqual({
sourceEloMethod: "projectedWins",
});
});
});
describe("parseBaseEloPriorityChoice", () => {
it("puts projections ahead of raw Elo", () => {
expect(parseBaseEloPriorityChoice("projectionsFirst", DEFAULT_BASE_ELO_PRIORITY)).toEqual([
"projectedWins",
"projectedTablePoints",
"sourceElo",
]);
});
it("puts raw Elo first for eloFirst", () => {
expect(parseBaseEloPriorityChoice("eloFirst", DEFAULT_BASE_ELO_PRIORITY)).toEqual(
DEFAULT_BASE_ELO_PRIORITY
);
});
it("keeps the stored ordering when the select was not on the form", () => {
// Simulators with no projection alternative never render the control; saving
// other config must not rewrite their ordering.
const custom: typeof DEFAULT_BASE_ELO_PRIORITY = ["projectedWins", "sourceElo"];
expect(parseBaseEloPriorityChoice(null, custom)).toEqual(custom);
});
it("preserves the relative order of the projection keys", () => {
expect(
parseBaseEloPriorityChoice("projectionsFirst", [
"projectedTablePoints",
"sourceElo",
"projectedWins",
])
).toEqual(["projectedTablePoints", "projectedWins", "sourceElo"]);
});
it("round-trips: flipping back restores Elo-first", () => {
const flipped = parseBaseEloPriorityChoice("projectionsFirst", DEFAULT_BASE_ELO_PRIORITY);
expect(parseBaseEloPriorityChoice("eloFirst", flipped)).toEqual(DEFAULT_BASE_ELO_PRIORITY);
});
});
describe("resolvedInputMethodLabel", () => {
it("badges nothing for a directly entered Elo or rating", () => {
expect(resolvedInputMethodLabel("direct")).toBeNull();
});
it("badges both projection methods the same way", () => {
expect(resolvedInputMethodLabel("projectedWins")).toBe("from projections");
expect(resolvedInputMethodLabel("projectedTablePoints")).toBe("from projections");
});
it("distinguishes futures and blended Elo", () => {
expect(resolvedInputMethodLabel("sourceOdds")).toBe("from futures");
expect(resolvedInputMethodLabel("blend")).toBe("blended");
});
it("badges every missing-input strategy as a fallback", () => {
expect(resolvedInputMethodLabel("fallbackElo")).toBe("fallback");
expect(resolvedInputMethodLabel("fallbackRating")).toBe("fallback");
expect(resolvedInputMethodLabel("averageKnown")).toBe("fallback");
expect(resolvedInputMethodLabel("worstKnownMinus")).toBe("fallback");
});
});

View file

@ -0,0 +1,130 @@
/**
* clear-bracket is the only path that can tear down a bracket, so the guard around it
* matters: it discards recorded results and the placements derived from them.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
findPlayoffMatchesByEventId,
deletePlayoffMatchesByEventId,
} from "~/models/playoff-match";
import { deleteParticipantResultsBySportsSeasonId } from "~/models/participant-result";
import { recalculateAffectedLeagues } from "~/models/scoring-calculator";
import { getScoringEventById } from "~/models/scoring-event";
import { action } from "../admin.sports-seasons.$id.events.$eventId.bracket.server";
vi.mock("~/models/scoring-event", async (importOriginal) => ({
...(await importOriginal<object>()),
getScoringEventById: vi.fn(),
updateScoringEvent: vi.fn(),
isReadOnlySibling: vi.fn(() => false),
}));
vi.mock("~/models/playoff-match", async (importOriginal) => ({
...(await importOriginal<object>()),
findPlayoffMatchesByEventId: vi.fn(),
deletePlayoffMatchesByEventId: vi.fn(),
}));
vi.mock("~/models/participant-result", async (importOriginal) => ({
...(await importOriginal<object>()),
deleteParticipantResultsBySportsSeasonId: vi.fn(),
}));
vi.mock("~/models/scoring-calculator", async (importOriginal) => ({
...(await importOriginal<object>()),
recalculateAffectedLeagues: vi.fn(),
}));
const EVENT = { id: "event-1", sportsSeasonId: "season-1" };
const params = { id: "season-1", eventId: "event-1" };
function clearRequest(confirm?: string): Request {
const body = new FormData();
body.set("intent", "clear-bracket");
if (confirm !== undefined) body.set("confirm", confirm);
return new Request("http://localhost/clear", { method: "POST", body });
}
function match(isComplete: boolean) {
return { id: `m-${Math.random()}`, isComplete };
}
// The action's real signature carries React Router's generated types; the clear path
// only reads request and params.
const run = (request: Request) =>
(action as unknown as (args: { request: Request; params: typeof params }) => Promise<{
error?: string;
success?: string;
}>)({ request, params });
describe("clear-bracket", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getScoringEventById).mockResolvedValue(
EVENT as unknown as Awaited<ReturnType<typeof getScoringEventById>>
);
vi.mocked(deletePlayoffMatchesByEventId).mockResolvedValue(undefined);
vi.mocked(deleteParticipantResultsBySportsSeasonId).mockResolvedValue(undefined);
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(
undefined as unknown as Awaited<ReturnType<typeof recalculateAffectedLeagues>>
);
});
it("deletes the matches", async () => {
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([
match(false),
match(false),
] as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>);
const result = await run(clearRequest());
expect(result.success).toContain("2 match(es) removed");
expect(deletePlayoffMatchesByEventId).toHaveBeenCalledWith("event-1");
});
it("leaves placements alone — they belong to the whole season, not this event", async () => {
// seasonParticipantResults is keyed by sports season, so deleting here would wipe
// every other event's placements with nothing to rebuild them. Reprocess Bracket is
// the tool that rebuilds them correctly.
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([
match(true),
] as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>);
const result = await run(clearRequest("true"));
expect(deleteParticipantResultsBySportsSeasonId).not.toHaveBeenCalled();
expect(result.success).toContain("Reprocess Bracket");
});
it("refuses to discard completed matches without confirmation", async () => {
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([
match(true),
match(false),
] as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>);
const result = await run(clearRequest());
expect(result.error).toContain("1 completed match(es)");
expect(deletePlayoffMatchesByEventId).not.toHaveBeenCalled();
});
it("discards completed matches once confirmed", async () => {
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([
match(true),
] as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>);
const result = await run(clearRequest("true"));
expect(result.success).toBeDefined();
expect(deletePlayoffMatchesByEventId).toHaveBeenCalledWith("event-1");
});
it("rejects an event with no bracket rather than reporting a no-op success", async () => {
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue(
[] as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>
);
const result = await run(clearRequest("true"));
expect(result.error).toContain("no bracket to clear");
expect(deletePlayoffMatchesByEventId).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,203 @@
/**
* generate-bracket banks the floors a seeding guarantees before anyone plays (an AFL
* top-4 seed cannot finish below the 5th-6th tier). Those floors only reach
* teamStandings.totalPoints through a standings recalculation, so the action has to be
* sure one ran markEliminatedAndAnnounce runs one for its Discord announcement in some
* cases but not others.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { generateBracketFromTemplate } from "~/models/playoff-match";
import {
findParticipantResultsBySportsSeasonId,
setParticipantResult,
} from "~/models/participant-result";
import {
applyBracketEntryFloors,
recalculateAffectedLeagues,
} from "~/models/scoring-calculator";
import { getScoringEventById, updateScoringEvent } from "~/models/scoring-event";
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
import { action } from "../admin.sports-seasons.$id.events.$eventId.bracket.server";
vi.mock("~/database/context", () => ({ database: vi.fn(() => ({})) }));
vi.mock("~/models/scoring-event", async (importOriginal) => ({
...(await importOriginal<object>()),
getScoringEventById: vi.fn(),
updateScoringEvent: vi.fn(),
isReadOnlySibling: vi.fn(() => false),
}));
vi.mock("~/models/playoff-match", async (importOriginal) => ({
...(await importOriginal<object>()),
generateBracketFromTemplate: vi.fn(),
}));
vi.mock("~/models/participant-result", async (importOriginal) => ({
...(await importOriginal<object>()),
findParticipantResultsBySportsSeasonId: vi.fn(),
setParticipantResult: vi.fn(),
}));
vi.mock("~/models/scoring-calculator", async (importOriginal) => ({
...(await importOriginal<object>()),
applyBracketEntryFloors: vi.fn(),
recalculateAffectedLeagues: vi.fn(),
}));
vi.mock("~/models/season-participant", async (importOriginal) => ({
...(await importOriginal<object>()),
findParticipantsBySportsSeasonId: vi.fn(),
}));
const params = { id: "season-1", eventId: "event-1" };
const EVENT = {
id: "event-1",
name: "AFL Finals",
sportsSeasonId: "season-1",
isQualifyingEvent: false,
bracketTemplateId: "afl_10",
};
/** afl_10 takes exactly 10 seeded participants. */
const SEEDED = Array.from({ length: 10 }, (_, i) => `seed-${i + 1}`);
function generateRequest(): Request {
const body = new FormData();
body.set("intent", "generate-bracket");
body.set("templateId", "afl_10");
SEEDED.forEach((id, i) => body.set(`participant${i}`, id));
return new Request("http://localhost/generate", { method: "POST", body });
}
const run = (request: Request) =>
(action as unknown as (args: { request: Request; params: typeof params }) => Promise<{
error?: string;
success?: string;
}>)({ request, params });
/**
* @param extras participants in the season beyond the 10 seeded into the bracket
* these are the ones generate-bracket marks eliminated.
* @param withExistingResults ids that already carry a result row, so
* markEliminatedAndAnnounce treats them as not newly eliminated.
*/
function setSeason(extras: string[], withExistingResults: string[] = []) {
vi.mocked(findParticipantsBySportsSeasonId).mockResolvedValue(
[...SEEDED, ...extras].map((id) => ({ id })) as unknown as Awaited<
ReturnType<typeof findParticipantsBySportsSeasonId>
>
);
vi.mocked(findParticipantResultsBySportsSeasonId).mockResolvedValue(
withExistingResults.map((participantId) => ({ participantId })) as unknown as Awaited<
ReturnType<typeof findParticipantResultsBySportsSeasonId>
>
);
}
describe("generate-bracket entry-floor standings recalculation", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getScoringEventById).mockResolvedValue(
EVENT as unknown as Awaited<ReturnType<typeof getScoringEventById>>
);
vi.mocked(generateBracketFromTemplate).mockResolvedValue(
undefined as unknown as Awaited<ReturnType<typeof generateBracketFromTemplate>>
);
vi.mocked(updateScoringEvent).mockResolvedValue(
undefined as unknown as Awaited<ReturnType<typeof updateScoringEvent>>
);
vi.mocked(setParticipantResult).mockResolvedValue(
undefined as unknown as Awaited<ReturnType<typeof setParticipantResult>>
);
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(
undefined as unknown as Awaited<ReturnType<typeof recalculateAffectedLeagues>>
);
// afl_10 seeds 1-4 into the Qualifying Finals, whose entry floor is the 5th-6th tier.
vi.mocked(applyBracketEntryFloors).mockResolvedValue(4);
});
it("recalculates when every eliminated team already had a result row", async () => {
// The second run of a generation: the first wrote position 0 for the non-bracket
// participants, so nobody is *newly* eliminated and the announcement is skipped.
// The floors banked moments ago would never reach the standings.
setSeason(["extra-1"], ["extra-1"]);
const result = await run(generateRequest());
expect(result.success).toBeDefined();
expect(recalculateAffectedLeagues).toHaveBeenCalledTimes(1);
expect(recalculateAffectedLeagues).toHaveBeenCalledWith(
"season-1",
expect.anything(),
expect.objectContaining({ skipDiscord: true })
);
});
it("recalculates for a qualifying event, which never announces eliminations", async () => {
vi.mocked(getScoringEventById).mockResolvedValue(
{ ...EVENT, isQualifyingEvent: true } as unknown as Awaited<
ReturnType<typeof getScoringEventById>
>
);
setSeason(["extra-1"]);
await run(generateRequest());
expect(recalculateAffectedLeagues).toHaveBeenCalledTimes(1);
expect(recalculateAffectedLeagues).toHaveBeenCalledWith(
"season-1",
expect.anything(),
expect.objectContaining({ skipDiscord: true })
);
});
it("recalculates when the bracket field is the whole season", async () => {
setSeason([]);
await run(generateRequest());
expect(recalculateAffectedLeagues).toHaveBeenCalledTimes(1);
});
it("recalculates when the elimination announcement threw", async () => {
// The announcement is best-effort and its failure is swallowed — but a failed recalc
// is exactly when the floors still need one.
setSeason(["extra-1"]);
vi.mocked(recalculateAffectedLeagues)
.mockRejectedValueOnce(new Error("discord down"))
.mockResolvedValue(undefined as unknown as Awaited<ReturnType<typeof recalculateAffectedLeagues>>);
const result = await run(generateRequest());
expect(result.success).toBeDefined();
expect(recalculateAffectedLeagues).toHaveBeenCalledTimes(2);
expect(recalculateAffectedLeagues).toHaveBeenLastCalledWith(
"season-1",
expect.anything(),
expect.objectContaining({ skipDiscord: true })
);
});
it("does not recalculate twice when the announcement already did", async () => {
setSeason(["extra-1"]);
await run(generateRequest());
expect(recalculateAffectedLeagues).toHaveBeenCalledTimes(1);
// The announcing call, not the floor fallback.
expect(recalculateAffectedLeagues).toHaveBeenCalledWith(
"season-1",
expect.anything(),
expect.objectContaining({ eliminatedParticipantIds: ["extra-1"] })
);
});
it("does not recalculate at all when no floors were banked", async () => {
// A template that guarantees nothing at seeding: no floors, nobody to eliminate,
// so there is nothing for a recalculation to pick up.
vi.mocked(applyBracketEntryFloors).mockResolvedValue(0);
setSeason([]);
await run(generateRequest());
expect(recalculateAffectedLeagues).not.toHaveBeenCalled();
});
});

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,208 @@
/**
* reprocess-bracket rebuilds a bracket's placements from scratch. What it wipes first
* decides whether the clear-bracket regenerate reprocess repair path actually works,
* and whether it takes the rest of the season's placements down with it.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { findPlayoffMatchesByEventId } from "~/models/playoff-match";
import {
deleteParticipantResultsBySportsSeasonId,
deleteParticipantResultsForParticipants,
setParticipantResult,
} from "~/models/participant-result";
import {
applyBracketEntryFloors,
processMatchResult,
processQualifyingBracketEvent,
recalculateAffectedLeagues,
} from "~/models/scoring-calculator";
import { getScoringEventById } from "~/models/scoring-event";
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
import { findSportsSeasonById } from "~/models/sports-season";
import { action } from "../admin.sports-seasons.$id.events.$eventId.bracket.server";
vi.mock("~/database/context", () => ({ database: vi.fn(() => ({})) }));
vi.mock("~/models/scoring-event", async (importOriginal) => ({
...(await importOriginal<object>()),
getScoringEventById: vi.fn(),
updateScoringEvent: vi.fn(),
isReadOnlySibling: vi.fn(() => false),
}));
vi.mock("~/models/playoff-match", async (importOriginal) => ({
...(await importOriginal<object>()),
findPlayoffMatchesByEventId: vi.fn(),
}));
vi.mock("~/models/participant-result", async (importOriginal) => ({
...(await importOriginal<object>()),
deleteParticipantResultsBySportsSeasonId: vi.fn(),
deleteParticipantResultsForParticipants: vi.fn(),
setParticipantResult: vi.fn(),
}));
vi.mock("~/models/scoring-calculator", async (importOriginal) => ({
...(await importOriginal<object>()),
applyBracketEntryFloors: vi.fn(),
processMatchResult: vi.fn(),
recalculateAffectedLeagues: vi.fn(),
processQualifyingBracketEvent: vi.fn(),
finalizeQualifyingPoints: vi.fn(),
}));
vi.mock("~/models/season-participant", async (importOriginal) => ({
...(await importOriginal<object>()),
findParticipantsBySportsSeasonId: vi.fn(),
}));
vi.mock("~/models/sports-season", async (importOriginal) => ({
...(await importOriginal<object>()),
findSportsSeasonById: vi.fn(),
}));
const params = { id: "season-1", eventId: "event-1" };
const EVENT = {
id: "event-1",
name: "AFL Finals",
sportsSeasonId: "season-1",
isQualifyingEvent: false,
isPrimary: false,
tournamentId: null,
bracketTemplateId: "afl_10",
};
function reprocessRequest(): Request {
const body = new FormData();
body.set("intent", "reprocess-bracket");
return new Request("http://localhost/reprocess", { method: "POST", body });
}
/** A seeded, unplayed bracket slot. */
function slot(matchNumber: number, participant1Id: string, participant2Id: string) {
return {
id: `m-${matchNumber}`,
round: "Qualifying Finals",
matchNumber,
participant1Id,
participant2Id,
winnerId: null,
loserId: null,
isComplete: false,
isScoring: true,
};
}
const run = (request: Request) =>
(action as unknown as (args: { request: Request; params: typeof params }) => Promise<{
error?: string;
success?: string;
}>)({ request, params });
function setEvent(overrides: Partial<typeof EVENT> = {}) {
vi.mocked(getScoringEventById).mockResolvedValue(
{ ...EVENT, ...overrides } as unknown as Awaited<ReturnType<typeof getScoringEventById>>
);
}
function setMatches(matches: ReturnType<typeof slot>[]) {
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue(
matches as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>
);
}
describe("reprocess-bracket", () => {
beforeEach(() => {
vi.clearAllMocks();
setEvent();
vi.mocked(applyBracketEntryFloors).mockResolvedValue(4);
vi.mocked(findParticipantsBySportsSeasonId).mockResolvedValue(
[] as unknown as Awaited<ReturnType<typeof findParticipantsBySportsSeasonId>>
);
vi.mocked(setParticipantResult).mockResolvedValue(
undefined as unknown as Awaited<ReturnType<typeof setParticipantResult>>
);
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(
undefined as unknown as Awaited<ReturnType<typeof recalculateAffectedLeagues>>
);
vi.mocked(processMatchResult).mockResolvedValue(
undefined as unknown as Awaited<ReturnType<typeof processMatchResult>>
);
vi.mocked(processQualifyingBracketEvent).mockResolvedValue(
undefined as unknown as Awaited<ReturnType<typeof processQualifyingBracketEvent>>
);
vi.mocked(findSportsSeasonById).mockResolvedValue(
{ qualifyingPointsFinalized: false } as unknown as Awaited<
ReturnType<typeof findSportsSeasonById>
>
);
});
it("clears placements even when no match has been played", async () => {
// The clear-bracket → regenerate → reprocess repair path lands here: the freshly
// re-seeded bracket has nothing completed, yet the discarded bracket's finalized
// placements are exactly what has to go. Skipping the wipe leaves them permanently,
// because upsertParticipantResult refuses to un-finalize a result.
setMatches([slot(1, "p1", "p2"), slot(2, "p3", "p4")]);
const result = await run(reprocessRequest());
expect(result.success).toBeDefined();
expect(deleteParticipantResultsForParticipants).toHaveBeenCalledTimes(1);
const [sportsSeasonId, ids] = vi.mocked(deleteParticipantResultsForParticipants).mock.calls[0];
expect(sportsSeasonId).toBe("season-1");
expect([...ids].toSorted()).toEqual(["p1", "p2", "p3", "p4"]);
});
it("scopes the wipe to this bracket, never the whole season", async () => {
// A season-wide delete would take every other event's placements with it, with only
// this bracket's replay able to rebuild them.
setMatches([slot(1, "p1", "p2")]);
await run(reprocessRequest());
expect(deleteParticipantResultsBySportsSeasonId).not.toHaveBeenCalled();
const [, ids] = vi.mocked(deleteParticipantResultsForParticipants).mock.calls[0];
expect(ids).not.toContain("p3");
});
it("passes each participant once when a team appears in more than one slot", async () => {
setMatches([slot(1, "p1", "p2"), slot(2, "p1", "p3")]);
await run(reprocessRequest());
const [, ids] = vi.mocked(deleteParticipantResultsForParticipants).mock.calls[0];
expect(ids).toHaveLength(3);
expect([...ids].toSorted()).toEqual(["p1", "p2", "p3"]);
});
it("skips empty slots rather than passing nulls through", async () => {
setMatches([
{ ...slot(1, "p1", "p2"), participant2Id: null as unknown as string },
]);
await run(reprocessRequest());
const [, ids] = vi.mocked(deleteParticipantResultsForParticipants).mock.calls[0];
expect(ids).toEqual(["p1"]);
});
it("still takes the season-wide delete for a qualifying event", async () => {
// Qualifying seasons have no legitimate per-major fantasy placements — those come
// from finalizeQualifyingPoints across all majors — so that path wipes the season
// on purpose and rebuilds QP from the bracket.
setEvent({ isQualifyingEvent: true });
setMatches([slot(1, "p1", "p2")]);
await run(reprocessRequest());
expect(deleteParticipantResultsBySportsSeasonId).toHaveBeenCalledWith("season-1", {});
expect(deleteParticipantResultsForParticipants).not.toHaveBeenCalled();
});
it("rejects an event with no bracket rather than wiping anything", async () => {
setMatches([]);
const result = await run(reprocessRequest());
expect(result.error).toContain("No bracket to reprocess");
expect(deleteParticipantResultsForParticipants).not.toHaveBeenCalled();
expect(deleteParticipantResultsBySportsSeasonId).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,146 @@
/**
* The Fix Semi-Final Pairings admin action.
*
* Elimination Final n feeds Semi-Final n, but brackets advanced before that was fixed
* crossed the two winners, and nothing re-runs advancement a completed match cannot be
* re-submitted from the UI.
*
* It moves qualifier slots only no scoring runs, so nothing reaches Discord.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { reseedAflSemiFinals } from "~/models/playoff-match";
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
import { getScoringEventById } from "~/models/scoring-event";
import { processMatchResult, recalculateAffectedLeagues } from "~/models/scoring-calculator";
import { sendDiscordWebhook } from "~/services/discord";
import { action } from "../admin.sports-seasons.$id.events.$eventId.bracket.server";
vi.mock("~/database/context", () => ({ database: vi.fn(() => ({})) }));
vi.mock("~/models/scoring-event", async (importOriginal) => ({
...(await importOriginal<object>()),
getScoringEventById: vi.fn(),
isReadOnlySibling: vi.fn(() => false),
}));
vi.mock("~/models/playoff-match", async (importOriginal) => ({
...(await importOriginal<object>()),
reseedAflSemiFinals: vi.fn(),
}));
vi.mock("~/models/season-participant", async (importOriginal) => ({
...(await importOriginal<object>()),
findParticipantsBySportsSeasonId: vi.fn(),
}));
vi.mock("~/models/scoring-calculator", async (importOriginal) => ({
...(await importOriginal<object>()),
processMatchResult: vi.fn(),
recalculateAffectedLeagues: vi.fn(),
}));
vi.mock("~/services/discord", async (importOriginal) => ({
...(await importOriginal<object>()),
sendDiscordWebhook: vi.fn(),
}));
const params = { id: "season-1", eventId: "event-1" };
const EVENT = {
id: "event-1",
name: "AFL Finals",
sportsSeasonId: "season-1",
isQualifyingEvent: false,
bracketTemplateId: "afl_10",
};
function request() {
const body = new FormData();
body.set("intent", "reseed-afl-semifinals");
return new Request("http://localhost/bracket", { method: "POST", body });
}
const run = () => action({ request: request(), params } as never);
describe("reseed-afl-semifinals", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getScoringEventById).mockResolvedValue(EVENT as never);
vi.mocked(findParticipantsBySportsSeasonId).mockResolvedValue([
{ id: "geelong", name: "Geelong Cats" },
{ id: "adelaide", name: "Adelaide Crows" },
] as never);
});
it("names the teams that moved", async () => {
vi.mocked(reseedAflSemiFinals).mockResolvedValue({
vacated: [1, 2],
filled: [
{ matchNumber: 2, participantId: "adelaide" },
{ matchNumber: 1, participantId: "geelong" },
],
});
const result = await run();
expect(reseedAflSemiFinals).toHaveBeenCalledWith("event-1");
expect(result).toEqual({
success:
"Re-seeded the Semi-Finals: match 1 now hosts Geelong Cats, " +
"match 2 now hosts Adelaide Crows.",
});
});
it("reports a slot that was emptied without being refilled", async () => {
// Un-recording an Elimination Final result takes its winner back out of the semi.
vi.mocked(reseedAflSemiFinals).mockResolvedValue({
vacated: [1, 2],
filled: [{ matchNumber: 2, participantId: "adelaide" }],
});
expect(await run()).toEqual({
success:
"Re-seeded the Semi-Finals: match 1 is back to TBD, " +
"match 2 now hosts Adelaide Crows.",
});
});
it("says so when the pairings are already right", async () => {
vi.mocked(reseedAflSemiFinals).mockResolvedValue({ vacated: [], filled: [] });
expect(await run()).toEqual({
success: "Semi-Finals already match the Elimination Finals results — nothing to re-seed.",
});
});
it("scores nothing and announces nothing", async () => {
vi.mocked(reseedAflSemiFinals).mockResolvedValue({
vacated: [1, 2],
filled: [{ matchNumber: 1, participantId: "geelong" }],
});
await run();
expect(processMatchResult).not.toHaveBeenCalled();
expect(recalculateAffectedLeagues).not.toHaveBeenCalled();
expect(sendDiscordWebhook).not.toHaveBeenCalled();
});
it("refuses a bracket that is not an AFL finals bracket", async () => {
vi.mocked(getScoringEventById).mockResolvedValue({
...EVENT,
bracketTemplateId: "nfl_14",
} as never);
expect(await run()).toEqual({
error: "This action only applies to AFL finals brackets",
});
expect(reseedAflSemiFinals).not.toHaveBeenCalled();
});
it("surfaces a refusal to re-seed a game that has been played", async () => {
vi.mocked(reseedAflSemiFinals).mockRejectedValue(
new Error("Semi-Finals match 1 already has a recorded result")
);
expect(await run()).toEqual({
error: "Semi-Finals match 1 already has a recorded result",
});
});
});

View file

@ -0,0 +1,133 @@
/**
* The Re-seed Wildcard Winners admin action.
*
* Advancement pairs the Wildcard winners with 5th and 6th by ladder position on every
* result, so this action exists for brackets advanced before that rule: their winners sit
* in the wrong Elimination Finals and nothing re-runs advancement, because a completed
* match cannot be re-submitted from the UI.
*
* It moves qualifier slots only no scoring runs, so nothing reaches Discord.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { reseedAflEliminationFinals } from "~/models/playoff-match";
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
import { getScoringEventById } from "~/models/scoring-event";
import { processMatchResult, recalculateAffectedLeagues } from "~/models/scoring-calculator";
import { sendDiscordWebhook } from "~/services/discord";
import { action } from "../admin.sports-seasons.$id.events.$eventId.bracket.server";
vi.mock("~/database/context", () => ({ database: vi.fn(() => ({})) }));
vi.mock("~/models/scoring-event", async (importOriginal) => ({
...(await importOriginal<object>()),
getScoringEventById: vi.fn(),
isReadOnlySibling: vi.fn(() => false),
}));
vi.mock("~/models/playoff-match", async (importOriginal) => ({
...(await importOriginal<object>()),
reseedAflEliminationFinals: vi.fn(),
}));
vi.mock("~/models/season-participant", async (importOriginal) => ({
...(await importOriginal<object>()),
findParticipantsBySportsSeasonId: vi.fn(),
}));
vi.mock("~/models/scoring-calculator", async (importOriginal) => ({
...(await importOriginal<object>()),
processMatchResult: vi.fn(),
recalculateAffectedLeagues: vi.fn(),
}));
vi.mock("~/services/discord", async (importOriginal) => ({
...(await importOriginal<object>()),
sendDiscordWebhook: vi.fn(),
}));
const params = { id: "season-1", eventId: "event-1" };
const EVENT = {
id: "event-1",
name: "AFL Finals",
sportsSeasonId: "season-1",
isQualifyingEvent: false,
bracketTemplateId: "afl_10",
};
function request() {
const body = new FormData();
body.set("intent", "reseed-afl-wildcard");
return new Request("http://localhost/bracket", { method: "POST", body });
}
const run = () => action({ request: request(), params } as never);
describe("reseed-afl-wildcard", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getScoringEventById).mockResolvedValue(EVENT as never);
vi.mocked(findParticipantsBySportsSeasonId).mockResolvedValue([
{ id: "carlton", name: "Carlton Blues" },
{ id: "bulldogs", name: "Western Bulldogs" },
] as never);
});
it("names the teams that moved", async () => {
vi.mocked(reseedAflEliminationFinals).mockResolvedValue({
vacated: [1, 2],
filled: [
{ matchNumber: 2, participantId: "bulldogs" },
{ matchNumber: 1, participantId: "carlton" },
],
});
const result = await run();
expect(reseedAflEliminationFinals).toHaveBeenCalledWith("event-1");
expect(result).toEqual({
success:
"Re-seeded the Elimination Finals: match 1 now hosts Carlton Blues, " +
"match 2 now hosts Western Bulldogs.",
});
});
it("says so when the pairings are already right", async () => {
vi.mocked(reseedAflEliminationFinals).mockResolvedValue({ vacated: [], filled: [] });
expect(await run()).toEqual({
success: "Elimination Finals already match the Wildcard results — nothing to re-seed.",
});
});
it("scores nothing and announces nothing", async () => {
vi.mocked(reseedAflEliminationFinals).mockResolvedValue({
vacated: [1, 2],
filled: [{ matchNumber: 1, participantId: "carlton" }],
});
await run();
expect(processMatchResult).not.toHaveBeenCalled();
expect(recalculateAffectedLeagues).not.toHaveBeenCalled();
expect(sendDiscordWebhook).not.toHaveBeenCalled();
});
it("refuses a bracket that is not an AFL finals bracket", async () => {
vi.mocked(getScoringEventById).mockResolvedValue({
...EVENT,
bracketTemplateId: "nfl_14",
} as never);
expect(await run()).toEqual({
error: "This action only applies to AFL finals brackets",
});
expect(reseedAflEliminationFinals).not.toHaveBeenCalled();
});
it("surfaces a refusal to re-seed a game that has been played", async () => {
vi.mocked(reseedAflEliminationFinals).mockRejectedValue(
new Error("Elimination Finals match 1 already has a recorded result")
);
expect(await run()).toEqual({
error: "Elimination Finals match 1 already has a recorded result",
});
});
});

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) { function statusBadge(status: string) {
if (status === "ready") return <Badge className="gap-1"><CheckCircle2 className="h-3 w-3" /> Ready</Badge>; 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>; 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> </div>
</TableCell> </TableCell>
<TableCell className="text-sm text-muted-foreground"> <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>
<TableCell className="text-sm text-muted-foreground"> <TableCell className="text-sm text-muted-foreground">
{sim.lastSimulatedDate ?? "Never"} {sim.lastSimulatedDate ?? "Never"}

View file

@ -31,7 +31,7 @@ import {
projectedWinsToElo, projectedWinsToElo,
} from '~/services/probability-engine'; } from '~/services/probability-engine';
import { runSportsSeasonSimulation } from '~/services/simulations/runner'; import { runSportsSeasonSimulation } from '~/services/simulations/runner';
import { getSportsSeasonSimulatorConfig } from '~/models/simulator'; import { getParticipantSimulatorInputs, getSportsSeasonSimulatorConfig } from '~/models/simulator';
// Simulator types that use worldRanking in addition to sourceElo // Simulator types that use worldRanking in addition to sourceElo
const RANKING_SIMULATOR_TYPES = new Set(['darts_bracket', 'cs2_major_qualifying_points', 'college_hockey_bracket']); const RANKING_SIMULATOR_TYPES = new Set(['darts_bracket', 'cs2_major_qualifying_points', 'college_hockey_bracket']);
@ -80,14 +80,39 @@ export async function loader({ params }: Route.LoaderArgs) {
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId); const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
const existingEVs = await getAllParticipantEVsForSeason(sportsSeasonId); const existingEVs = await getAllParticipantEVsForSeason(sportsSeasonId);
const simulatorInputs = await getParticipantSimulatorInputs(sportsSeasonId);
const existingData: Record<string, { elo: number | null; ranking: number | null }> = {}; // The projection a participant was actually saved with. Read it back verbatim:
for (const ev of existingEVs) { // deriving the field from the stored Elo instead (as this page used to) shows the
existingData[ev.participantId] = { // admin a different number than they typed, because wins → Elo rounds to an
elo: ev.sourceElo ?? null, // integer Elo and a simulation run then re-resolves that Elo through the input
ranking: ev.worldRanking ?? null, // policy (clamping, and blending in futures odds when a season has them).
const projectionsByParticipant = new Map(
simulatorInputs.map((input) => [
input.participantId,
{ projectedWins: input.projectedWins, projectedTablePoints: input.projectedTablePoints },
])
);
const existingData: Record<
string,
{ elo: number | null; ranking: number | null; projectedWins: number | null; projectedTablePoints: number | null }
> = {};
for (const participant of participants) {
const projection = projectionsByParticipant.get(participant.id);
existingData[participant.id] = {
elo: null,
ranking: null,
projectedWins: projection?.projectedWins ?? null,
projectedTablePoints: projection?.projectedTablePoints ?? null,
}; };
} }
for (const ev of existingEVs) {
const existing = existingData[ev.participantId];
if (!existing) continue;
existing.elo = ev.sourceElo ?? null;
existing.ranking = ev.worldRanking ?? null;
}
const usesRanking = RANKING_SIMULATOR_TYPES.has(sportsSeason.sport?.simulatorType ?? ''); const usesRanking = RANKING_SIMULATOR_TYPES.has(sportsSeason.sport?.simulatorType ?? '');
@ -252,7 +277,16 @@ export default function AdminSportsSeasonEloRatings() {
if (simulatorConfig) { if (simulatorConfig) {
participants.forEach(p => { participants.forEach(p => {
const d = existingData[p.id]; const d = existingData[p.id];
if (d?.elo !== null && d?.elo !== undefined) { // A stored projection is shown exactly as it was entered. Only fall back to
// deriving it from the Elo when this season has no projection saved (a
// season that has only ever had Elos entered still gets a useful starting
// point) — that derived value is lossy and must never overwrite a real one.
const stored = simulatorConfig.projectionInput === 'tablePoints'
? d?.projectedTablePoints
: d?.projectedWins;
if (stored !== null && stored !== undefined) {
initial[p.id] = stored.toString();
} else if (d?.elo !== null && d?.elo !== undefined) {
initial[p.id] = (simulatorConfig.projectionInput === 'tablePoints' initial[p.id] = (simulatorConfig.projectionInput === 'tablePoints'
? eloToProjectedTablePoints(d.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo) ? eloToProjectedTablePoints(d.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
: eloToProjectedWins(d.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo) : eloToProjectedWins(d.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
@ -265,8 +299,8 @@ export default function AdminSportsSeasonEloRatings() {
const [bulkText, setBulkText] = useState(''); const [bulkText, setBulkText] = useState('');
const [parseResults, setParseResults] = useState<{ const [parseResults, setParseResults] = useState<{
matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; inputName: string }>; matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; projection: number | null; inputName: string }>;
unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null }>; unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null; projection: number | null }>;
} | null>(null); } | null>(null);
function findParticipantMatch(inputName: string) { function findParticipantMatch(inputName: string) {
@ -291,8 +325,8 @@ export default function AdminSportsSeasonEloRatings() {
function parseBulkText() { function parseBulkText() {
const lines = bulkText.split('\n'); const lines = bulkText.split('\n');
const matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; inputName: string }> = []; const matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; projection: number | null; inputName: string }> = [];
const unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null }> = []; const unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null; projection: number | null }> = [];
const seen = new Set<string>(); const seen = new Set<string>();
for (const line of lines) { for (const line of lines) {
@ -315,9 +349,9 @@ export default function AdminSportsSeasonEloRatings() {
const participant = findParticipantMatch(inputName); const participant = findParticipantMatch(inputName);
if (participant && !seen.has(participant.id)) { if (participant && !seen.has(participant.id)) {
seen.add(participant.id); seen.add(participant.id);
matched.push({ participantId: participant.id, name: participant.name, elo, ranking: null, inputName }); matched.push({ participantId: participant.id, name: participant.name, elo, ranking: null, projection: projectedWins, inputName });
} else if (!participant) { } else if (!participant) {
unmatched.push({ inputName, elo, ranking: null }); unmatched.push({ inputName, elo, ranking: null, projection: projectedWins });
} }
} else { } else {
const match = usesRanking const match = usesRanking
@ -342,9 +376,9 @@ export default function AdminSportsSeasonEloRatings() {
const participant = findParticipantMatch(inputName); const participant = findParticipantMatch(inputName);
if (participant && !seen.has(participant.id)) { if (participant && !seen.has(participant.id)) {
seen.add(participant.id); seen.add(participant.id);
matched.push({ participantId: participant.id, name: participant.name, elo, ranking, inputName }); matched.push({ participantId: participant.id, name: participant.name, elo, ranking, projection: null, inputName });
} else if (!participant) { } else if (!participant) {
unmatched.push({ inputName, elo, ranking }); unmatched.push({ inputName, elo, ranking, projection: null });
} }
} }
} }
@ -360,11 +394,11 @@ export default function AdminSportsSeasonEloRatings() {
for (const m of parseResults.matched) { for (const m of parseResults.matched) {
if (m.elo !== null) newElos[m.participantId] = m.elo.toString(); if (m.elo !== null) newElos[m.participantId] = m.elo.toString();
if (m.ranking !== null) newRanks[m.participantId] = m.ranking.toString(); if (m.ranking !== null) newRanks[m.participantId] = m.ranking.toString();
if (inputMode === 'projectedWins' && simulatorConfig && m.elo !== null) { // The pasted number goes in as typed. Round-tripping it through the derived
newWins[m.participantId] = (simulatorConfig.projectionInput === 'tablePoints' // Elo (as this used to) drifts it by up to half an Elo point — a pasted 95
? eloToProjectedTablePoints(m.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo) // came back as 95.1 before anything was even saved.
: eloToProjectedWins(m.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo) if (inputMode === 'projectedWins' && m.projection !== null) {
).toFixed(1); newWins[m.participantId] = m.projection.toString();
} }
} }
setEloValues(newElos); setEloValues(newElos);
@ -489,7 +523,10 @@ Mark Selby, 2432`
<div key={m.participantId} className="flex justify-between px-3 py-1.5"> <div key={m.participantId} className="flex justify-between px-3 py-1.5">
<span className="text-muted-foreground">{m.inputName}</span> <span className="text-muted-foreground">{m.inputName}</span>
<span className="font-medium"> <span className="font-medium">
{m.name} &rarr; {m.elo !== null ? `Elo ${m.elo}` : 'No Elo'} {m.name} &rarr;{' '}
{m.projection !== null
? `${m.projection} ${projectionUnit} (Elo ${m.elo})`
: m.elo !== null ? `Elo ${m.elo}` : 'No Elo'}
{usesRanking && m.ranking !== null ? `, ${rankLabel} #${m.ranking}` : ''} {usesRanking && m.ranking !== null ? `, ${rankLabel} #${m.ranking}` : ''}
</span> </span>
</div> </div>
@ -509,7 +546,9 @@ Mark Selby, 2432`
<div key={u.inputName} className="flex justify-between px-3 py-1.5"> <div key={u.inputName} className="flex justify-between px-3 py-1.5">
<span>{u.inputName}</span> <span>{u.inputName}</span>
<span className="font-medium"> <span className="font-medium">
{u.elo !== null ? `Elo ${u.elo}` : 'No Elo'} {u.projection !== null
? `${u.projection} ${projectionUnit} (Elo ${u.elo})`
: u.elo !== null ? `Elo ${u.elo}` : 'No Elo'}
{usesRanking && u.ranking !== null ? `, ${rankLabel} #${u.ranking}` : ''} {usesRanking && u.ranking !== null ? `, ${rankLabel} #${u.ranking}` : ''}
</span> </span>
</div> </div>
@ -540,7 +579,7 @@ Mark Selby, 2432`
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{inputMode === 'projectedWins' {inputMode === 'projectedWins'
? `Enter each team's projected total season ${projectionUnit}. Converted to Elo automatically. Saving will run the simulation and update expected values.` ? `Enter each team's projected total season ${projectionUnit} — the number you enter is stored as-is and re-derives the Elo on every run. Mid-season it is treated as a projected final total, so the simulation spreads the difference over the games still to play. Saving will run the simulation and update expected values.`
: usesRanking : usesRanking
? `Enter each ${participantLabel.toLowerCase()}'s Elo${allowsRankOnly ? ' (optional)' : ''} and ${rankLabel}. Saving will automatically run the simulation and update expected values.` ? `Enter each ${participantLabel.toLowerCase()}'s Elo${allowsRankOnly ? ' (optional)' : ''} and ${rankLabel}. Saving will automatically run the simulation and update expected values.`
: `Enter each ${participantLabel.toLowerCase()}'s current Elo rating. Saving will automatically run the simulation and update expected values.`} : `Enter each ${participantLabel.toLowerCase()}'s current Elo rating. Saving will automatically run the simulation and update expected values.`}

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,16 +1,23 @@
import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket"; import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket";
import { findSportsSeasonById } from "~/models/sports-season"; import { findSportsSeasonById } from "~/models/sports-season";
import { findParticipantsBySportsSeasonId } from "~/models/season-participant"; import {
import { getScoringEventById, updateScoringEvent } from "~/models/scoring-event"; findParticipantsBySportsSeasonId,
createParticipant,
updateParticipant,
} from "~/models/season-participant";
import { getScoringEventById, updateScoringEvent, isReadOnlySibling } from "~/models/scoring-event";
import { import {
findPlayoffMatchesByEventId, findPlayoffMatchesByEventId,
deletePlayoffMatchesByEventId,
generateBracketFromTemplate, generateBracketFromTemplate,
setMatchWinner, setMatchWinner,
advanceWinnerTemplate, advanceWinnerTemplate,
findPlayoffMatchById, findPlayoffMatchById,
assignParticipantsToKnockout, assignParticipantsToKnockout,
doesLoserAdvance, doesLoserAdvance,
reseedAflEliminationFinals,
reseedAflSemiFinals,
} from "~/models/playoff-match"; } from "~/models/playoff-match";
import { import {
createGame, createGame,
@ -25,13 +32,22 @@ import {
import { import {
processPlayoffEvent, processPlayoffEvent,
processMatchResult, processMatchResult,
processQualifyingBracketEvent,
processQualifyingEvent,
finalizeQualifyingPoints,
recalculateAffectedLeagues, recalculateAffectedLeagues,
recalculateStandings, recalculateStandings,
autoCompleteRoundIfDone, autoCompleteRoundIfDone,
applyBracketEntryFloors,
} from "~/models/scoring-calculator"; } from "~/models/scoring-calculator";
import { updateProbabilitiesAfterResult } from "~/services/probability-updater"; import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
import { getBracketTemplate, ALL_16_SEEDS, type BracketRegion } from "~/lib/bracket-templates"; import { getBracketTemplate, ALL_16_SEEDS, type BracketRegion } from "~/lib/bracket-templates";
import { setParticipantResult, findParticipantResultsBySportsSeasonId } from "~/models/participant-result"; import {
setParticipantResult,
findParticipantResultsBySportsSeasonId,
deleteParticipantResultsBySportsSeasonId,
deleteParticipantResultsForParticipants,
} from "~/models/participant-result";
import { findSeasonSportsBySportsSeasonId } from "~/models/season-sport"; import { findSeasonSportsBySportsSeasonId } from "~/models/season-sport";
import { createDailySnapshot } from "~/models/standings"; import { createDailySnapshot } from "~/models/standings";
import { import {
@ -54,6 +70,10 @@ import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.server"; 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) { export async function loader({ params }: Route.LoaderArgs) {
const sportsSeason = await findSportsSeasonById(params.id); const sportsSeason = await findSportsSeasonById(params.id);
@ -103,10 +123,230 @@ 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.
*
* "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".
*
* Returns the number of participants marked alongside whether a standings recalculation
* actually ran. The caller banks entry floors before calling this and needs them to
* reach teamStandings.totalPoints; it cannot infer that from the participant count,
* because the recalc is skipped for qualifying events, when every eliminated team
* already had a result row (the second run of a generation), and when the announcement
* threw. `recalculated` reports the fact rather than making the caller re-derive it.
*/
async function markEliminatedAndAnnounce(
event: { id: string; name: string | null; sportsSeasonId: string; isQualifyingEvent: boolean },
participantIds: string[]
): Promise<{ markedCount: number; recalculated: boolean }> {
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);
}
let recalculated = false;
// 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,
});
recalculated = true;
} catch (err) {
logger.error("[Eliminations] Discord announcement failed:", err);
}
}
return { markedCount: participantIds.length, recalculated };
}
export async function action({ request, params }: Route.ActionArgs) { export async function action({ request, params }: Route.ActionArgs) {
const formData = await request.formData(); const formData = await request.formData();
const intent = formData.get("intent"); 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" };
}
}
// The only way to repair a mis-seeded bracket: nothing else can rewrite a match's
// participants. Clearing brings back the setup form, so the admin re-seeds from there.
if (intent === "clear-bracket") {
try {
const event = await getScoringEventById(params.eventId);
if (!event) return { error: "Event not found" };
const existing = await findPlayoffMatchesByEventId(params.eventId);
if (existing.length === 0) {
return { error: "This event has no bracket to clear" };
}
// Clearing discards recorded results, so make the admin confirm once games have
// actually been played.
const completed = existing.filter((m) => m.isComplete).length;
if (completed > 0 && formData.get("confirm") !== "true") {
return {
error: `This bracket has ${completed} completed match(es). Confirm to discard those results.`,
};
}
await deletePlayoffMatchesByEventId(params.eventId);
// Placements are deliberately left alone. seasonParticipantResults is keyed by
// sports season, not by event, so a season-wide delete here would wipe the
// placements of every other event in the season with nothing to rebuild them —
// and on a finalized qualifying season that means permanently zeroed standings.
// Reprocess Bracket already rebuilds placements correctly, qualifying path
// included, so point the admin at it once the new bracket is in place.
const note =
completed > 0
? " Run Reprocess Bracket after rebuilding to clear the placements those results produced."
: "";
return {
success: `Bracket cleared (${existing.length} match(es) removed). Set it up again below.${note}`,
};
} catch (error) {
logger.error("Error clearing bracket:", error);
return {
error: error instanceof Error ? error.message : "Failed to clear bracket",
};
}
}
if (intent === "generate-bracket") { if (intent === "generate-bracket") {
const templateId = formData.get("templateId"); const templateId = formData.get("templateId");
@ -170,36 +410,50 @@ export async function action({ request, params }: Route.ActionArgs) {
try { try {
await generateBracketFromTemplate(params.eventId, templateId, participantIds, regionOverride); await generateBracketFromTemplate(params.eventId, templateId, participantIds, regionOverride);
// PHASE 5.3: Mark participants NOT in the bracket as eliminated // The template ID has to land on the event before entry floors can be derived
const event = await getScoringEventById(params.eventId); // (getBracketEntryFloor reads it), so persist it here rather than after the
if (event) { // elimination pass below.
// 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`);
}
// Update the event to store the template ID, scoring start round, and region config
await updateScoringEvent(params.eventId, { await updateScoringEvent(params.eventId, {
bracketTemplateId: templateId, bracketTemplateId: templateId,
scoringStartsAtRound: template.scoringStartsAtRound, scoringStartsAtRound: template.scoringStartsAtRound,
bracketRegionConfig: regionOverride, bracketRegionConfig: regionOverride,
}); });
// Some seedings guarantee points before a ball is bounced — an AFL top-4 seed
// has the double chance, so the 5th-6th tier is locked in at generation. Bank
// those provisional floors now, ahead of the elimination announcement below so
// the standings it posts already reflect them.
const entryFloorCount = await applyBracketEntryFloors(params.eventId);
if (entryFloorCount > 0) {
logger.log(`[BracketGeneration] Applied entry floors to ${entryFloorCount} participant(s)`);
}
// PHASE 5.3: Mark participants NOT in the bracket as eliminated (and announce).
const event = await getScoringEventById(params.eventId);
if (event) {
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
const participantsInBracket = new Set(participantIds);
const toEliminate = allParticipants
.filter((p) => !participantsInBracket.has(p.id))
.map((p) => p.id);
const { markedCount, recalculated } = await markEliminatedAndAnnounce(event, toEliminate);
logger.log(`[BracketGeneration] Marked ${markedCount} participants as eliminated`);
// The floors banked above only reach teamStandings.totalPoints via a recalc, and
// markEliminatedAndAnnounce runs one for its announcement in some cases but not
// others: not for a qualifying event, not when every eliminated team already had
// a result row (the second run of a generation, since the first wrote 0 for all
// of them), not when there was nobody to eliminate, and not when the announcement
// threw. Drive off what it reports rather than re-deriving it from toEliminate.
// skipDiscord: seeding floors are not a result to announce.
if (entryFloorCount > 0 && !recalculated) {
await recalculateAffectedLeagues(event.sportsSeasonId, database(), {
eventName: event.name ?? undefined,
skipDiscord: true,
});
}
}
return { success: "Bracket generated successfully" }; return { success: "Bracket generated successfully" };
} catch (error) { } catch (error) {
logger.error("Error generating bracket:", error); logger.error("Error generating bracket:", error);
@ -246,6 +500,13 @@ export async function action({ request, params }: Route.ActionArgs) {
return { error: "Could not determine loser" }; 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 // Set the winner
await setMatchWinner(matchId, winnerId, loserId); await setMatchWinner(matchId, winnerId, loserId);
@ -267,6 +528,22 @@ export async function action({ request, params }: Route.ActionArgs) {
} }
} }
const db = database();
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, // Immediately score this match: loser gets their final placement,
// winner gets provisional floor points (isPartialScore=true). // winner gets provisional floor points (isPartialScore=true).
// Prefer the template-defined isScoring over the DB field: the DB column // Prefer the template-defined isScoring over the DB field: the DB column
@ -285,8 +562,8 @@ export async function action({ request, params }: Route.ActionArgs) {
loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""), loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""),
}); });
const db = database();
await autoCompleteRoundIfDone(event.id, match.round, event.sportsSeasonId, db); await autoCompleteRoundIfDone(event.id, match.round, event.sportsSeasonId, db);
}
return { success: "Winner set successfully" }; return { success: "Winner set successfully" };
} catch (error) { } catch (error) {
@ -337,6 +614,10 @@ export async function action({ request, params }: Route.ActionArgs) {
let successCount = 0; let successCount = 0;
const errors: string[] = []; const errors: string[] = [];
const processedMatchIds: 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) { for (const { matchId, winnerId } of winnerAssignments) {
try { try {
@ -385,6 +666,9 @@ export async function action({ request, params }: Route.ActionArgs) {
// Score this match without triggering standings/probability recalc yet — // Score this match without triggering standings/probability recalc yet —
// we batch those side effects into a single call after the loop. // we batch those side effects into a single call after the loop.
// 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({ await processMatchResult({
round: match.round, round: match.round,
winnerId, winnerId,
@ -398,9 +682,14 @@ export async function action({ request, params }: Route.ActionArgs) {
skipSideEffects: true, skipSideEffects: true,
loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""), loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""),
}); });
}
successCount++; successCount++;
processedMatchIds.push(matchId); 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) { } catch (error) {
logger.error(`Error setting winner for match ${matchId}:`, error); logger.error(`Error setting winner for match ${matchId}:`, error);
errors.push( errors.push(
@ -414,6 +703,15 @@ export async function action({ request, params }: Route.ActionArgs) {
// previously completed matches in the event. // previously completed matches in the event.
if (successCount > 0) { if (successCount > 0) {
const db = database(); 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 // Update probabilities first so recalculateAffectedLeagues reads fresh EVs
// when computing projected points. // when computing projected points.
try { try {
@ -422,7 +720,18 @@ export async function action({ request, params }: Route.ActionArgs) {
logger.error(`Error updating probabilities after batch round winners:`, error); 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 recalculateAffectedLeagues(event.sportsSeasonId, db, { eventId: event.id, eventName: event.name ?? undefined, matchIds: processedMatchIds });
// 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); 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) { if (errors.length > 0 && successCount === 0) {
@ -474,6 +783,14 @@ export async function action({ request, params }: Route.ActionArgs) {
return { error: `Not all matches in ${round} are complete` }; 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 // Validate round order: ensure previous rounds are complete
const existingResults = await findParticipantResultsBySportsSeasonId( const existingResults = await findParticipantResultsBySportsSeasonId(
params.id params.id
@ -551,6 +868,101 @@ export async function action({ request, params }: Route.ActionArgs) {
} }
} }
// Re-seed the AFL Wildcard winners into the Elimination Finals they belong in.
// Advancement does this on every Wildcard result, so this is only needed for a
// bracket advanced before that rule existed: the winners sit in the wrong games and
// no admin action re-runs advancement (a completed match cannot be re-submitted).
if (intent === "reseed-afl-wildcard") {
try {
const event = await getScoringEventById(params.eventId);
if (!event) return { error: "Event not found" };
if (event.bracketTemplateId !== "afl_10") {
return { error: "This action only applies to AFL finals brackets" };
}
const participants = await findParticipantsBySportsSeasonId(params.id);
const nameOf = (id: string) => participants.find((p) => p.id === id)?.name ?? id;
const reseed = await reseedAflEliminationFinals(params.eventId);
if (reseed.vacated.length === 0 && reseed.filled.length === 0) {
return {
success:
"Elimination Finals already match the Wildcard results — nothing to re-seed.",
};
}
// Only the qualifier slots move, so there is nothing to re-score: no placement,
// score or elimination changes, and so nothing to announce.
const moves = reseed.filled
.toSorted((a, b) => a.matchNumber - b.matchNumber)
.map((slot) => `match ${slot.matchNumber} now hosts ${nameOf(slot.participantId)}`)
.join(", ");
return {
success: `Re-seeded the Elimination Finals: ${moves}.`,
};
} catch (error) {
logger.error("Error re-seeding AFL Wildcard winners:", error);
return {
error:
error instanceof Error ? error.message : "Failed to re-seed the Elimination Finals",
};
}
}
// Put the Elimination Final winners in the Semi-Finals they belong in. Elimination
// Final n feeds Semi-Final n, but brackets advanced before that was fixed crossed the
// two winners, and no admin action re-runs advancement (a completed match cannot be
// re-submitted).
if (intent === "reseed-afl-semifinals") {
try {
const event = await getScoringEventById(params.eventId);
if (!event) return { error: "Event not found" };
if (event.bracketTemplateId !== "afl_10") {
return { error: "This action only applies to AFL finals brackets" };
}
const participants = await findParticipantsBySportsSeasonId(params.id);
const nameOf = (id: string) => participants.find((p) => p.id === id)?.name ?? id;
const reseed = await reseedAflSemiFinals(params.eventId);
if (reseed.vacated.length === 0 && reseed.filled.length === 0) {
return {
success:
"Semi-Finals already match the Elimination Finals results — nothing to re-seed.",
};
}
// Only the qualifier slots move, so there is nothing to re-score: no placement,
// score or elimination changes, and so nothing to announce.
//
// A slot can be vacated without being refilled — un-recording an Elimination Final
// result takes its winner back out — so report those too rather than rendering an
// empty list.
const filled = reseed.filled.map((slot) => ({
matchNumber: slot.matchNumber,
text: `match ${slot.matchNumber} now hosts ${nameOf(slot.participantId)}`,
}));
const emptied = reseed.vacated
.filter((matchNumber) => !reseed.filled.some((slot) => slot.matchNumber === matchNumber))
.map((matchNumber) => ({ matchNumber, text: `match ${matchNumber} is back to TBD` }));
const moves = [...filled, ...emptied]
.toSorted((a, b) => a.matchNumber - b.matchNumber)
.map((move) => move.text)
.join(", ");
return {
success: `Re-seeded the Semi-Finals: ${moves}.`,
};
} catch (error) {
logger.error("Error re-seeding AFL Elimination Finals winners:", error);
return {
error:
error instanceof Error ? error.message : "Failed to re-seed the Semi-Finals",
};
}
}
if (intent === "reprocess-bracket") { if (intent === "reprocess-bracket") {
try { try {
const event = await getScoringEventById(params.eventId); const event = await getScoringEventById(params.eventId);
@ -559,20 +971,87 @@ export async function action({ request, params }: Route.ActionArgs) {
const matches = await findPlayoffMatchesByEventId(params.eventId); const matches = await findPlayoffMatchesByEventId(params.eventId);
const completed = matches.filter((m) => m.isComplete && m.winnerId && m.loserId); const completed = matches.filter((m) => m.isComplete && m.winnerId && m.loserId);
if (completed.length === 0) { // Qualifying majors: this is also the cleanup tool for majors that wrongly banked
return { error: "No completed matches to reprocess" }; // 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.` };
} }
// Delete ALL results for this sports season and rebuild from scratch. if (matches.length === 0) {
// Only deleting partial rows leaves stale finalized rows that block return { error: "No bracket to reprocess" };
// the "never un-finalize" guard in upsertParticipantResult. }
// Wipe this bracket's participants' results and rebuild from scratch. Deleting
// only the partial rows would leave stale finalized ones, which the "never
// un-finalize" guard in upsertParticipantResult then refuses to correct.
//
// Scoped to the participants this bracket actually holds, not the whole season:
// seasonParticipantResults is keyed by sports season, not by event, so a
// season-wide delete takes every other event's placements with it and only this
// bracket's replay could rebuild them (the hazard clear-bracket documents).
//
// Unconditional, because zero completed matches is precisely the clear-bracket →
// regenerate → reprocess repair path: the discarded bracket's finalized
// placements are exactly what needs clearing, and there is always something to
// rebuild from — the entry floors below, then the replay.
const db = database(); const db = database();
await db // Reused further down to decide who is *not* in the bracket and so eliminated.
.delete(schema.seasonParticipantResults) const bracketParticipantIds = new Set<string>();
.where( for (const match of matches) {
eq(schema.seasonParticipantResults.sportsSeasonId, event.sportsSeasonId) if (match.participant1Id) bracketParticipantIds.add(match.participant1Id);
if (match.participant2Id) bracketParticipantIds.add(match.participant2Id);
}
await deleteParticipantResultsForParticipants(
event.sportsSeasonId,
[...bracketParticipantIds],
db
); );
// Re-bank the seeding-derived floors the delete above wipes (e.g. the AFL
// top-4's 5th-6th tier). Done before the replay so real match results overwrite
// them; a bracket with no completed matches still gets its guaranteed points.
const entryFloorCount = await applyBracketEntryFloors(params.eventId, db);
// Replay each completed match in bracket order (earlier rounds first). // Replay each completed match in bracket order (earlier rounds first).
const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null; const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null;
const roundOrder = template ? template.rounds.map((r) => r.name) : []; const roundOrder = template ? template.rounds.map((r) => r.name) : [];
@ -612,11 +1091,6 @@ export async function action({ request, params }: Route.ActionArgs) {
// Mark participants NOT in any bracket match as eliminated (finalPosition = 0). // Mark participants NOT in any bracket match as eliminated (finalPosition = 0).
// This covers teams that didn't make the playoffs/play-in tournament. // This covers teams that didn't make the playoffs/play-in tournament.
const allParticipants = await findParticipantsBySportsSeasonId(params.id); const allParticipants = await findParticipantsBySportsSeasonId(params.id);
const bracketParticipantIds = new Set<string>();
for (const match of matches) {
if (match.participant1Id) bracketParticipantIds.add(match.participant1Id);
if (match.participant2Id) bracketParticipantIds.add(match.participant2Id);
}
let eliminatedCount = 0; let eliminatedCount = 0;
for (const participant of allParticipants) { for (const participant of allParticipants) {
if (!bracketParticipantIds.has(participant.id)) { if (!bracketParticipantIds.has(participant.id)) {
@ -632,7 +1106,12 @@ export async function action({ request, params }: Route.ActionArgs) {
// skipDiscord: reprocess-bracket is a data-correction tool, not a result announcement. // skipDiscord: reprocess-bracket is a data-correction tool, not a result announcement.
await recalculateAffectedLeagues(event.sportsSeasonId, undefined, { skipDiscord: true }); await recalculateAffectedLeagues(event.sportsSeasonId, undefined, { skipDiscord: true });
return { success: `Reprocessed bracket: ${sortedMatches.length} match(es) replayed, ${eliminatedCount} non-bracket participant(s) eliminated` }; return {
success:
`Reprocessed bracket: ${sortedMatches.length} match(es) replayed, ` +
`${entryFloorCount} seeded participant(s) given their guaranteed entry floor, ` +
`${eliminatedCount} non-bracket participant(s) eliminated`,
};
} catch (error) { } catch (error) {
logger.error("Error reprocessing bracket:", error); logger.error("Error reprocessing bracket:", error);
return { return {
@ -669,6 +1148,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 // Get all participants in this sports season
const allParticipants = await findParticipantsBySportsSeasonId(params.id); const allParticipants = await findParticipantsBySportsSeasonId(params.id);
@ -783,14 +1287,19 @@ export async function action({ request, params }: Route.ActionArgs) {
await generateBracketFromTemplate(params.eventId, templateId); await generateBracketFromTemplate(params.eventId, templateId);
// Eliminate participants from this sport season who are not in any group // Eliminate participants from this sport season who are not in any group
// (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 allParticipants = await findParticipantsBySportsSeasonId(params.id);
let eliminatedCount = 0; const toEliminate = allParticipants
for (const participant of allParticipants) { .filter((p) => !uniqueParticipants.has(p.id))
if (!uniqueParticipants.has(participant.id)) { .map((p) => p.id);
await setParticipantResult(participant.id, params.id, 0); const { markedCount: eliminatedCount } = await markEliminatedAndAnnounce(
eliminatedCount++; groupsEvent,
} toEliminate
} );
return { return {
success: `Groups and knockout bracket structure created successfully${eliminatedCount > 0 ? ` (${eliminatedCount} participant(s) not in any group marked as eliminated)` : ""}`, success: `Groups and knockout bracket structure created successfully${eliminatedCount > 0 ? ` (${eliminatedCount} participant(s) not in any group marked as eliminated)` : ""}`,
@ -915,11 +1424,10 @@ export async function action({ request, params }: Route.ActionArgs) {
// Assign participants to knockout bracket // Assign participants to knockout bracket
await assignParticipantsToKnockout(params.eventId, assignments); 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); const eliminatedIds = await getEliminatedParticipantIds(params.eventId);
for (const participantId of eliminatedIds) { await markEliminatedAndAnnounce(event, eliminatedIds);
await setParticipantResult(participantId, event.sportsSeasonId, 0);
}
logger.log( logger.log(
`[PopulateKnockout] Assigned 32 participants to knockout, marked ${eliminatedIds.length} as eliminated` `[PopulateKnockout] Assigned 32 participants to knockout, marked ${eliminatedIds.length} as eliminated`

View file

@ -1,6 +1,6 @@
import { Form, Link } from "react-router"; import { Form, Link, useFetcher } from "react-router";
import { Fragment, useState, useEffect, useMemo } from "react"; import { Fragment, useState, useEffect, useMemo, useRef } from "react";
import { localDateTimeToUtcIso } from "~/lib/date-utils"; import { localDateTimeToUtcIso, utcIsoToLocalDateTime } from "~/lib/date-utils";
import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket"; import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket";
import { loader, action } from "./admin.sports-seasons.$id.events.$eventId.bracket.server"; 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` }]; 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 }; 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({ export default function EventBracket({
loaderData, loaderData,
actionData, actionData,
@ -323,6 +431,164 @@ export default function EventBracket({
</div> </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. {/* Reprocess Bracket - Full rebuild of participant results.
Hidden once every match is complete at that point all placements are final Hidden once every match is complete at that point all placements are final
and "finalize bracket" should be used instead. */} and "finalize bracket" should be used instead. */}
@ -347,6 +613,110 @@ export default function EventBracket({
</Card> </Card>
)} )}
{/* Re-seed AFL Wildcard winners. Advancement pairs them by ladder position on
every Wildcard result, so this is only for a bracket advanced before that
rule existed a completed match cannot be re-submitted to re-run it. */}
{event.bracketTemplateId === "afl_10" && matches.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Re-seed Wildcard Winners</CardTitle>
<CardDescription>
Pair the Elimination Finals by ladder position: 5th hosts the
lower-ranked Wildcard winner and 6th the higher-ranked one. Only moves
the qualifier slots no results, scores or placements change, and
nothing is announced. Does nothing if the pairings are already right.
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post">
<input type="hidden" name="intent" value="reseed-afl-wildcard" />
<Button type="submit" variant="outline">
Re-seed Wildcard Winners
</Button>
</Form>
</CardContent>
</Card>
)}
{/* Fix the Semi-Final pairings. Elimination Final n feeds Semi-Final n, but
brackets advanced before that was fixed crossed the two winners, and no
admin action re-runs advancement. */}
{event.bracketTemplateId === "afl_10" && matches.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Fix Semi-Final Pairings</CardTitle>
<CardDescription>
Feed each Elimination Final into the Semi-Final it belongs to: EF1
winner into SF1 and EF2 winner into SF2. Only moves the qualifier slots
no results, scores or placements change, and nothing is announced.
Does nothing if the pairings are already right.
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post">
<input type="hidden" name="intent" value="reseed-afl-semifinals" />
<Button type="submit" variant="outline">
Fix Semi-Final Pairings
</Button>
</Form>
</CardContent>
</Card>
)}
{/* Clear Bracket - the only escape hatch for a mis-seeded bracket. Nothing else
can rewrite a match's participants, so a wrong seeding has to be torn down
and rebuilt via the setup form below, which reappears once this runs. */}
{matches.length > 0 && (
<Card className="border-destructive/40">
<CardHeader>
<CardTitle>Clear Bracket</CardTitle>
<CardDescription>
Delete every match in this bracket so it can be set up again from
scratch. Use this when the wrong participants were seeded. Placements
are left alone run Reprocess Bracket after rebuilding to clear any
that the discarded results produced.
</CardDescription>
</CardHeader>
<CardContent>
<Form
method="post"
className="space-y-3"
onSubmit={(e) => {
if (
!confirm(
`Delete all ${matches.length} match(es) in this bracket? Recorded results will be lost.`
)
) {
e.preventDefault();
}
}}
>
<input type="hidden" name="intent" value="clear-bracket" />
{/* The server refuses to discard completed matches unless this is
checked. Sending it unconditionally from a hidden field would make
that guard unreachable, including for a submit without JS. */}
{matches.some((m: { isComplete: boolean }) => m.isComplete) && (
<div className="flex items-center gap-2">
<input
type="checkbox"
id="confirm-clear-bracket"
name="confirm"
value="true"
className="h-4 w-4"
/>
<Label htmlFor="confirm-clear-bracket" className="font-normal">
Yes, discard the results already recorded in this bracket
</Label>
</div>
)}
<Button type="submit" variant="destructive">
Clear Bracket
</Button>
</Form>
</CardContent>
</Card>
)}
{/* ====== SETUP PHASE ====== */} {/* ====== SETUP PHASE ====== */}
{showSetup && ( {showSetup && (
<Card> <Card>
@ -623,7 +993,7 @@ export default function EventBracket({
return ( return (
// eslint-disable-next-line react/no-array-index-key // eslint-disable-next-line react/no-array-index-key
<div key={i} className="flex items-center gap-2"> <div key={i} className="flex items-center gap-2">
<Label className="w-20 text-sm text-muted-foreground shrink-0"> <Label className="w-28 text-sm text-muted-foreground shrink-0">
{slotLabel} {slotLabel}
</Label> </Label>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@ -806,24 +1176,7 @@ export default function EventBracket({
{groupMatches.map((match) => ( {groupMatches.map((match) => (
<div key={match.id} className="text-xs space-y-1"> <div key={match.id} className="text-xs space-y-1">
{/* Schedule row */} {/* Schedule row */}
<Form method="post" className="flex items-center gap-1"> <GroupMatchScheduleForm match={match} localTzAbbr={localTzAbbr} />
<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>
{/* Score row */} {/* Score row */}
<Form method="post" className="flex items-center gap-1"> <Form method="post" className="flex items-center gap-1">
<input type="hidden" name="intent" value="update-group-match" /> <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 type { Route } from './+types/admin.sports-seasons.$id.events.$eventId.cs2-setup';
import { findSportsSeasonById } from '~/models/sports-season'; 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 { findParticipantsBySportsSeasonId } from '~/models/season-participant';
import { import {
getCs2StageResultsForEvent, getCs2StageResultsForEvent,
upsertCs2StageAssignments, upsertCs2StageAssignments,
markCs2StageEliminations, markCs2StageEliminations,
clearCs2StageAssignments, resetCs2Event,
clearCs2EliminationsAtStage, clearCs2EliminationsAtStage,
assignCs2EliminationQP, assignCs2EliminationQP,
} from '~/models/cs2-major-stage'; } from '~/models/cs2-major-stage';
import { findSeasonMatchesByScoringEventId } from '~/models/season-match'; import { findSeasonMatchesByScoringEventId } from '~/models/season-match';
import { syncMatches } from '~/services/match-sync'; import { syncMatches } from '~/services/match-sync';
import { fanOutMajorIfPrimary } from '~/services/sync-tournament-results';
import { Button } from '~/components/ui/button'; import { Button } from '~/components/ui/button';
import { import {
Card, Card,
@ -61,9 +62,22 @@ export async function action({ request, params }: Route.ActionArgs) {
const formData = await request.formData(); const formData = await request.formData();
const intent = formData.get('intent') as string; 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') { if (intent === 'reset') {
await clearCs2StageAssignments(eventId); // Local recovery tool: clears only the primary's torn state. Siblings are not
return { success: true, message: 'Stage assignments cleared.' }; // 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') { if (intent === 'assign') {
@ -126,6 +140,10 @@ export async function action({ request, params }: Route.ActionArgs) {
} }
await markCs2StageEliminations(eventId, eliminations); await markCs2StageEliminations(eventId, eliminations);
await assignCs2EliminationQP(eventId, params.id); 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.` }; return { success: true, message: `Marked ${eliminations.length} eliminations.` };
} catch (err) { } catch (err) {
return { success: false, message: err instanceof Error ? err.message : 'Failed to save eliminations.' }; 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"> <CardTitle className="flex items-center justify-between">
Stage Assignments Stage Assignments
{stageResults.length > 0 && ( {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" /> <input type="hidden" name="intent" value="reset" />
<Button type="submit" variant="ghost" size="sm" className="text-destructive"> <Button type="submit" variant="ghost" size="sm" className="text-destructive">
<Trash2 className="h-4 w-4 mr-1" /> <Trash2 className="h-4 w-4 mr-1" />

View file

@ -6,6 +6,7 @@ import {
getScoringEventById, getScoringEventById,
completeScoringEvent, completeScoringEvent,
updateScoringEvent, updateScoringEvent,
isReadOnlySibling,
} from "~/models/scoring-event"; } from "~/models/scoring-event";
import { import {
getEventResults, getEventResults,
@ -77,6 +78,9 @@ export async function loader({ params }: Route.LoaderArgs) {
) )
: new Set(); : 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 { return {
sportsSeason: sportsSeason as typeof sportsSeason & { sportsSeason: sportsSeason as typeof sportsSeason & {
sport: { id: string; name: string; type: string; slug: string; simulatorType: string | null }; sport: { id: string; name: string; type: string; slug: string; simulatorType: string | null };
@ -89,13 +93,45 @@ export async function loader({ params }: Route.LoaderArgs) {
qpConfig, qpConfig,
qpStandings, qpStandings,
notParticipatingIds, 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) { export async function action({ request, params }: Route.ActionArgs) {
const formData = await request.formData(); const formData = await request.formData();
const intent = formData.get("intent"); 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") { if (intent === "mark-qualifying") {
try { try {
const event = await getScoringEventById(params.eventId); const event = await getScoringEventById(params.eventId);

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