Adds live standings sync and display for bracket-based sports (NBA/NHL),
so league members can see W/L tables and which teams their opponents drafted
during the regular season — not just after the playoff bracket is set.
- New `regular_season_standings` table with upsert-on-conflict sync
- Standings sync service with NHL (api-web.nhle.com) and NBA (ESPN) adapters,
externalId write-back for future syncs, and unmatched-team resolution UI
- `RegularSeasonStandings` component: flat (NBA) + division/wild-card (NHL) modes,
playoff line, TeamOwnerBadge, projected Brackt points (EV), mobile horizontal scroll
- Admin "Sync Standings" card + "Resolve Unmatched" UI on sports season page
- Admin manual standings edit hatch at /admin/sports-seasons/:id/regular-standings
- Show standings above bracket until matches exist; below once bracket is set
- `normalize-team-name` utility extracted to shared lib
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements a new "standard" timer mode alongside the existing chess clock
mode. In standard mode the per-pick timer resets to a fixed value after
every pick (no carry-over), and the speed selector shows plain time values
instead of named chess-clock presets.
Key changes:
- Add `draft_timer_mode` enum column to `seasons` table (migration 0053)
- `draft.start`: standard mode seeds timers at `draftIncrementTime` (the
per-pick value) rather than `draftInitialTime`
- `draft.make-pick`: three-way branch — standard resets, chess clock
owner earns increment, commissioner/admin pick leaves bank frozen
- `draft.force-manual-pick`: commissioner picks never earn bank time;
chess clock path uses a pre-pick snapshot to avoid a race window with
the 1-second timer loop
- `executeAutoPick` in draft-utils: auto picks never earn bank time;
chess clock path skips the DB update (timer already at 0)
- League creation and settings pages: mode-aware speed selector (raw
seconds for standard, named presets for chess clock); shared
`parseDraftSpeed` utility extracted to `app/lib/draft-timer.ts`
- Tests added for draft.start timer init and make-pick timer mode
behavior; force-manual-pick tests updated for new timer semantics
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* Show team name + username in standings, extract TeamNameDisplay component
- Add TeamNameDisplay component that renders team name (as link) with owner username below, matching the league homepage style
- Update StandingsTable to use TeamNameDisplay with owner username shown below team name
- Update league homepage standings section to use TeamNameDisplay
- Add ownerName/teamOwnerId fields to TeamStanding type
- Extend getSeasonStandings to include teamOwnerId from team relation
- Fetch and attach owner display names in the full standings page loader
https://claude.ai/code/session_01EYgGnuTBaRVdBDapJRTxDZ
* Address code review: type hygiene, explicit types, parallel fetching, style fix
- Remove teamOwnerId from TeamStanding type (was an internal server concern, not display data)
- Remove teamOwnerId from getSeasonStandings return value
- Add TeamStandingWithChange interface extending TeamStanding with sevenDayRankChange/sevenDayOldRank fields
- Annotate getSevenDayStandingsChange with explicit Promise<TeamStandingWithChange[]> return type
- Re-export TeamStandingWithChange from models/standings.ts
- Standings loader: query teams table directly for ownerIds instead of relying on teamOwnerId in standings data
- Standings loader: parallelize all independent queries (standings, teams, progressionData, seasonComplete, completionPercentage) with Promise.all
- Restore font-medium on StandingsTable team TableCell
https://claude.ai/code/session_01EYgGnuTBaRVdBDapJRTxDZ
---------
Co-authored-by: Claude <noreply@anthropic.com>
* Refine Discord webhook scoring notifications (fixes#180, #181)
- Filter scored matches to only show matchups where a manager scores
Brackt points (winner drafted) or has a team eliminated (loser drafted);
matches with no manager involvement are suppressed entirely.
- Escape Discord markdown characters (_*~`|\) in team names and usernames
to prevent formatting issues (e.g. double-underscore names causing
unintended underlines).
- Replace full standings with a "Standings Changes" section that shows
only teams whose points changed, plus any teams whose rank shifted as a
result, with ↑N / ↓N indicators for rank movement.
- Pass previousRanks map from scoring-calculator to the notification so
rank-displaced teams (who didn't score points themselves) are included.
- Update test webhook in league settings to demonstrate rank changes and
a sample scored match.
- Update all discord service tests to cover the new filtering, escaping,
and standings-change behaviour.
https://claude.ai/code/session_01FciwfdG9Sfr5ZrXUHPeB18
* fix: only set winnerUsername when winning team's score actually changed
Previously, winnerUsername was set for any drafted winner regardless of
whether points were actually scored. This caused R64 wins (where the
scoring system may not award points until later rounds) to appear in
Discord's Scored Matches section even when no Brackt points were earned.
Now winnerUsername is only set when the winner's team's totalPoints
changed after recalculation. loserUsername (eliminations) is always set
when the loser is drafted, since being knocked out is always notable.
https://claude.ai/code/session_01FciwfdG9Sfr5ZrXUHPeB18
* Refactor Discord notification logic in scoring calculator
- Compute changedTeamIds once up front; derive hasChanges from it
instead of duplicating the same iteration
- Merge usernameForParticipant and winnerUsernameForParticipant into a
single function with a requireScoreChange flag
- Replace hasDraftedParticipantMatches with hasScoredMatchesToShow,
which checks that at least one scoredMatch has a displayable username
— prevents sending a contentless Discord embed when a drafted winner
doesn't score any points and the loser isn't drafted
- Update inline comment to be sport-agnostic
https://claude.ai/code/session_01FciwfdG9Sfr5ZrXUHPeB18
---------
Co-authored-by: Claude <noreply@anthropic.com>
Admins can now enable/disable a sport season from appearing in league
creation and pre-draft league settings. Non-draftable seasons are hidden
from selection but remain visible (and checked) if already linked to an
existing pre-draft league. Leagues in draft status or beyond are unaffected.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fall back to displayName when username is null for Discord webhook
Users who sign up via OAuth (Google, GitHub, etc.) without setting a
Clerk username have a null `username` field but always have a `displayName`
(computed from firstName+lastName or email). Previously, `usernameByClerkId`
was filtered to only include users with a non-null username, causing those
owners to appear without any identifier in Discord standings messages
(e.g. "Liverpool def. Galatasaray" instead of "Liverpool def. Galatasaray (Madmike)").
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
* Extract getUserDisplayName helper and use consistently throughout
Add a single getUserDisplayName(user) function to app/models/user.ts that
encapsulates the username → displayName fallback logic. Replace 9 scattered
inline expressions across the codebase (owner-map, scoring-calculator,
league routes, settings, invite flow, draft API, Clerk webhook) with calls
to the shared helper.
No behaviour change — all existing logic preserved, just centralised.
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
* Fix N+1 user queries in league loader and settings loader
Add findUsersByClerkIds() batch function to the user model and replace two
separate Promise.all+findUserByClerkId loops (one for owners, one for
commissioners) with a single inArray query in both $leagueId.server.ts and
$leagueId.settings.tsx. The merged query covers both owner and commissioner
IDs in one round-trip.
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
* Fix N+1 user queries in buildOwnerMap
Replace the Promise.all+findUserByClerkId loop with a single
findUsersByClerkIds batch query, consistent with the league loader
and settings loader fixes.
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
---------
Co-authored-by: Claude <noreply@anthropic.com>
* Auto-name teams on claim and harden owner-assignment logic
- Add claimTeam() model function that atomically sets ownerId + name in
a single DB write, replacing the previous two-step assignTeamOwner +
renameTeam pattern that could leave partial state on failure
- Rename team to "Team <username>" (fallback: displayName, then "Member")
when a user joins via invite link or an admin assigns a team owner
- Fetch the user record before claiming the team in both paths so we fail
fast with a clear error rather than silently skipping the rename
- Guard against assigning an already-owned team in the admin settings
action (closes#107, closes#76)
- Show owner username below team name in draft order settings UI;
teams with an owner but no matching user record display "Unknown"
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fixing dropdown for admins
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* Grant sitewide admins commissioner-level access in leagues
- `isCommissioner()` now returns true for site admins, covering all
commissioner-gated loaders (league home, settings, sport season detail)
and draft API routes (start, pause, resume, rollback, replace-pick,
force-autopick, force-manual-pick, adjust-time-bank, make-pick)
- Added `hasCommissionerRecord()` (DB-only, no admin bypass) for the
"already a commissioner" duplicate-entry check in the settings action,
preventing a false positive when adding a site admin as commissioner
- `isCommissioner()` now runs the admin check and DB query in parallel
via Promise.all to avoid a serial roundtrip on every check
- Added "admin" to the `picked_by_type` enum (migration 0049) so picks
forced by a site admin are recorded accurately in the audit log rather
than as "commissioner"
- 8 unit tests covering both isCommissioner and hasCommissionerRecord
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix draft.force-manual-pick tests broken by isUserAdminByClerkId
The route now calls isUserAdminByClerkId which hits database().query.users,
but the test's mock DB had no query.users entry. Add a vi.mock for
~/models/user and default isUserAdminByClerkId to false in beforeEach.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
- NCAAM R64/R32 winners no longer receive T5 floor points; only Sweet
Sixteen winners entering the first scoring round get a floor
- AFL T5-T6 and T7-T8 score as separate tiers (avg([5,6]) and avg([7,8]))
instead of one shared avg([5-8]) pool
- Eliminated teams now always show their correct final rank (e.g. T33 for
NCAAM R64 losers) even during partial round scoring, by deriving the
rank label from the total match count per round rather than the dynamic
still-alive count
- Discord notifications now fire when a drafted participant is eliminated
with 0 points (e.g. R64 losers whose team score doesn't change)
- Discord notifications no longer include all prior matches — scoped to
the current batch via matchIds
- Discord section headers have spacing; rank medals replaced with numbers
- Rounds auto-complete when all matches are marked done; manual Complete
Round dropdown removed
- Fixed double Discord notification when the last match in a round
triggers auto-complete: processPlayoffEvent now accepts skipRecalculate
so the caller controls when the notification fires
- Fixed falsy check on finalPosition=0 in calculateTeamScore
- Undrafted 0-point participants filtered from Eliminated Teams display
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add Discord webhook notifications for standings updates
Adds a Discord webhook integration that posts standings to a configured
Discord channel whenever scores change after a scoring event. Commissioners
set the webhook URL in league settings and can send a test notification.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix Discord notification edge cases from code review
- Fix point delta sign: negative diffs now correctly show -5 pts not +-5 pts
- Replace hardcoded soccer emoji with a neutral bullet (works for all sports)
- Truncate embed description at Discord's 4096-char limit
- Thread eventId through processMatchResult and bracket batch handler so
scored matches appear in single-match notifications too
- Pass eventName to finalizeQualifyingPoints ("Final Standings") and
processSeasonStandings ("Season Complete") so those notifications have context
- Test notification now fetches current season to show "League 2025" format
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix process-match-result tests: add sportsSeasons to db mock
recalculateAffectedLeagues now queries sportsSeasons to get the sport
name for Discord notifications; the test mock db needed the stub added.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
## Partial bracket scoring
- `processMatchResult`: new exported function that scores a single match
immediately (loser → final placement, winner → provisional floor).
Called from `set-winner` and `set-round-winners` so points are awarded
as soon as a winner is set, before the full round is complete.
- `set-winner`: passes `eventName` to `processMatchResult`.
- `set-round-winners`: batches side effects — calls `recalculateAffectedLeagues`
and `updateProbabilitiesAfterResult` once after the loop instead of per-match
(`skipSideEffects: true` per match).
## Code review fixes
- **ROUND_CONFIG** (S1): extracted a `RoundScoringConfig` lookup table +
`getRoundConfig()` helper, eliminating three parallel `if/else` chains in
`processPlayoffEvent`, `processMatchResult`, and `getGuaranteedMinimumPosition`.
AFL template overrides live in `TEMPLATE_ROUND_CONFIG` with an explanatory
comment about why the `bracketTemplateId` guard is required.
- **skipSideEffects** (C2): new param on `processMatchResult`; bracket server
uses it to batch standings/probability recalc in `set-round-winners`.
- **eventName** (W1): passed through `processMatchResult` → `recalculateAffectedLeagues`.
- **Round validation** (W2): `set-round-winners` now guards `match.round === round`
before processing each assignment.
- **Comments** (C3/S3/W3): added notes on non-scoring loser assumption,
`isScoring ?? true` default, and AFL Semi-Finals template requirement.
## PlayoffBracket eliminated-teams fix + tests
- Fixed `computeEliminatedByRound` to track participant *appearances* (not just
wins), so AFL QF losers who advance to Semi-Finals via double-chance are
correctly excluded from the QF eliminated list.
- Extracted the logic as an exported pure function for testability.
- Added 4 tests: standard elimination, AFL QF loser → SF win, AFL QF loser →
SF loss, and normal advancement not protecting a later loser.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace per-sport cards with a single flat table; add Sport column (linked to sport season) before Participant
- Replace three summary stat cards with a compact "X remaining" line in the header
- Format pick numbers as round.pick notation (e.g. 2.02 for pick 16 in a 14-team league)
- Add getNumTeamsInSeason model function to compute pick-within-round correctly
- Remove dead bySport/totalPoints from standings model and component interface
- Narrow standing prop type to only currentRank (placementCounts was unused)
- Guard against numTeams === 0 edge case in pick notation
- Fix 0.0 → 0.00 decimal inconsistency
- Fix unsafe finalPosition! non-null assertion (null treated as Did Not Score)
- Update tests to match current UI; add coverage for new edge cases
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
- 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>
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>
When a participant wins a bracket round, they immediately earn provisional
"floor" points (the averaged minimum they'd receive if eliminated next round).
These update as they advance and are replaced by finalized scores on elimination.
Key changes:
- Add `is_partial_score` column to `participant_results` (migration 0038)
- `processPlayoffEvent`: assign provisional position 5 to non-scoring round
winners; assign round-appropriate floors to scoring round winners via
`getGuaranteedMinimumPosition`; add catch-all for unrecognized round names
- `upsertParticipantResult`: guard against un-finalizing rows (never overwrite
isPartialScore=false with true)
- `calculateBracketPoints`: new function averaging tied bracket tiers
(5-8 → 20 pts, 3-4 → avg, 1-2 solo); used in `calculateTeamScore` for
playoff_bracket pattern (pattern-aware, doesn't affect F1/golf scoring)
- `PlayoffBracket`: "In Contention" table for still-active participants;
AFL double-chance fix (participants who won a later match excluded from
earlier round's loser list); correct `nextRank` starting position
- Server loader: batch owner DB queries (one query vs N+1); deduplicate
participantPoints; use calculateBracketPoints for bracket point display
- Clean up Phase/Q-number tracking comments throughout scoring-calculator.ts
- 3 new tests for non-scoring round provisional floor behavior
Also includes a dev admin bypass via DEV_ADMIN_CLERK_ID env var (separate
change on this branch, not part of floor scoring feature).
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat: redesign playoff bracket UI with compact layout and owner display
- Replace per-match Card wrappers with compact left-vs-right rows (stacks on mobile)
- Add TeamOwnerBadge component (colored avatar + team name + username), matching the standings "Drafted By" style
- Show owner info below each participant name in bracket matches
- Add TBD forward-reference labels ("Winner of Quarterfinals M1") computed via buildFeederMap
- Add Final Rankings table below bracket showing all participants ranked by elimination round
- Fix round ordering in loader: sort by match count descending (more matches = earlier round)
- Add loser relation to playoff matches query for elimination tracking
- Extract getAvatarColor to app/lib/color-hash.ts (shared across GroupStageDisplay, SeasonStandings, TeamOwnerBadge)
- Extract groupMatchesByRound helper; share grouped map between feeder computation and render
- Remove dead getTeamAvatar from SeasonStandings after TeamOwnerBadge adoption
- Add tests for groupMatchesByRound and buildFeederMap (7 tests)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat: add draft order card to league home page
Show draft order on the league home page when order is set and season
is in pre_draft or draft status. Includes team name, owner name, and
a link to the draft room.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat: enhance playoff bracket with eliminated teams, points, and ownership highlights
- Show eliminated teams (including group-stage losers) in Final Rankings card
- Display computed fantasy points per participant in rankings table
- Card title switches between "Eliminated Teams" and "Final Rankings" based on bracket completion
- Highlight owned participants with electric blue name, dot indicator, and card border in bracket matches
- Highlight owned rows with electric border/background in rankings table
- Suppress EventSchedule for playoff_bracket sports seasons
- Fix title redundancy: bracket section title no longer repeats sport name
- Two-column match grid on desktop (md:grid-cols-2)
- Code review fixes: parallel DB fetches, targeted query for pre-eliminated, single-pass parseFloat, remove IIFE
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
- SeasonStandings: highlight rows for the logged-in user's drafted participants
with electric accent (in-points) or muted (outside points) left border + star icon
- Loader: derive userParticipantIds from current user's teams and draft picks
- EventSchedule: hide type badge for schedule_event; show "Results Pending" only
when event is past and not yet marked complete (matches admin page logic)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
- Sort sports seasons by status (active → upcoming → completed), with
season_standings pattern last within active, then alphabetical
- Fix rank display for teams without standings: T1 when no scores exist,
T{n+1} when some teams are scored (avoids misleading T1 for unranked teams)
- Extract rank logic to getDisplayRank() in standings-display.ts with tests
- Pre-compute standingsMap and sortedTeams to eliminate O(n²) render lookups
- Fix invite URL SSR flash by deriving origin in the loader
- Guard toast useEffect with processedParamsRef to prevent double-fire
- Hide Teams Filled and Draft Order Set post-draft; show Standings sidebar
link only when active/completed
- Show "Final standings" label when season is completed
- Remove duplicate Season Status from sidebar; add Flex Spots description
- Fix status type cast to occur at the model mapping layer, not in JSX
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
After every pick, recalculate draft eligibility for all teams and
remove any queued participants whose sport is no longer eligible
(e.g. a team queued a snooker player but just filled their last flex
slot). Previously this was only caught lazily when autodraft fired,
which could pause the draft or pick an unwanted player.
- Add getAllQueuesForSeason to draft-queue.ts — fetches all queue rows
for a season in one query (Map<teamId, QueueItem[]>) instead of N+1
per-team queries
- Add pruneIneligibleQueueItems to draft-utils.ts — uses Promise.all
for the four required data fetches, collects ineligible items in a
single loop pass, warns on orphaned participant references
- Call from both pick paths: executeAutoPick and draft.make-pick.ts
- Emit queue-eligibility-pruned socket event per affected team so the
client updates the queue UI in real time
- Add 5 tests covering: single ineligible removal, all eligible (no-op),
empty queues (no delete called, getTeamQueue never called), mixed
queue (only ineligible item removed), and unknown participant (warn)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: improve JWT expiry handling and revalidation logic in DraftRoom component
* fix: ensure authRecoveryTimerRef is initialized to undefined to prevent potential errors
- Extract groupPicksByTeamAndSport into shared picks-utils.ts to remove
duplication between TeamRosterView and DraftSummaryView
- Fix TeamRosterView: make teamPicks a proper useMemo dep on selectedTeamId,
add useEffect to reset stale selection if draftSlots changes
- Add accessible label/htmlFor to TeamRosterView team Select
- Remove dead `season` prop from DraftSummaryView (was inherited from
TeamsDraftedGrid but never used)
- Fix DraftSummaryView: sport name cells are now <th scope="row">, column
headers have scope="col", Total header uses solid bg-muted to match Sport
- Replace || null with explicit > 0 check in Total cell for clarity
- Consolidate to summaryViewProps/rosterViewProps in route for consistency
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat: enhance autodraft settings with detailed option descriptions and improved state handling
* test: update AutodraftSettings tests for button interactions and API payloads
Primary fix: setTeamTimers now bails out with `return prev` when the
value hasn't changed, preventing a full DraftRoom re-render on every
1-second timer tick (was 33% of profiler samples).
Memoization: wrap AvailableParticipantsSection, TeamsDraftedGrid,
QueueSection, SidebarRecentPicks, and DraftGridSection in React.memo
so timer ticks don't cascade into heavy components that don't use
timer state.
Stable refs: wrap nine action handlers in useCallback and extract two
inline arrow functions from props objects so memo() comparisons
actually bail out. Memoize the { numFlexPicks } object passed to
TeamsDraftedGrid.
socketVersion: expose an incrementing counter from useDraftSocket so
the socket handler effect re-registers on socket recreation.
Async cleanup: add abort flag + in-flight guard to the visibilitychange
JWT refresh handler to prevent concurrent executions and stale state
updates after unmount. Add abort flag to useDraftNotifications
permissions.query() to prevent dangling onchange if unmounted
mid-promise.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
- Move "Show drafted sports" into the sport filter dropdown (sheet/popover)
instead of a standalone checkbox in the filter bar
- When unchecked, drafted sports are hidden from the dropdown list entirely;
when checked, they appear in alphabetical order with muted text
- Auto-deselect drafted sport filters when hiding drafted sports
- Reset button also restores "Show drafted sports" to checked
- Extract SportFilterContent component to eliminate sheet/popover duplication
- Make userDraftedSportNames required; add useCallback for handlers;
memoize triggerText/triggerAriaLabel; unify all checkboxes to ShadCN Checkbox;
replace empty div divider with hr
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the single-sport native <select> with a popover (desktop) and
bottom sheet (mobile) checkbox multi-select. No sports selected shows all
participants; selecting one or more narrows the list. Trigger label adapts
to reflect current filter state and a Reset button appears when filters
are active.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Clerk's short-lived JWTs (60s) can expire when browser tabs are
backgrounded and the SDK's refresh interval is throttled. This left
users silently logged out — actions returned 401s shown as generic
error toasts with no recovery path.
Prevention: refresh the Clerk JWT on tab visibility change so the
token is always fresh when the user returns from a backgrounded tab.
Detection: replace all 12 raw fetch() calls with an authFetch wrapper
that catches 401 responses and sets an authDegraded state, triggering
a blocking recovery overlay instead of confusing toasts.
Recovery: AuthRecoveryOverlay (mirrors ConnectionOverlay pattern)
prompts the user to reload the page, which re-authenticates via
Clerk's long-lived session cookie.
Also adds missing try/catch to handleStartDraft, handleMakePick,
handleForceAutopick, handleForceManualPick, and handleReplacePick
which previously had no network error handling.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Add a reference-equality check so that if HTTP revalidation fails (network
error, expired token), the sync effect does not overwrite fresh data that
draft-state-sync already applied. Also adds missing dependency array entries
(userQueue, timers, autodraftSettings) to the revalidation sync effect.
https://claude.ai/code/session_01JxJ1CYTiFWV4KTPxTnjrms (#48)
* fix: sync all draft state on mobile reconnection
After a mobile browser returns from a long background period, the draft
room had stale picks, wrong "on the clock" display, and inaccurate
available player lists. The root cause was that reconnection relied
solely on an HTTP revalidation that could fail (expired JWT, flaky
network), and the timer-update handler ignored currentPickNumber.
Changes:
- Server emits draft-state-sync on join-draft with full picks, timers,
and season state, giving the client an immediate socket-based sync
path that doesn't depend on HTTP revalidation
- timer-update handler now syncs currentPickNumber, fixing the "on the
clock" display within 1 second of reconnection
- Revalidation retry with 3s delay ensures the HTTP path succeeds even
when the network is slow to stabilize on mobile return
- Revalidation completion now also syncs teamTimers and autodraftStatus
- Added draft-state-sync client handler that applies the server snapshot
immediately (skipped when revalidation is in-flight to avoid conflicts)
Tests: 32 new tests covering reconnection sync, pick buffering/merge,
timer-update currentPickNumber sync, draft-state-sync handling,
available player filtering, on-the-clock correctness, and revalidation
retry logic.
https://claude.ai/code/session_01JxJ1CYTiFWV4KTPxTnjrms
* fix: guard against stale revalidation overwriting fresh socket data
Add a reference-equality check so that if HTTP revalidation fails (network
error, expired token), the sync effect does not overwrite fresh data that
draft-state-sync already applied. Also adds missing dependency array entries
(userQueue, timers, autodraftSettings) to the revalidation sync effect.
https://claude.ai/code/session_01JxJ1CYTiFWV4KTPxTnjrms
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix: eliminate queue tab flash on mobile by using useSyncExternalStore
useMediaQuery initialized to false and updated in useEffect, causing a
paint where isMobile was false on mobile devices. This rendered the
desktop layout first, hiding the queue tab until the effect fired.
useSyncExternalStore reads matchMedia synchronously on the client so
the correct layout is used on the first render with no extra paint.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: revalidate draft room when Clerk JWT expires at load time
Clerk's short-lived JWTs (~1 min) can expire between navigations.
Server-side clerkMiddleware can't refresh them for client-side loader
fetches, so getAuth() returns userId=null — causing userTeam=undefined,
the queue button to disappear, and the tab to default to "board".
Added a useEffect that detects the mismatch: if the Clerk client SDK
has a valid userId but the loader ran without one, trigger revalidate()
so the loader re-runs with a fresh token and returns correct
userTeam/userQueue data.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: prevent infinite revalidation loop in auth guard
If the server consistently fails to populate currentUserId after JWT
refresh (e.g. misconfigured CLERK_SECRET_KEY), the previous effect
would loop indefinitely. A one-shot ref ensures we attempt auth
recovery at most once per mount.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Add text-base md:text-sm to all native <input> and <select> elements
in the draft room (including the ShadCN SelectTrigger) so iOS Safari
doesn't auto-zoom when focused.
Also extract the two near-duplicate participant selection dialogs into
a shared ParticipantSelectionDialog component (-236 lines), and replace
O(n) queue.find() scans with a memoized Map in AvailableParticipantsSection.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
- Show owner username as primary label in "On the Clock" section (mobile bar + desktop sidebar), with team name as a secondary label below
- Memoize currentDraftSlot to avoid O(n) array scan on every timer tick
- Derive currentDraftSlotOwnerName once instead of looking up ownerMap four times per render
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
- Change default desktop tab from "participants" to "board"
- Extract mobileTabs/mobileColCount before JSX to eliminate IIFE pattern
- Remove redundant h-full wrapper divs from TabsContent for consistency
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a pulsing yellow alert banner between the league header and content
sections when the season status is "draft", with a link to the draft room.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
On reconnect, revalidate() re-fetches fresh loader data but the local
useState copies (picks, currentPick, isPaused, isDraftComplete, queue)
never synced with the new values — leaving the UI stale until a manual
refresh.
- Watch revalidatorState (idle→loading→idle) to detect when a
revalidation completes and apply the fresh loader snapshot to all
affected local state
- Buffer pick-made socket events received during the revalidation window
(instead of discarding or double-applying them); merge into the DB
snapshot on completion, deduplicated by pick ID
- Keep setCurrentPick paired with setPicks in handlePickMade so the
"on the clock" indicator and the picks list never desync
- Sync queue state from fresh userQueue after revalidation so
participants drafted while the user was away disappear from their
queue even if the participant-removed-from-queues events were missed
Adds useDraftSocket reconnect test suite (9 tests) covering initial
connect, socket reconnect, visibility-change triggering, network
flapping, error exhaustion, and cleanup.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* Redesign autodraft queue system with three-state control and queue-only constraint
Core Logic & Database:
- Add `queue_only` boolean column to `autodraft_settings` (migration 0031)
- Rename autodraft UI states: Off / Next Pick / All Picks (while_on mode maps to All Picks)
- `autoPickForTeam`: respects new `queueOnly` param — skips EV fallback when enabled
- `executeAutoPick`: auto-disables autodraft + emits socket event when queue empties with queueOnly ON (AC3)
- `autodraft-updated` socket event now includes `queueOnly` field
Mobile UI Overhaul:
- Rename "Lobby" tab → "Available" (AC6)
- Add new "Queue" tab to mobile bottom nav with drag-reorder, per-item Draft buttons, and autodraft controls (AC5)
- Controls tab retains commissioner tools, notifications, exit; queue controls moved to Queue tab
- Turn indicator appears on both Available and Queue tabs
Components:
- `AutodraftSettings`: replaces toggle+radio with three-state button group (Off | Next Pick | All Picks) + "Only autodraft from queue" switch (AC1, AC2)
- `QueueSection`: adds `canPick` prop + per-item Draft buttons for instant drafting when on the clock
Desktop (AC4):
- Sidebar QueueSection unchanged in position; gains same three-state controls and Draft buttons
Tests (AC7):
- `autodraft.test.ts`: updated for queueOnly field and socket event shape
- `timer-autodraft.test.ts`: new tests for queue-only constraint, auto-shutoff transitions, and all three autodraft states
https://claude.ai/code/session_01PYhJicAStoJ2u6q6dV1naB
* fix: remove erroneous ?? fallbacks in autodraft socket emissions and add autoPickForTeam tests
- Remove `?? true` default on queueOnly in queue-empty auto-disable socket emit
(line 488) — was dead code since the column is NOT NULL, but semantically wrong
and would have caused client-side UI desync if the type ever relaxed
- Remove `?? false` default on the next_pick auto-disable path for consistency
- Add app/models/__tests__/auto-pick.test.ts with 6 tests covering the queueOnly
constraint: empty queue, all items drafted, partial queue skip, and EV fallback
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: add missing queueOnly prop to AutodraftSettings test fixtures
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test: rewrite AutodraftSettings tests for three-state button group UI
The component was redesigned from a switch + radio buttons to Off/Next Pick/All Picks
buttons with a separate queue-only Switch toggle. Updated 17 stale tests and added 5
new tests covering the queue-only toggle and the All Picks/Off button interactions.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
- Reduce ping detection window from ~45s to ~15s (pingTimeout 5s, pingInterval 10s)
- Listen to window offline/online events for instant UI feedback on network drop
- Force immediate reconnect on online event instead of waiting for backoff
- Fix stuck-reconnecting bug when network returns within ping timeout window
- Wrap on/off/emit in useCallback to prevent listener re-registration every second
- Expose reconnectCount to trigger revalidator.revalidate() after each reconnect
- Reset hasConnectedOnce ref per socket instance to avoid spurious revalidations
- Remove stale socket ref from hook return value; add emit helper instead
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add optimistic remove to handleRemoveFromQueue (was missing, causing visible lag)
- Fix stale closure on previousQueue in reorder/remove by capturing inside setQueue updater
- Replace filter(Boolean) with typed type guard filter in handleReorderQueue
- Add QueueItem type alias from schema.$inferSelect, typed useState, remove all `any` casts
- Complete temp item fields in handleAddToQueue to satisfy QueueItem type
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
- Display sport name as muted subtitle under participant name to distinguish
players across sports (e.g. Florida NCAAM vs Florida NCAAW)
- Remove unused eligibility prop from QueueSection
- Deduplicate double .find() per queue item into a single lookup
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
- DraftGrid: wrap header and rows in a single min-w-max container so
column widths share a sizing context and can't desync
- Settings: wrap team + draft slot creation in a DB transaction so a
slot insert failure can't leave orphaned teams with no slots
- Settings: replace Math.max(...spread) with reduce to avoid the JS
argument limit (defensive, given the 6-16 team cap)
- Settings: remove now-unused createManyTeams import
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
- Fix settings action silently resetting timer values when draft speed
select is disabled (add null guard before overwriting draftInitialTime/
draftIncrementTime)
- Make timer decrement and pick increment atomic using SQL expressions to
prevent read-modify-write races between the timer loop and HTTP handlers
- Add UNIQUE INDEX on (season_id, pick_number) in draft_picks to prevent
duplicate picks from concurrent requests (TOCTOU guard)
- Add socket join-draft team ownership validation via DB query
- Add iteration cap to autodraft chain while loop (max = totalTeams)
- Add draftPaused re-check before firing autodraft chain in make-pick
and force-manual-pick
- Consolidate all snake draft calculations into calculatePickInfo (DRY);
remove duplicated logic from timer.ts, make-pick, force-manual-pick,
executeAutoPick, and checkAndTriggerNextAutodraft
- Fix calculatePickInfo to return snake-adjusted pickInRound matching
draftOrder values instead of raw pre-snake value
- Fix timer-update socket events to emit nextPickNumber after a pick
instead of the already-completed currentPickNumber
- Fix force-manual-pick to validate submitted teamId matches the team
whose turn it actually is at the given pick number
- Replace inline timer init in draft.start with deleteSeasonTimers +
initializeDraftTimers model functions (dead code fix)
- Fix draft loader to not crash when getTeamQueue fails (returns [])
- Fix misleading 403 message for commissioner picks
- Remove dead hidden inputs from league settings form
- Document connectedTeams single-instance limitation in socket.ts
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove 10-pick limit in SidebarRecentPicks
- Fix scroll by using max-h-[45vh] on AccordionContent instead of the
broken flex-1 approach (AccordionPrimitive.Content has overflow-hidden
hardcoded, making flex-1 and overflow-y-auto ineffective without a
bounded height)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
When a league has the "Make draft board publicly accessible" setting enabled,
unauthenticated users could view the draft-board route but were incorrectly
blocked from the draft room route with a 401. Restructure the loader to check
isPublicDraftBoard before enforcing auth/membership requirements, matching the
behaviour already implemented in the draft-board route.
https://claude.ai/code/session_016JiVAG2FrPYjAvSJi3daGD
Co-authored-by: Claude <noreply@anthropic.com>
Replace h-screen (100vh) with h-dvh (100dvh) on the root draft room
container. On mobile browsers, 100vh equals the max viewport height
when the address bar is hidden, causing the page to overflow when the
address bar is visible. 100dvh (dynamic viewport height) correctly
adjusts to the actual visible area at all times.
https://claude.ai/code/session_01GecUeTHw4Mc2ErSYJ6WXae
Co-authored-by: Claude <noreply@anthropic.com>
- Add bottom nav bar (Lobby/Board/Roster/Controls) with 56px touch targets
- Add mobile card list view in AvailableParticipantsSection with 44px Draft/Queue buttons
- Add round labels and sticky header row to DraftGridSection grid
- Add mobile Sheet for commissioner actions in DraftGridSection (replaces right-click context menu)
- Use useMediaQuery to render a single layout tree, eliminating duplicate component
instances (previously both desktop and mobile layouts were always in the DOM)
- Add compact header on mobile (text-lg vs text-3xl); hide commissioner buttons
and Exit link in header on mobile — surfaced in Controls tab instead
- Add "On Clock" bar on mobile showing current team and timer during active draft
- Add "your turn" indicator dot on Lobby bottom nav tab
- Default mobile tab to "board" for commissioner-only viewers
- Fix sticky corner cell z-index in TeamsDraftedGrid (z-10 → z-20)
- Fix round column spacer in DraftGridSection header not being sticky-left
- Extract shared prop objects to eliminate copy-paste between layouts
- Extract getParticipantState() helper to deduplicate participant render logic
- Move MobileSheetData type and MOBILE_TABS array to module scope
- Remove unnecessary non-null assertions in DraftGridSection Sheet handlers
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>