Commit graph

406 commits

Author SHA1 Message Date
Chris Parsons
8c5389909d
Redesign standings page with sortable table, 7-day change, and chart repositioned (#205)
* Redesign standings page with sortable table, 7-day change, and chart repositioned

- Move point progression chart below the standings table
- Replace separate Points/Projected/Placement columns with:
  - Single stacked "actual / projected" Points column (shared PointsDisplay component)
  - "7-Day Change" column showing points gained + rank change over past 7 days
- Remove Placement breakdown column
- Sort ties alphabetically by team name (rank sort only)
- All columns are sortable via new reusable useSortableData hook
- Add sevenDayPointChange to TeamStandingWithChange type and model

https://claude.ai/code/session_01CuCKFVYbpsKSQoFDcTYfY7

* Code review fixes: module-level comparators, correct type on SortableHead, handle negative point change, remove redundant spread

- Move comparators object to module level (closes over nothing, no useMemo needed)
- Use SortConfig<StandingRow> on SortableHead instead of inline duplicate type
- SevenDayChange: handle negative pointChange with correct sign and color
- Remove redundant sevenDayPointChange explicit assignment (already in ...standing spread)

https://claude.ai/code/session_01CuCKFVYbpsKSQoFDcTYfY7

* Update StandingsTable tests to match redesigned component

- Remove showPlacementBreakdown prop (no longer exists on component)
- Replace Placement Breakdown test suite with 7-Day Change column tests
- Update header assertion: "Placements" → "7-Day Change"
- Add tests for positive/negative point change display and rank change indicators
- Remove accessibility test for placement title attributes

https://claude.ai/code/session_01CuCKFVYbpsKSQoFDcTYfY7

* Fix movement indicator arrow count assertion to exclude sort header arrows

queryAllByText(/↑|↓/) was matching the active sort column's ↑ indicator.
Narrow to /[↑↓]\d/ so only movement indicators (↑1, ↓1) are counted.

https://claude.ai/code/session_01CuCKFVYbpsKSQoFDcTYfY7

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-22 11:05:13 -07:00
Chris Parsons
c1be92b2af
Add AFL season + finals simulator (closes #126) (#204)
Monte Carlo simulation of the 2026 AFL season and 10-team finals series.
Elo ratings are backsolved from Squiggle's projected season win totals using
the inverse formula: elo = 1500 - 450×log₁₀((1−wins/23)/(wins/23)).

- New `afl_bracket` simulator type (schema + migration)
- `afl-simulator.ts`: projects remaining regular season via Elo, seeds the
  AFL_10 finals bracket (Wildcard → QF/EF → SF → PF → Grand Final), and
  correctly tracks P5/P6 and P7/P8 as separate scoring tiers
- `afl.ts` standings sync adapter pulling from Squiggle API (no auth required)
- Finals line on standings display set to 10 for AFL seasons
- Substring name matching with longest-key-wins to prevent "Port Adelaide"
  colliding with "Adelaide" when matching "Port Adelaide Power"
- 27 unit tests covering bracket logic, column-sum guarantees, and name matching

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 01:57:39 -07:00
Chris Parsons
46a2e9c199
Tune NHL simulator: Vegas futures blending + parity factor calibration (closes #168) (#202)
- Add 70/30 Elo + Vegas futures blending for per-game win probability,
  mirroring the UCL simulator pattern; falls back to Elo-only when no
  sourceOdds are stored in participantExpectedValues
- Adjust PARITY_FACTOR to better match Vegas championship implied
  probabilities (COL was ~12% vs ~20% market expectation)
- Add 15 unit tests covering name normalization, team data lookup,
  parity-factor math, seeding probability sanity, and eliminated-team checks

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 00:14:18 -07:00
Chris Parsons
7093b22726
Simulate NBA remaining regular season games from standings (#201)
Replace static Basketball-Reference seed probability distributions
(p_1..p_10) with dynamic simulation of each team's remaining games
based on current standings from the DB.

- Load regularSeasonStandings in parallel with participants query
- Compute remainingGames = 82 - gamesPlayed per team
- Pre-compute per-game win probability (Elo vs average opponent 1500)
  on TeamEntry at construction time — not inside the hot loop
- Sort conference standings by projected wins to assign seeds, replacing
  the drawSeed() weighted-probability approach
- Conference resolved from standings table, falls back to TEAMS_DATA
- Strip all p_1..p_10 seed probability data from TEAMS_DATA
- Move simulateProjectedWins to module scope (no closure captures)
- Remove const N alias; use NUM_SIMULATIONS directly

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 23:37:20 -07:00
Chris Parsons
618bc57ec1
Replace console.* with structured logger, fix no-inferrable-types (closes #98) (#199)
- Add app/lib/logger.ts: dev passes through to console; prod routes errors
  to Sentry.captureException and warnings to Sentry.captureMessage, with
  extra context preserved. Uses captureMessage (not captureException) for
  string-only args to avoid fabricated stack traces.
- Add server/logger.ts: dev passes through; prod silences log/info but
  keeps warn/error on stderr (Sentry not initialized in that process).
- Replace all console.* calls across 44 app files and 4 server files.
- Upgrade no-console from warn → error in oxlint; exempt logger files and
  scripts/** via overrides.
- Add typescript/no-inferrable-types rule; fix violations in services and
  simulators. Exempt test files (intentional string widening for switch/if
  tests would break under literal type inference).

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 13:41:39 -07:00
Chris Parsons
4bffa40606
Fix oxlint warnings: no-shadow, consistent-function-scoping, no-non-null-assertion, and others (#196)
* Fix no-shadow and consistent-function-scoping lint violations

Resolves all 11 no-shadow and 16 consistent-function-scoping oxlint
warnings and promotes both rules to errors in .oxlintrc.json.

no-shadow: renamed Drizzle callback params (sports→s, matches→m,
seasons→s) to avoid shadowing outer imports; removed shadowed
destructures (eq, inArray) from where callbacks; renamed inner
template→bracketTemplate, prev→currentTimers, season→ss, name→teamName
(with name: teamName fix to preserve semantics).

consistent-function-scoping: moved formatDate, getRankBadge,
getMovementIndicator, getPositionBadge, getStatusBadge, toDateStr,
elo (×2), weightedPick, sortByMatchNumber (×2) to module scope;
moved formatTime (×2), isValidLeagueName, getDraftTimes,
makeSeasonQueues to file scope in test files.

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

* Fix no-non-null-assertion lint violations and promote to error

Eliminates all 208 no-non-null-assertion warnings across 38 files.
Promotes typescript/no-non-null-assertion from warn to error in
.oxlintrc.json.

Fix patterns applied:
- Map.get(key)! after .has() check → extract with get() + null guard
- Map.get(key)! on pre-populated count maps → ?? 0 default
- .set(id, map.get(id)! + 1) increment → ?? 0 before adding
- participant1Id!/participant2Id! on DB matches → ?? "" fallback
- array.find()! in tests → guard + throw or expect().toBeDefined()
- bracketTemplateCache.get(id)! → null guard extract
- Various nullable field accesses → optional chain or ?? default

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

* Fix prefer-add-event-listener, no-unassigned-import, require-module-specifiers

Resolves all 9 remaining non-console lint warnings and promotes all
three rules to errors in .oxlintrc.json.

- prefer-add-event-listener: converted onchange/onclick/onload
  assignments to addEventListener in useDraftNotifications.ts and
  admin.data-sync.tsx; stored changeHandler ref for proper cleanup
  with removeEventListener
- no-unassigned-import: configured rule with allow list for legitimate
  side-effect imports (*.css, @testing-library/jest-dom,
  @testing-library/cypress/add-commands)
- require-module-specifiers: removed redundant `export {}` from
  cypress/support/e2e.ts (file already has an import)

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

* Fix TypeScript errors from no-non-null-assertion fixes

Two fixes introduced by the non-null assertion cleanup produced type
errors:

- scoring-event.ts: `?? ""` was wrong type for a participant object map;
  restructured to explicit null guards so TypeScript can narrow correctly
- standings-sync/index.ts: `?? null` after name-match lookup lost the
  truthy guarantee, causing TS18047 on the write-back block; added
  `participant &&` guard before accessing its properties

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

* Add npm run typecheck as Stop hook in Claude settings

Runs a full project typecheck at the end of each Claude turn so type
errors surface as feedback before the next message.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 10:59:51 -07:00
Chris Parsons
180aad1520
Fix postgres connection pool leak in development (#195)
In dev mode, viteDevServer.ssrLoadModule re-evaluates server/app.ts on
every request, creating a new postgres connection pool each time and
eventually exhausting Postgres's max_connections limit. Cache the client
on globalThis so HMR re-evaluations reuse the same pool.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:57:53 -07:00
Chris Parsons
e2b178221a
Add oxlint linting setup with zero errors (#194)
* Add oxlint and fix all lint errors

- Install oxlint, add .oxlintrc.json with rules for TypeScript/React
- Add npm run lint / lint:fix scripts
- Add Claude PostToolUse hook to run oxlint on every edited file
- Fix 101 errors: unused vars/imports, eqeqeq, prefer-const, no-new-array
- Fix no-array-index-key (use stable keys or suppress positional cases)
- Fix exhaustive-deps missing dependency in useEffect
- Promote exhaustive-deps and no-array-index-key to errors
- Fix Map.get() !== null bug in $leagueId.server.ts (should be !== undefined)

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

* Fix no-explicit-any warnings and upgrade tsconfig to ES2023

- Replace all `any` types with proper types or `unknown` across ~20 files
- Add typed socket payload interfaces in draft route and useDraftSocket
- Use any[] with eslint-disable for socket.io callbacks (legitimate escape hatch)
- Bump all tsconfigs from ES2022 → ES2023 to support toSorted/toReversed
- Fix cascading type errors uncovered by removing any: Map.get narrowing,
  participant relation types, ChartDataPoint, Partial<NewSeason> indexing
- Add ParticipantResultWithParticipant type to participant-result model
- Fix test fixtures to match updated interfaces (DraftCell, ParticipantResult)
- Fix duplicate getQPStandings import in sportsSeasonId.server.ts

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

* Promote no-explicit-any to error

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:44:05 -07:00
Chris Parsons
bcca8b76fa
Add regular season standings for NBA/NHL (fixes #89) (#192)
Adds live standings sync and display for bracket-based sports (NBA/NHL),
so league members can see W/L tables and which teams their opponents drafted
during the regular season — not just after the playoff bracket is set.

- New `regular_season_standings` table with upsert-on-conflict sync
- Standings sync service with NHL (api-web.nhle.com) and NBA (ESPN) adapters,
  externalId write-back for future syncs, and unmatched-team resolution UI
- `RegularSeasonStandings` component: flat (NBA) + division/wild-card (NHL) modes,
  playoff line, TeamOwnerBadge, projected Brackt points (EV), mobile horizontal scroll
- Admin "Sync Standings" card + "Resolve Unmatched" UI on sports season page
- Admin manual standings edit hatch at /admin/sports-seasons/:id/regular-standings
- Show standings above bracket until matches exist; below once bracket is set
- `normalize-team-name` utility extracted to shared lib

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 00:12:01 -07:00
Chris Parsons
2949ca733a
Add standard draft clock mode (#67) (#189)
Implements a new "standard" timer mode alongside the existing chess clock
mode. In standard mode the per-pick timer resets to a fixed value after
every pick (no carry-over), and the speed selector shows plain time values
instead of named chess-clock presets.

Key changes:
- Add `draft_timer_mode` enum column to `seasons` table (migration 0053)
- `draft.start`: standard mode seeds timers at `draftIncrementTime` (the
  per-pick value) rather than `draftInitialTime`
- `draft.make-pick`: three-way branch — standard resets, chess clock
  owner earns increment, commissioner/admin pick leaves bank frozen
- `draft.force-manual-pick`: commissioner picks never earn bank time;
  chess clock path uses a pre-pick snapshot to avoid a race window with
  the 1-second timer loop
- `executeAutoPick` in draft-utils: auto picks never earn bank time;
  chess clock path skips the DB update (timer already at 0)
- League creation and settings pages: mode-aware speed selector (raw
  seconds for standard, named presets for chess clock); shared
  `parseDraftSpeed` utility extracted to `app/lib/draft-timer.ts`
- Tests added for draft.start timer init and make-pick timer mode
  behavior; force-manual-pick tests updated for new timer semantics

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:36:39 -07:00
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