Code review of 41a0237 found the same-origin Referer exception never
fires in production, which defeats the guarantee that change was built
on: broken internal links stay visible while scanners are dropped.
@react-router/express builds request.url from req.protocol and nothing
sets `trust proxy`, so request.url reads http:// inside the container
while browsers send an https:// Referer. Comparing full origins never
matched, so every router-internal 4xx was dropped. Compare host instead
-- protocol says nothing about whether a link was ours, and this is the
same mismatch $leagueId.server.ts already works around for invite URLs.
Adding `trust proxy` would fix it too but changes req.ip and req.secure
server-wide for one comparison.
Removing ignoreErrors also dropped the .css/.js filters with nothing to
replace them. Stale clients request the previous deploy's hashed bundles
with a same-host Referer, so once the Referer check worked, every deploy
would spike Sentry. Assets now never report, referer or not.
Narrow the dropped statuses from all 4xx to 404 and 405, the two that
mean nothing matched the request. React Router's internal 400 (route
missing a loader) and 403 (route does not match URL) are real
misconfigurations and were being swallowed whenever no Referer was sent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESrxBMZMx2BD9rcKTCgLe4
Automated scanners probing for WordPress paths (/blog/wp/v2/posts/*,
POST /) generate a React Router 404 or 405 on every hit, and handleError
forwarded all of them to Sentry, exhausting the quota. The existing
defence was a list of ignoreErrors regexes that needed a new entry for
each scanner pattern.
Filter on what the error is instead: shouldReportServerError drops 4xx
responses React Router generated itself (internal: true) for requests
that matched nothing. Responses the app throws deliberately still report,
as do all 5xx and real exceptions.
A request carrying a same-origin Referer is still reported, so a broken
internal link remains visible in Sentry -- only cold scanner hits are
dropped. No HTTP response changes; every URL returns the status and page
it did before.
Also adds /blog to the Express bot-probe filter so the highest-volume
path 404s without an SSR render, and removes the now-redundant
route-404 ignoreErrors entries.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESrxBMZMx2BD9rcKTCgLe4
The two Wildcard Round winners were crossed into the Elimination Finals by
which game they came out of — the 7v10 winner always met 6th and the 8v9
winner always met 5th. The AFL pairs those games by ladder position, as the
classic final eight pairs 5v8 and 6v7: the higher host draws the
lower-ranked survivor. So when 10th wins through, 5th should meet 10th, not
the 8v9 winner.
Placement now resolves both games together. A 7v10 winner is placed as soon
as it is decided (7th outranks either possible opponent, 10th is outranked
by both), while an 8v9 winner is held until the other game is decided rather
than being placed and later moved, so results can be entered in either
order.
The simulator paired the Elimination Finals the same fixed way, which biased
every projection that ran off an undecided Wildcard Round; it now re-seeds
too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDbHrCce1UhahbkwKkc7hK
Review caught that the generic feeder rule was being applied to templates
that route by their own logic. It was harmless as dead code; driving the
renderer with it made several brackets worse than before.
The rule pairs rounds by array order and assumes match n is fed by 2n-1
and 2n. That describes advanceWinnerTemplate, not every bracket:
- afl_10's Wildcard Round feeds the Elimination Finals, skipping the round
listed next to it, so array order fabricated the entire chain and drew
ten wrong connectors contradicting advanceAFLWinner.
- fifa_48's Third Place Game sits between the Semifinals and the Finals,
so the Finals came out fed by the third place game. Once BracketTreeView
filtered the consolation round out, the group had three roots and the
whole World Cup bracket rendered with no connectors at all.
- ncaa_68 labelled Round of 64 #1/#2 with First Four feeds that
advanceFirstFourWinner doesn't use.
- nba_20's play-in halves in size but pairs the 7v8 loser with the 9v10
winner.
Follow each round's declared feedsInto, and derive edges only where the
round halves exactly — the condition under which the generic ceil(n/2)
mapping is true. Bespoke transitions that happen to halve are named
explicitly. Slots left without a feeder read TBD, which is honest.
Dropping those edges sends the group to the fallback, so the fallback now
has to keep drawing what those brackets already drew: halving U-shapes by
round size, and winner tracing through irregular shapes. Previously it
drew nothing, which also silently removed every connector from brackets
with no bracketTemplateId.
Also from review:
- clear-bracket deleted seasonParticipantResults for the entire sports
season with no rebuild. That table is keyed by season, not event, so it
wiped placements for every other event in the season — permanently
zeroing standings on a finalized qualifying season. Delete only the
matches and point the admin at Reprocess Bracket, which rebuilds
placements correctly.
- The clear-bracket form sent confirm=true from a hidden field, making the
server's completed-match guard unreachable. It's a checkbox now, so the
guard is real, including without JS.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HnzbrCHoM8ESbtbDamaqFb
The LLWS bracket didn't read as a bracket: cards sat above games that
don't feed them, connectors joined the wrong pairs, and several games had
no line at all.
The stored data was correct — LLWS_ADVANCEMENT already matches the
official 2026 LLBWS bracket game for game. The renderer was the problem.
TreeColumns placed cards at `index * (height / roundSize)` and
ConnectorColumn assumed matches 2k and 2k+1 feed match k, which holds
only for an exact halving. The LLWS winners bracket is not one: two of
the four Opening Round games skip Winners Round 2 and go straight to the
semifinals, so those two got stranded in column one with nothing beside
them, and the halving branch drew confident, wrong connectors for the
rest.
Lay out from the graph instead. app/lib/bracket-layout.ts inverts a
template's advancement into "what fills each slot", then assigns columns
by depth from the group's final, orders each column by the parent's slot
order, and centres each card on its feeders. Counting back from the final
is what makes a printed bracket line up: a team entering late is drawn in
the column where it actually plays. This reproduces the official
International bracket exactly, and fixes Elimination Round 3, where the
official bracket prints the later game on top but match-number sort put
it below.
Because column is depth, every in-group edge spans exactly one gutter, so
connectors now draw for unplayed games too. Cards also take a fixed
height rather than stretching to fill their column, which is what made a
lone final tower over the rest.
Empty slots name their source — "Loser of Winners SF 1" rather than
"TBD". That is the only way to show the feeds crossing between the
winners and elimination brackets, which render as separate trees.
Also:
- Move the LLWS routing table to app/lib/llws-bracket.ts so the renderer
can import it without pulling the database context into the browser
bundle; models/playoff-match re-exports it.
- Page the mobile view one group at a time, matching desktop. A whole
double-elimination phase is a DAG, not a tree, so its columns would be
arbitrary.
- Add a clear-bracket admin action. Nothing else could rewrite a match's
participants, so a mis-seeded bracket had no repair path at all.
- Lift the PDF transcription into app/test/fixtures/llws-bracket.ts so the
routing and layout tests check against one copy of the official bracket.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HnzbrCHoM8ESbtbDamaqFb
Add a per-event "Sync Draw" that pulls a tennis major's full 128-player
draw from its Wikipedia article, auto-creates/links participants (and
propagates them to every linked sibling season), builds the bracket, and
runs the qualifying-points scorer. Re-running advances the bracket and
scoring as matches complete.
Core
- match-sync: WikipediaTennisAdapter + wikitext bracket parser, DrawSync
DTOs, syncTennisDraw orchestrator, pure draw->rows mapping
- playoff-match: populateBracketFromDraw (idempotent upsert on externalMatchId)
- scoring_events.externalSourceKey column (Wikipedia article; migration 0123)
- admin bracket "Sync Draw" card (accepts URL or title) + cron pass
Scoring fix
- deriveBracketQualifyingStates only floors players who have reached the
scoring stage; for deep brackets (tennis_128) early-round losers earn 0 QP
instead of a phantom 9th-place floor. CS2/simple_8 behavior preserved
(gated on rounds[0].isScoring). Re-sync reconciles stale QP rows in a
transaction.
Matching & parsing
- accent-folding in normalizeTeamName; strip Wikipedia "(tennis)"
disambiguators; treat Bye/TBD/Qualifier as TBD; extract wikilink before
template-stripping so {{nowrap}}-wrapped players parse
Admin UX
- dry-run preview (matched / will-create / possible duplicates / unfilled
slots) with inline "rename existing" / "create as new" resolution via
fetcher (no full reload)
Tests: parser vs real 2025 Wimbledon fixture, draw mapping, tennis_128
scoring, accent/disambiguator/nowrap parsing, URL parsing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The World Cup Monte Carlo simulator ignored the admin-populated knockout
draw and re-paired its own simulated group output sequentially, so bracket
paths from the Round of 32 onward did not match the real tournament. It
also silently invented 12 synthetic groups when none were configured,
discarding real group-stage results.
Rework the simulator around three regimes:
- draw set → simulate the real R32 matchups, advancing through the
bracket tree via the canonical ceil(matchNumber/2) rule (matching
advanceWinnerTemplate) so sim paths match the admin tooling.
- groups only → simulate remaining group matches, then seed the R32 with
the official 2026 group-position template + FIFA's exact Annex C
best-third-place allocation (495-row published table).
- neither → futures-seeded bracket fallback, flagged via result source.
Completed group and knockout matches still lock in real results at every
round.
New app/lib/fifa-2026-bracket.ts encodes the R32 group-position template
(official FIFA matches 73-88 mapped onto DB matchNumbers 1-16 so the tree
reproduces the real halves) and assignThirdPlaceSlots(). The Annex C table
is generated deterministically by scripts/gen-fifa-third-place.mjs into
app/lib/fifa-2026-third-place-allocation.ts.
Tests assert exact Annex C values, cross-check the template's eligible
groups against all 495 combinations, and cover each regime including the
partial-draw dedup and fully-drawn fast path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Fix draft order showing only new team when league size increased before order was set
When a commissioner increased the team count on a league that had teams
but no draft order set yet, the server created draft slots only for the
newly-added teams. This left the DB with N+K teams but only K slots,
causing the drag-and-drop list to display only the K new teams.
Two fixes:
1. Server: only append new draft slots when an order was already set
(existingSlots.length > 0). If no order exists yet, skip slot creation
so the page correctly treats the order as unset for all teams.
2. Frontend: buildDraftOrderTeams() appends any unslotted teams after the
slotted ones, so a partially-corrupt DB state still shows all teams.
https://claude.ai/code/session_01M3H55gnMxRztJK9KMXqqZo
* Address code review feedback on draft order bug fix
- Move buildDraftOrderTeams to app/lib/draft-order.ts so it is importable
and testable; remove the local copy from the settings component
- Add unit tests for buildDraftOrderTeams covering the empty, full, and
partial-slot cases
- Tighten the draft slot guard from existingSlots.length > 0 to
existingSlots.length === currentTeamCount so partial legacy states are
treated the same as "order not set"
- Condense the 3-line server comment to a single line per project style
- Rename getNumTeamsInSeason → getNumDraftSlotsBySeasonId to reflect what
the function actually counts, and update its one call site
- Add tests for the server-side slot-creation guard logic
https://claude.ai/code/session_01M3H55gnMxRztJK9KMXqqZo
* Fix lint: move shouldAppendDraftSlots to outer scope
oxlint (consistent-function-scoping) requires functions that don't close
over any variables to be defined at the outer scope rather than inside a
describe block.
https://claude.ai/code/session_01M3H55gnMxRztJK9KMXqqZo
---------
Co-authored-by: Claude <noreply@anthropic.com>
* Fix custom chess-clock timer detection and save settings blocker
- Extract getInitialDraftSpeed() helper so both useState init and
resetSettingsFormState use the same logic; unrecognized presets
now produce a "custom:{bank}:{incr}" string instead of falling
back to "standard", so the custom section auto-expands correctly
(e.g. 8 hr bank + 30 min increment is no longer shown as Standard)
- resetSettingsFormState was not resetting draftSpeed at all; fixed
- Clear hasUnsavedSettingsChanges in the Form's onSubmit so the
useBlocker check is false at the moment the save navigation starts,
preventing the "Leave without saving?" dialog from firing on save
- Replace the cleared-on-success useEffect with one that re-marks
dirty when the action returns an error, so the warning reappears
after a failed save
https://claude.ai/code/session_01DgHNkvJXGizx41CE2tkVS1
* Address code review: single source of truth for presets, useEffect guard, tests
- Extract CHESS_CLOCK_PRESETS (pure data) into draft-timer.ts as the
canonical source of truth; DraftSpeedPicker.tsx now spreads those
values into CHESS_PRESETS rather than duplicating the numbers
- getInitialDraftSpeed moved to draft-timer.ts and rewritten to use
CHESS_CLOCK_PRESETS.find() — no more hardcoded bankSec/incrSec values
in three separate places
- parseDraftSpeed switch replaced with CHESS_CLOCK_PRESETS.find() and
an explicit fallback comment; eliminates the silent default-handles-
"standard" fragility called out in review
- useEffect that re-marks settings dirty now guards against non-settings
errors (draft-order, commissioner actions) which carry a `section`
field — previously any error actionData would spuriously set
hasUnsavedSettingsChanges=true
- Add parseDraftSpeed and getInitialDraftSpeed test suites to
draft-timer.test.ts, including parametrised preset coverage via
it.each(CHESS_CLOCK_PRESETS)
https://claude.ai/code/session_01DgHNkvJXGizx41CE2tkVS1
* Fix lint: use !== null/undefined instead of != null
oxlint enforces eqeqeq; the null check in getInitialDraftSpeed used
!= which triggered two errors.
https://claude.ai/code/session_01DgHNkvJXGizx41CE2tkVS1
---------
Co-authored-by: Claude <noreply@anthropic.com>
Introduces a shared email module that wraps all transactional emails in a
cohesive dark-themed template matching the site's visual identity (Barlow
font, #14171e background, #adf661 CTA buttons, logomark PNG header).
- app/lib/email.server.ts: new module with sendEmail, wrapInEmailTemplate,
emailButton, emailParagraph, and escapeHtml helpers; Resend instance is
lazily initialized as a singleton
- public/email-logo.png: 96×124px PNG of the logomark for email use (SVG
gradients are not supported in Outlook)
- auth.server.ts: password reset and email verification now use the branded
template; errors are thrown so Better Auth can surface failures
- support.tsx: internal notification uses sendEmail; adds a user-facing
branded confirmation email (fire-and-forget so failures don't surface to
the user); plain text fallback included
- scripts/sync-prod-db.sh: remove first_name/last_name from sanitize query
(columns were dropped in #399)
- 13 unit tests covering all template helpers
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Closes#72
Upgrades ineligibleReasons from Record<string, string> to a structured
{code, message} type so the UI can render the human-readable reason
inline below the Ineligible badge in both AvailableParticipantsSection
and ParticipantSelectionDialog. API routes surface the message in 400
responses unchanged.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add overnight pause feature for draft timers
Protects players from having their pick timer expire while asleep.
Admins configure a nightly window (league-wide or per-user timezone);
the timer freezes during that window while autodraft can still fire.
Fixes#66
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix TS error: guard getUserDisplayName against undefined user
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Show visual dividers in the available participants list indicating where
your future draft picks fall relative to the current pick position.
Dividers are suppressed when any filter is active (search, sport filter,
hide drafted, etc.) since positional references are meaningless in a
filtered view.
- Add getProjectedPicks() to draft-order.ts for computing future pick
positions in a snake draft
- Interleave divider items into the virtualized list in
AvailableParticipantsSection
- Extract ListItem type to module scope
- Add tests for getProjectedPicks, divider rendering, and filter
suppression
Fixes#289
* docs: add batch qualifying results entry design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs: add batch qualifying results entry implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore: ignore .worktrees directory
* feat: add qualifying results text parser with tests
* fix: handle no-space after dot separator, tighten T-prefix to uppercase only
* feat: add batch-add-results server intent with duplicate filtering
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat: add create-participant intent for inline participant creation
* fix: trim sportsSeasonId validation in create-participant intent
* feat: fill 0-QP rows for unplaced participants on qualifying event finalize
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* perf: use bulk insert for 0-QP rows on qualifying event finalize
* feat: add BatchResultEntry component for paste-and-parse batch result import
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: disable add participant button while create request is in flight
* feat: render BatchResultEntry on qualifying event pages
Add BatchResultEntry component import and render it on event pages when
the event is a qualifying event, not a final_standings/playoff_game type,
and not yet complete.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: address final review issues (sportsSeasonId authority, participant validation, CRLF, unused prop)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* Show tied ranks with T prefix across standings and Discord notifications, fixes#197
- Add buildTiedRankChecker() utility to standings-display.ts; replaces
four copies of inline rank-count logic spread across components,
the league home route, and the Discord service
- Prefix shared ranks with "T" (e.g. "T3") in StandingsTable (league
panel), StandingsTable (full standings), the league home preview, and
Discord webhook notifications
- Add useMemo wrapping in StandingsTable components so tie detection
does not recompute on every render
- Fix compareTeamsForRanking to round total points to hundredths before
comparing, preventing floating-point noise from producing spurious
non-ties in the standings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix discord test to expect escaped period in rank display
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
- Fix UTC midnight rollover bug: server now queries from yesterday UTC
as a buffer; UpcomingCalendarPanel filters to local-today client-side
via useEffect + Intl.DateTimeFormat, removing the need for any
cookie or server-side timezone detection
- Cap homepage and league page panels at 6 events with a "View all" link
- Add /upcoming-events page (60-day view across all leagues)
- Add /leagues/:leagueId/upcoming-events page (60-day per-league view)
- Add emptyMessage prop to UpcomingCalendarPanel for context-specific copy
- Change getUpcomingEventsForDraftedParticipants to accept pre-computed
date strings instead of Date objects
fixes#213
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* 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>
* 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>
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>
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>
- 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>
* 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>
- 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>
- Add prominent clock badge to tab bar (lights up on your turn) with
correct Fischer increment chess-clock model: bank starts at initialTime,
+= incrementTime after each pick, other teams' banks untouched
- Extract pure timer helpers to app/lib/draft-timer.ts and add 29 unit
tests covering formatClockTime, calculateTimeAfterPick, getTimerColorClass,
and full snake-draft lifecycle regression scenarios
- Fix make-pick.ts: add status !== 'draft' and draftPaused server guards
- Fix draft-utils.ts: replace (global as any).__socketIO with getSocketIO(),
fix timeUsed to store actual timeRemaining at pick moment (not always 120),
add null timer warning, move timer fetch before pick insert
- Fix draft.start.ts: batch timer inserts, guard against empty draftSlots
- Fix DraftGridSection: memoize currentTeamId, widen teamTimers type to
Record<string, number | undefined>
- Fix duplicate animate-pulse (getTimerColorClass already includes it)
- Clamp negative seconds in formatClockTime to guard against timer drift
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
- Added recharts dependency to package.json for data visualization.
- Implemented PointProgressionChart component to display team point progression over time.
- Updated scoring-system.md to reflect completion of historical views and added implementation notes.
- Created unit tests for PointProgressionChart to ensure correct rendering and data handling.
- Developed season completion detection and percentage calculation functions in season-helpers.server.ts.
- Added tests for season completion logic and point progression data formatting.
- Enhanced league loader to fetch necessary data for current season and teams.
- Created draft eligibility calculation logic in `app/lib/draft-eligibility.ts`
- Added interfaces for sport availability and draft eligibility
- Implemented `calculateDraftEligibility` function to determine eligible sports based on team picks and global availability
- Developed `getEligibilitySummary` function for human-readable eligibility status
- Updated API endpoints to validate eligibility before making picks
- Enhanced frontend draft room UI to reflect eligibility status and provide visual indicators for ineligible participants
- Comprehensive implementation summary and testing documentation added
- All changes type-checked and unit tests passed successfully