Commit graph

79 commits

Author SHA1 Message Date
Claude
75960a8826 Fix review findings in the tie-split ledger and backfill
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m5s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m20s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
A review of the previous two commits found five defects in the new ledger
writer and backfill, plus one pre-existing scoring bug the refactor
exposed.

season_standings ties were never split. processSeasonStandings
deliberately writes the same finalPosition to every driver in a tied group
-- its comment says "the scoring system will handle averaging" -- but no
path ever did, so two drivers tied for 3rd each banked the full 50 instead
of the published 45. This predates the tie-split work; the original
cascade had only bracket and qualifying_points arms. Introduce
usesSharedPlacementSplit as the single definition of which patterns record
ties as a repeated placement, and route both calculatePickPoints and every
caller-side gate through it. The caller gates matter as much as the
helper: a gate left hardcoded to qualifying_points silently passes a tie
count of 1, which reads as "no tie" and makes the fix inert.

The ledger anchor picked the wrong event. Ordering on completedAt with no
isComplete filter ranked never-completed events first, because drizzle's
desc() emits a bare desc and Postgres orders DESC as NULLS FIRST.
Restrict to completed events and order explicitly with NULLS LAST plus a
stable tiebreak. The anchor is also no longer load-bearing for
idempotence: stale event-level rows for the sports season are cleared
before writing, so a re-run whose anchor moved replaces rather than
duplicates.

A ledger failure could abort finalization. The call sat unguarded after
the season was already marked completed, so a throw in any of its queries
would skip the standings recalculation and the Discord notification. Guard
both call sites the way the probability refresh directly below already is.

Rows could be mislabelled permanently. The backfill passed no eventName,
and the upsert never rewrote scoringEventName. Derive the label from the
scoring pattern inside the writer so omitting it is impossible, and
refresh it on conflict so existing rows can be repaired.

The backfill damaged unrelated leagues. recalculateStandings rewrites
previousRank, so sweeping every season wiped rank-movement arrows league
wide, including leagues holding no tie at all. Scope it to seasons
drafting from a sports season that actually contains a tied placement, and
correct the docblock that called it a pure recompute.

Also drops the inert Number.EPSILON guard from calculateAveragedPoints
(EPSILON is below the ULP for any value >= 2, and integer averages landing
on .5 are exactly representable) and extracts countSharedPlacements so the
ledger writer stops re-querying rows it already holds.

Every fix is covered by a test confirmed to fail when that fix alone is
reverted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-23 01:57:54 +00:00
Claude
75798d9711 Record final-placement points in the score-events ledger
qualifying_points (golf, tennis, CS2) and season_standings (F1) award all
of their points in one step at finalization, so they produce no per-match
deltas. recordMatchScoreEvents is bracket-only and fires from match
processing, which meant these sports were never written to
team_score_events at all and silently never appeared in Recent Scores.

Add recordFinalPlacementScoreEvents, called from finalizeQualifyingPoints
and processSeasonStandings. It writes one row per team at the ledger's
event-level grain, carrying that team's summed award and every
contributing participant, with points from calculatePickPoints so a tied
golfer contributes the same split award the standings show.

The row is anchored to a real scoring event rather than a null one: the
event-level unique index is (teamId, seasonId, scoringEventId) and
Postgres treats NULLs as distinct, so a null anchor would duplicate rows
on every re-finalization instead of upserting. When no anchor can be
resolved the ledger write is skipped, which leaves standings unaffected.

Adds regression coverage for the two screens that had diverged —
getTeamScoreBreakdown and computeCoronaStates — including an assertion
that the team page's actualPoints equals calculateTeamScore's totalPoints
for the same roster, and cases proving undrafted participants still count
toward a tie span.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-23 01:57:54 +00:00
Claude
430526104c Unify tie-split point math across every screen
A participant tied for a scoring placement splits the combined points of
the tied positions. Four code paths computed a pick's points, each
re-implementing the same bracket/qualifying_points/default cascade, and
two of them omitted the qualifying_points arm entirely. A golfer tied for
8th was therefore worth the full 15 points on the team page and draft
board but the split 7.5 in the standings, so a team read 225 on one
screen and 218 on another.

Collapse the cascade into a single calculatePickPoints helper and route
all six call sites through it, backed by one shared getSharedPlacementCounts
loader replacing the two separate tie-count queries. Tie counts span every
participant in the sports season, not just drafted ones, since an
undrafted tie partner still halves the award.

Also round split awards to the nearest whole point in
calculateAveragedPoints. The /rules page states ties are "combined and
split equally among them, rounded to the nearest whole point", and its own
worked example rounds 18.33 down to 18, so this is nearest rather than
ceiling. Season point values are integer columns, making this averaging
the only source of fractional points; rounding here means the standings'
218 is now correct by construction rather than a display artifact, and
per-pick values visibly sum to the team total.

Existing assertions encoding the unrounded results are updated, and the
rules page's two published examples are asserted directly so the code and
the published rule cannot drift apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-23 01:57:54 +00:00
Claude
569081fe29
Only derive feeders where the halving rule actually holds
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m55s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m25s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Review caught that the generic feeder rule was being applied to templates
that route by their own logic. It was harmless as dead code; driving the
renderer with it made several brackets worse than before.

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HnzbrCHoM8ESbtbDamaqFb
2026-08-21 17:50:59 +00:00
Claude
37af2f1fc3
Fix final rankings for double-elimination brackets
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m2s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m22s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Review of the llws_20 bracket turned up two display bugs in the shared
ranking code, both triggered by the template setting loserFeedsInto on
every winners-bracket round.

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

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

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

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

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

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

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

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

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

