Commit graph

446 commits

Author SHA1 Message Date
Chris Parsons
ebe06b2522
Improve drag handle UX in queue items with activator node (#188)
* Fix queue drag to only activate on handle and number, not entire row

Restricts drag listeners to the grab handle icon and order number badge
using setActivatorNodeRef, and removes touch-none from the whole row so
mobile users can scroll without accidentally reordering the queue.

https://claude.ai/code/session_01HPtNkL5m9xhYgtzWaGViSC

* Code review cleanup: use GripVertical icon and add drag activation constraint

- Replace inline SVG drag handle with GripVertical from lucide-react,
  which is already used project-wide
- Add activationConstraint (distance: 8px) to PointerSensor so a touch
  on the handle doesn't immediately hijack scroll before the user has
  moved far enough to signal intent to drag

https://claude.ai/code/session_01HPtNkL5m9xhYgtzWaGViSC

* Fix O(n²) lookup, memoize components and callbacks in QueueSection

- Build a participantMap (Map<id, participant>) with useMemo so each
  queue item lookup is O(1) instead of O(n) per render
- Memoize queueIds array for SortableContext to avoid churn
- Wrap handleDragEnd in useCallback so DndContext gets a stable reference
- Wrap SortableQueueItem in memo so it skips re-renders when props haven't
  changed (important since the parent re-renders every second during a
  live draft from timer-update socket events)
- Pass onRemoveFromQueue and onMakePick directly as props instead of
  creating new arrow functions per item per render; SortableQueueItem
  now calls them with the relevant id itself

https://claude.ai/code/session_01HPtNkL5m9xhYgtzWaGViSC

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-20 16:19:44 -07:00
Chris Parsons
3f3401f0d8
Recalculate league standings after EV simulation updates (#186)
* Force standings recalculation after EV simulation

After persisting updated EVs to participantExpectedValues, call
recalculateAffectedLeagues so that teamStandings.projectedPoints
(and all related standing fields) immediately reflect the new EV
rather than remaining stale until the next scoring event.

https://claude.ai/code/session_01S9Pk4wTSphANMhPWaURPTn

* Use recalculateStandings directly after EV simulation

Replaces recalculateAffectedLeagues (which carries Discord notification
and daily snapshot side effects) with a direct call to recalculateStandings
per affected fantasy season. This updates the cached projectedPoints in
teamStandings so the standings page stays in sync with the team page after
a simulation, without triggering unrelated side effects.

https://claude.ai/code/session_01S9Pk4wTSphANMhPWaURPTn

* Polish simulate route after code review

- Remove redundant db variable; use database() inline consistent with
  all other model calls in this file
- Parallelize standings recalculation with Promise.all
- Update file header comment to include the standings refresh step

https://claude.ai/code/session_01S9Pk4wTSphANMhPWaURPTn

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-20 14:39:05 -07:00
Chris Parsons
063834d8e6
Display team owner names in standings views (#184)
* Show team name + username in standings, extract TeamNameDisplay component

- Add TeamNameDisplay component that renders team name (as link) with owner username below, matching the league homepage style
- Update StandingsTable to use TeamNameDisplay with owner username shown below team name
- Update league homepage standings section to use TeamNameDisplay
- Add ownerName/teamOwnerId fields to TeamStanding type
- Extend getSeasonStandings to include teamOwnerId from team relation
- Fetch and attach owner display names in the full standings page loader

https://claude.ai/code/session_01EYgGnuTBaRVdBDapJRTxDZ

* Address code review: type hygiene, explicit types, parallel fetching, style fix

- Remove teamOwnerId from TeamStanding type (was an internal server concern, not display data)
- Remove teamOwnerId from getSeasonStandings return value
- Add TeamStandingWithChange interface extending TeamStanding with sevenDayRankChange/sevenDayOldRank fields
- Annotate getSevenDayStandingsChange with explicit Promise<TeamStandingWithChange[]> return type
- Re-export TeamStandingWithChange from models/standings.ts
- Standings loader: query teams table directly for ownerIds instead of relying on teamOwnerId in standings data
- Standings loader: parallelize all independent queries (standings, teams, progressionData, seasonComplete, completionPercentage) with Promise.all
- Restore font-medium on StandingsTable team TableCell

https://claude.ai/code/session_01EYgGnuTBaRVdBDapJRTxDZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-19 20:19:28 -07:00
Chris Parsons
fa0e798db7
Add "Games to Score" widget to admin dashboard (#183)
* Add "Games to Score" widget to admin dashboard

Shows scoring events for today and tomorrow in a tabbed card, with
status badges (Pending/Scored), a scored/total progress counter, and
direct "Score" / "View" links to each event's results page. Excludes
non-scoring schedule_event types so only actionable events surface.

- app/models/scoring-event.ts: add getEventsForDates() model function
  that queries scoringEvents joined with sportsSeasons/sport for a
  given list of date strings
- app/routes/admin._index.tsx: call getEventsForDates([today, tomorrow])
  in the loader and render the GamesToScore component below the stats row
- app/models/__tests__/scoring-event-dashboard.test.ts: 8 unit tests
  covering empty input, shape mapping, multi-date filtering, completed
  events, and the inArray where conditions

https://claude.ai/code/session_014zKK15o5RJfTaodLvnZpF5

* Fix getEventsForDates to include bracket events by game scheduledAt

The previous implementation only matched on scoringEvents.eventDate,
which is often null for bracket events where individual game times are
set on playoffMatchGames.scheduledAt instead.

Switched to a two-step query mirroring getUpcomingEventsForDraftedParticipants:
1. selectDistinct event IDs via left joins through playoffMatches →
   playoffMatchGames, matching where eventDate IN dates OR any game's
   scheduledAt falls within the date range's timestamp bounds
2. findMany on the matched IDs with sportsSeason + sport relations

Updated tests to cover the two-step flow, leftJoin usage, and the
scheduledAt timestamp bounds appearing in the where clause.

https://claude.ai/code/session_014zKK15o5RJfTaodLvnZpF5

* Document dual game date storage in CLAUDE.md

scoringEvents.eventDate and playoffMatchGames.scheduledAt both hold game
dates depending on the event type. Added a dedicated section under
Important Patterns explaining when each is used and the two-step
selectDistinct + findMany pattern required for correct date queries.

https://claude.ai/code/session_014zKK15o5RJfTaodLvnZpF5

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-19 19:25:37 -07:00
Chris Parsons
d271cc6792
Refactor standings notifications to show only changed teams (#182)
* Refine Discord webhook scoring notifications (fixes #180, #181)

- Filter scored matches to only show matchups where a manager scores
  Brackt points (winner drafted) or has a team eliminated (loser drafted);
  matches with no manager involvement are suppressed entirely.
- Escape Discord markdown characters (_*~`|\) in team names and usernames
  to prevent formatting issues (e.g. double-underscore names causing
  unintended underlines).
- Replace full standings with a "Standings Changes" section that shows
  only teams whose points changed, plus any teams whose rank shifted as a
  result, with ↑N / ↓N indicators for rank movement.
- Pass previousRanks map from scoring-calculator to the notification so
  rank-displaced teams (who didn't score points themselves) are included.
- Update test webhook in league settings to demonstrate rank changes and
  a sample scored match.
- Update all discord service tests to cover the new filtering, escaping,
  and standings-change behaviour.

https://claude.ai/code/session_01FciwfdG9Sfr5ZrXUHPeB18

* fix: only set winnerUsername when winning team's score actually changed

Previously, winnerUsername was set for any drafted winner regardless of
whether points were actually scored. This caused R64 wins (where the
scoring system may not award points until later rounds) to appear in
Discord's Scored Matches section even when no Brackt points were earned.

Now winnerUsername is only set when the winner's team's totalPoints
changed after recalculation. loserUsername (eliminations) is always set
when the loser is drafted, since being knocked out is always notable.

https://claude.ai/code/session_01FciwfdG9Sfr5ZrXUHPeB18

* Refactor Discord notification logic in scoring calculator

- Compute changedTeamIds once up front; derive hasChanges from it
  instead of duplicating the same iteration
- Merge usernameForParticipant and winnerUsernameForParticipant into a
  single function with a requireScoreChange flag
- Replace hasDraftedParticipantMatches with hasScoredMatchesToShow,
  which checks that at least one scoredMatch has a displayable username
  — prevents sending a contentless Discord embed when a drafted winner
  doesn't score any points and the loser isn't drafted
- Update inline comment to be sport-agnostic

https://claude.ai/code/session_01FciwfdG9Sfr5ZrXUHPeB18

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-19 15:52:57 -07:00
Chris Parsons
52e7c98805 Fix migration 2026-03-18 22:54:01 -07:00
Chris Parsons
51a0a3ebba
Add isDraftable toggle to sport seasons (#165) (#178)
Admins can now enable/disable a sport season from appearing in league
creation and pre-draft league settings. Non-draftable seasons are hidden
from selection but remain visible (and checked) if already linked to an
existing pre-draft league. Leagues in draft status or beyond are unaffected.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 22:40:19 -07:00
Chris Parsons
a054247a55
Fix standings snapshot upsert: atomic writes, correct date, and event-driven updates (#177)
- Add unique index on (team_id, season_id, snapshot_date) in team_standings_snapshots
  so concurrent writes can't produce duplicate rows (migration 0051)
- Rewrite createDailySnapshot to use INSERT ... ON CONFLICT DO UPDATE inside a
  transaction, replacing the SELECT-then-insert/skip pattern (N+1 queries, race-prone)
- Remove early-exit guard in server/snapshots.ts background job so it always
  upserts rather than skipping seasons that already have a snapshot for today
- Call createDailySnapshot in recalculateAffectedLeagues after recalculateStandings,
  so every bracket score update refreshes today's snapshot; wrapped in try/catch so
  a snapshot failure can't block Discord notifications
- Also call createDailySnapshot from the admin rescore and finalize-bracket actions
- Fix UTC date bug: replace toISOString().split('T')[0] with local date parts in
  both standings.ts and server/snapshots.ts (and the 7-day lookback query)
- Convert all dynamic await import() calls in bracket.server.ts to static imports

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 22:15:28 -07:00
Chris Parsons
09b9b9bdad
Optimize user data fetching with batch queries and centralize display name logic (#176)
* Fall back to displayName when username is null for Discord webhook

Users who sign up via OAuth (Google, GitHub, etc.) without setting a
Clerk username have a null `username` field but always have a `displayName`
(computed from firstName+lastName or email). Previously, `usernameByClerkId`
was filtered to only include users with a non-null username, causing those
owners to appear without any identifier in Discord standings messages
(e.g. "Liverpool def. Galatasaray" instead of "Liverpool def. Galatasaray (Madmike)").

https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH

* Extract getUserDisplayName helper and use consistently throughout

Add a single getUserDisplayName(user) function to app/models/user.ts that
encapsulates the username → displayName fallback logic. Replace 9 scattered
inline expressions across the codebase (owner-map, scoring-calculator,
league routes, settings, invite flow, draft API, Clerk webhook) with calls
to the shared helper.

No behaviour change — all existing logic preserved, just centralised.

https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH

* Fix N+1 user queries in league loader and settings loader

Add findUsersByClerkIds() batch function to the user model and replace two
separate Promise.all+findUserByClerkId loops (one for owners, one for
commissioners) with a single inArray query in both $leagueId.server.ts and
$leagueId.settings.tsx. The merged query covers both owner and commissioner
IDs in one round-trip.

https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH

* Fix N+1 user queries in buildOwnerMap

Replace the Promise.all+findUserByClerkId loop with a single
findUsersByClerkIds batch query, consistent with the league loader
and settings loader fixes.

https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-18 16:46:07 -07:00
Chris Parsons
143698cb96
Fix NCAAM/NCAAW simulators: FF completion handling and validation hardening (#169)
Fixes the "slot is already filled" simulation error when First Four games
have already been played and their winners advanced to the Round of 64.

Changes:
- Fix FF→R64 validation to allow a filled R64 slot when the corresponding
  First Four match is already complete (the original bug)
- Fix isComplete guard for E8/FF/Championship: derive loser from stored
  participant IDs when loserId is null rather than re-simulating a real game
- Add round count validation for all rounds (R32 16, S16 8, E8 4, FF 2, Champ 1)
  to catch malformed brackets before the hot sim loop instead of producing
  silent garbage output
- Add participant1Id null check in the First Four path (mirrors the no-FF path)
- Add console.warn when participant IDs are missing from DB (fall-back to
  average strength instead of crashing)
- Import NCAA_68.regions from bracket-templates.ts instead of duplicating the
  hardcoded fallback in each simulator
- Fix duplicate step-9 comment number in ncaam-simulator.ts

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 01:37:55 -07:00
Chris Parsons
6f5ea35952
Add NHL playoff simulator and fix simulator dropdown (#166)
Closes #115

- Add `NHLSimulator` using NHL's divisional bracket format (top 3 per
  division + 2 wildcards per conference, best-of-7 series, parity
  factor 800). Elo ratings and seeding probabilities sourced from
  elo.harvitronix.com/nhl/2025-2026 as of March 18, 2026.
- Add `nhl_bracket` to `simulatorTypeEnum` with migration.
- Register `NHLSimulator` in the simulator registry.
- Fix simulator type dropdown in admin to auto-generate from the
  registry instead of a hardcoded list, so new simulators appear
  automatically without a separate UI change.

Code review fixes applied to the simulator:
- Narrow `weightedPick` key type to the five seeding probability fields
  (was `keyof NhlTeamData`, allowing `"elo"` which would silently
  corrupt results)
- Remove uniform-random fallback in `weightedPick` — zero-weight draws
  now return `undefined` and are skipped, preventing eliminated teams
  from becoming wildcards
- Exclude unrecognized participants entirely (with a console warning)
  instead of silently routing them into the Eastern wildcard pool
- Replace the confusing for-of loop over both conferences (with unused
  `champ` variable and `!`-suppressed unitialized vars) with two
  explicit sequential East/West blocks
- Track `effectiveN` for skipped degenerate draws so probability
  denominators stay accurate; throw if all iterations are skipped

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 01:23:53 -07:00
Chris Parsons
9d1765d8a4
Auto-name teams on claim and harden owner-assignment logic (#163)
* Auto-name teams on claim and harden owner-assignment logic

- Add claimTeam() model function that atomically sets ownerId + name in
  a single DB write, replacing the previous two-step assignTeamOwner +
  renameTeam pattern that could leave partial state on failure
- Rename team to "Team <username>" (fallback: displayName, then "Member")
  when a user joins via invite link or an admin assigns a team owner
- Fetch the user record before claiming the team in both paths so we fail
  fast with a clear error rather than silently skipping the rename
- Guard against assigning an already-owned team in the admin settings
  action (closes #107, closes #76)
- Show owner username below team name in draft order settings UI;
  teams with an owner but no matching user record display "Unknown"

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

* Fixing dropdown for admins

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 00:53:40 -07:00
Chris Parsons
41384f08fb
Grant sitewide admins commissioner-level access in leagues (#162)
* Grant sitewide admins commissioner-level access in leagues

- `isCommissioner()` now returns true for site admins, covering all
  commissioner-gated loaders (league home, settings, sport season detail)
  and draft API routes (start, pause, resume, rollback, replace-pick,
  force-autopick, force-manual-pick, adjust-time-bank, make-pick)
- Added `hasCommissionerRecord()` (DB-only, no admin bypass) for the
  "already a commissioner" duplicate-entry check in the settings action,
  preventing a false positive when adding a site admin as commissioner
- `isCommissioner()` now runs the admin check and DB query in parallel
  via Promise.all to avoid a serial roundtrip on every check
- Added "admin" to the `picked_by_type` enum (migration 0049) so picks
  forced by a site admin are recorded accurately in the audit log rather
  than as "commissioner"
- 8 unit tests covering both isCommissioner and hasCommissionerRecord

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

* Fix draft.force-manual-pick tests broken by isUserAdminByClerkId

The route now calls isUserAdminByClerkId which hits database().query.users,
but the test's mock DB had no query.users entry. Add a vi.mock for
~/models/user and default isUserAdminByClerkId to false in beforeEach.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 00:41:56 -07:00
Chris Parsons
dfaf1f0589
Fix participantsRemaining not counting non-scoring-round eliminations (#161)
Participants eliminated in a non-scoring bracket round (e.g. Round of 16
in a 16-team bracket) receive finalPosition=0 and isPartialScore=false.
The previous calculateTeamScore loop only entered participantsCompleted++
when finalPosition > 0, so these participants were silently treated as
still "remaining" in the standings table — causing e.g. a team with one
eliminated participant to show "25 remaining" instead of "24 remaining".

Fix: add an else-if branch that increments participantsCompleted for any
finalized result (isPartialScore=false) regardless of finalPosition value.
The condition is intentionally broader than `=== 0` to also cover the
theoretical case of finalPosition=null with isPartialScore=false.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 15:02:19 -07:00
Chris Parsons
7236c8aefb
Add Discord usernames, fix calculateTeamScore partial-score bug, and add admin re-score action (#160)
**Discord webhook improvements**
- Show team owner username in parentheses in standings (e.g. "1. Alpha FC (christhrowsrocks) — 150 pts")
- Show username in parentheses in match results for drafted participants
  (e.g. "Sporting (christhrowsrocks) def. Bodø/Glimt (apatel)"), omitting
  the parenthesis for the undrafted side
- Username lookup only runs after the webhook URL guard to avoid wasted DB queries
- 4 new tests covering all username display combinations

**Fix calculateTeamScore partial-score counting bug**
- isPartialScore participants were incorrectly counted in participantsCompleted
  and placementCounts, causing standings to show wrong remaining count and
  phantom placement badges (e.g. "5th×1") for still-alive bracket participants
- Floor points still flow into totalPoints (guaranteed value for ranking)
- 3 new unit tests covering partial vs finalized behavior in calculateTeamScore

**Admin Force Re-score action**
- New "Fantasy Standings" card on every sports season admin page with a
  Force Re-score button that triggers recalculateStandings on all linked
  fantasy seasons — useful after data corrections without waiting for a
  new scoring event
- Auth guard added to the action (was missing — layout loader only protects GET)
- Returns a meaningful message when no linked seasons exist
- Intent discriminant on action returns so success banners use actionData.intent
  instead of fragile message.includes() checks

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 14:34:09 -07:00
Chris Parsons
af99f6d002
Fix bracket point averaging and partial-score display in standings/breakdown (#159)
* Fix bracket point averaging and partial-score display in standings/breakdown

Bracket sports (UCL, NBA, NFL, etc.) use averaged points for tied
positions (e.g. QF losers all share avg of 5th-8th = 20 pts), but
both calculateTeamProjectedScore and getTeamScoreBreakdown were using
raw pointsFor5th (25) instead of the bracket-averaged value. This caused
the team breakdown page to show incorrect actual points (25 vs 20) and
the standings page to show incorrect actualPoints/projectedPoints totals.

Additionally, participants with isPartialScore=true (still alive in a
bracket with a provisional floor position) were being displayed with a
final placement badge (e.g. "5th") instead of "Pending".

Changes:
- calculateTeamProjectedScore: use calculateBracketPoints for bracket
  sports; handle isPartialScore participants separately (floor in
  actualPoints, incremental EV in projectedPoints, Math.max guard for
  EV < floor edge case)
- getTeamScoreBreakdown: same bracket averaging fix; pass isPartialScore
  through to picks; use explicit finalPosition != null && > 0 check
- TeamScoreBreakdown component: show Pending badge for isPartialScore
  participants; display actual/floor + EV row for all incomplete picks;
  add "actual / projected" column header subtitle
- 9 new unit tests covering all scoring branches including the EV-below-
  floor clamp edge case

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

* Fix typecheck: cast DUMMY_EV_ROW to any in test fixture

ParticipantEV has additional required fields (id, participantId, etc.)
that aren't needed for the mock — cast to any to satisfy the type checker.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 14:00:32 -07:00
Chris Parsons
beab6400b2
Fix partial bracket scoring, rank labels, and Discord notification bugs (#158)
- NCAAM R64/R32 winners no longer receive T5 floor points; only Sweet
  Sixteen winners entering the first scoring round get a floor
- AFL T5-T6 and T7-T8 score as separate tiers (avg([5,6]) and avg([7,8]))
  instead of one shared avg([5-8]) pool
- Eliminated teams now always show their correct final rank (e.g. T33 for
  NCAAM R64 losers) even during partial round scoring, by deriving the
  rank label from the total match count per round rather than the dynamic
  still-alive count
- Discord notifications now fire when a drafted participant is eliminated
  with 0 points (e.g. R64 losers whose team score doesn't change)
- Discord notifications no longer include all prior matches — scoped to
  the current batch via matchIds
- Discord section headers have spacing; rank medals replaced with numbers
- Rounds auto-complete when all matches are marked done; manual Complete
  Round dropdown removed
- Fixed double Discord notification when the last match in a round
  triggers auto-complete: processPlayoffEvent now accepts skipRecalculate
  so the caller controls when the notification fires
- Fixed falsy check on finalPosition=0 in calculateTeamScore
- Undrafted 0-point participants filtered from Eliminated Teams display

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 12:34:10 -07:00
Chris Parsons
79f7a41837
Add Discord webhook notifications for standings updates (#157)
* Add Discord webhook notifications for standings updates

Adds a Discord webhook integration that posts standings to a configured
Discord channel whenever scores change after a scoring event. Commissioners
set the webhook URL in league settings and can send a test notification.

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

* Fix Discord notification edge cases from code review

- Fix point delta sign: negative diffs now correctly show -5 pts not +-5 pts
- Replace hardcoded soccer emoji with a neutral bullet (works for all sports)
- Truncate embed description at Discord's 4096-char limit
- Thread eventId through processMatchResult and bracket batch handler so
  scored matches appear in single-match notifications too
- Pass eventName to finalizeQualifyingPoints ("Final Standings") and
  processSeasonStandings ("Season Complete") so those notifications have context
- Test notification now fetches current season to show "League 2025" format

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

* Fix process-match-result tests: add sportsSeasons to db mock

recalculateAffectedLeagues now queries sportsSeasons to get the sport
name for Discord notifications; the test mock db needed the stub added.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 11:16:36 -07:00
Chris Parsons
dbffddc9ff
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## Partial bracket scoring
- `processMatchResult`: new exported function that scores a single match
  immediately (loser → final placement, winner → provisional floor).
  Called from `set-winner` and `set-round-winners` so points are awarded
  as soon as a winner is set, before the full round is complete.
- `set-winner`: passes `eventName` to `processMatchResult`.
- `set-round-winners`: batches side effects — calls `recalculateAffectedLeagues`
  and `updateProbabilitiesAfterResult` once after the loop instead of per-match
  (`skipSideEffects: true` per match).

## Code review fixes
- **ROUND_CONFIG** (S1): extracted a `RoundScoringConfig` lookup table +
  `getRoundConfig()` helper, eliminating three parallel `if/else` chains in
  `processPlayoffEvent`, `processMatchResult`, and `getGuaranteedMinimumPosition`.
  AFL template overrides live in `TEMPLATE_ROUND_CONFIG` with an explanatory
  comment about why the `bracketTemplateId` guard is required.
- **skipSideEffects** (C2): new param on `processMatchResult`; bracket server
  uses it to batch standings/probability recalc in `set-round-winners`.
- **eventName** (W1): passed through `processMatchResult` → `recalculateAffectedLeagues`.
- **Round validation** (W2): `set-round-winners` now guards `match.round === round`
  before processing each assignment.
- **Comments** (C3/S3/W3): added notes on non-scoring loser assumption,
  `isScoring ?? true` default, and AFL Semi-Finals template requirement.

## PlayoffBracket eliminated-teams fix + tests
- Fixed `computeEliminatedByRound` to track participant *appearances* (not just
  wins), so AFL QF losers who advance to Semi-Finals via double-chance are
  correctly excluded from the QF eliminated list.
- Extracted the logic as an exported pure function for testability.
- Added 4 tests: standard elimination, AFL QF loser → SF win, AFL QF loser →
  SF loss, and normal advancement not protecting a later loser.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 10:50:30 -07:00
Chris Parsons
33f9ad6ebe
Simplify team breakdown page layout and fix code quality issues (#155)
- Replace per-sport cards with a single flat table; add Sport column (linked to sport season) before Participant
- Replace three summary stat cards with a compact "X remaining" line in the header
- Format pick numbers as round.pick notation (e.g. 2.02 for pick 16 in a 14-team league)
- Add getNumTeamsInSeason model function to compute pick-within-round correctly
- Remove dead bySport/totalPoints from standings model and component interface
- Narrow standing prop type to only currentRank (placementCounts was unused)
- Guard against numTeams === 0 edge case in pick notation
- Fix 0.0 → 0.00 decimal inconsistency
- Fix unsafe finalPosition! non-null assertion (null treated as Did Not Score)
- Update tests to match current UI; add coverage for new edge cases

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 09:20:41 -07:00
Chris Parsons
5a48327102 Fix NBA simulator missing from dropdown and enum not applied to prod DB
- Add nba_bracket SelectItem to simulator type dropdown in admin sport edit page
- Add 0046 to _journal.json (it was missing, causing Drizzle to never run the ALTER TYPE on prod)
- Add remediation migration 0047 with IF NOT EXISTS to safely add nba_bracket enum value on next deploy
- Improve error message in updateSport action to surface actual DB error instead of always blaming the slug

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 22:18:50 -07:00
Chris Parsons
e5431ebd0b Fix missing NBA simulator dropdown. 2026-03-16 22:02:50 -07:00
Chris Parsons
cbaab920f9
Add NBA playoff bracket Monte Carlo simulator (#154)
Adds a new `nba_bracket` simulator that projects both regular season
seedings and the full NBA playoff bracket, unlike other simulators that
operate on a pre-set bracket stored in the DB.

Algorithm:
- Per iteration: each team draws a conference seed (1–10) weighted by
  BBRef seed probabilities, then seeds 7–10 play the Play-In tournament
  (single elimination), then the resulting 8-team bracket is simulated
  as best-of-7 series using Elo playoff ratings
- Elo source: playoff rating (last 110 games, no regression to mean,
  postseason games weighted 3×) — appropriate for playoff matchup quality
- Seed probs source: Basketball-Reference (March 16, 2026), scaled from
  conditional to unconditional by multiplying by each team's Playoffs%

Placement tiers map to SimulationProbabilities:
  probFirst/Second → NBA champion / Finals loser
  probThird/Fourth → Conference Finals losers (2 per sim)
  probFifth–Eighth → Conference Semis losers (4 per sim)

Files:
- app/services/simulations/nba-simulator.ts (new)
- drizzle/0046_add_nba_bracket_simulator_type.sql (new)
- database/schema.ts: adds `nba_bracket` to simulatorTypeEnum
- app/services/simulations/registry.ts: registers NBASimulator

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 21:43:39 -07:00
Chris Parsons
b1535b2976
Fix First Four appearing out of order in NCAA bracket display (#152)
Use bracket template's canonical round order instead of sorting by match
count, which placed First Four (4 matches) after Elite Eight (also 4
matches). Extract getOrderedRoundsFromMatches() into bracket-templates.ts
to eliminate identical logic duplicated in the admin bracket component.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 12:27:43 -07:00
Chris Parsons
84787c9a4b
Consolidate F1/IndyCar simulators into shared AutoRacingSimulator class (#151)
Replaces the duplicated F1Simulator (which was copy-pasted for IndyCar) with a
single AutoRacingSimulator that accepts a race points table at construction time.
Registry entries for f1_standings and indycar_standings remain separate, each
wired to the correct scoring system (F1: top-10, IndyCar: top-26 positions).

Also imports SIMULATOR_TYPES from registry into the admin route to eliminate the
duplicate hardcoded array, and updates stale comments mentioning F1/IndyCar.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 12:22:08 -07:00
Chris Parsons
7f9b564a5d Revert "Fix deploy warnings. Fixes #134."
This reverts commit 6c1036a3bb.
2026-03-15 23:52:00 -07:00
Chris Parsons
6c1036a3bb Fix deploy warnings. Fixes #134. 2026-03-15 23:44:21 -07:00
Chris Parsons
714e6e5908 Fix UConn in NCAAW sim 2026-03-15 23:43:46 -07:00
Chris Parsons
47b8935be6
Add NCAAM and NCAAW bracket Monte Carlo simulators (#150)
- NCAAM: KenPom AEM logistic formula (1/(1+exp(-diff/7.5))), data through 2025-26 March 15
- NCAAW: Barttorvik Barthag Log5 formula (A*(1-B)/(A*(1-B)+B*(1-A))), same bracket structure
- Both simulators: 50,000-iteration Monte Carlo, First Four simulation, honors completed matches
- Track E8+ placements only: champion, finalist, FF losers (3rd/4th), E8 losers (5th–8th)
- Add bracket configuration validation: null R64 slots must exactly match First Four mapping
- Fix DEFAULT_SCORING_RULES to 100/70/45/45/20/20/20/20 (3rd/4th=45, 5th–8th=20)
- Align scoring constants across simulate route, expected-values display, and server action
- Zero out EVs for non-bracket participants on every simulation run (prevents EV inflation)
- Add EV total invariant warning (expected ~340) on expected-values admin page
- 98 unit tests across NCAAM, NCAAW, and UCL simulators — all passing

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 23:31:47 -07:00
Chris Parsons
0277c4d25e
Add per-event configurable region config for NCAA-style brackets (#149) (#149)
Replaces the hardcoded 2026 Men's region structure with a per-event
configurable system so the same ncaa_68 template works for Women's
March Madness (different region names and play-in placements each year).

- Add `bracket_region_config jsonb` column to `scoring_events`
- Export `ALL_16_SEEDS` and `BracketRegion`/`BracketPlayIn` interfaces
  from bracket-templates.ts so both server and client share the type
- Admin bracket entry form now shows a "Configure Regions" section:
  editable region names + add/remove play-in seed selectors (ShadCN
  Select) per region; slot map updates live as config changes
- Server action parses region config from form, validates total = 68,
  stores it on the event, and passes it to bracket generation
- `advanceFirstFourWinner` reads stored `bracketRegionConfig` from the
  event row first, falls back to template.regions for existing events
- Fix opponentSeed formula (was `seedNum <= 8 ? 17-n : n`, always `17-n`)
- Remove duplicate ALL_SEEDS inline definitions (now a shared constant)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 21:52:47 -07:00
Chris Parsons
67c7de1924
Fix event date timezone bugs (UTC rollover + same-day status) (#148)
* Fix event date display off-by-one for late-night events in UTC-negative timezones

Events saved at 10 PM PDT (UTC-7) cross UTC midnight, so eventDate is stored
as the next UTC calendar day (e.g. March 28 10 PM PDT → eventDate "2026-03-29").
Displaying that date string via parseISO() gives March 29 local midnight, so
the events list showed "Mar 29" when the user expected "Mar 28".

Fix: when eventStartsAt is available, derive the display date from it directly
(format(new Date(eventStartsAt), ...)) rather than from the stored eventDate
string. This correctly converts the UTC timestamp to the user's local calendar
date. Date-only events (no eventStartsAt) are unchanged and continue to use
parseISO(eventDate).

Also tighten the getDisplayDate() guard: replace a misleading try/catch (new
Date() never throws) with an explicit isNaN check, and replace a non-null
assertion (eventDate!) with null-coalescing in the admin events list.

Tests cover both UTC and PDT environments and are timezone-independent.

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

* Fix same-day events incorrectly showing Upcoming instead of Results Pending

String date comparison (eventDate < today) is strictly less-than, so events
on the current day always showed Upcoming regardless of time. When eventStartsAt
is available, use timestamp comparison (new Date(eventStartsAt) < new Date())
for accurate past/future determination.

Fixed in three locations: admin events list (getStatusBadge call site),
admin event detail isPast check, and EventSchedule upcoming badge.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 12:28:39 -07:00
Chris Parsons
d546585fb6
Fix event date display off-by-one for late-night events in UTC-negative timezones (#147)
Events saved at 10 PM PDT (UTC-7) cross UTC midnight, so eventDate is stored
as the next UTC calendar day (e.g. March 28 10 PM PDT → eventDate "2026-03-29").
Displaying that date string via parseISO() gives March 29 local midnight, so
the events list showed "Mar 29" when the user expected "Mar 28".

Fix: when eventStartsAt is available, derive the display date from it directly
(format(new Date(eventStartsAt), ...)) rather than from the stored eventDate
string. This correctly converts the UTC timestamp to the user's local calendar
date. Date-only events (no eventStartsAt) are unchanged and continue to use
parseISO(eventDate).

Also tighten the getDisplayDate() guard: replace a misleading try/catch (new
Date() never throws) with an explicit isNaN check, and replace a non-null
assertion (eventDate!) with null-coalescing in the admin events list.

Tests cover both UTC and PDT environments and are timezone-independent.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 11:18:16 -07:00
Chris Parsons
21597ba57b Filter unnecessary sentry css and js errors. 2026-03-15 10:24:57 -07:00
Chris Parsons
dc13d99f39
Add event_starts_at timestamp to scoring events for sub-day ordering (#146)
- New `event_starts_at timestamptz` column on `scoring_events`; backfilled
  from `event_date` (midnight UTC) via migration for all existing rows
- Admin create/edit forms consolidated from separate Date + Time inputs into
  a single `datetime-local` field; server derives `eventDate` from the UTC
  date portion of `eventStartsAt` so calendar-window queries continue to work
- Edit form pre-fill computed client-side via useEffect to avoid server-timezone
  mismatch for non-UTC admins
- Sort fix: replaced `eventDate ?? earliestGameTime.split("T")[0]` with
  `toEventSortKey` helper that prefers `earliestGameTime ?? eventDate` so
  bracket events with only per-game timestamps sort in the correct calendar position
- DB sort queries updated to use `eventStartsAt` as a tiebreaker after `eventDate`
- F1/IndyCar/golf events now populate `earliestGameTime` from `eventStartsAt`,
  giving time display in `UpcomingCalendarPanel`
- Bulk import gains optional `HH:MM` (UTC) third column
- New unit tests for `toEventSortKey` and `eventStartsAt` model behavior

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 10:22:42 -07:00
Chris Parsons
4fe76b6845
Display upcoming matches (#141) 2026-03-12 10:12:38 -07:00
Chris Parsons
f97ee4e4d8
Add NProgress navigation progress bar (#139)
Installs nprogress and wires it to React Router's useNavigation() hook
so a progress bar appears at the top of the page during route transitions.
Themed to the site's --electric CSS variable with the spinner disabled.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 22:02:48 -07:00
Chris Parsons
3dc6549640 MCP update 2026-03-11 21:45:19 -07:00
Chris Parsons
ed4e99b40e
Fix datetime-local to UTC conversion for game scheduling (#138)
* fix: use hidden input for UTC date value in add-game form

The onSubmit handler was setting the datetime-local input's value to an
ISO UTC string (e.g. "2026-03-11T17:00:00.000Z"). datetime-local inputs
only accept the format "YYYY-MM-DDTHH:MM" — values with a 'Z' timezone
suffix are invalid and the browser silently clears the field to "". This
caused the form to submit an empty scheduledAt, which the server treated
as null, so games were always saved without a date.

Fix by keeping the datetime-local input for user interaction (renamed
scheduledAtLocal, not submitted) and writing the converted UTC ISO string
into a separate hidden input named scheduledAt in the onSubmit handler.

Also adds app/lib/date-utils.ts with a localDateTimeToUtcIso helper and
matching tests to document and verify the datetime-local ↔ ISO conversion
behaviour.

https://claude.ai/code/session_0148fgZiXpyvGX8ZX3BFRGCs

* refactor: use localDateTimeToUtcIso in component and clean up tests

- Import and call localDateTimeToUtcIso in the add-game onSubmit handler
  instead of duplicating the conversion inline; also applies the null/
  invalid-date safety from the utility to the component
- Remove the redundant "incompatible with datetime-local" test case (its
  intent is better expressed as a code comment than a test assertion)
- Move the datetime-local incompatibility explanation into a NOTE comment
  on the utility function's JSDoc

https://claude.ai/code/session_0148fgZiXpyvGX8ZX3BFRGCs

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-11 16:22:29 -07:00
Chris Parsons
c1270f6c9c
Fix game datetime timezone handling in bracket admin (#137)
- Convert datetime-local input value to UTC ISO string on form submit
  so server correctly parses local time instead of treating it as UTC
- Show local timezone abbreviation (e.g. PDT) in the field label
- Hoist timezone abbreviation computation out of the matches render loop

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 15:13:50 -07:00
Chris Parsons
8666ff73c3 Found it in a second spot. 2026-03-11 14:47:19 -07:00
Chris Parsons
9d76178f56 Fix bad migration. 2026-03-11 14:38:45 -07:00
Chris Parsons
b1f778bab2 Fixes missing icon. 2026-03-11 14:20:53 -07:00
Chris Parsons
2f14565d43
Add playoff match game scheduling and odds management (#135)
* Add playoff match games and odds storage

Introduces two new tables for bracket matchup detail storage:

- `playoff_match_games`: tracks individual game schedules within a
  series matchup (game number, scheduledAt, status, per-game scores,
  winner). Supports scheduled/complete/postponed status enum.
- `playoff_match_odds`: stores moneyline odds per participant per
  matchup (single upsert record, no isLatest complexity).

Includes:
- Drizzle schema + relations with CASCADE deletes from playoff_matches
- Migration 0040_fat_puma.sql
- playoff-match-game.ts model with pure helpers: computeSeriesScore,
  isSeriesComplete, getSeriesLeader — plus full CRUD
- playoff-match-odds.ts model with pure helpers: americanToImpliedProbability,
  impliedProbabilityToAmerican, normalizeOdds — plus upsert/read/delete
- findPlayoffMatchesByEventId and findPlayoffMatchById updated to
  include games and odds in their query results
- Bracket server route: add-game, update-game, delete-game,
  upsert-odds, delete-odds actions
- Bracket admin UI: expandable per-match panel for game schedule
  management and moneyline odds entry
- 41 new unit tests (18 game + 23 odds), all 810 tests passing

https://claude.ai/code/session_01Twt3D1bsEK3eXUhMaz6ee7

* Code review fixes: type safety, abstraction, and React correctness

- Derive PlayoffMatchGameStatus from schema enum instead of hardcoding
  the string union, eliminating the duplicate source of truth
- updateGame now returns PlayoffMatchGame | undefined to reflect reality
  when no row matches the ID
- Remove TOCTOU check-then-act in update-game action: call updateGame
  directly and check the return value instead of a pre-flight findGameById
- Add status enum validation before the cast in update-game action
- Move impliedProbability computation inside upsertMatchOdds so callers
  only provide moneylineOdds; the model owns the derivation
- Remove unnecessary dynamic import of americanToImpliedProbability in
  the upsert-odds action (was already imported from the same module)
- Fix React list reconciliation bug: replace bare <> fragment with
  <Fragment key={match.id}> so React can correctly track rows

https://claude.ai/code/session_01Twt3D1bsEK3eXUhMaz6ee7

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-11 14:17:43 -07:00
Chris Parsons
c8b0da5cad Fix instrument file not being added to docker image 2026-03-11 09:18:08 -07:00
Chris Parsons
cbae4cc0c4
fix: replace react-router-serve with node dist/server.js in start script (#133)
The project uses a custom Express server (built to dist/server.js) rather
than the react-router-serve CLI, which was never installed. Also adds the
start:production alias referenced in CLAUDE.md.

https://claude.ai/code/session_011hnBY83ui5P5MzWt3iYppc

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-11 09:06:11 -07:00
Chris Parsons
35a3b71579
feat: add Sentry error monitoring (#132)
* feat: add Sentry error monitoring (#77)

Installs and configures @sentry/react-router with server and client
instrumentation. Disabled in development to avoid noise; only active
in production.

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

* chore: add VSCode Sentry MCP server config

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

* fix: pass vite env to sentryReactRouter plugin

sentryReactRouter requires the ConfigEnv as a second argument to read
the vite `command` property.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 23:35:33 -07:00
Chris Parsons
70c22d03fc
feat: add UCL bracket Monte Carlo simulator (#131)
Implements a 16-team UEFA Champions League knockout bracket simulator
using blended Elo + futures odds probabilities (70/30 split). Tracks
integer placement counts per tier to avoid fractional accumulation
drift, ensuring total EV sums to exactly 340 by construction.

- New UCLSimulator class reading live playoffMatches from DB
- Respects completed match results (eliminated teams get exact EV)
- New ucl_bracket simulator_type enum value + migration
- Registered in simulator registry; added to admin sport selector
- 18 unit tests covering math, bracket path, and integration scenarios

Fixes #113

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 23:04:51 -07:00
Chris Parsons
80bdbbc3bb
fix: clean up derived data when deleting scoring events (#130)
- Bracket events (playoff_game): delete all participantResults for the
  sports season atomically with the event, then recalculate league standings
- Qualifying events: capture affected participant IDs before cascade
  deletes eventResults, recalculate participantQualifyingTotals from
  remaining events, decrement majorsCompleted if QP had been processed,
  and recalculate league standings

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 21:47:47 -07:00
Chris Parsons
50722531ca
Create label-subissues.yaml 2026-03-10 13:40:44 -07:00
Chris Parsons
d784571f29
feat: add meta title to all route pages (#109)
Every route now exports a meta function so the browser title bar reflects
the current page. Static pages use fixed titles; dynamic pages pull names
from loader data with a sensible fallback (e.g. league name, sport season
name, team name).

Titles follow the pattern "Page Name - Brackt" for user-facing routes and
"Page Name - Brackt Admin" for admin routes.

Fixes #74

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 12:10:52 -07:00