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
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
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
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
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
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
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
- 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
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
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
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
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
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>
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
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
- 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
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
- 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
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
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.
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
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>
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
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>
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>
## Summary
- 0-3 teams in Stage 3 were being assigned slots 9–10 (high QP) when marked eliminated before other Stage 3 teams, because `computeStage3ExitQP` filled from slot 9 upward using only the teams passed in
- 1-3 teams had the same problem when marked before 2-3 teams
- Fix: gate all Stage 3 QP computation on `stage3Exits.length === STAGE3_TOTAL_EXITS` (8) — partial saves write 0 QP as a placeholder, and correct QP/placements are assigned once all 8 exits are known
- As belt-and-suspenders, `computeStage3ExitQP` now places 0-wins teams from the bottom of the slot range so the function itself is correct even if called with a partial set
## Test plan
- [ ] Run `npm run test:run -- app/models/__tests__/cs2-major-stage.test.ts` — all 17 tests pass
- [ ] Mark 2 Stage 3 teams as 0-3 eliminated and save — confirm they show 0 QP (not 2 QP)
- [ ] Mark all 8 Stage 3 exits and save — confirm 0-3 teams get slots 15–16 QP, 1-3 teams get slots 12–14 QP split correctly
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #89
## Summary
- Deletes 12 `it("calls onX with correct args")` test blocks from `MiniDraftGrid.test.tsx` and `DraftGridSection.test.tsx`
- Removes now-unused `import userEvent` from both files
## Why
`userEvent.setup().click()` hangs indefinitely on Radix UI `ContextMenu` items in jsdom — pointer-event and animation checks stall waiting for CSS transitions that never fire `transitionend` in the test environment. This caused a flaky 5 s timeout in CI.
The deleted tests were verifying that clicking a `ContextMenuItem` fires its `onClick` — React/Radix wiring, not app logic. The remaining presence/absence tests already cover the conditional rendering (which items appear under which conditions), which is where the actual app logic lives.
## Test plan
- [ ] `npm run test:run -- MiniDraftGrid DraftGridSection` — all remaining tests pass, no timeouts
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #81
## Summary
- **Timer bank broadcasts**: emit `timer-bank-updated` after every pick so all clients immediately see the updated bank instead of waiting for the next `timer-pick-started`
- **Increment accuracy**: capture `pickMadeAt` at route entry (before auth/DB overhead) and use `Math.ceil` so credited seconds always match the client countdown display
- **Race condition fix**: hold `schedulingInProgress` lock for the full timer callback to prevent the recovery interval from scheduling a duplicate timeout mid-pick
- **force-autopick fix**: call `rescheduleTimer` so the next team's clock starts immediately instead of waiting for the old timeout to naturally expire
- **adjust-time-bank fix**: for on-clock teams, shift `picksExpiresAt` by the adjustment and reschedule so the client countdown updates; block adjustments that would reduce the bank to zero
- **New socket events**: `timer-pick-started`, `timer-overnight-paused`, `timer-bank-updated` with full type definitions; removed dead `timer-update` event
- **Reconnect sync**: `draft-state-sync` now includes `expiresAt` for the active timer and `isOvernightPause` state so reconnecting clients see accurate countdown and pause banner immediately without a page reload
- **Room closure countdown**: capture client-side timestamp when draft completes so the "Room closes in X" countdown actually ticks down before the loader revalidates with `draftCompletedAt`
- **Countdown interval**: run at 500ms with `Math.ceil` to prevent skipped seconds under event loop pressure
- **Overnight pause UX**: `canPick` only blocks on commissioner pause — overnight pause freezes the timer but the on-clock player can still pick early
- **Overnight pause refactor**: extract `checkOvernightPause` to `server/overnight-pause-check.ts`, breaking the `timer↔socket` circular import and sharing the timezone cache across both callers with correct eviction
- **PostgreSQL type fix**: cast `varchar` owner ID to `uuid` in `getTeamTimezone` join
## Test plan
- [ ] Manual pick: all clients see bank increment immediately after pick
- [ ] Timeout pick: all clients see bank update (0 → increment); next clock starts within ~1s
- [ ] Force-autopick: next team's clock starts immediately; no "Pick already made" log
- [ ] Force-manual-pick: all clients see bank increment
- [ ] Pause while clock running: countdown freezes on all clients
- [ ] Resume: clock continues from frozen value
- [ ] adjust-time-bank on on-clock team: countdown shifts immediately
- [ ] adjust-time-bank to zero: returns 400 error
- [ ] Reconnect (socket disconnect/connect): countdown resumes for correct team
- [ ] Hard refresh mid-draft: on-clock indicator and countdown correct immediately
- [ ] Draft complete: "Room closes in X" counts down
- [ ] Overnight pause: banner shows, pick buttons still enabled, timer frozen
- [ ] `npm run test:run` — all 158 files / 2351 tests pass
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #72
## Summary
- **Root cause**: `statsapi.mlb.com` returns 406 (deprecated). Switched MLB to ESPN's free standings API (`site.api.espn.com/apis/v2/sports/baseball/mlb/standings`), consistent with NBA, WNBA, and MLS.
- **Refactor**: Extracted a shared `espn.ts` utility module, eliminating 4× duplication of `statsMap()`, `flattenEspnStandings()`, ESPN interfaces, and the `playoffSeed` → conference rank logic across adapters.
- **Bug fixes** found during review and applied across all affected adapters:
- `parseConferenceRank`: `|| undefined` falsy-zero bug replaced with `isNaN` guard
- Sort comparators: stable alphabetical tiebreaker added to MLB, NBA, WNBA
- `winPct`: falls back to `wins/(wins+losses)` if ESPN omits the stat (was silently 0)
- statsMap pre-built once per entry before sorting in all adapters (was rebuilt per comparison)
- WNBA `parseEntry`: accepts pre-computed `sm` instead of rebuilding it internally
- MLB `gamesBack`: tests updated to reflect ESPN returns numeric `0` for division leaders (old API used `"-"` → `undefined`)
## Test plan
- [ ] All 60 standings-sync unit tests pass (`npm run test:run -- app/services/standings-sync`)
- [ ] Trigger MLB standings sync from admin panel and confirm it returns data without a 406
- [ ] Confirm NBA, WNBA, MLS syncs still work (adapters touched but behaviour unchanged)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #62
## Summary
- **Per-league queue**: replaced the single global notification queue with a \`Map<leagueId, queue>\` so concurrent drafts in different leagues drain independently and don't block each other
- **Deadlock fix**: wrapped the drainer loop in \`try/finally\` so \`drainingLeagues\` is always cleaned up even if a task throws unexpectedly — previously a crash would leave the flag set permanently, silently dropping all future notifications for that league
- **Wait cap fix**: moved \`Math.min\` to wrap \`(retryAfter * (attempt+1))\` so the 10s ceiling applies to the final product, not just the base value — settings actions that directly await webhook calls were previously blockable for up to 30s on a rate-limited third attempt
## Root cause of the original problem
During an autodraft chain, \`notifyPickMadeOnDiscord\` was called fire-and-forget for each pick in the chain, causing all webhook requests to fly concurrently. Discord's per-webhook rate limit (~5 req/2s) rejected most of them; the single retry logic fired for all simultaneously, hit the limit again, and the \`.catch()\` swallowed the errors silently.
## Test plan
- [ ] Enable autodraft for 5+ consecutive teams; trigger the chain; confirm all picks announce in Discord in order
- [ ] Confirm a slow/rate-limited webhook in League A does not delay League B's announcements
- [ ] \`npm run test:run\` — 158 test files, 2336 tests, all pass
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #57
## Summary
- **Group stage match list**: Removed MD 1/2/3 headings; matches now listed chronologically under date separators (e.g. "Jun 14"). Kick-off time appears below the team names instead of between them.
- **Sort order**: \`findMatchesByGroupIds\` / \`findMatchesByEventId\` now order by \`scheduledAt ASC NULLS LAST\` so unscheduled matches always trail scheduled ones.
- **Groups/Bracket toggle**: When both group stage and knockout bracket exist, a toggle appears (mirroring the NBA/AFL standings toggle). Sports with both regular-season standings and a group stage get all views available.
- **Upcoming events**: \`getUpcomingEventsForDraftedParticipants\` now also queries \`groupStageMatches\`, so World Cup group fixtures appear on the home page calendar sorted by kick-off time with a label like "Group A — France vs Germany".
- **\`isAllCompete\` fix**: \`UpcomingEventsCard\` and \`UpcomingCalendarPanel\` now treat \`group_stage_match\` as a bracket-style event, showing team badges instead of "N of your picks".
Fixes#53
## Test plan
- [ ] Navigate to a World Cup sports season page — groups should be listed chronologically under date headers with no MD labels; kick-off time should appear below each match row
- [ ] Once the knockout bracket has matches, confirm the Groups/Bracket toggle appears and both views render correctly
- [ ] On the home page, a user with drafted World Cup participants should see upcoming group fixtures in the calendar panel with individual team badges (not a participant count)
- [ ] NBA/AFL/NHL pages unaffected — their standings/playoffs/finished toggle still works normally
- [ ] \`npm run typecheck\` and \`npm run test:run\` pass
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #55