Fixes#124
* Add NCAA Football CFP simulator (12-team bracket)
Implements a Monte Carlo simulator for the College Football Playoff using
the 2024-present 12-team format. Elo/FPI ratings are entered manually via
the existing admin Elo Ratings page; championship futures odds can
optionally be blended in (60% Elo / 40% odds).
- Add CFP_12 bracket template (First Round not scoring, QFs onward score)
- Add generateCFP12Bracket() with correct seeding: 5v12, 6v11, 7v10, 8v9
in First Round; seeds 1–4 receive QF byes
- Add NCAAFootballSimulator: 50k Monte Carlo sims, seeds teams by blended
Elo+odds strength, tracks champion/finalist/SF/QF placement tiers
- Register ncaa_football_bracket simulator type in registry and schema enum
- Add migration 0071: ALTER TYPE simulator_type ADD VALUE 'ncaa_football_bracket'
- Add tests: 30 tests covering bracket template structure and simulator
probability distributions, seeding, edge cases, futures blending
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix lint errors in NCAA Football CFP simulator
Replace non-null assertions with optional chaining, change let to const,
use toSorted() instead of sort(), and add a bump() helper to avoid
repeated map lookups with non-null assertions in simulateBracket.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix TS2345 in NCAA football simulator test
Add ?? 0 fallback so optional-chained probFirst is number, not number | undefined.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Fixes#262
* Replace isDraftable boolean with draftOn/draftOff date fields on sport seasons
Admins can now schedule when a sport season becomes available for drafting
by setting explicit open and close dates instead of a manual toggle.
https://claude.ai/code/session_01LHYgpyimF8v8odUB6kj8Qc
* Address code review feedback on draft window implementation
- sports-data-sync: use unambiguous past placeholder dates for synced seasons,
with a comment explaining the intent
- sports-season model: remove redundant top-level lte/gte imports (callback
form already supplies them)
- _journal.json: add missing trailing newline
- sports-season test: mock ~/database/schema instead of stubbing all drizzle
builder functions, matching the established pattern in the codebase
- admin list: compute today once in component body instead of per-row
https://claude.ai/code/session_01LHYgpyimF8v8odUB6kj8Qc
* Fix: restore lte/gte imports removed in review cleanup
The relational query where-callback in this version of Drizzle does not
expose lte/gte as helper args, so they must be imported from drizzle-orm.
Removing them broke CI TypeScript.
https://claude.ai/code/session_01LHYgpyimF8v8odUB6kj8Qc
---------
Co-authored-by: Claude <noreply@anthropic.com>
* Add CS2 Major qualifying points simulator
Implements a full CS2 Major tournament simulator with:
- 3-stage Swiss format (Opening Bo1, Elimination Bo1/Bo3, Decider all Bo3)
+ Champions Stage 8-team single-elimination (QF Bo3, SF Bo3, GF Bo5)
- Monte Carlo simulation (10,000 iterations) accumulating QP across 2 majors/season
- Sampled 24-team field per iteration: top 12 guaranteed, remaining weighted by 1/rank
- Stage 3 exits (placements 9-16) sub-ranked by W-L record (2-3 > 1-3 > 0-3)
- Stage assignments stored per-event so actual field composition drives simulation
- Admin CS Elo form for entering team Elo + HLTV world rankings
- Admin CS2 stage setup page for assigning teams to stages and tracking advancement
- Database migration: cs2_major_qualifying_points enum value + cs2_major_stage_results table
- 24 unit tests covering all exported pure functions
https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR
* Consolidate Elo + ranking input into generic elo-ratings page
The darts-elo and cs-elo pages were unreachable from the admin nav,
which always links to the generic elo-ratings page. Extended elo-ratings
to conditionally show world ranking fields for simulator types that need
it (darts_bracket, cs2_major_qualifying_points), then deleted the
redundant sport-specific pages.
https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR
* Consolidate server postgres connections into one shared pool
Four separate postgres() clients were open simultaneously (app, timer,
snapshots, socket), each defaulting to 10 connections, exhausting the
database's max_connections limit. Replaced with a single shared lazy-
initialized client in server/db.ts using a Proxy to defer the
DATABASE_URL check until first use (preserving test compatibility).
Also bumps the CS2 Champions Stage stochastic test from 200 → 1000
iterations to eliminate flakiness.
https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR
* Fix and() bug and add Swiss loop safety guard
- cs2-major-stage.ts: markCs2StageEliminations and setCs2FinalPlacements
were using JS && instead of Drizzle and(), causing WHERE to filter only
by participantId (not scoringEventId), which would update rows across
all events instead of just the target event
- cs-major-simulator.ts: add break guard in simulateSwiss while loop to
prevent infinite loop if pairGroups returns no pairs
https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR
* Fix all remaining code review issues
- cs2-major-stage.ts: use schema column reference for stageEliminated
in markCs2StageEliminations instead of raw SQL string
- cs-major-simulator.ts: simulateOneMajor now locks in known stage
results when a stage is complete (8 recorded eliminations), only
simulating the remaining stages during live events
- admin event page: add CS2 Stage Setup button for cs2_major_qualifying_points
simulator types; expose simulatorType in server loader type cast
- cs2-setup.tsx: replace document.getElementById DOM manipulation with
React state (eliminatedChecked map) for checkbox show/hide logic;
remove unused stageMap and unassignedParticipants variables
https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR
* Fix oxlint errors: non-null assertions, sort→toSorted, unused vars
- cs-major-simulator.ts: replace 5 non-null assertions (!) with safe
optional chaining / if-guards; replace 6 .sort() with .toSorted()
- cs2-major-stage.ts: remove unused `inArray` import
- cs2-setup.tsx: remove unused `assignedIds` variable
https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR
* Fix flaky Champions Stage stochastic test
The makeTeams(8) helper creates only a 70-pt Elo spread (1800→1730).
With the Champions Stage bracket math this gives team-0 a ~19.6% win
rate — right at the 0.2 threshold, causing the test to fail ~63% of
the time in CI despite 1000 iterations.
Use 100-pt steps (1800→1100) instead, giving team-0 a ~40% win rate
and raising the assertion threshold to 0.25 for a clear safety margin.
https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR
---------
Co-authored-by: Claude <noreply@anthropic.com>
* Fix admin Games to Score panel missing bracket events
The panel was silently dropping bracket events in two cases:
1. When a bracket event had `scoringEvents.eventDate` set to a future
round date (e.g. "April 5") but an individual game's `scheduledAt`
was today, the old dedup code picked `eventDate` first and the
component filter `displayDate === today` never matched.
2. When an event had games on both today and tomorrow, whichever row
the unordered step-1 query returned first "won" — potentially hiding
a today game from the today tab entirely.
Fix: replace the `Map<id, firstDate>` deduplication with
`Map<id, Set<date>>` that collects every requested date an event
qualifies for (via either `eventDate` or `gameDate`). The return uses
`flatMap` to emit one entry per (event, date) pair, so events with
games on multiple days now appear correctly in both tabs.
https://claude.ai/code/session_01QkFxCF4xmKjdVCNPysWDML
* Fix lint errors: replace .sort() with .toSorted() and remove non-null assertion
- Replace Array#sort() with Array#toSorted() in two places (unicorn rule)
- Remove non-null assertion on Map#get(); use early continue instead
https://claude.ai/code/session_01QkFxCF4xmKjdVCNPysWDML
---------
Co-authored-by: Claude <noreply@anthropic.com>
Fixes#123
* Add PDC World Darts Championship simulator (128-player bracket)
- New DartsSimulator: 128-player single-elimination, 7 rounds with
PDC-accurate best-of-sets formats (bo3/bo5/bo5/bo7/bo7/bo11/bo13).
ELO_DIVISOR=500 via set-level Bernoulli model. Two simulation paths:
Path A (bracket drawn) simulates from actual DB matches; Path B
(pre-bracket) seeds top 32 by world ranking in fixed positions and
randomly draws the remaining 96 per simulation run (50,000 iterations).
- darts_bracket added to simulatorTypeEnum and simulator registry.
- world_ranking nullable integer column added to participant_expected_values
(migration 0067); batchSaveSourceElos now accepts and persists it.
- Admin route /admin/sports-seasons/:id/darts-elo: bulk import with format
"Player Name, 2099, 1" (name, Elo, optional world ranking), fuzzy name
matching, auto-runs simulation and updates EV/snapshots on save.
- DARTS_128 bracket template added (scoring starts at Quarterfinals).
- 30 unit tests: math helpers, bracket seeding structure, Path A/B integration.
https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz
* Review fixes: pre-compute hot-loop invariants, import normalizeName
- Pre-compute seededSlots and getSeededMatchOrder(32) before the 50k
simulation loop — was being recomputed every iteration
- Pad unseeded pool to 96 once before the loop instead of inside it
- Inline bracket-building in the hot loop using pre-computed seededSlots;
removes the per-iteration call to buildR1Bracket/getSeededMatchOrder
- Remove dead SEEDED_R1_PAIRS constant (was never referenced)
- Import normalizeName from ~/lib/fuzzy-match instead of redefining it locally
https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz
* Fix lint failures: unused vars, eqeqeq, toSorted
- Remove unused seededSet/unseededSet variables in test
- Replace != null with !== null && !== undefined (eqeqeq rule)
- Replace .sort() with .toSorted() in simulator and route (unicorn/no-array-sort)
https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz
* Fix TS2552: restore seededSet declaration removed during lint fix
https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz
---------
Co-authored-by: Claude <noreply@anthropic.com>
* Exclude scored bracket matches from upcoming events calendar
Add `eq(schema.playoffMatches.isComplete, false)` to the bracket-sports
query in `getUpcomingEventsForDraftedParticipants` so that individual
playoff matches already scored (isComplete = true) no longer appear on
the upcoming events calendar, even when the parent scoring event is still
open.
Also update the schema mock in upcoming-calendar.test.ts to include
`playoffMatches.isComplete` and add a test covering this behaviour.
https://claude.ai/code/session_01GMLmjN9mwoANfpgSciD7Bi
* Fix leaked mockReturnValueOnce in scored-match test
The new test only triggers one selectDistinct call (rows is empty so the
max-game-number lookup is skipped). The second mockReturnValueOnce was
never consumed, leaving a makeMaxGameChain value in the queue that was
then picked up by the subsequent "uses leftJoin" test, causing it to fail
with "leftJoin is not a function".
https://claude.ai/code/session_01GMLmjN9mwoANfpgSciD7Bi
---------
Co-authored-by: Claude <noreply@anthropic.com>
Placement counts (used to break points ties) previously only included
fully-finalized participants. A team whose events all resolved first would
accumulate real placement counts while tied teams with pending events had
zeros, causing the tie to be broken in favour of whichever team finished
scoring first rather than on merit.
Fix: count a participant's current position toward the tiebreaker regardless
of isPartialScore. Their position is the best available signal; if it changes,
recalculateStandings reruns and ranks update naturally.
Also removes a redundant bounds check (finalPosition > 0 was already
asserted by the outer if-condition).
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator, fixes#127
- New `groupStageMatches` table for recording group play results (W/D/L, scores, matchday, schedule)
- `computeGroupStandings()` model function: pts → GD → GF → name tiebreaker ordering
- `GroupStageStandings` component showing all 12 groups with standings table and manager column
- Admin bracket UI: group match score entry, per-group standings, "Recalculate Floors" action
- `WorldCupSimulator`: 50k Monte Carlo covering group stage + best-8 3rd-place + knockout + 3rd place game
- Fuzzy name matching for national team Elo lookup (exact → substring → word-overlap), warns on miss
- Partial group completion: completed matches replayed with real scores, remaining matches simulated
- Elo priority: admin-entered sourceElo > futures odds converted to Elo > hardcoded national team ratings
- `fifa_48` bracket template: added Third Place Game round with `loserFeedsInto` on Semifinals
- Scoring rules: distinct 3rd/4th place for `fifa_48` (not averaged), QF losers share 5th–8th equally
- Floor scoring: SF participants guaranteed 4th (provisional), finalized after 3rd place game
- `recalculate-floors` admin action deletes and replays all results from scratch (fixes stale guard bug)
- Unique index on `(tournamentGroupId, participant1Id, participant2Id)` to prevent duplicate pairings
- Batch `findMatchesByGroupIds()` replacing N sequential queries in the sport season loader
- League home mini-standings now shows `actualPoints` (includes floor) instead of `totalPoints` only
- Elo ratings admin page supports World Cup (same bulk-import flow as snooker)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix TypeScript errors: update GroupStandingData type to use findMatchesByGroupIds
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Increase Node heap to 4GB for unit tests in CI to prevent OOM
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix OOM in CI: make WorldCupSimulator simulation count configurable for tests
Tests now pass numSimulations=500 instead of the production default of 50,000.
Six simulator tests × 50k iterations each was exhausting the 4GB heap on GitHub
Actions runners. Also reduce simGroupMatch stat tests from 50k to 5k iterations.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* Show tied ranks with T prefix across standings and Discord notifications, fixes#197
- Add buildTiedRankChecker() utility to standings-display.ts; replaces
four copies of inline rank-count logic spread across components,
the league home route, and the Discord service
- Prefix shared ranks with "T" (e.g. "T3") in StandingsTable (league
panel), StandingsTable (full standings), the league home preview, and
Discord webhook notifications
- Add useMemo wrapping in StandingsTable components so tie detection
does not recompute on every render
- Fix compareTeamsForRanking to round total points to hundredths before
comparing, preventing floating-point noise from producing spurious
non-ties in the standings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix discord test to expect escaped period in rank display
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add unique index on (sports_season_id, name) in participants table
- findParticipantByName uses case-insensitive lower() comparison
- Single add: check for existing name before insert, return clear error
- Bulk add: load existing names once upfront (1 query vs N), dedup
input case-insensitively, report skipped names in UI
- Fix golf-skills and surface-elo routes which called createParticipant
without any duplicate guard (would have thrown DB constraint errors)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
- Fix UTC midnight rollover bug: server now queries from yesterday UTC
as a buffer; UpcomingCalendarPanel filters to local-today client-side
via useEffect + Intl.DateTimeFormat, removing the need for any
cookie or server-side timezone detection
- Cap homepage and league page panels at 6 events with a "View all" link
- Add /upcoming-events page (60-day view across all leagues)
- Add /leagues/:leagueId/upcoming-events page (60-day per-league view)
- Add emptyMessage prop to UpcomingCalendarPanel for context-specific copy
- Change getUpcomingEventsForDraftedParticipants to accept pre-computed
date strings instead of Date objects
fixes#213
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add WNBA playoff simulator with SRS-based Elo ratings, fixes#125
- New WNBASimulator: Monte Carlo (50k sims) projecting remaining regular
season games → seeding → R1 Bo3 / Semis Bo5 / Finals Bo7 bracket
- Hybrid Elo sourcing: pre-season uses futures odds (ICM); once avg
gamesPlayed ≥ 5, switches automatically to SRS-derived Elo
(elo = 1500 + srs × 20)
- New WnbaStandingsAdapter: fetches ESPN standings + teams endpoints in
parallel; includes zero-records for 2026 expansion teams (Portland
Fire, Toronto Tempo) not yet in standings
- Added srs column to regular_season_standings (migration 0063);
stored as net rating proxy (avgPointsFor − avgPointsAgainst)
- Added wnba_bracket to simulatorTypeEnum
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix missing afterEach import in wnba standings test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* Improve admin dashboard stats and fix scoring event date bugs, fixes#92
- Add "Seasons by Status" breakdown card (pre-draft / drafting / active / completed)
- Replace full-table fetches with COUNT queries for sports, sports seasons, and templates
- Fix bracket events with null eventDate disappearing from the Games to Score tabs by
computing a displayDate from matched playoffMatchGames.scheduledAt in getEventsForDates
- Fix Today tab badge showing stale count when all events are scored
- Hide "Getting Started" checklist once the system has sports configured
- Use explicit "en-US" locale in formatEventTime for deterministic output
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix scoring-event-dashboard tests after select/sql changes
- Rename mockSelectDistinct → mockSelect to match implementation change
from .selectDistinct() to .select()
- Add sql to drizzle-orm mock
- Update mock row shapes to include eventDate and gameDate fields
- Add two tests covering displayDate derivation from eventDate and gameDate
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add golf QP simulator with Plackett-Luce model, fixes#120
- New `participant_golf_skills` table (migration 0061) for SG: Total and
per-major American odds per player/season
- New `app/models/golf-skills.ts` with getGolfSkillsMap, getGolfSkillsForSeason,
batchUpsertGolfSkills
- Full `GolfSimulator` implementation replacing the TODO stub: Plackett-Luce
ranking model (PL_BETA=1.5, FIELD_SIZE=156), 10k Monte Carlo iterations,
awards QP by finishing position, ranks by total QP across all 4 majors
- New admin route `sports-seasons/:id/golf-skills` with bulk CSV import,
fuzzy name matching, per-player SG + per-major odds inputs; saves skills
and auto-runs simulation on submit
- Simulator dropdown on sport admin sorted alphabetically; renamed to
"Golf Qualifying Points Monte Carlo"
- Golf Skills button shown on sports season admin when simulator type is
golf_qualifying_points
- Extract normalizeName/diceCoefficient to shared `app/lib/fuzzy-match.ts`,
removing duplication from surface-elo and golf-skills routes
- Parallelize 4 DB queries in GolfSimulator.simulate() with Promise.all
- O(1) field array removal via swap-to-end + pop (was O(N) splice)
- Fix source tag: performance_model (not elo_simulation) for SG-based model
- 23 unit tests covering americanToImplied, getMajorOddsKey, resolveSkill,
simulateMajor, and Monte Carlo calibration properties
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix oxlint errors: no-non-null-assertion and eqeqeq
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
The chain (checkAndTriggerNextAutodraft) picks consecutive while_on teams
immediately after a pick, but is capped at totalTeams iterations. Once the
cap is hit, the next while_on team had to wait for the full timer countdown
before their pick fired — which users experienced as autodraft intermittently
not working.
Root cause fix: the timer loop now detects while_on mode and triggers the
pick immediately (≤1 s) regardless of time remaining, bypassing the
countdown. A timer-update with timeRemaining:0 is emitted first so clients
don't see a frozen timer. If the chain already handled the pick, executeAutoPick
returns "Pick already made" and the timer moves on harmlessly.
Additional fixes:
- Add timerTickRunning guard so concurrent setInterval ticks (when a chain
takes >1 s) don't race on the same pick slot
- Use onConflictDoNothing on the draft pick INSERT in executeAutoPick so a
DB unique-constraint collision is treated as "Pick already made" rather
than pausing the draft
- Cache draftSlots per season in the timer loop (slots never change during
an active draft) — eliminates one DB query per tick
- currentPickNumber || 1 → ?? 1
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Fixes#220
When standard draft mode was added, the chess clock increment was
accidentally restricted to owner-only picks. Commissioner, admin, and
auto-picks stopped earning the increment, causing teams that timed out
to freeze at 0s and instant-autopick every subsequent round.
Fix:
- make-pick: all pick types earn the increment in chess clock mode
- force-manual-pick: same; deduplicate standard/chess-clock branches
into a single update with mode-selected SQL; remove now-unused
timerSnapshot query
- draft-utils executeAutoPick: restore increment for chess clock
auto-picks; add missing seed insert when no timer row exists
- server/timer.ts: fix fallback initialization to use draftIncrementTime
in standard mode (was always using draftInitialTime)
- leagues/$leagueId.tsx: show Draft Timer Mode in League Info panel
Tests:
- Update draft.make-pick.timer-mode to cover owner/commissioner/admin
in both modes (commissioner section previously asserted frozen bank)
- Add draft.force-manual-pick.timer-mode for commissioner/admin force
picks in both modes
- Add executeAutoPick.timer for timer-triggered auto-picks in both modes
- Update draft.force-manual-pick to reflect new chess clock behavior
- Replace fragile toHaveBeenCalledTimes(2) assertions with
toHaveBeenCalledWith checks on the timer set call
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements a Monte Carlo simulator for men's/women's tennis seasons scored
on the qualifying_points pattern. Simulates all 4 Grand Slam majors
(Australian Open, French Open, Wimbledon, US Open) using surface-specific
Elo ratings and ATP/WTA world rankings for seeding.
New table: participant_surface_elos — one row per (participant, season)
storing worldRanking, eloHard, eloClay, eloGrass.
Key design decisions:
- Seeding uses ATP/WTA world ranking (not Elo), matching real draw procedure
- Top 32 seeded with standard slot placement (1→0, 2→64, 3-4→quarters, etc.)
- QP per round with tie-splitting pre-applied: W=20, F=14, SF=9, QF=4, R16=1.5
- Completed majors read actual qualifyingPointsAwarded from eventResults
- 10,000 Monte Carlo simulations; column sums naturally 1.0 (no normalization)
Admin UI at /admin/sports-seasons/:id/surface-elo:
- 5-column grid (Player | Rank | Hard | Clay | Grass)
- Bulk import: "Name, ranking, hardElo, clayElo, grassElo" one per line
- Fuzzy name matching (bigram Dice coefficient) with "Did you mean?" suggestions
- Inline participant creation for unmatched names via useFetcher
- Saves Elos and auto-runs simulation on submit
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix upsert standings no-op: use EXCLUDED pseudo-table for conflict update
Drizzle's onConflictDoUpdate set block was referencing the existing table
columns instead of the incoming values, causing every sync to silently
overwrite records with their own current data (no-op).
Fixes#211
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add unit tests for regular-season-standings model
Covers upsertRegularSeasonStandings (empty-array short-circuit, value
mapping, syncedAt behaviour), upsertManualStanding, getRegularSeasonStandings
sorting, getLastSyncedAt, deleteRegularSeasonStandings, and
getParticipantStanding — 19 tests total.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
- SnookerSimulator: Monte Carlo simulation of the 32-player World
Championship bracket using per-frame Bernoulli win probabilities
derived from Elo ratings (ELO_DIVISOR=700). Two paths: bracket-
populated (respects completed matches) and pre-bracket (simulates
qualifying, then seeds full draw).
- Admin route /sports-seasons/:id/elo-ratings: bulk-import and per-
player Elo entry with fuzzy name matching; saves ratings and auto-
runs the simulation in one action.
- schema: add snooker_bracket simulator type, source_elo column on
participant_expected_values, unique index on (participant_id,
sports_season_id).
- Fix batchSaveSourceElos to use INSERT ... ON CONFLICT DO UPDATE
instead of N+1 SELECT+UPDATE loop.
- Fix existingElos returned as plain Record (Map doesn't survive JSON
serialization through useLoaderData).
- Fix "How It Works" formula to show correct divisor (700, not 400).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Don't show Game #1 label for single-game matchups in calendar
When a matchup has only one game, showing "Game #1" is redundant and
unhelpful. Only show game numbers when there are multiple games in a
matchup (gameKeys.size > 1). For single-game matchups, show just the
round name (or null if it matches the event name).
Closes#198https://claude.ai/code/session_01CRrqu43FKHshpJt1NyrDdX
* Show game number for Game #2+ in series, hide Game #1 for single-game matchups
If a matchup has only one game (game #1), the number is redundant — show
just the round name. If game #2 or higher is upcoming, it clearly belongs
to a multi-game series, so show the game number to distinguish it.
https://claude.ai/code/session_01CRrqu43FKHshpJt1NyrDdX
* Fix Game #1 label for multi-game series when only Game #1 is in date window
Previously, Game #1 of a multi-game series showed no game number (e.g.
"Semifinals") if Game #2 fell outside the calendar date range. Since at
least 2 games are always entered for a series from the start, a second
un-date-filtered query now checks the max game number per event. If max
> 1, the event is a series and Game #1 is labelled accordingly
(e.g. "Semifinals Game #1").
https://claude.ai/code/session_01CRrqu43FKHshpJt1NyrDdX
* Show game number when multiple games from same series are in date window
The gameKeys.size > 1 branch previously showed only the round name,
contradicting the rule that multi-game series always show "Game #x".
Now shows the lowest game number visible in the window (e.g. both
Game #1 and #2 upcoming → "Semifinals Game #1").
https://claude.ai/code/session_01CRrqu43FKHshpJt1NyrDdX
* Split series games into individual calendar entries, each showing Game #x
Each game in a multi-game bracket series (e.g. Game #1 on Mar 23,
Game #2 on Mar 25) now appears as its own row on the calendar with its
own date and "Round Game #x" label. Single-game matchups are unchanged.
Grouping key changes from eventId to eventId|gameNumber for series events.
The max-game-number lookup now runs before grouping so the correct key
can be determined while processing each row.
https://claude.ai/code/session_01CRrqu43FKHshpJt1NyrDdX
* Cache isSeries per entry to avoid re-deriving it in the finalization loop
https://claude.ai/code/session_01CRrqu43FKHshpJt1NyrDdX
---------
Co-authored-by: Claude <noreply@anthropic.com>
* Redesign standings page with sortable table, 7-day change, and chart repositioned
- Move point progression chart below the standings table
- Replace separate Points/Projected/Placement columns with:
- Single stacked "actual / projected" Points column (shared PointsDisplay component)
- "7-Day Change" column showing points gained + rank change over past 7 days
- Remove Placement breakdown column
- Sort ties alphabetically by team name (rank sort only)
- All columns are sortable via new reusable useSortableData hook
- Add sevenDayPointChange to TeamStandingWithChange type and model
https://claude.ai/code/session_01CuCKFVYbpsKSQoFDcTYfY7
* Code review fixes: module-level comparators, correct type on SortableHead, handle negative point change, remove redundant spread
- Move comparators object to module level (closes over nothing, no useMemo needed)
- Use SortConfig<StandingRow> on SortableHead instead of inline duplicate type
- SevenDayChange: handle negative pointChange with correct sign and color
- Remove redundant sevenDayPointChange explicit assignment (already in ...standing spread)
https://claude.ai/code/session_01CuCKFVYbpsKSQoFDcTYfY7
* Update StandingsTable tests to match redesigned component
- Remove showPlacementBreakdown prop (no longer exists on component)
- Replace Placement Breakdown test suite with 7-Day Change column tests
- Update header assertion: "Placements" → "7-Day Change"
- Add tests for positive/negative point change display and rank change indicators
- Remove accessibility test for placement title attributes
https://claude.ai/code/session_01CuCKFVYbpsKSQoFDcTYfY7
* Fix movement indicator arrow count assertion to exclude sort header arrows
queryAllByText(/↑|↓/) was matching the active sort column's ↑ indicator.
Narrow to /[↑↓]\d/ so only movement indicators (↑1, ↓1) are counted.
https://claude.ai/code/session_01CuCKFVYbpsKSQoFDcTYfY7
---------
Co-authored-by: Claude <noreply@anthropic.com>
- Add app/lib/logger.ts: dev passes through to console; prod routes errors
to Sentry.captureException and warnings to Sentry.captureMessage, with
extra context preserved. Uses captureMessage (not captureException) for
string-only args to avoid fabricated stack traces.
- Add server/logger.ts: dev passes through; prod silences log/info but
keeps warn/error on stderr (Sentry not initialized in that process).
- Replace all console.* calls across 44 app files and 4 server files.
- Upgrade no-console from warn → error in oxlint; exempt logger files and
scripts/** via overrides.
- Add typescript/no-inferrable-types rule; fix violations in services and
simulators. Exempt test files (intentional string widening for switch/if
tests would break under literal type inference).
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix no-shadow and consistent-function-scoping lint violations
Resolves all 11 no-shadow and 16 consistent-function-scoping oxlint
warnings and promotes both rules to errors in .oxlintrc.json.
no-shadow: renamed Drizzle callback params (sports→s, matches→m,
seasons→s) to avoid shadowing outer imports; removed shadowed
destructures (eq, inArray) from where callbacks; renamed inner
template→bracketTemplate, prev→currentTimers, season→ss, name→teamName
(with name: teamName fix to preserve semantics).
consistent-function-scoping: moved formatDate, getRankBadge,
getMovementIndicator, getPositionBadge, getStatusBadge, toDateStr,
elo (×2), weightedPick, sortByMatchNumber (×2) to module scope;
moved formatTime (×2), isValidLeagueName, getDraftTimes,
makeSeasonQueues to file scope in test files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix no-non-null-assertion lint violations and promote to error
Eliminates all 208 no-non-null-assertion warnings across 38 files.
Promotes typescript/no-non-null-assertion from warn to error in
.oxlintrc.json.
Fix patterns applied:
- Map.get(key)! after .has() check → extract with get() + null guard
- Map.get(key)! on pre-populated count maps → ?? 0 default
- .set(id, map.get(id)! + 1) increment → ?? 0 before adding
- participant1Id!/participant2Id! on DB matches → ?? "" fallback
- array.find()! in tests → guard + throw or expect().toBeDefined()
- bracketTemplateCache.get(id)! → null guard extract
- Various nullable field accesses → optional chain or ?? default
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix prefer-add-event-listener, no-unassigned-import, require-module-specifiers
Resolves all 9 remaining non-console lint warnings and promotes all
three rules to errors in .oxlintrc.json.
- prefer-add-event-listener: converted onchange/onclick/onload
assignments to addEventListener in useDraftNotifications.ts and
admin.data-sync.tsx; stored changeHandler ref for proper cleanup
with removeEventListener
- no-unassigned-import: configured rule with allow list for legitimate
side-effect imports (*.css, @testing-library/jest-dom,
@testing-library/cypress/add-commands)
- require-module-specifiers: removed redundant `export {}` from
cypress/support/e2e.ts (file already has an import)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix TypeScript errors from no-non-null-assertion fixes
Two fixes introduced by the non-null assertion cleanup produced type
errors:
- scoring-event.ts: `?? ""` was wrong type for a participant object map;
restructured to explicit null guards so TypeScript can narrow correctly
- standings-sync/index.ts: `?? null` after name-match lookup lost the
truthy guarantee, causing TS18047 on the write-back block; added
`participant &&` guard before accessing its properties
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add npm run typecheck as Stop hook in Claude settings
Runs a full project typecheck at the end of each Claude turn so type
errors surface as feedback before the next message.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add oxlint and fix all lint errors
- Install oxlint, add .oxlintrc.json with rules for TypeScript/React
- Add npm run lint / lint:fix scripts
- Add Claude PostToolUse hook to run oxlint on every edited file
- Fix 101 errors: unused vars/imports, eqeqeq, prefer-const, no-new-array
- Fix no-array-index-key (use stable keys or suppress positional cases)
- Fix exhaustive-deps missing dependency in useEffect
- Promote exhaustive-deps and no-array-index-key to errors
- Fix Map.get() !== null bug in $leagueId.server.ts (should be !== undefined)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix no-explicit-any warnings and upgrade tsconfig to ES2023
- Replace all `any` types with proper types or `unknown` across ~20 files
- Add typed socket payload interfaces in draft route and useDraftSocket
- Use any[] with eslint-disable for socket.io callbacks (legitimate escape hatch)
- Bump all tsconfigs from ES2022 → ES2023 to support toSorted/toReversed
- Fix cascading type errors uncovered by removing any: Map.get narrowing,
participant relation types, ChartDataPoint, Partial<NewSeason> indexing
- Add ParticipantResultWithParticipant type to participant-result model
- Fix test fixtures to match updated interfaces (DraftCell, ParticipantResult)
- Fix duplicate getQPStandings import in sportsSeasonId.server.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Promote no-explicit-any to error
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds live standings sync and display for bracket-based sports (NBA/NHL),
so league members can see W/L tables and which teams their opponents drafted
during the regular season — not just after the playoff bracket is set.
- New `regular_season_standings` table with upsert-on-conflict sync
- Standings sync service with NHL (api-web.nhle.com) and NBA (ESPN) adapters,
externalId write-back for future syncs, and unmatched-team resolution UI
- `RegularSeasonStandings` component: flat (NBA) + division/wild-card (NHL) modes,
playoff line, TeamOwnerBadge, projected Brackt points (EV), mobile horizontal scroll
- Admin "Sync Standings" card + "Resolve Unmatched" UI on sports season page
- Admin manual standings edit hatch at /admin/sports-seasons/:id/regular-standings
- Show standings above bracket until matches exist; below once bracket is set
- `normalize-team-name` utility extracted to shared lib
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements a new "standard" timer mode alongside the existing chess clock
mode. In standard mode the per-pick timer resets to a fixed value after
every pick (no carry-over), and the speed selector shows plain time values
instead of named chess-clock presets.
Key changes:
- Add `draft_timer_mode` enum column to `seasons` table (migration 0053)
- `draft.start`: standard mode seeds timers at `draftIncrementTime` (the
per-pick value) rather than `draftInitialTime`
- `draft.make-pick`: three-way branch — standard resets, chess clock
owner earns increment, commissioner/admin pick leaves bank frozen
- `draft.force-manual-pick`: commissioner picks never earn bank time;
chess clock path uses a pre-pick snapshot to avoid a race window with
the 1-second timer loop
- `executeAutoPick` in draft-utils: auto picks never earn bank time;
chess clock path skips the DB update (timer already at 0)
- League creation and settings pages: mode-aware speed selector (raw
seconds for standard, named presets for chess clock); shared
`parseDraftSpeed` utility extracted to `app/lib/draft-timer.ts`
- Tests added for draft.start timer init and make-pick timer mode
behavior; force-manual-pick tests updated for new timer semantics
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* Show team name + username in standings, extract TeamNameDisplay component
- Add TeamNameDisplay component that renders team name (as link) with owner username below, matching the league homepage style
- Update StandingsTable to use TeamNameDisplay with owner username shown below team name
- Update league homepage standings section to use TeamNameDisplay
- Add ownerName/teamOwnerId fields to TeamStanding type
- Extend getSeasonStandings to include teamOwnerId from team relation
- Fetch and attach owner display names in the full standings page loader
https://claude.ai/code/session_01EYgGnuTBaRVdBDapJRTxDZ
* Address code review: type hygiene, explicit types, parallel fetching, style fix
- Remove teamOwnerId from TeamStanding type (was an internal server concern, not display data)
- Remove teamOwnerId from getSeasonStandings return value
- Add TeamStandingWithChange interface extending TeamStanding with sevenDayRankChange/sevenDayOldRank fields
- Annotate getSevenDayStandingsChange with explicit Promise<TeamStandingWithChange[]> return type
- Re-export TeamStandingWithChange from models/standings.ts
- Standings loader: query teams table directly for ownerIds instead of relying on teamOwnerId in standings data
- Standings loader: parallelize all independent queries (standings, teams, progressionData, seasonComplete, completionPercentage) with Promise.all
- Restore font-medium on StandingsTable team TableCell
https://claude.ai/code/session_01EYgGnuTBaRVdBDapJRTxDZ
---------
Co-authored-by: Claude <noreply@anthropic.com>
* Add "Games to Score" widget to admin dashboard
Shows scoring events for today and tomorrow in a tabbed card, with
status badges (Pending/Scored), a scored/total progress counter, and
direct "Score" / "View" links to each event's results page. Excludes
non-scoring schedule_event types so only actionable events surface.
- app/models/scoring-event.ts: add getEventsForDates() model function
that queries scoringEvents joined with sportsSeasons/sport for a
given list of date strings
- app/routes/admin._index.tsx: call getEventsForDates([today, tomorrow])
in the loader and render the GamesToScore component below the stats row
- app/models/__tests__/scoring-event-dashboard.test.ts: 8 unit tests
covering empty input, shape mapping, multi-date filtering, completed
events, and the inArray where conditions
https://claude.ai/code/session_014zKK15o5RJfTaodLvnZpF5
* Fix getEventsForDates to include bracket events by game scheduledAt
The previous implementation only matched on scoringEvents.eventDate,
which is often null for bracket events where individual game times are
set on playoffMatchGames.scheduledAt instead.
Switched to a two-step query mirroring getUpcomingEventsForDraftedParticipants:
1. selectDistinct event IDs via left joins through playoffMatches →
playoffMatchGames, matching where eventDate IN dates OR any game's
scheduledAt falls within the date range's timestamp bounds
2. findMany on the matched IDs with sportsSeason + sport relations
Updated tests to cover the two-step flow, leftJoin usage, and the
scheduledAt timestamp bounds appearing in the where clause.
https://claude.ai/code/session_014zKK15o5RJfTaodLvnZpF5
* Document dual game date storage in CLAUDE.md
scoringEvents.eventDate and playoffMatchGames.scheduledAt both hold game
dates depending on the event type. Added a dedicated section under
Important Patterns explaining when each is used and the two-step
selectDistinct + findMany pattern required for correct date queries.
https://claude.ai/code/session_014zKK15o5RJfTaodLvnZpF5
---------
Co-authored-by: Claude <noreply@anthropic.com>
* Refine Discord webhook scoring notifications (fixes#180, #181)
- Filter scored matches to only show matchups where a manager scores
Brackt points (winner drafted) or has a team eliminated (loser drafted);
matches with no manager involvement are suppressed entirely.
- Escape Discord markdown characters (_*~`|\) in team names and usernames
to prevent formatting issues (e.g. double-underscore names causing
unintended underlines).
- Replace full standings with a "Standings Changes" section that shows
only teams whose points changed, plus any teams whose rank shifted as a
result, with ↑N / ↓N indicators for rank movement.
- Pass previousRanks map from scoring-calculator to the notification so
rank-displaced teams (who didn't score points themselves) are included.
- Update test webhook in league settings to demonstrate rank changes and
a sample scored match.
- Update all discord service tests to cover the new filtering, escaping,
and standings-change behaviour.
https://claude.ai/code/session_01FciwfdG9Sfr5ZrXUHPeB18
* fix: only set winnerUsername when winning team's score actually changed
Previously, winnerUsername was set for any drafted winner regardless of
whether points were actually scored. This caused R64 wins (where the
scoring system may not award points until later rounds) to appear in
Discord's Scored Matches section even when no Brackt points were earned.
Now winnerUsername is only set when the winner's team's totalPoints
changed after recalculation. loserUsername (eliminations) is always set
when the loser is drafted, since being knocked out is always notable.
https://claude.ai/code/session_01FciwfdG9Sfr5ZrXUHPeB18
* Refactor Discord notification logic in scoring calculator
- Compute changedTeamIds once up front; derive hasChanges from it
instead of duplicating the same iteration
- Merge usernameForParticipant and winnerUsernameForParticipant into a
single function with a requireScoreChange flag
- Replace hasDraftedParticipantMatches with hasScoredMatchesToShow,
which checks that at least one scoredMatch has a displayable username
— prevents sending a contentless Discord embed when a drafted winner
doesn't score any points and the loser isn't drafted
- Update inline comment to be sport-agnostic
https://claude.ai/code/session_01FciwfdG9Sfr5ZrXUHPeB18
---------
Co-authored-by: Claude <noreply@anthropic.com>
Admins can now enable/disable a sport season from appearing in league
creation and pre-draft league settings. Non-draftable seasons are hidden
from selection but remain visible (and checked) if already linked to an
existing pre-draft league. Leagues in draft status or beyond are unaffected.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add unique index on (team_id, season_id, snapshot_date) in team_standings_snapshots
so concurrent writes can't produce duplicate rows (migration 0051)
- Rewrite createDailySnapshot to use INSERT ... ON CONFLICT DO UPDATE inside a
transaction, replacing the SELECT-then-insert/skip pattern (N+1 queries, race-prone)
- Remove early-exit guard in server/snapshots.ts background job so it always
upserts rather than skipping seasons that already have a snapshot for today
- Call createDailySnapshot in recalculateAffectedLeagues after recalculateStandings,
so every bracket score update refreshes today's snapshot; wrapped in try/catch so
a snapshot failure can't block Discord notifications
- Also call createDailySnapshot from the admin rescore and finalize-bracket actions
- Fix UTC date bug: replace toISOString().split('T')[0] with local date parts in
both standings.ts and server/snapshots.ts (and the 7-day lookback query)
- Convert all dynamic await import() calls in bracket.server.ts to static imports
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fall back to displayName when username is null for Discord webhook
Users who sign up via OAuth (Google, GitHub, etc.) without setting a
Clerk username have a null `username` field but always have a `displayName`
(computed from firstName+lastName or email). Previously, `usernameByClerkId`
was filtered to only include users with a non-null username, causing those
owners to appear without any identifier in Discord standings messages
(e.g. "Liverpool def. Galatasaray" instead of "Liverpool def. Galatasaray (Madmike)").
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
* Extract getUserDisplayName helper and use consistently throughout
Add a single getUserDisplayName(user) function to app/models/user.ts that
encapsulates the username → displayName fallback logic. Replace 9 scattered
inline expressions across the codebase (owner-map, scoring-calculator,
league routes, settings, invite flow, draft API, Clerk webhook) with calls
to the shared helper.
No behaviour change — all existing logic preserved, just centralised.
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
* Fix N+1 user queries in league loader and settings loader
Add findUsersByClerkIds() batch function to the user model and replace two
separate Promise.all+findUserByClerkId loops (one for owners, one for
commissioners) with a single inArray query in both $leagueId.server.ts and
$leagueId.settings.tsx. The merged query covers both owner and commissioner
IDs in one round-trip.
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
* Fix N+1 user queries in buildOwnerMap
Replace the Promise.all+findUserByClerkId loop with a single
findUsersByClerkIds batch query, consistent with the league loader
and settings loader fixes.
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
---------
Co-authored-by: Claude <noreply@anthropic.com>
* Auto-name teams on claim and harden owner-assignment logic
- Add claimTeam() model function that atomically sets ownerId + name in
a single DB write, replacing the previous two-step assignTeamOwner +
renameTeam pattern that could leave partial state on failure
- Rename team to "Team <username>" (fallback: displayName, then "Member")
when a user joins via invite link or an admin assigns a team owner
- Fetch the user record before claiming the team in both paths so we fail
fast with a clear error rather than silently skipping the rename
- Guard against assigning an already-owned team in the admin settings
action (closes#107, closes#76)
- Show owner username below team name in draft order settings UI;
teams with an owner but no matching user record display "Unknown"
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fixing dropdown for admins
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* Grant sitewide admins commissioner-level access in leagues
- `isCommissioner()` now returns true for site admins, covering all
commissioner-gated loaders (league home, settings, sport season detail)
and draft API routes (start, pause, resume, rollback, replace-pick,
force-autopick, force-manual-pick, adjust-time-bank, make-pick)
- Added `hasCommissionerRecord()` (DB-only, no admin bypass) for the
"already a commissioner" duplicate-entry check in the settings action,
preventing a false positive when adding a site admin as commissioner
- `isCommissioner()` now runs the admin check and DB query in parallel
via Promise.all to avoid a serial roundtrip on every check
- Added "admin" to the `picked_by_type` enum (migration 0049) so picks
forced by a site admin are recorded accurately in the audit log rather
than as "commissioner"
- 8 unit tests covering both isCommissioner and hasCommissionerRecord
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix draft.force-manual-pick tests broken by isUserAdminByClerkId
The route now calls isUserAdminByClerkId which hits database().query.users,
but the test's mock DB had no query.users entry. Add a vi.mock for
~/models/user and default isUserAdminByClerkId to false in beforeEach.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Participants eliminated in a non-scoring bracket round (e.g. Round of 16
in a 16-team bracket) receive finalPosition=0 and isPartialScore=false.
The previous calculateTeamScore loop only entered participantsCompleted++
when finalPosition > 0, so these participants were silently treated as
still "remaining" in the standings table — causing e.g. a team with one
eliminated participant to show "25 remaining" instead of "24 remaining".
Fix: add an else-if branch that increments participantsCompleted for any
finalized result (isPartialScore=false) regardless of finalPosition value.
The condition is intentionally broader than `=== 0` to also cover the
theoretical case of finalPosition=null with isPartialScore=false.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
**Discord webhook improvements**
- Show team owner username in parentheses in standings (e.g. "1. Alpha FC (christhrowsrocks) — 150 pts")
- Show username in parentheses in match results for drafted participants
(e.g. "Sporting (christhrowsrocks) def. Bodø/Glimt (apatel)"), omitting
the parenthesis for the undrafted side
- Username lookup only runs after the webhook URL guard to avoid wasted DB queries
- 4 new tests covering all username display combinations
**Fix calculateTeamScore partial-score counting bug**
- isPartialScore participants were incorrectly counted in participantsCompleted
and placementCounts, causing standings to show wrong remaining count and
phantom placement badges (e.g. "5th×1") for still-alive bracket participants
- Floor points still flow into totalPoints (guaranteed value for ranking)
- 3 new unit tests covering partial vs finalized behavior in calculateTeamScore
**Admin Force Re-score action**
- New "Fantasy Standings" card on every sports season admin page with a
Force Re-score button that triggers recalculateStandings on all linked
fantasy seasons — useful after data corrections without waiting for a
new scoring event
- Auth guard added to the action (was missing — layout loader only protects GET)
- Returns a meaningful message when no linked seasons exist
- Intent discriminant on action returns so success banners use actionData.intent
instead of fragile message.includes() checks
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix bracket point averaging and partial-score display in standings/breakdown
Bracket sports (UCL, NBA, NFL, etc.) use averaged points for tied
positions (e.g. QF losers all share avg of 5th-8th = 20 pts), but
both calculateTeamProjectedScore and getTeamScoreBreakdown were using
raw pointsFor5th (25) instead of the bracket-averaged value. This caused
the team breakdown page to show incorrect actual points (25 vs 20) and
the standings page to show incorrect actualPoints/projectedPoints totals.
Additionally, participants with isPartialScore=true (still alive in a
bracket with a provisional floor position) were being displayed with a
final placement badge (e.g. "5th") instead of "Pending".
Changes:
- calculateTeamProjectedScore: use calculateBracketPoints for bracket
sports; handle isPartialScore participants separately (floor in
actualPoints, incremental EV in projectedPoints, Math.max guard for
EV < floor edge case)
- getTeamScoreBreakdown: same bracket averaging fix; pass isPartialScore
through to picks; use explicit finalPosition != null && > 0 check
- TeamScoreBreakdown component: show Pending badge for isPartialScore
participants; display actual/floor + EV row for all incomplete picks;
add "actual / projected" column header subtitle
- 9 new unit tests covering all scoring branches including the EV-below-
floor clamp edge case
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix typecheck: cast DUMMY_EV_ROW to any in test fixture
ParticipantEV has additional required fields (id, participantId, etc.)
that aren't needed for the mock — cast to any to satisfy the type checker.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
- NCAAM R64/R32 winners no longer receive T5 floor points; only Sweet
Sixteen winners entering the first scoring round get a floor
- AFL T5-T6 and T7-T8 score as separate tiers (avg([5,6]) and avg([7,8]))
instead of one shared avg([5-8]) pool
- Eliminated teams now always show their correct final rank (e.g. T33 for
NCAAM R64 losers) even during partial round scoring, by deriving the
rank label from the total match count per round rather than the dynamic
still-alive count
- Discord notifications now fire when a drafted participant is eliminated
with 0 points (e.g. R64 losers whose team score doesn't change)
- Discord notifications no longer include all prior matches — scoped to
the current batch via matchIds
- Discord section headers have spacing; rank medals replaced with numbers
- Rounds auto-complete when all matches are marked done; manual Complete
Round dropdown removed
- Fixed double Discord notification when the last match in a round
triggers auto-complete: processPlayoffEvent now accepts skipRecalculate
so the caller controls when the notification fires
- Fixed falsy check on finalPosition=0 in calculateTeamScore
- Undrafted 0-point participants filtered from Eliminated Teams display
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add Discord webhook notifications for standings updates
Adds a Discord webhook integration that posts standings to a configured
Discord channel whenever scores change after a scoring event. Commissioners
set the webhook URL in league settings and can send a test notification.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix Discord notification edge cases from code review
- Fix point delta sign: negative diffs now correctly show -5 pts not +-5 pts
- Replace hardcoded soccer emoji with a neutral bullet (works for all sports)
- Truncate embed description at Discord's 4096-char limit
- Thread eventId through processMatchResult and bracket batch handler so
scored matches appear in single-match notifications too
- Pass eventName to finalizeQualifyingPoints ("Final Standings") and
processSeasonStandings ("Season Complete") so those notifications have context
- Test notification now fetches current season to show "League 2025" format
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix process-match-result tests: add sportsSeasons to db mock
recalculateAffectedLeagues now queries sportsSeasons to get the sport
name for Discord notifications; the test mock db needed the stub added.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
## Partial bracket scoring
- `processMatchResult`: new exported function that scores a single match
immediately (loser → final placement, winner → provisional floor).
Called from `set-winner` and `set-round-winners` so points are awarded
as soon as a winner is set, before the full round is complete.
- `set-winner`: passes `eventName` to `processMatchResult`.
- `set-round-winners`: batches side effects — calls `recalculateAffectedLeagues`
and `updateProbabilitiesAfterResult` once after the loop instead of per-match
(`skipSideEffects: true` per match).
## Code review fixes
- **ROUND_CONFIG** (S1): extracted a `RoundScoringConfig` lookup table +
`getRoundConfig()` helper, eliminating three parallel `if/else` chains in
`processPlayoffEvent`, `processMatchResult`, and `getGuaranteedMinimumPosition`.
AFL template overrides live in `TEMPLATE_ROUND_CONFIG` with an explanatory
comment about why the `bracketTemplateId` guard is required.
- **skipSideEffects** (C2): new param on `processMatchResult`; bracket server
uses it to batch standings/probability recalc in `set-round-winners`.
- **eventName** (W1): passed through `processMatchResult` → `recalculateAffectedLeagues`.
- **Round validation** (W2): `set-round-winners` now guards `match.round === round`
before processing each assignment.
- **Comments** (C3/S3/W3): added notes on non-scoring loser assumption,
`isScoring ?? true` default, and AFL Semi-Finals template requirement.
## PlayoffBracket eliminated-teams fix + tests
- Fixed `computeEliminatedByRound` to track participant *appearances* (not just
wins), so AFL QF losers who advance to Semi-Finals via double-chance are
correctly excluded from the QF eliminated list.
- Extracted the logic as an exported pure function for testability.
- Added 4 tests: standard elimination, AFL QF loser → SF win, AFL QF loser →
SF loss, and normal advancement not protecting a later loser.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace per-sport cards with a single flat table; add Sport column (linked to sport season) before Participant
- Replace three summary stat cards with a compact "X remaining" line in the header
- Format pick numbers as round.pick notation (e.g. 2.02 for pick 16 in a 14-team league)
- Add getNumTeamsInSeason model function to compute pick-within-round correctly
- Remove dead bySport/totalPoints from standings model and component interface
- Narrow standing prop type to only currentRank (placementCounts was unused)
- Guard against numTeams === 0 edge case in pick notation
- Fix 0.0 → 0.00 decimal inconsistency
- Fix unsafe finalPosition! non-null assertion (null treated as Did Not Score)
- Update tests to match current UI; add coverage for new edge cases
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the duplicated F1Simulator (which was copy-pasted for IndyCar) with a
single AutoRacingSimulator that accepts a race points table at construction time.
Registry entries for f1_standings and indycar_standings remain separate, each
wired to the correct scoring system (F1: top-10, IndyCar: top-26 positions).
Also imports SIMULATOR_TYPES from registry into the admin route to eliminate the
duplicate hardcoded array, and updates stale comments mentioning F1/IndyCar.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the hardcoded 2026 Men's region structure with a per-event
configurable system so the same ncaa_68 template works for Women's
March Madness (different region names and play-in placements each year).
- Add `bracket_region_config jsonb` column to `scoring_events`
- Export `ALL_16_SEEDS` and `BracketRegion`/`BracketPlayIn` interfaces
from bracket-templates.ts so both server and client share the type
- Admin bracket entry form now shows a "Configure Regions" section:
editable region names + add/remove play-in seed selectors (ShadCN
Select) per region; slot map updates live as config changes
- Server action parses region config from form, validates total = 68,
stores it on the event, and passes it to bracket generation
- `advanceFirstFourWinner` reads stored `bracketRegionConfig` from the
event row first, falls back to template.regions for existing events
- Fix opponentSeed formula (was `seedNum <= 8 ? 17-n : n`, always `17-n`)
- Remove duplicate ALL_SEEDS inline definitions (now a shared constant)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
- New `event_starts_at timestamptz` column on `scoring_events`; backfilled
from `event_date` (midnight UTC) via migration for all existing rows
- Admin create/edit forms consolidated from separate Date + Time inputs into
a single `datetime-local` field; server derives `eventDate` from the UTC
date portion of `eventStartsAt` so calendar-window queries continue to work
- Edit form pre-fill computed client-side via useEffect to avoid server-timezone
mismatch for non-UTC admins
- Sort fix: replaced `eventDate ?? earliestGameTime.split("T")[0]` with
`toEventSortKey` helper that prefers `earliestGameTime ?? eventDate` so
bracket events with only per-game timestamps sort in the correct calendar position
- DB sort queries updated to use `eventStartsAt` as a tiebreaker after `eventDate`
- F1/IndyCar/golf events now populate `earliestGameTime` from `eventStartsAt`,
giving time display in `UpcomingCalendarPanel`
- Bulk import gains optional `HH:MM` (UTC) third column
- New unit tests for `toEventSortKey` and `eventStartsAt` model behavior
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add playoff match games and odds storage
Introduces two new tables for bracket matchup detail storage:
- `playoff_match_games`: tracks individual game schedules within a
series matchup (game number, scheduledAt, status, per-game scores,
winner). Supports scheduled/complete/postponed status enum.
- `playoff_match_odds`: stores moneyline odds per participant per
matchup (single upsert record, no isLatest complexity).
Includes:
- Drizzle schema + relations with CASCADE deletes from playoff_matches
- Migration 0040_fat_puma.sql
- playoff-match-game.ts model with pure helpers: computeSeriesScore,
isSeriesComplete, getSeriesLeader — plus full CRUD
- playoff-match-odds.ts model with pure helpers: americanToImpliedProbability,
impliedProbabilityToAmerican, normalizeOdds — plus upsert/read/delete
- findPlayoffMatchesByEventId and findPlayoffMatchById updated to
include games and odds in their query results
- Bracket server route: add-game, update-game, delete-game,
upsert-odds, delete-odds actions
- Bracket admin UI: expandable per-match panel for game schedule
management and moneyline odds entry
- 41 new unit tests (18 game + 23 odds), all 810 tests passing
https://claude.ai/code/session_01Twt3D1bsEK3eXUhMaz6ee7
* Code review fixes: type safety, abstraction, and React correctness
- Derive PlayoffMatchGameStatus from schema enum instead of hardcoding
the string union, eliminating the duplicate source of truth
- updateGame now returns PlayoffMatchGame | undefined to reflect reality
when no row matches the ID
- Remove TOCTOU check-then-act in update-game action: call updateGame
directly and check the return value instead of a pre-flight findGameById
- Add status enum validation before the cast in update-game action
- Move impliedProbability computation inside upsertMatchOdds so callers
only provide moneylineOdds; the model owns the derivation
- Remove unnecessary dynamic import of americanToImpliedProbability in
the upsert-odds action (was already imported from the same module)
- Fix React list reconciliation bug: replace bare <> fragment with
<Fragment key={match.id}> so React can correctly track rows
https://claude.ai/code/session_01Twt3D1bsEK3eXUhMaz6ee7
---------
Co-authored-by: Claude <noreply@anthropic.com>
- Bracket events (playoff_game): delete all participantResults for the
sports season atomically with the event, then recalculate league standings
- Qualifying events: capture affected participant IDs before cascade
deletes eventResults, recalculate participantQualifyingTotals from
remaining events, decrement majorsCompleted if QP had been processed,
and recalculate league standings
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
When a participant wins a bracket round, they immediately earn provisional
"floor" points (the averaged minimum they'd receive if eliminated next round).
These update as they advance and are replaced by finalized scores on elimination.
Key changes:
- Add `is_partial_score` column to `participant_results` (migration 0038)
- `processPlayoffEvent`: assign provisional position 5 to non-scoring round
winners; assign round-appropriate floors to scoring round winners via
`getGuaranteedMinimumPosition`; add catch-all for unrecognized round names
- `upsertParticipantResult`: guard against un-finalizing rows (never overwrite
isPartialScore=false with true)
- `calculateBracketPoints`: new function averaging tied bracket tiers
(5-8 → 20 pts, 3-4 → avg, 1-2 solo); used in `calculateTeamScore` for
playoff_bracket pattern (pattern-aware, doesn't affect F1/golf scoring)
- `PlayoffBracket`: "In Contention" table for still-active participants;
AFL double-chance fix (participants who won a later match excluded from
earlier round's loser list); correct `nextRank` starting position
- Server loader: batch owner DB queries (one query vs N+1); deduplicate
participantPoints; use calculateBracketPoints for bracket point display
- Clean up Phase/Q-number tracking comments throughout scoring-calculator.ts
- 3 new tests for non-scoring round provisional floor behavior
Also includes a dev admin bypass via DEV_ADMIN_CLERK_ID env var (separate
change on this branch, not part of floor scoring feature).
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat: EV simulation framework with F1 Monte Carlo simulator
- Add EV snapshot tables (participant_ev_snapshots, team_ev_snapshots) and simulation_status column on sports seasons
- Add ev-snapshot model with upsert and history query functions
- Add simulator framework: types, bracket/F1/golf simulators, registry
- F1 simulator: vig-removed ICM weighted draw (pre-season) + race-by-race Monte Carlo from current standings (in-season); per-position column normalization to prevent floating-point EV drift
- Add admin simulate route and Run Simulation button on sports season page
- Rework futures-odds admin page to save odds then run simulation in one action
- Remove recalculate-probabilities route (superseded by simulate route)
- Remove EV trend chart panel and associated DB queries
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat: map simulators to sports via simulatorType field
Adds a `simulator_type` enum column to the `sports` table so each sport
can be assigned a specific simulation algorithm rather than deriving it
from the sports season's scoring pattern.
- Add `simulatorTypeEnum` (f1_standings, indycar_standings,
golf_qualifying_points, playoff_bracket) + `simulatorType` nullable
column on `sports` table; migration 0037
- Rewrite simulator registry to key off `SimulatorType` instead of
`ScoringPattern`; indycar_standings shares F1Simulator for now
- `findSportsSeasonById` now returns `SportsSeasonWithSport` so callers
have typed access to `sport.simulatorType`
- Simulate and futures-odds actions read `sport.simulatorType`; guard
fires before setting `simulationStatus: running`
- Admin sport edit page gains a Simulator Type dropdown
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>