- Auto-heal now only promotes a new primary when the deleted event was
itself the primary window. Golf-style shared majors intentionally have
no primary (scored on the canonical tournament page), so deleting one
of their windows no longer flips a sibling into a primary and changes
its scoring/guard behavior. Adds a regression test for that case.
- Replace the relation-heavy getSportsSeasonsByTournament + double
Array.find in the events loader with a new countWindowsByTournament
helper (count(distinct sports_season_id)) and a single cached lookup.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBzjCLVkVaQMj1MF3K54t2
Deleting an event from a season only affects that season's scoring
event; the shared canonical tournament and the other linked seasons
survive. The old flow never communicated this and offered no way to
manage the relationship, so it felt like deleting an event might wipe
the whole shared tournament.
- deleteScoringEvent is now shared-tournament-aware: it auto-heals the
primary window (promotes the earliest remaining window when the
primary is removed) and optionally deletes the canonical tournament
when the last linked window is removed. Returns a result describing
what happened.
- Add deleteTournament model helper.
- Events page: delete confirmation now explains exactly what a delete
does (removed from this season only vs. last window), with an opt-in
checkbox to also remove the orphaned shared tournament.
- Tournament page: add a per-window "Remove" control on the Linked
Sports Seasons card to unlink a season, reusing the auto-heal path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBzjCLVkVaQMj1MF3K54t2
- Use make_interval(months => $n) instead of parameterized interval
multiplication so Postgres can infer the bound param's type (the prior
form risked a "could not determine data type of parameter" runtime error)
- Fix subject/verb grammar in the coverage-gap warning for a single sport
("1 sport has" vs "N sports have")
- Stack overlapping draft windows for the same sport into separate lanes
via greedy interval scheduling so bars no longer render on top of one
another; row height grows with the number of concurrent windows
- Add a test covering overlapping windows rendering as separate bars
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd51U9DPMNz4KzBeucR6CK
Adds an admin page at /admin/draft-schedule that visualizes sport-season
draft windows (draftOn -> draftOff) as a Gantt-style timeline: Y axis is
the sport, X axis is a 6- or 12-month horizon. A warning panel highlights
sports with no open or upcoming draft window so gaps in coverage are
obvious at a glance.
- New findDraftScheduleForHorizon model query (overlap test against the
horizon, admin sport-seasons only)
- Presentational DraftScheduleGantt component with month gridlines, a
"today" marker, and status-colored bars linking to each sport-season
- Route registered in routes.ts and linked from the admin nav
- Unit tests for the model query and component
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd51U9DPMNz4KzBeucR6CK
scoring-event.ts exported getEventTypeLabel, a pure display helper used
directly in route components, from the same module that imports
scoring-calculator.ts (which now pulls in the Discord notification
service, a .server-only module). Because that whole chain was
value-imported into the client bundle, Vite's server-only-module guard
failed the build. Move getEventTypeLabel into a new client-safe
scoring-event-types.ts module with no server dependencies, and import
it directly where it's rendered.
Futures odds entry was split across two unrelated places: a standalone
/futures-odds page (which auto-ran the simulation on save and surfaced a
"Simulator is not ready" run failure as if the save itself had failed) and
the Bulk Simulator Inputs CSV importer on the simulator setup page.
Consolidate everything onto the Bulk Simulator Inputs card:
- batchUpsertParticipantSimulatorInputs now COALESCE-wraps every conflict
update column, so a partial paste (e.g. odds-only) updates just the columns
it provides instead of nulling out previously stored Elo/rating/etc.
- The bulk importer accepts sportsbook-style paste (one team per line ending
in American odds) when no CSV header is present, reusing the existing fuzzy
matcher, and auto-runs the simulation on save. A not-ready run is reported
as a successful save plus the readiness gap, never as a failed save.
- Retire the standalone /futures-odds page (now redirects to the simulator
setup page) and drop the redundant Futures links.
- Remove the now-dead futures-only model paths (batchSaveSourceOdds,
clearSourceOddsForParticipants, batchSaveFuturesOddsForSimulator,
batchSaveParticipantSimulatorSourceOdds); the EV-table bridge is preserved
by batchUpsertParticipantSimulatorInputs.
- Repoint the test to the consolidated path and assert the non-destructive
COALESCE behaviour.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHnoQ7myY3PzNdTnd7iGNE
Co-authored-by: Claude <noreply@anthropic.com>
Reviewed-on: #117
## What
Tennis Grand Slam majors already store the full 128-player draw in `playoff_matches` (synced from Wikipedia), and the `PlayoffBracket` component + `tennis_128` template already exist — but users couldn't see any of it. The public league event page only rendered a bracket for `eventType === "playoff_game"`, while tennis majors are `major_tournament` events, so they fell through to the QP-only results table.
## Change
- New `kind: "tennis"` branch in the event loader, gated on `isBracketMajor(simulatorType) && eventType === "major_tournament"`. Loads the bracket (primary-keyed for shared majors, via the existing read-only-sibling ownership remap) plus this window's local QP results.
- The event page renders the full draw via the existing `PlayoffBracket`, followed by the QP results table.
- Extracted the duplicated results-table markup into a shared `QpResultsTable` used by both the `tennis` and `results` branches.
- "In Contention" table now sorts manager-drafted players first, then alphabetically by name.
CS2 majors (handled earlier) and golf (`isBracketMajor` false) are unaffected.
## Verification
- `npm run typecheck` — clean
- `npm run test:run` — 2533 passed
- `oxlint` — clean
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #113
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>
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>
Surface projected final points on the full standings page and restructure
the rank/point change indicators so columns align cleanly on desktop and
mobile.
- StatHelpers: stacked label/value/delta columns with a per-row reservable
delta line; merge rank/point indicators into a single DeltaBadge;
parameterize StatDivider height.
- StandingsPreview: opt-in `showProjected` column (projected points only),
gated on participants remaining; reserve the delta line only when a row
actually moved (no stray em-dashes).
- Full standings page: pass projected data + showProjected (hidden when the
season is complete).
- League home: fetch via getSevenDayStandingsChange so the preview shows the
same 7-day rank/point changes as the full standings page.
- LeagueRow: top-align stats and restore h-8 dividers so the shared-component
changes don't alter the league list rows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The CS2 Major EV simulation silently discarded the Champions Stage
bracket for IEM Cologne 2026: the bracket seeded "Team Spirit" in QF1
while the stage assignments listed the (different) "Spirit Academy", so
the realBracket gate found an out-of-field participant and rejected the
whole bracket. The phantom "Spirit Academy" also left a stray
event_results row (33 rows for 32 teams).
The existing "Reset all" button only cleared cs2_major_stage_results, so
re-entering couldn't remove the phantom result or its cached QP total.
Add resetCs2Event, which also deletes the event's event_results and
recomputes the affected participants' cached QP totals — all inside a
single transaction so a mid-operation failure can't leave the event in a
torn state. It deliberately preserves the Champions Stage bracket so
re-entry realigns the stage data to it. The reset button now confirms
and states that results/QP are cleared too.
Tests: resetCs2Event clear+recompute behavior, and a simulator
regression pinning that one out-of-field QF participant disables the
whole bracket.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
## Summary
- 0-3 teams in Stage 3 were being assigned slots 9–10 (high QP) when marked eliminated before other Stage 3 teams, because `computeStage3ExitQP` filled from slot 9 upward using only the teams passed in
- 1-3 teams had the same problem when marked before 2-3 teams
- Fix: gate all Stage 3 QP computation on `stage3Exits.length === STAGE3_TOTAL_EXITS` (8) — partial saves write 0 QP as a placeholder, and correct QP/placements are assigned once all 8 exits are known
- As belt-and-suspenders, `computeStage3ExitQP` now places 0-wins teams from the bottom of the slot range so the function itself is correct even if called with a partial set
## Test plan
- [ ] Run `npm run test:run -- app/models/__tests__/cs2-major-stage.test.ts` — all 17 tests pass
- [ ] Mark 2 Stage 3 teams as 0-3 eliminated and save — confirm they show 0 QP (not 2 QP)
- [ ] Mark all 8 Stage 3 exits and save — confirm 0-3 teams get slots 15–16 QP, 1-3 teams get slots 12–14 QP split correctly
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #89
- Reposition admin mobile bar from top-0/z-10 to top-16/z-40 so it
sits below the main Navbar instead of behind it
- Make Sheet controlled so the drawer closes when a nav link is tapped
- Pass onNavigate callback to AdminNavLinks to close Sheet on navigation
https://claude.ai/code/session_01F7rJW6gpaXMiSF3wQYrmhC
- IndyCar points were showing as 0 because ESPN uses 'championshipPts' not 'points'
as the stat name; add it as primary key in the fallback chain
- Rename season_standings card title from sportSeasonName to "${name} Standings"
- Remove non-finalized subtext from SeasonStandings CardDescription
- Restore finalized-season badge (season complete / top-8 points locked) which
was dropped when removing the subtext; derive from sportsSeason.status at the
component level instead of the loader so the dead seasonIsFinalized field is
also removed from the loader return
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
## Summary
- Deletes 12 `it("calls onX with correct args")` test blocks from `MiniDraftGrid.test.tsx` and `DraftGridSection.test.tsx`
- Removes now-unused `import userEvent` from both files
## Why
`userEvent.setup().click()` hangs indefinitely on Radix UI `ContextMenu` items in jsdom — pointer-event and animation checks stall waiting for CSS transitions that never fire `transitionend` in the test environment. This caused a flaky 5 s timeout in CI.
The deleted tests were verifying that clicking a `ContextMenuItem` fires its `onClick` — React/Radix wiring, not app logic. The remaining presence/absence tests already cover the conditional rendering (which items appear under which conditions), which is where the actual app logic lives.
## Test plan
- [ ] `npm run test:run -- MiniDraftGrid DraftGridSection` — all remaining tests pass, no timeouts
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #81
## Summary
- 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
- 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>
SportIcon renders <img> elements whose onError handler calls setFailed(true),
scheduling state updates asynchronously outside React act() in jsdom. With
multiple icons on the Sports page this caused the synchronous render test to
hit the 5000ms Vitest timeout intermittently.
Mock SportIcon in the test using resolveSportIconUrl() so the mock stays in
sync with the real URL resolution logic if it ever changes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Filter the QP table to show only participants with QP > 0 or drafted by any team in the league (undrafted 0-QP participants hidden)
- Compute global ranks with tie handling across the full field before filtering so displayed rank numbers and the top-8 Points Line remain correct after rows are removed
- Fix flaky CI timeouts: world-cup simulator test (100 → 50 iterations), SportsSection userEvent test (explicit 15s timeout)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 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>
## Summary
- The countdown display used `data.expiresAt` (server-side Unix timestamp) subtracted from `client Date.now()`. When the server clock runs ~2-3s ahead of the client (observed in production), the display was inflated by that delta.
- Fixes three linked symptoms: timer starting at 2:02 instead of 2:00, chess-clock increment crediting 1:54 instead of 1:55, and the commissioner's adjust-time-bank dialog disagreeing with the visible countdown.
- Fix: replace all server `expiresAt` timestamps with a client-local expiry from the server-provided `timeRemaining` integer (`Date.now() + timeRemaining * 1000`). The skew then cancels algebraically when the server computes credit.
## Sites changed
- `handleTimerPickStarted` — fires after every pick (the main fix)
- `handleDraftStateSync` — reconnect path
- `useState` initializer in the draft room — initial page load seed from loader data (uses `loaderTimerNow` server timestamp to compute remaining server-side, then re-anchors to client clock)
## Test plan
- [ ] Start a chess-clock draft (2:00 bank, 15s increment) — timer should show exactly **2:00** when a team goes on the clock
- [ ] Pick at 1:40 — new bank should show **1:55** (100 + 15s)
- [ ] Commissioner adjust-time-bank dialog — displayed remaining should match the countdown
- [ ] Force autopick at any displayed time T — new bank should be T + increment
- [ ] Reconnect mid-pick — timer should resume at the correct remaining time
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #74
## Summary
- **Timer bank broadcasts**: emit `timer-bank-updated` after every pick so all clients immediately see the updated bank instead of waiting for the next `timer-pick-started`
- **Increment accuracy**: capture `pickMadeAt` at route entry (before auth/DB overhead) and use `Math.ceil` so credited seconds always match the client countdown display
- **Race condition fix**: hold `schedulingInProgress` lock for the full timer callback to prevent the recovery interval from scheduling a duplicate timeout mid-pick
- **force-autopick fix**: call `rescheduleTimer` so the next team's clock starts immediately instead of waiting for the old timeout to naturally expire
- **adjust-time-bank fix**: for on-clock teams, shift `picksExpiresAt` by the adjustment and reschedule so the client countdown updates; block adjustments that would reduce the bank to zero
- **New socket events**: `timer-pick-started`, `timer-overnight-paused`, `timer-bank-updated` with full type definitions; removed dead `timer-update` event
- **Reconnect sync**: `draft-state-sync` now includes `expiresAt` for the active timer and `isOvernightPause` state so reconnecting clients see accurate countdown and pause banner immediately without a page reload
- **Room closure countdown**: capture client-side timestamp when draft completes so the "Room closes in X" countdown actually ticks down before the loader revalidates with `draftCompletedAt`
- **Countdown interval**: run at 500ms with `Math.ceil` to prevent skipped seconds under event loop pressure
- **Overnight pause UX**: `canPick` only blocks on commissioner pause — overnight pause freezes the timer but the on-clock player can still pick early
- **Overnight pause refactor**: extract `checkOvernightPause` to `server/overnight-pause-check.ts`, breaking the `timer↔socket` circular import and sharing the timezone cache across both callers with correct eviction
- **PostgreSQL type fix**: cast `varchar` owner ID to `uuid` in `getTeamTimezone` join
## Test plan
- [ ] Manual pick: all clients see bank increment immediately after pick
- [ ] Timeout pick: all clients see bank update (0 → increment); next clock starts within ~1s
- [ ] Force-autopick: next team's clock starts immediately; no "Pick already made" log
- [ ] Force-manual-pick: all clients see bank increment
- [ ] Pause while clock running: countdown freezes on all clients
- [ ] Resume: clock continues from frozen value
- [ ] adjust-time-bank on on-clock team: countdown shifts immediately
- [ ] adjust-time-bank to zero: returns 400 error
- [ ] Reconnect (socket disconnect/connect): countdown resumes for correct team
- [ ] Hard refresh mid-draft: on-clock indicator and countdown correct immediately
- [ ] Draft complete: "Room closes in X" counts down
- [ ] Overnight pause: banner shows, pick buttons still enabled, timer frozen
- [ ] `npm run test:run` — all 158 files / 2351 tests pass
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #72
## Problem
Running a simulation on MLB (and any sport where the readiness check fails) showed a confusing React Router 400 error instead of the real failure reason. The cause: the simulate route had only an \`action\` export. When the action **threw** instead of redirecting, React Router tried to render the route via GET to display the error — hit the missing loader — and replaced the real error with its own internal 400.
A secondary issue: the interim fix used \`?simulationError=\` query params, which had three problems identified in code review:
- Stale errors re-appeared when pressing browser Back after fixing the issue
- The param reflected arbitrary text into the UI (social engineering surface)
- The styling didn't match the established \`bg-destructive/15\` error pattern
## Changes
**\`admin.sports-seasons.\$id.simulate.tsx\`** — strip the action; keep a loader-only redirect stub so bookmarked URLs degrade gracefully to the season page.
**\`admin.sports-seasons.\$id.tsx\`** — add \`intent="simulate"\` to the action dispatcher (matching \`delete\`, \`rescore\`, \`sync-standings\`, \`finalize-standings\`). On success: redirect to expected-values (unchanged). On failure: return \`{ simulateError: message }\` displayed inline with the correct \`bg-destructive/15\` styling.
## Test plan
- [ ] Click Run Simulation on a season with a missing readiness input → error message appears inline below the description text, styled consistently with other errors on the page
- [ ] Click Run Simulation on a fully-configured season → redirects to expected-values as before
- [ ] Navigate directly to \`/admin/sports-seasons/<id>/simulate\` in browser → redirects to season page (no 400)
- [ ] Run simulation, see error, fix the issue, run again successfully → no stale error message persists
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #64
## Summary
- **Per-league queue**: replaced the single global notification queue with a \`Map<leagueId, queue>\` so concurrent drafts in different leagues drain independently and don't block each other
- **Deadlock fix**: wrapped the drainer loop in \`try/finally\` so \`drainingLeagues\` is always cleaned up even if a task throws unexpectedly — previously a crash would leave the flag set permanently, silently dropping all future notifications for that league
- **Wait cap fix**: moved \`Math.min\` to wrap \`(retryAfter * (attempt+1))\` so the 10s ceiling applies to the final product, not just the base value — settings actions that directly await webhook calls were previously blockable for up to 30s on a rate-limited third attempt
## Root cause of the original problem
During an autodraft chain, \`notifyPickMadeOnDiscord\` was called fire-and-forget for each pick in the chain, causing all webhook requests to fly concurrently. Discord's per-webhook rate limit (~5 req/2s) rejected most of them; the single retry logic fired for all simultaneously, hit the limit again, and the \`.catch()\` swallowed the errors silently.
## Test plan
- [ ] Enable autodraft for 5+ consecutive teams; trigger the chain; confirm all picks announce in Discord in order
- [ ] Confirm a slow/rate-limited webhook in League A does not delay League B's announcements
- [ ] \`npm run test:run\` — 158 test files, 2336 tests, all pass
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #57
## Summary
- **Group stage match list**: Removed MD 1/2/3 headings; matches now listed chronologically under date separators (e.g. "Jun 14"). Kick-off time appears below the team names instead of between them.
- **Sort order**: \`findMatchesByGroupIds\` / \`findMatchesByEventId\` now order by \`scheduledAt ASC NULLS LAST\` so unscheduled matches always trail scheduled ones.
- **Groups/Bracket toggle**: When both group stage and knockout bracket exist, a toggle appears (mirroring the NBA/AFL standings toggle). Sports with both regular-season standings and a group stage get all views available.
- **Upcoming events**: \`getUpcomingEventsForDraftedParticipants\` now also queries \`groupStageMatches\`, so World Cup group fixtures appear on the home page calendar sorted by kick-off time with a label like "Group A — France vs Germany".
- **\`isAllCompete\` fix**: \`UpcomingEventsCard\` and \`UpcomingCalendarPanel\` now treat \`group_stage_match\` as a bracket-style event, showing team badges instead of "N of your picks".
Fixes#53
## Test plan
- [ ] Navigate to a World Cup sports season page — groups should be listed chronologically under date headers with no MD labels; kick-off time should appear below each match row
- [ ] Once the knockout bracket has matches, confirm the Groups/Bracket toggle appears and both views render correctly
- [ ] On the home page, a user with drafted World Cup participants should see upcoming group fixtures in the calendar panel with individual team badges (not a participant count)
- [ ] NBA/AFL/NHL pages unaffected — their standings/playoffs/finished toggle still works normally
- [ ] \`npm run typecheck\` and \`npm run test:run\` pass
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #55
## Summary
- Swap side-effect order so `updateProbabilitiesAfterResult` runs before `recalculateAffectedLeagues` in `processMatchResult`, `processPlayoffEvent`, and the `set-round-winners` route — projected points now reflect the new result immediately instead of waiting on the next simulation.
- Eliminate the N+1 EV lookup in `calculateTeamProjectedScore` by pre-fetching every EV the season needs in a single `inArray` query inside `recalculateStandings` and passing the map down.
- Add `skipProbabilities` option to `processPlayoffEvent`, used from `autoCompleteRoundIfDone` (callers have already refreshed EVs), avoiding a redundant pass on round-completing saves.
Fixes#31
## Test plan
- [x] `npm run typecheck`
- [x] `npx vitest run app/models/__tests__/team-projected-score.test.ts app/models/__tests__/process-match-result.test.ts app/services/__tests__/probability-updater.test.ts` — 57/57 passing
- [ ] Manual: save a round of bracket winners on a sports season with at least one fantasy league and confirm (a) the request returns faster and (b) the team standings show projected points updated to reflect the result without re-running the simulator
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #54
- Move notifyPickMadeOnDiscord() before checkAndTriggerNextAutodraft() in
both executeAutoPick() and the manual pick action. Previously the
triggering pick's announcement fired after all chained autodraft picks
had already announced, causing Discord to show e.g. pick #106 before
#105 before #104. Now each pick announces itself before the next pick
in the chain is triggered.
- Add a single retry with Retry-After delay in sendDiscordWebhook() on
HTTP 429. A burst of consecutive autodraft picks could hit Discord's
per-webhook rate limit and silently drop announcements. Use || 1
(not ?? 1) to handle non-numeric/NaN headers, and cap at 10 s so
that awaited callers such as the draft-order settings actions are
never hung for a full global rate-limit window.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When next_queue autodraft fires and turns itself off, two bugs prevented
the UI from behaving correctly:
1. After reconnect, useDraftAuthRecovery synced autodraftStatus (draft
grid) but not userAutodraft (settings panel), leaving the settings
badge stale until the next page load.
2. Toast notifications for autodraft auto-disable were unreliable: the
condition checked prev.mode rather than why the server disabled it,
causing the wrong toast for manual disables and queue-empty events.
Fix the reconnect sync by calling setUserAutodraft in the revalidation
effect alongside setAutodraftStatus. Fix the toast logic by adding a
reason field ("pick_complete" | "queue_empty") to the server's
autodraft-updated emit, so the client can show the right message without
guessing from local state. Also removes the now-unused userAutodraftRef.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add findTeamByNameInSeason (exact case-insensitive match via lower()) to the team model
- Validate uniqueness in the user-facing team settings rename action
- Validate uniqueness in admin assign-owner and remove-owner paths that auto-generate names
- Reject whitespace-only team names that would trim to an empty string
- Add DB-level unique index on (season_id, lower(name)) to close the TOCTOU race
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Use x-forwarded-proto header (allowlisted to http/https only) when
building the invite link origin so it shows https behind a TLS proxy
- Show the "View Draft Board" bare card in the right column while a
draft is running, matching the card already shown for active/completed
seasons
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When a manual pick triggered an immediate autodraft chain, both Discord
announcements re-read currentPickNumber from the DB, which had already
advanced past the chained pick. Both messages showed the same "On the
clock" person, pinging them twice.
Fix: pass nextPickNumber (the pick immediately following each specific
pick, before any chain) as an explicit param to notifyPickMadeOnDiscord
instead of re-reading from the DB. Also removes a now-unnecessary DB
round-trip on every pick announcement.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
## Summary
- **Discord snake draft fix**: pick notifications now show the sequential pick-in-round (e.g. "Round 2, Pick 9") rather than the snake-adjusted slot position ("Round 2, Pick 5"). Also removes the now-unused \`pickInRound\` param from \`notifyPickMadeOnDiscord\` and adds a regression test for the 13-team case.
- **League card**: league name in draft-in-progress cards is now a link to the league homepage (the Enter Draft button still goes to the draft room).
- **Overnight pause label**: shortened to "🌙 Pause" — the "Resumes 4:00 AM" line below already provides context, so "Overnight" was just causing wrapping.
- **Queue & Picks backgrounds**: items use \`bg-card\` instead of \`bg-muted\` for better visual separation from the panel background.
## Test plan
- [x] Discord: in a snake draft, verify pick #22 of 13 shows "Round 2, Pick 9" in Discord
- [x] League card: dashboard with a draft-in-progress league — click name → league homepage, click Enter Draft → draft room
- [x] Overnight pause: pause a draft overnight — cell shows "🌙 Pause" on one line, "Resumes X:XX" below
- [x] Queue/Picks: open draft room and confirm queue items and recent picks visually pop from the sidebar background
- [x] Unit tests: `npm run test:run` passes (13 discord tests)
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #2
* Add Discord draft pick announcements
Posts a message to the league Discord webhook each time a pick is made, announcing the picked participant and pinging the next team on the clock if their owner has opted into Discord notifications. Enabled via a separate toggle in league settings (independent from standings update notifications).
https://claude.ai/code/session_01Tvwsv3LfL9JUqxoLct8dTn
* Address code review feedback on Discord pick announcements
- Fix "on the clock" timing: read currentPickNumber fresh from DB post-autodraft-chain (matches sendOnTheClockEmail guarantee)
- Remove outer try/catch from notifyPickMadeOnDiscord so callers' .catch() is not dead code
- Add missing draft-discord.server.test.ts with 12 tests covering all early-exit and happy paths
- Fix silent empty-string fallback for missing pickedSlot: warn and skip instead
- Eliminate sequential season→league DB queries by accepting leagueId as a direct param
- Show "save webhook URL to configure options" hint when URL is typed but not yet saved
- Remove block-scope braces at both call sites (plain const declarations)
- Remove redundant "Round N, Pick M" description line (title already carries this info)
- Inline pickInRoundFor helper to avoid circular import with draft-utils
https://claude.ai/code/session_01Tvwsv3LfL9JUqxoLct8dTn
* Fix lint errors from review fixes
- Remove unused logger import (no longer needed after removing try/catch)
- Remove unused OWNER_ID constant in test fixture
- Use toSorted() instead of sort() in sendDraftOrderNotification
https://claude.ai/code/session_01Tvwsv3LfL9JUqxoLct8dTn
---------
Co-authored-by: Claude <noreply@anthropic.com>
* Add on-the-clock email notifications and fix push notification modal
- Add `draft_email_notifications_enabled` column to users table
- New `sendOnTheClockEmail` service sends an email when a user's turn arrives
in a draft; detects back-to-back picks and sends one combined email instead
of two; skips notification if the team has autodraft enabled
- Email is triggered after every manual pick (make-pick route) and every
timer-expiry pick (executeAutoPick) once the full autodraft chain settles,
so the notified user is always the first human on the clock
- Add email notification toggle to the user settings Notifications section
- Fix push notification dropdown in the draft room: the bare absolute div is
replaced with a Radix Popover so clicking outside dismisses it without
toggling notifications off
- Rename draft room label from "Pick Notifications" to "Push Notifications"
to distinguish from the new email notifications
https://claude.ai/code/session_01LMGxgYvtE3CF8Jf3u2pXgA
* Address code review feedback on email notification feature
- sendOnTheClockEmail now fetches season (and currentPickNumber) internally,
removing the blocking postChainSeason pre-fetch from both make-pick.ts and
executeAutoPick; callers are now true fire-and-forget with no IIFE needed
- Parallel Promise.all for team, autodraftSettings, and league queries reduces
sequential DB round-trips from 5 to 3
- Remove team.ownerId type cast; assign to local after the null guard
- Combine the two calculatePickInfo calls for the same pick into one
- Fix popoverOpen/enabled divergence: remove the {enabled && ...} guard on
PopoverContent so popoverOpen is the sole visibility control
- Unify NotificationsSection feedback pattern: both Discord and email sections
now read success/error directly from their respective fetcher data via a
shared feedbackFromFetcher helper; removes the success/error props and the
verbose cast that was used for email
- Add 13 unit tests for sendOnTheClockEmail covering: early-exit paths
(no season, past totalPicks, no owner, notifications disabled, autodraft
enabled, no league), single vs back-to-back subject selection, draft room
link presence, error logging without throwing, and parallel query execution
https://claude.ai/code/session_01LMGxgYvtE3CF8Jf3u2pXgA
---------
Co-authored-by: Claude <noreply@anthropic.com>
* Add Discord account linking from settings page
Adds a "Connect Discord" button in the Account settings section that
triggers BetterAuth's linkSocial flow to attach a Discord account to
an existing user without requiring a sign-out/sign-in. After the OAuth
callback, the ?section= URL param returns the user to the Account tab.
The Notifications toggle was already gated on Discord being linked.
https://claude.ai/code/session_01UerTpTuBWjreTNEns8srkp
* Fix Discord linking error handling and section routing issues
- Reset linkingDiscord state and show error message when linkSocial throws,
so users can retry if the OAuth flow fails
- Move VALID_SECTION_IDS to module level and derive it from SECTIONS to
avoid per-render allocation and prevent the two from drifting out of sync
- Remove premature SectionId cast before the allowlist validation in
the ?section= URL param handling
- Add tests for loading state and error recovery in AccountSection
Fixes#455https://claude.ai/code/session_01UerTpTuBWjreTNEns8srkp
---------
Co-authored-by: Claude <noreply@anthropic.com>