Also:

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAxqBVzfmKJe6WQ6VF9guj
2026-08-03 18:06:56 +00:00
Chris Parsons
81d063faa1 Auto-populate & auto-score tennis Grand Slam brackets from Wikipedia
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m13s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m23s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Add a per-event "Sync Draw" that pulls a tennis major's full 128-player
draw from its Wikipedia article, auto-creates/links participants (and
propagates them to every linked sibling season), builds the bracket, and
runs the qualifying-points scorer. Re-running advances the bracket and
scoring as matches complete.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:32:22 -07:00
d31c23d63b claude/practical-newton-4v041g (#98)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m32s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m21s
🚀 Deploy / 🐳 Build (push) Successful in 1m13s
🚀 Deploy / 🚀 Deploy (push) Successful in 10s
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #98
2026-06-18 02:33:05 +00:00
08ae2bb88a Fix SSR/hydration timezone flip in event date and time display (#95)
All checks were successful
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m38s
🚀 Deploy / 🧪 Test (push) Successful in 3m30s
🚀 Deploy / 🐳 Build (push) Successful in 1m9s
🚀 Deploy / 🚀 Deploy (push) Successful in 11s
## Summary

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

## Test plan

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

Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #95
2026-06-17 06:09:29 +00:00
1f41748261 claude/funny-maxwell-vcyb19 (#91)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m6s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m20s
🚀 Deploy / 🐳 Build (push) Successful in 1m9s
🚀 Deploy / 🚀 Deploy (push) Successful in 11s
Co-authored-by: Claude <noreply@anthropic.com>
Reviewed-on: #91
2026-06-15 22:05:25 +00:00
Chris Parsons
97a92aba7d Debug sync-and-simulate cron job: surface curl output and log auth failures
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m57s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m29s
🚀 Deploy / 🧪 Test (push) Successful in 2m52s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m28s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🐳 Build (push) Successful in 1m16s
🚀 Deploy / 🚀 Deploy (push) Successful in 11s
Remove -s (silent) from curl so CI logs show the HTTP response on failure.
Add logger.error in requireCronSecret so 401s from bad/missing secrets appear in Sentry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 16:50:30 +00:00
273742f02e Phase A: external HTTP cron jobs for snapshots, standings sync, and simulation (#79)
Some checks failed
🚀 Deploy / 🧪 Test (push) Failing after 2m45s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m27s
🚀 Deploy / 🐳 Build (push) Has been skipped
🚀 Deploy / 🚀 Deploy (push) Has been skipped
## Summary

- Removes the last in-process \`setInterval\` (\`server/snapshots.ts\` 24h loop) and replaces it with an external HTTP cron job via Forgejo Actions
- Adds automated standings sync + conditional simulation: syncs every 2h, only simulates when standings actually changed (detected by comparing \`gamesPlayed\`/\`leagueRank\` before upsert)
- Adds \`GET /healthz\` for Docker healthcheck (Phase B prerequisite)

## What's new

| Endpoint | Triggered by | What it does |
|---|---|---|
| \`POST /admin/jobs/run-daily-snapshots\` | Forgejo schedule \`5 0 * * *\` | Creates daily fantasy standings snapshots for all active/draft seasons |
| \`POST /admin/jobs/sync-and-simulate\` | Forgejo schedule \`0 */2 * * *\` | Syncs standings from external APIs; runs simulation only if standings changed |
| \`GET /healthz\` | Docker / Traefik | Returns 200 \`{ok:true}\` when DB reachable, 503 otherwise |

Both cron endpoints are protected by \`X-Cron-Secret\` header (set \`CRON_SECRET\` in Forgejo repo secrets + production env).

## Schema changes (migration 0118)

Two new nullable columns on \`sports_seasons\`:
- \`standings_last_changed_at\` — written by \`syncStandings()\` when data actually changes
- \`last_simulated_at\` — written by the cron job after a successful simulation run

## Deployment notes

1. Add \`CRON_SECRET\` to Forgejo repo secrets (generate with \`openssl rand -hex 32\`)
2. Add same value to production environment
3. Migration runs automatically via the \`migrate\` container on deploy

## Test plan

- [ ] \`curl -X POST https://brackt.com/admin/jobs/run-daily-snapshots -H "X-Cron-Secret: ..."\` → 200 \`{total, succeeded, errors}\`
- [ ] \`curl -X POST https://brackt.com/admin/jobs/sync-and-simulate -H "X-Cron-Secret: ..."\` → 200 with \`synced\`/\`unchanged\`/\`simulated\` breakdown
- [ ] \`curl https://brackt.com/healthz\` → 200 \`{ok:true}\`
- [ ] Verify Forgejo workflow runs appear in Actions tab after merge
- [ ] Kill web process mid-day; confirm external cron still fires (no in-process dependency)

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

Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #79
2026-06-08 07:42:14 +00:00
Chris Parsons
77c0f47552 Fix draft pause clock: use Math.ceil consistently via shared msToSeconds helper
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m30s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m19s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
- Pause snapshot used Math.floor while the client display used Math.ceil,
  causing the visible clock to round down by 1s on pause click
- draft-state-sync also used Math.floor for reconnecting clients
- Extract msToSeconds() to app/lib/draft-timer.ts as the single source of
  truth for all ms→seconds conversions across server and client

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 16:39:53 -07:00
Chris Parsons
677b8ce816 Fix three timer-pause gaps found in code review
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m34s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m19s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
- pauseDraftOnError now calls onDraftPaused() before emitting so the
  error-path pause carries the same snapshotted timer data as the
  manual-pause route (fixes inconsistency between the two paths)
- Parallelize DB updates in onDraftPaused with Promise.all instead of
  sequential awaits
- Extract clientExpiresAt() helper to draft-timer.ts and use it in all
  three places that re-anchor timeRemaining to the client clock, replacing
  duplicated Date.now() + timeRemaining * 1000 expressions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 08:52:04 -07:00
Chris Parsons
0ba2b391f8
Fix draft order initialization for teams added mid-season (#449)
* Fix draft order showing only new team when league size increased before order was set

When a commissioner increased the team count on a league that had teams
but no draft order set yet, the server created draft slots only for the
newly-added teams. This left the DB with N+K teams but only K slots,
causing the drag-and-drop list to display only the K new teams.

Two fixes:
1. Server: only append new draft slots when an order was already set
   (existingSlots.length > 0). If no order exists yet, skip slot creation
   so the page correctly treats the order as unset for all teams.
2. Frontend: buildDraftOrderTeams() appends any unslotted teams after the
   slotted ones, so a partially-corrupt DB state still shows all teams.

https://claude.ai/code/session_01M3H55gnMxRztJK9KMXqqZo

* Address code review feedback on draft order bug fix

- Move buildDraftOrderTeams to app/lib/draft-order.ts so it is importable
  and testable; remove the local copy from the settings component
- Add unit tests for buildDraftOrderTeams covering the empty, full, and
  partial-slot cases
- Tighten the draft slot guard from existingSlots.length > 0 to
  existingSlots.length === currentTeamCount so partial legacy states are
  treated the same as "order not set"
- Condense the 3-line server comment to a single line per project style
- Rename getNumTeamsInSeason → getNumDraftSlotsBySeasonId to reflect what
  the function actually counts, and update its one call site
- Add tests for the server-side slot-creation guard logic

https://claude.ai/code/session_01M3H55gnMxRztJK9KMXqqZo

* Fix lint: move shouldAppendDraftSlots to outer scope

oxlint (consistent-function-scoping) requires functions that don't close
over any variables to be defined at the outer scope rather than inside a
describe block.

https://claude.ai/code/session_01M3H55gnMxRztJK9KMXqqZo

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-19 18:22:47 -07:00
Chris Parsons
d468385d90
Add auto-start draft at scheduled time (#437) (#438)
Commissioners can now opt in to having a draft start automatically at
its scheduled draftDateTime rather than requiring a manual "Start Draft"
click. The 1-second timer loop checks for eligible seasons each tick and
calls the new shared startDraft() service, which is also used by the
existing commissioner HTTP route.

- Add autoStartDraft boolean to seasons table (migration 0108)
- Extract core start logic into app/services/draft-autostart.ts so both
  the API route and the timer can call it without AsyncLocalStorage
- Add checkAndAutoStartDrafts() to the timer tick; disables autoStartDraft
  and stops retrying if no draft order is set at fire time
- Guard against null draftDateTime in both the timer query (isNotNull)
  and server actions (autoStartDraft forced false when datetime is null)
- Add "Auto-start at scheduled time" checkbox to league creation wizard
  (step 3) and league settings, gated on date+time being set
- Show countdown + "Start Now" button in draft room when auto-start is
  scheduled; reuses the existing nowForPauseCheck 1-second ticker
- Show orange warning banner on league homepage to commissioners when
  auto-start is within 1 hour and no draft order has been configured

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 23:37:29 -07:00
Chris Parsons
d92f73e449
Extract chess clock presets to shared constant (#432)
* Fix custom chess-clock timer detection and save settings blocker

- Extract getInitialDraftSpeed() helper so both useState init and
  resetSettingsFormState use the same logic; unrecognized presets
  now produce a "custom:{bank}:{incr}" string instead of falling
  back to "standard", so the custom section auto-expands correctly
  (e.g. 8 hr bank + 30 min increment is no longer shown as Standard)
- resetSettingsFormState was not resetting draftSpeed at all; fixed
- Clear hasUnsavedSettingsChanges in the Form's onSubmit so the
  useBlocker check is false at the moment the save navigation starts,
  preventing the "Leave without saving?" dialog from firing on save
- Replace the cleared-on-success useEffect with one that re-marks
  dirty when the action returns an error, so the warning reappears
  after a failed save

https://claude.ai/code/session_01DgHNkvJXGizx41CE2tkVS1

* Address code review: single source of truth for presets, useEffect guard, tests

- Extract CHESS_CLOCK_PRESETS (pure data) into draft-timer.ts as the
  canonical source of truth; DraftSpeedPicker.tsx now spreads those
  values into CHESS_PRESETS rather than duplicating the numbers
- getInitialDraftSpeed moved to draft-timer.ts and rewritten to use
  CHESS_CLOCK_PRESETS.find() — no more hardcoded bankSec/incrSec values
  in three separate places
- parseDraftSpeed switch replaced with CHESS_CLOCK_PRESETS.find() and
  an explicit fallback comment; eliminates the silent default-handles-
  "standard" fragility called out in review
- useEffect that re-marks settings dirty now guards against non-settings
  errors (draft-order, commissioner actions) which carry a `section`
  field — previously any error actionData would spuriously set
  hasUnsavedSettingsChanges=true
- Add parseDraftSpeed and getInitialDraftSpeed test suites to
  draft-timer.test.ts, including parametrised preset coverage via
  it.each(CHESS_CLOCK_PRESETS)

https://claude.ai/code/session_01DgHNkvJXGizx41CE2tkVS1

* Fix lint: use !== null/undefined instead of != null

oxlint enforces eqeqeq; the null check in getInitialDraftSpeed used
!= which triggered two errors.

https://claude.ai/code/session_01DgHNkvJXGizx41CE2tkVS1

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-15 14:02:49 -07:00
Chris Parsons
9e9a37d4d2
Improve unmatched team reconciliation UX (#425) 2026-05-14 16:45:02 -07:00
Chris Parsons
04a92ec7de
Add branded dark-themed HTML email template (#402)
Introduces a shared email module that wraps all transactional emails in a
cohesive dark-themed template matching the site's visual identity (Barlow
font, #14171e background, #adf661 CTA buttons, logomark PNG header).

- app/lib/email.server.ts: new module with sendEmail, wrapInEmailTemplate,
  emailButton, emailParagraph, and escapeHtml helpers; Resend instance is
  lazily initialized as a singleton
- public/email-logo.png: 96×124px PNG of the logomark for email use (SVG
  gradients are not supported in Outlook)
- auth.server.ts: password reset and email verification now use the branded
  template; errors are thrown so Better Auth can surface failures
- support.tsx: internal notification uses sendEmail; adds a user-facing
  branded confirmation email (fire-and-forget so failures don't surface to
  the user); plain text fallback included
- scripts/sync-prod-db.sh: remove first_name/last_name from sanitize query
  (columns were dropped in #399)
- 13 unit tests covering all template helpers

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 11:18:57 -07:00
Chris Parsons
c8748d1b14
Add email verification for email/password signups (#400)
New email/password accounts must verify before signing in. Social login
users (Google, Discord) are unaffected. Existing users are grandfathered
via an idempotent migration. Adds a /check-email interstitial with a
resend button, and a --success CSS token for consistent styling.

Closes #343

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 23:58:45 -07:00
Chris Parsons
c9f08c869f
Remove first and last name from user profiles (#399)
Fixes #326
2026-05-09 09:18:50 -07:00
Chris Parsons
e64cdabb17
Harden sport icon cleanup (#397) 2026-05-08 21:19:09 -07:00
Chris Parsons
79092f0409
Fix college hockey bracket resolution (#396) 2026-05-08 12:29:39 -07:00
Chris Parsons
20685bae6b
Add Cloudinary sports icon uploads (#393) 2026-05-07 16:07:34 -07:00
Chris Parsons
6df4f86920
Improve flag config validation and remove settings completion tracking (#390)
* Fix mobile league settings: prevent sports scroll and remove completion checkmarks

- Add overflow-hidden to sport label text span so flex truncation is properly contained
- Add min-w-0 to SettingsSection header inner flex item to prevent title/description overflow
- Remove completion check marks from mobile grid nav buttons (league settings has no completion concept)
- Remove "X of Y set" counter from mobile nav header
- Remove isComplete field from SettingsGridSection type and all data

https://claude.ai/code/session_01T7iTb9YZLuWJFtKV753tdB

* Fix race window in user creation and restore FlagSvg safety fallback

auth.server.ts: switch generateId to application-generated UUIDs so the
ID is known before the insert, then move flagConfig assignment from
create.after (a separate UPDATE) to create.before so it lands in the
initial INSERT with no race window.

FlagSvg.tsx: validate the config with parseFlagConfig before rendering;
corrupt or missing data falls back to a neutral gray triband flag rather
than silently rendering blank SVG shapes.

https://claude.ai/code/session_01T7iTb9YZLuWJFtKV753tdB

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-07 10:09:45 -07:00
Chris Parsons
d342f1ad25
Remove backfill-flag-configs script (#389)
* Remove backfill-flag-configs script

One-time migration has been run; script is no longer needed.

https://claude.ai/code/session_014RUwPfm1qc539xLxW4m9Uy

* Clean up flag config code

- FlagSvg: remove redundant getDisplayFlagConfig call; config is already a
  valid FlagConfig by type, so the validation/fallback was dead code. Also
  replace the unreachable `?? "#adf661"` fallback with a non-null assertion.
- team/user models: pre-generate ID before insert so flagConfig is persisted
  immediately on creation rather than relying on display-time generation.
- auth.server.ts: add create.after hook so BetterAuth-created users also get
  a flagConfig written to the DB on signup.

https://claude.ai/code/session_014RUwPfm1qc539xLxW4m9Uy

* Fix lint: replace non-null assertion in FlagSvg with destructure defaults

oxlint forbids the ! operator; destructuring with empty-string defaults
is equivalent since FlagConfig always has 3 colors.

https://claude.ai/code/session_014RUwPfm1qc539xLxW4m9Uy

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-06 20:35:02 -07:00
Chris Parsons
a447ba54de
Add Cloudinary-backed avatar system (#385) 2026-05-06 14:47:37 -07:00
Chris Parsons
049ec8a596
Show why a participant is ineligible to draft (#378)
Closes #72

Upgrades ineligibleReasons from Record<string, string> to a structured
{code, message} type so the UI can render the human-readable reason
inline below the Ineligible badge in both AvailableParticipantsSection
and ParticipantSelectionDialog. API routes surface the message in 400
responses unchanged.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 21:54:14 -07:00
Chris Parsons
5b03b22267
Add Brackt as a league-private meta-sport (#200) (#377)
Commissioners can add Brackt to any league. Each team drafts one manager
from their own league; at season end, that manager's final overall fantasy
ranking earns placement points. Resolution fires automatically when all
other sports finalize.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 20:31:44 -07:00
Chris Parsons
48b1d470f1
Canonical tournament layer:
cutover + cleanup (2/2) (#367)

* refactor(schema): rename per-window tables to season_* prefix

Renames participants, participant_expected_values, participant_qualifying_totals,
participant_results, participant_surface_elos to season_* prefixed names.
Renames event_results.participant_id to season_participant_id.
Phase 1a of canonical tournament layer migration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor: rename participant.ts model file to season-participant.ts

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(models): update model layer to use renamed schema exports

Updated all model files to use the renamed schema exports from Task 1:
- participants → seasonParticipants
- participantExpectedValues → seasonParticipantExpectedValues
- participantQualifyingTotals → seasonParticipantQualifyingTotals
- participantResults → seasonParticipantResults
- participantSurfaceElos → seasonParticipantSurfaceElos
- eventResults.participantId → eventResults.seasonParticipantId
- db.query relation accessors updated
- Relation field .participant → .seasonParticipant where applicable
- Import paths updated: ./participant → ./season-participant

Files updated (14 model files + 3 test files):
- draft-pick.ts
- draft-utils.ts
- event-result.ts
- group-stage-match.ts
- participant-result.ts
- qualifying-points.ts
- scoring-calculator.ts
- scoring-event.ts
- sports-season.ts
- surface-elo.ts
- team-score-events.ts
- cs2-major-stage.ts
- golf-skills.ts
- participant-expected-value.ts
- __tests__/sports-season.clone.test.ts
- __tests__/auto-pick.test.ts
- __tests__/executeAutoPick.timer.test.ts

Typecheck errors decreased: 779 → 499 (280 fewer)
All model file errors related to renamed schemas resolved.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(routes): update route layer to use renamed schema exports

- Update model import from ~/models/participant to ~/models/season-participant
- Rename schema.participants to schema.seasonParticipants
- Rename schema.participantResults to schema.seasonParticipantResults
- Rename db.query.participants to db.query.seasonParticipants
- Update 9 route files and 1 test file

Affected files:
- admin.sports-seasons.$id.events.$eventId.bracket.server.ts
- admin.sports-seasons.$id.participants.tsx
- api/draft.force-manual-pick.ts
- api/draft.make-pick.ts
- api/draft.replace-pick.ts
- api/seasons.$seasonId.draft.ts
- leagues/$leagueId.draft-board.$seasonId.tsx
- leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts
- admin/__tests__/sports-seasons-participants.test.ts

Error count reduced from 499 to 453 (46 errors fixed).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(routes): update route files for schema rename

Update route imports from ~/models/participant to ~/models/season-participant
and fix references to .participant/.participantId on event results to use
.seasonParticipant/.seasonParticipantId after schema rename.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(services): update simulators and services for renamed schema

Update all simulators, services, and server files to use renamed schema tables:
- participants → seasonParticipants
- participantExpectedValues → seasonParticipantExpectedValues
- participantResults → seasonParticipantResults
- eventResults.participantId → eventResults.seasonParticipantId

Files updated:
- 20 sport simulators (NBA, NHL, NFL, MLB, etc.)
- probability-updater.ts
- standings-sync/index.ts
- sports-data-sync.server.ts
- server/socket.ts

Typecheck errors reduced from 365 to 0.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* migration: rename per-window tables to season_* prefix

* fix(tests): update mock query keys after participants table rename

Change mock db.query.participants to db.query.seasonParticipants in test
files to match the schema rename from commit 66145a9. This fixes
"Cannot read properties of undefined (reading 'findFirst'/'findMany')"
errors that occurred when production code queries db.query.seasonParticipants
but test mocks only defined the old participants key.

Files updated:
- app/services/simulations/__tests__/world-cup-simulator.test.ts
- app/routes/api/__tests__/draft.force-manual-pick.test.ts
- app/routes/api/__tests__/draft.force-manual-pick.timer-mode.test.ts
- app/routes/api/__tests__/draft.make-pick.timer-mode.test.ts
- server/__tests__/timer-autodraft.test.ts
- app/models/__tests__/team-score-events.test.ts

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(tests): update remaining mock paths and keys after schema rename

* fix(tests): final two mock stragglers after schema rename

- draft-pick.test.ts: assertion on db.query.participantQualifyingTotals
- process-match-result.test.ts: mock key participants → seasonParticipants

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: add post-phase1a baseline capture (temp, for diff verification)

* chore: capture pre-migration baselines

* chore: remove post-phase1a capture helper after verification

* schema: add canonical tournament & participant tables

Adds tournaments, participants (canonical), tournament_results, and
participant_surface_elos (canonical). Adds nullable tournament_id to
scoring_events and nullable participant_id to season_participants.
Phase 1b of canonical tournament layer migration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(models): add canonical tournament, participant, result, surface-elo models

Adds CRUD modules for the canonical tables created in commit 775b905.
Each module mirrors existing app/models conventions (database() from
~/database/context, schema from ~/database/schema, mock-based tests).

Key implementation notes:
- participant.ts exports use "Canonical" prefix (CanonicalParticipant,
  createCanonicalParticipant, etc.) to avoid collision with existing
  season-participant.ts exports
- All four models include comprehensive unit tests following the
  audit-log.test.ts pattern
- Tests use mocked db responses (no real database access)
- Upsert functions use onConflictDoUpdate for appropriate unique constraints

Part of Phase 1b of canonical tournament layer migration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* migration: create canonical tables, add nullable FKs

* scripts: add extractTournamentIdentity helper for backfill

Pure function that derives canonical (name, year) identity from a
scoring_events row, stripping trailing 4-digit years from the name or
falling back to eventDate. Used by the Phase 2 backfill to group
per-window events into canonical tournaments.

* scripts: add backfill orchestrator for canonical layer

Populates canonical tournaments, participants, tournament_results, and
participant_surface_elos from per-window data for qualifying-points
sports. Skips already-linked rows, is idempotent, and supports dry-run
mode.

Critical invariants enforced by the implementation:
- qualifying_points_awarded is never copied to tournament_results
- season_participant_qualifying_totals is never touched
- conflicting surface-Elo values between windows raise a loud error
  (recorded in report.errors) rather than overwriting

* scripts: add backfill CLI with dry-run default

Wires backfill-canonical-layer.ts to a CLI entry point exposed as
`npm run backfill:canonical`. Defaults to --dry-run; requires --apply
to actually write. Supports --sport=<uuid> to limit to a single sport.
Exits 2 if the backfill reports errors (e.g., surface-Elo conflicts).

* fix(backfill-cli): wrap runBackfill in DatabaseContext.run

The orchestrator uses database() from ~/database/context, which requires
AsyncLocalStorage to be populated. Wrap the CLI invocation with
DatabaseContext.run(db, ...) using server/db's cached connection pool.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(backfill-cli): exit 0 on success so pg pool doesn't block

The cached postgres connection pool keeps the Node event loop open after
main() returns. Explicit process.exit(0) on success mirrors the pattern
in scripts/capture-baseline.ts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* schema: add check constraint requiring tournament_id for qualifying events

Team sports (NHL, NBA, etc.) have scoring_events with no canonical
tournament, so we can't require tournament_id globally. The constraint
only applies when is_qualifying_event=true.

Phase 3 Task 1 of canonical tournament layer migration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(BatchResultEntry): make form intent configurable

Canonical tournament admin page will submit with a different intent
(batch-upsert-results). Existing per-window callers continue to get
"batch-add-results" as the default, so no behavior changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(services): syncTournamentResults fan-out service

* schema: add unique (scoring_event_id, season_participant_id) on event_results

syncTournamentResults uses check-then-write on event_results. The
unique index defends against race conditions (unlikely today — admin
is single-writer — but safe to enforce).

WARNING: if existing prod data has duplicate (scoring_event_id,
season_participant_id) pairs, the migration will fail. Verify with:
  SELECT scoring_event_id, season_participant_id, count(*)
  FROM event_results
  GROUP BY 1, 2
  HAVING count(*) > 1;

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(surface-elo): getSurfaceEloMap reads from canonical

Switch the tennis simulator's Elo source from the per-window table
to the canonical participant_surface_elos via season_participants.participant_id.
Map key stays season_participant.id so the simulator's call pattern is unchanged.

Other functions in this file still read/write the per-window table
(seasonParticipantSurfaceElos). They back the old admin surface-elo page
and are removed in Phase 4 after the canonical admin flow replaces them.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(admin): canonical tournament list + result entry routes

New routes /admin/tournaments and /admin/tournaments/:id. The detail
page lets admins enter tournament results once via paste-and-parse,
then calls syncTournamentResults to fan out to every linked window.

Phase 3 Task 4 of canonical tournament layer migration.

* feat: per-window batch-add-results routes through canonical when linked

If the scoring_event has a tournament_id, translate season_participant ids
to canonical participant ids, upsert tournament_results, and fan out via
syncTournamentResults. Team sports (no tournament link) keep the legacy
direct-write path.

Phase 3 Task 8 of canonical tournament layer migration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(surface-elo): mirror batchUpsert writes to canonical table

The simulator now reads surface Elo from the canonical table
(participant_surface_elos). The existing per-window admin page still
posts to batchUpsertSurfaceElos; without this change, those edits
would silently fail to affect simulator output. Mirror every write
to both tables until Phase 4 removes the per-window path entirely.

Phase 3 Task 5 (partial — canonical write path; dedicated canonical
admin UI deferred; existing page continues to work and now
edits both tables).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat: auto-provision canonical tournaments and participants on create

Creating new qualifying scoring_events or season_participants now
auto-upserts the matching canonical rows. Needed so the check constraint
doesn't reject qualifying events, and so new season participants
flow through syncTournamentResults.

- Extract tournament identity shared between backfill and event-creation
  into app/lib/tournament-identity.ts; scripts/backfill re-exports it.
- createScoringEvent + bulkCreateScoringEvents now accept tournamentId.
- Event creation routes auto-compute (name, year) and upsert tournament.
- createParticipant + createManyParticipants auto-link to canonical
  participants by (sportId, name).

Phase 3 Task 7 of canonical tournament layer migration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(tests): update surface-elo tests for canonical mirror

getSurfaceEloMap now joins through season_participants and returns
rows keyed by seasonParticipantId (was participantId).

batchUpsertSurfaceElos now performs a second db.select to resolve
canonical links before mirroring writes; tests mock the lookup
explicitly and exercise both the "no canonical link" short-circuit
path and the "mirror to canonical" path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* migration: add check constraint + unique index for canonical

* refactor(surface-elo): canonical-only; drop per-window path

batchUpsertSurfaceElos now writes only to the canonical
participant_surface_elos table (the mirror step is now the only step).
getSurfaceElosForSeason joins through season_participants → canonical
participants so the admin UI keeps its existing (id, participantId,
sportsSeasonId, ...) row shape.

season_participants without a canonical link get silently skipped. In
practice every qualifying-points roster entry is canonical-linked after
Phase 2 backfill + Phase 3 auto-linking on createParticipant.

Phase 4 Tasks 2 + 3 of canonical tournament layer migration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor: remove legacy direct-write fallback in batch-add-results

Every qualifying scoring_event now has tournament_id (check constraint
enforces it), so the non-canonical fallback is dead. Replace with an
explicit error surface so any configuration regression becomes visible
instead of silently bypassing canonical.

Phase 4 Task 4.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* schema: drop season_participant_surface_elos table

Table had no remaining readers after Phase 3 and no remaining writers
after Phase 4 Task 2. Also removes the now-unrunnable Phase 2 backfill
scripts and the backfill:canonical npm script — the backfill ran once
and is preserved in git history (commits 85bca8b, 8186dbb, 6f3438b,
bc0f530, 327c7b9).

Phase 4 Task 5.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(agents): describe canonical/per-window split

Update database.md with the canonical/per-window model explanation,
the syncTournamentResults fan-out contract, the check constraint,
and the tennis-Men/Women quirk.

Update domain-models.md to reflect the renamed per-window entities
and the new canonical layer.

Also update the drizzle-kit gotchas section with lessons from the
migration: do not use --custom for renames; FK constraint naming
can mismatch Drizzle convention.

Phase 4 Task 6.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* migration: drop season_participant_surface_elos

* feat(admin): add Tournaments link to admin sidebar

Missed in Phase 3 Task 4 — the /admin/tournaments route exists but
was not reachable from the admin nav without typing the URL.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(lint): address 4 oxlint errors on canonical-layer-pr2

- Remove unused filterNewResults import (events batch-add-results legacy
  path was removed in Phase 4 Task 4).
- Replace import() type annotation in sync-tournament-results test with
  a top-level type import (consistent-type-imports rule).
- Hoist tableName helper out of makeFakeDb closure so it's not recreated
  per call (consistent-function-scoping rule).
- eqeqeq: replace `!= null` with explicit null/undefined checks in
  tournament-identity.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Chris Parsons <chrisp@extrahop.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 21:04:48 -07:00
Chris Parsons
5268e07365
Migrate auth from Clerk to BetterAuth (#354)
* Fix BetterAuth field mapping and add owner-prefixed team names

- Fix auth.server.ts: use camelCase Drizzle field names for BetterAuth
  adapter (was snake_case, causing user inserts to fail); add
  generateId: "uuid" so PostgreSQL UUID columns accept generated IDs
- Add prependOwnerToTeamName / stripOwnerFromTeamName utilities
- Invite join, league creation, and settings assign/remove all now
  manage the owner-prefix on team names atomically

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Complete BetterAuth migration: auth flows, rename, and cleanup

- Add /forgot-password and /reset-password pages (full password reset flow)
- Add 'Forgot password?' link on login page
- Fix register.tsx: add explicit window.location fallback after signUp
- Rename commissionerAuditLog.actorClerkId → actorUserId (migration 0084)
  and update all references in model, routes, components, and tests
- Rewrite docs/agents/auth.md to document BetterAuth (removes all Clerk refs)
- Update test fixtures and schema comments to remove Clerk ID references;
  use UUID-format IDs throughout test data
- Update docs/agents/domain-models.md ownerId description

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix BetterAuth ID generation, clean up review issues

- Set generateId: false so BetterAuth omits id from inserts; add
  $defaultFn(crypto.randomUUID) to accounts/sessions/verifications
  so Drizzle fills it in (fixes null id constraint violation on signup)
- Move renameTeam to static import; rename before removeTeamOwner so
  a failed rename leaves owner intact rather than leaving a stale prefix
- Clarify stripOwnerFromTeamName toTitleCase assumption in comment
- Annotate reset-password token fallback to explain loader guarantee

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 10:03:50 -07:00
Chris Parsons
e201ecd28a
Extract reusable league wizard components; wire settings page (#350)
* Extract reusable league wizard components; wire settings page (#103)

Extracts 9 domain components from new.tsx and $leagueId.settings.tsx into
app/components/league/ (each with a Storybook story), consolidates wizard
form-building into wizard-state.ts, and updates the settings page to use
the shared components instead of hand-rolled duplicates. new.tsx shrinks
from ~2000 → ~1280 lines.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix sports-season test: add participants to mock data

findDraftableSportsSeasons now returns participantCount, which requires
participants in the mock db response.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix sports-season test: loosen makeMockDb type to Record<string, unknown>[]

typeof mockSeasons became too strict after adding participants to the mock
data, breaking inline arrays in other tests that don't include participants.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 22:24:13 -07:00
Chris Parsons
dbc23f14ce
Add overnight pause for draft timers (#335)
* Add overnight pause feature for draft timers

Protects players from having their pick timer expire while asleep.
Admins configure a nightly window (league-wide or per-user timezone);
the timer freezes during that window while autodraft can still fire.

Fixes #66

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix TS error: guard getUserDisplayName against undefined user

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 22:31:52 -07:00
Chris Parsons
ba9bf64e37
Migrate authentication from Clerk to BetterAuth (#324)
* Migrate authentication from Clerk to BetterAuth (#322)

Replaces @clerk/react-router with self-hosted better-auth to eliminate
the external Clerk dependency and keep all user/session data in our own
PostgreSQL database.

**What changed**
- New: auth.server.ts (BetterAuth config w/ Drizzle adapter, bcrypt, Resend), auth-client.ts, api.auth.$.ts handler
- New: /login and /register pages with email+password and Google/Discord OAuth; open-redirect guard on redirectTo param
- New: UserMenu component replacing Clerk's UserButton
- Schema: sessions, accounts, verifications tables; emailVerified column; clerkId made nullable
- Migrations 0081 (BetterAuth tables) and 0082 (accounts extra columns for v1.6.9)
- All ~30 route files: getAuth → auth.api.getSession, isUserAdminByClerkId → isUserAdmin
- root.tsx: isAdmin read directly from session.user.isAdmin (no extra DB query)
- useDraftAuthRecovery: removed Clerk JWT refresh logic; replaced with cookie-session check
- models/user.ts: removed findUserByClerkId, findOrCreateUser, updateUserByClerkId (webhook pattern)
- Deleted: app/routes/api/webhooks/clerk.ts; uninstalled @clerk/react-router, @clerk/themes, svix
- scripts/migrate.mjs: extended with idempotent Clerk → BetterAuth data migration (FK conversion, email_verified, OAuth accounts)
- scripts/migrate-clerk-passwords.mjs: one-time script to import bcrypt hashes from Clerk CSV export
- BETTERAUTH_MIGRATION.md: dev and production runbooks
- All test mocks updated: vi.mock('~/lib/auth.server') instead of @clerk/react-router/server
- Test fixtures: added emailVerified field

**Follow-up (post-stable)**
- Rename actor_clerk_id column → actor_user_id in commissioner_audit_log
- Drop clerk_id column from users once migration confirmed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Add .npmrc with legacy-peer-deps for better-auth/drizzle peer dep conflict

better-auth@1.6.9 declares peerOptional deps on drizzle-orm ^0.45.2 and
drizzle-kit >=0.31.4, but we run drizzle-orm ~0.36.3 / drizzle-kit ~0.28.1.
The adapter works correctly at runtime with our versions — the peer dep is
only for stricter type checking. This unblocks npm ci in CI without a risky
drizzle major-version upgrade.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 22:00:49 -07:00
Chris Parsons
3566a97a1e
feat: add projected pick dividers to draft participant list (fixes #82) (#322)
Show visual dividers in the available participants list indicating where
your future draft picks fall relative to the current pick position.
Dividers are suppressed when any filter is active (search, sport filter,
hide drafted, etc.) since positional references are meaningless in a
filtered view.

- Add getProjectedPicks() to draft-order.ts for computing future pick
  positions in a snake draft
- Interleave divider items into the virtualized list in
  AvailableParticipantsSection
- Extract ListItem type to module scope
- Add tests for getProjectedPicks, divider rendering, and filter
  suppression
2026-04-24 09:32:11 -07:00
Chris Parsons
01dacbce27
Add Storybook stories for new components from staged changes (#321) 2026-04-23 22:09:25 -07:00
Chris Parsons
9ed0282fd0
New design (#309)
* Redesign home page with new layout and component system

- Two-column layout (My Leagues 2/3, Upcoming Events 1/3) with mobile stack
- LeagueRow: square avatar, gradient draft highlight, rank/points display, progress bar
- MyLeaguesCard, CreateLeagueCard with shared SectionCardHeader
- UpcomingEventsCard: vertical timeline with grouped multi-league events
- Shared gradient system: BracktGradients SVG defs, GradientIcon wrapper, brand.ts constants
- Button default variant updated to green→cyan gradient
- Navbar: plain nav links with gradient hover, support/admin icon buttons
- Accessibility fixes: semantic h2 headings, aria-label on LeagueAvatar and nav elements
- Storybook stories for all new components

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Responsive league row layout and mobile polish

- League rows stack avatar+name on top, stats full-width below on mobile
- Stats spread to right side on sm+ screens with border separator on mobile
- Tighter padding on mobile (px-3/py-3), full padding on sm+
- Card headers and content use px-3 sm:px-6 to reduce mobile gutters
- Two-column home layout deferred to lg breakpoint (tablet gets stacked)
- Active leagues sorted by completion percentage descending
- Default rank 1 / 0 points for active leagues with no scoring events yet
- Fix ordinal bug for 11th/12th/13th; add aria-labels to rank change indicators
- Remove dead StatDivider className prop

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Improve claude file.

* Add StandingsPreview card component with podium row styling

- New StandingsPreview component with gold/silver/bronze row tints for
  top 3, team avatar, and LeagueRow-style stat columns (Ranking + Points)
  with rank and 7-day point change indicators
- Fix GradientIcon in Storybook by adding BracktGradients decorator to
  preview.tsx (renamed from .ts to support JSX)
- Fix degenerate SVG gradient on horizontal strokes by switching
  BracktGradients to gradientUnits="userSpaceOnUse" with Lucide-space
  coordinates (0→24)
- Revert erroneous fill: url(#gradient) from GradientIcon; stroke-only
  fix was sufficient once gradientUnits was corrected

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Update components on league homepage.

* Finish up league page styling.

* Work on standings page.

* Add story for RecentScoresCard

* Update Point Progression Chart.

* Sort point progression legend by ranking and add team links to standings rows

* Fix standings discrepancy on change.

* Create draft cell component.

* Update draft board page

* Draft room improvements.

* Update some draft room styling.

* Fix context menu missing.

* Move tab navigation and autodraft to header row, narrow sidebar

* Virtualize available participants list, memoize draft room props

Adds @tanstack/react-virtual to replace separate mobile/desktop lists
with a single unified virtual scroll loop. Also memoizes miniDraftGrid
and availableParticipantsSectionProps, and switches pick lookup from
Array.find to a Map for O(1) access.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Update draft room UI.

* More draft room fixes.

* Draft room tweaks.

* Fix Rosters page.

* Queue Section fixes.

* Mobile Draft fixes.

* Fix draft board page.

* Create bracket look.

* Bracket work.

* Finish bracket page.

* Homepage initial styling

* homepage copy

* Add privacy policy. Fixes #88.

* how to play copy

* rules copy

* Fix brackets on homepage.

* Add footer to website.

* Glow on dots.

* Landing page copy.

* Fix sidebar.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:14:55 -07:00
Chris Parsons
4a875e7628
fix: league settings sports swap and audit log detail improvements (#294)
- Fix sports seasons not updating when saving league settings. The form
  submits intent="update" but sports were only handled under the dead
  intent="update-sports" branch, which nothing called.

- Fix spurious audit log entries (scoring rules, draft settings) firing
  on every settings save. Change detection now compares submitted values
  against current DB values before adding to seasonUpdates.

- Add previousValues to scoring_rules_changed and draft_settings_changed
  audit log entries so the detail formatter can show old → new diffs
  (e.g. "Scoring updated: 1st: 100 → 105").

- Add sports_changed audit action (migration 0076) with sport names
  resolved at log time. Batch-fetch added sports in one query instead
  of N individual queries.

- Improve audit log display for all action types: field-level old→new
  diffs, readable labels, truncated sport lists (+N more).

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 21:41:05 -07:00
Chris Parsons
442b392461
Add audit logging for commissioner actions (#293)
Closes #144

* feat: add commissioner audit log for league transparency (issue #144)

Adds a complete audit log system so league members can verify that
settings, draft order, picks, and time banks have not been quietly
changed without their awareness.

Changes:
- database/schema.ts: new `audit_action` enum + `commissioner_audit_log`
  table (seasonId, leagueId, actorClerkId, actorDisplayName, action,
  affectedTeamIds[], details jsonb, createdAt)
- drizzle/0075: generated migration for the new table
- app/models/audit-log.ts: createAuditLogEntry, getAuditLogForSeason
  (paginated), logCommissionerAction (resolves display name automatically)
- app/lib/audit-log-display.ts: shared formatAuditDetail() helper used by
  both the league home widget and the full audit log page
- app/routes/leagues/$leagueId.audit-log.tsx: new read-only route at
  /leagues/:id/audit-log, accessible to all league members, with
  action-type filter and pagination
- app/routes.ts: registers the new route
- League home page ($leagueId.server.ts / $leagueId.tsx): "Recent Activity"
  summary card showing the last 5 entries with "View all" link
- Settings page ($leagueId.settings.tsx): "View Full Audit Log" link card;
  audit log calls added for league/draft settings changes, draft order
  set/randomized, and draft reset
- API routes: audit log calls added to draft.start, draft.pause,
  draft.resume, draft.rollback, draft.adjust-time-bank, draft.force-autopick,
  draft.force-manual-pick, draft.replace-pick
- Tests: 11 new unit tests for the audit-log model; mocks added to 3
  existing route test files to account for the new logCommissionerAction call

https://claude.ai/code/session_01NdiwK2fbtKhAD3XuD58fTm

* fix: validate action filter URL param against known enum values

The action filter on the audit log route was cast directly from the URL
search param to AuditAction without validation. An invalid value would
be passed into the Drizzle inArray() call, potentially throwing a
PostgreSQL enum type error. Now validates against the actual enum values
before using the filter.

https://claude.ai/code/session_01NdiwK2fbtKhAD3XuD58fTm

* Fix lint errors: use !== instead of != and toSorted instead of sort

https://claude.ai/code/session_01NdiwK2fbtKhAD3XuD58fTm

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-13 15:45:39 -07:00
Chris Parsons
040c97137b
feat: NBA playoff bracket template and bracket-aware simulator (#291)
Adds the NBA_20 bracket template (20-team structure with play-in
tournament) and rewrites the NBA simulator to be bracket-aware like UCL.

- Add NBA_20 bracket template: PIR1 (4) → PIR2 (2) → First Round (8)
  → Conference Semifinals → Conference Finals → NBA Finals
- Add participantLabels (East/West 1–10) for admin bracket UI slot labels
- Add generateNBA20Bracket and advanceNBAPlayInWinner in playoff-match.ts
  with custom play-in advancement logic (PIR1 winners go to two different
  destinations)
- Rewrite NBASimulator with two auto-detected modes:
  - Bracket-aware (preferred): reads actual bracket state, simulates only
    remaining rounds forward using 50k Monte Carlo iterations
  - Season-projection (fallback): existing logic for pre-playoff use
- Guard resolveGame/resolveSeries against null participants (throws with
  match ID and slot info instead of silently using empty string)
- Document load-bearing First Round match ordering in generateNBA20Bracket
- Add 25 unit tests covering Elo math, team data, template structure,
  and all simulator integration paths

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 21:51:53 -07:00
Chris Parsons
1798020c74
feat: batch qualifying results entry (#290)
Fixes #289

* docs: add batch qualifying results entry design spec

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: add batch qualifying results entry implementation plan

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: ignore .worktrees directory

* feat: add qualifying results text parser with tests

* fix: handle no-space after dot separator, tighten T-prefix to uppercase only

* feat: add batch-add-results server intent with duplicate filtering

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: add create-participant intent for inline participant creation

* fix: trim sportsSeasonId validation in create-participant intent

* feat: fill 0-QP rows for unplaced participants on qualifying event finalize

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* perf: use bulk insert for 0-QP rows on qualifying event finalize

* feat: add BatchResultEntry component for paste-and-parse batch result import

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: disable add participant button while create request is in flight

* feat: render BatchResultEntry on qualifying event pages

Add BatchResultEntry component import and render it on event pages when
the event is a qualifying event, not a final_standings/playoff_game type,
and not yet complete.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: address final review issues (sportsSeasonId authority, participant validation, CRLF, unused prop)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 14:52:22 -07:00
Chris Parsons
4d5fea05ab
Add NCAA Football CFP simulator (12-team bracket) (#278)
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>
2026-04-08 09:48:32 -04:00
Chris Parsons
8c3663e01b
Add PDC World Darts Championship simulator with Elo-based bracket (#248)
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>
2026-03-31 15:37:28 -07:00
Chris Parsons
338979e0a8
Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator (#242)
* 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>
2026-03-29 10:27:47 -07:00
Chris Parsons
611e1ccf0a
Show tied ranks with T prefix across standings and Discord (#239)
* 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>
2026-03-27 20:45:15 -07:00
Chris Parsons
3c4ed67946
Add upcoming events pages and fix timezone filtering, fixes #213 (#235)
- 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>
2026-03-27 00:49:16 -07:00
Chris Parsons
e62e9554c9
Add golf qualifying points simulator (Plackett-Luce Monte Carlo) (#223)
* 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>
2026-03-24 21:46:02 -07:00