From 4bffa40606cd8a694ce22216e419bb77ac38e8b3 Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Sat, 21 Mar 2026 10:59:51 -0700 Subject: [PATCH] Fix oxlint warnings: no-shadow, consistent-function-scoping, no-non-null-assertion, and others (#196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: Claude Sonnet 4.6 --- .claude/settings.json | 10 +++ .oxlintrc.json | 12 ++- app/components/StandingsTable.tsx | 54 ++++++------- .../__tests__/AutodraftSettings.test.tsx | 4 +- app/components/__tests__/DraftGrid.test.tsx | 14 ++-- app/components/draft/TeamsDraftedGrid.tsx | 5 +- app/components/draft/picks-utils.ts | 5 +- app/components/scoring/PlayoffBracket.tsx | 10 +-- app/components/scoring/SeasonStandings.tsx | 76 +++++++++--------- .../sport-season/RegularSeasonStandings.tsx | 6 +- .../standings/PointProgressionChart.tsx | 11 ++- .../standings/TeamScoreBreakdown.tsx | 2 +- app/hooks/useDraftNotifications.ts | 10 ++- app/lib/__tests__/date-utils.test.ts | 4 +- app/lib/__tests__/utils.test.ts | 52 ++++++------- app/models/__tests__/auto-pick.test.ts | 9 +-- app/models/__tests__/ncaa-68-bracket.test.ts | 24 +++--- app/models/__tests__/nfl-bracket.test.ts | 8 +- app/models/draft-pick.ts | 2 +- app/models/playoff-match.ts | 3 +- app/models/qualifying-points.ts | 2 +- app/models/scoring-calculator.ts | 12 +-- app/models/scoring-event.ts | 22 +++--- app/models/sport.ts | 14 ++-- app/models/sports-season.ts | 6 +- app/models/standings.ts | 7 +- app/routes/admin._index.tsx | 5 +- app/routes/admin.data-sync.tsx | 4 +- ...ts-seasons.$id.events.$eventId.bracket.tsx | 10 +-- .../admin.sports-seasons.$id.events.tsx | 42 +++++----- ...min.sports-seasons.$id.expected-values.tsx | 6 +- app/routes/admin.standings-snapshots.tsx | 2 +- .../leagues/$leagueId.draft.$seasonId.tsx | 4 +- app/routes/leagues/$leagueId.server.ts | 4 +- app/routes/leagues/$leagueId.settings.tsx | 18 ++--- ...d.sports-seasons.$sportsSeasonId.server.ts | 5 +- .../__tests__/draft-reconnection-sync.test.ts | 3 +- app/routes/leagues/new.tsx | 4 +- .../__tests__/bracket-simulator.test.ts | 18 +++-- app/services/__tests__/icm-calculator.test.ts | 63 ++++++++++----- .../__tests__/probability-engine.test.ts | 12 +-- app/services/bracket-simulator.ts | 3 +- app/services/icm-calculator.ts | 5 +- app/services/probability-updater.ts | 4 +- .../__tests__/ucl-simulator.test.ts | 22 +++--- .../simulations/auto-racing-simulator.ts | 37 +++++---- app/services/simulations/bracket-simulator.ts | 4 +- app/services/simulations/nba-simulator.ts | 28 +++---- app/services/simulations/ncaam-simulator.ts | 61 +++++++++------ app/services/simulations/ncaaw-simulator.ts | 61 +++++++++------ app/services/simulations/nhl-simulator.ts | 78 ++++++++++--------- app/services/simulations/ucl-simulator.ts | 35 ++++----- .../standings-sync/__tests__/nba.test.ts | 12 ++- app/services/standings-sync/index.ts | 6 +- cypress/support/e2e.ts | 1 - server/socket.ts | 9 ++- 56 files changed, 521 insertions(+), 429 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index ca25008..42e4124 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -10,6 +10,16 @@ } ] } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "npm run typecheck 2>&1" + } + ] + } ] } } diff --git a/.oxlintrc.json b/.oxlintrc.json index 8d3c6b6..4417162 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -23,7 +23,7 @@ { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" } ], "typescript/no-explicit-any": "error", - "typescript/no-non-null-assertion": "warn", + "typescript/no-non-null-assertion": "error", "typescript/consistent-type-imports": [ "error", { "prefer": "type-imports" } @@ -36,7 +36,15 @@ "react/no-array-index-key": "error", "react/self-closing-comp": "warn", - "import/no-duplicates": "error" + "import/no-duplicates": "error", + "import/no-unassigned-import": ["error", { + "allow": ["**/*.css", "@testing-library/jest-dom", "@testing-library/cypress/add-commands"] + }], + + "no-shadow": "error", + "unicorn/consistent-function-scoping": "error", + "unicorn/prefer-add-event-listener": "error", + "unicorn/require-module-specifiers": "error" }, "overrides": [ { diff --git a/app/components/StandingsTable.tsx b/app/components/StandingsTable.tsx index 8a6be01..99372c6 100644 --- a/app/components/StandingsTable.tsx +++ b/app/components/StandingsTable.tsx @@ -32,6 +32,33 @@ interface StandingsTableProps { showPlacementBreakdown?: boolean; } +function getRankBadge(rank: number) { + if (rank === 1) { + return ( +
+ + {rank} +
+ ); + } else if (rank === 2) { + return ( +
+ + {rank} +
+ ); + } else if (rank === 3) { + return ( +
+ + {rank} +
+ ); + } else { + return {rank}; + } +} + export function StandingsTable({ standings, showMovement = true, @@ -66,33 +93,6 @@ export function StandingsTable({ } }; - const getRankBadge = (rank: number) => { - if (rank === 1) { - return ( -
- - {rank} -
- ); - } else if (rank === 2) { - return ( -
- - {rank} -
- ); - } else if (rank === 3) { - return ( -
- - {rank} -
- ); - } else { - return {rank}; - } - }; - return ( diff --git a/app/components/__tests__/AutodraftSettings.test.tsx b/app/components/__tests__/AutodraftSettings.test.tsx index 22e7246..95a7fb8 100644 --- a/app/components/__tests__/AutodraftSettings.test.tsx +++ b/app/components/__tests__/AutodraftSettings.test.tsx @@ -145,7 +145,7 @@ describe('AutodraftSettings Component', () => { describe('Optimistic UI', () => { it('does not disable buttons while a fetch is in flight', async () => { - let resolve: (v: any) => void; + let resolve: ((v: any) => void) | undefined; (global.fetch as any).mockReturnValueOnce(new Promise((r) => { resolve = r; })); render(); @@ -154,7 +154,7 @@ describe('AutodraftSettings Component', () => { // Buttons stay enabled — optimistic UI does not block interaction expect(screen.getByRole('button', { name: 'Off' })).not.toBeDisabled(); - resolve!({ ok: true, json: async () => ({ success: true }) }); + resolve?.({ ok: true, json: async () => ({ success: true }) }); }); it('fires a fetch for every rapid click, aborting previous in-flight requests', async () => { diff --git a/app/components/__tests__/DraftGrid.test.tsx b/app/components/__tests__/DraftGrid.test.tsx index a7f069d..4c80d46 100644 --- a/app/components/__tests__/DraftGrid.test.tsx +++ b/app/components/__tests__/DraftGrid.test.tsx @@ -3,6 +3,13 @@ import { render, screen } from '@testing-library/react'; import { DraftGrid } from '../DraftGrid'; import { mockDraftSlots } from '~/test/fixtures/team'; +function formatTime(seconds: number | undefined) { + if (seconds === undefined) return '--:--'; + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, '0')}`; +} + describe('DraftGrid Component', () => { const mockGrid = [ [ @@ -117,13 +124,6 @@ describe('DraftGrid Component', () => { }); it('should display timers when provided', () => { - const formatTime = (seconds: number | undefined) => { - if (seconds === undefined) return '--:--'; - const mins = Math.floor(seconds / 60); - const secs = seconds % 60; - return `${mins}:${secs.toString().padStart(2, '0')}`; - }; - render( (); + if (!map.has(pick.team.id)) map.set(pick.team.id, teamMap); if (!teamMap.has(pick.sport.id)) { teamMap.set(pick.sport.id, []); } - teamMap.get(pick.sport.id)!.push(pick); + teamMap.get(pick.sport.id)?.push(pick); }); return map; diff --git a/app/components/draft/picks-utils.ts b/app/components/draft/picks-utils.ts index 733cd5a..a521c67 100644 --- a/app/components/draft/picks-utils.ts +++ b/app/components/draft/picks-utils.ts @@ -11,9 +11,10 @@ export function groupPicksByTeamAndSport( const map = new Map>(); picks.forEach((pick) => { if (!map.has(pick.team.id)) map.set(pick.team.id, new Map()); - const teamMap = map.get(pick.team.id)!; + const teamMap = map.get(pick.team.id) ?? new Map(); + if (!map.has(pick.team.id)) map.set(pick.team.id, teamMap); if (!teamMap.has(pick.sport.id)) teamMap.set(pick.sport.id, []); - teamMap.get(pick.sport.id)!.push(pick); + teamMap.get(pick.sport.id)?.push(pick); }); return map; } diff --git a/app/components/scoring/PlayoffBracket.tsx b/app/components/scoring/PlayoffBracket.tsx index 993d587..0083759 100644 --- a/app/components/scoring/PlayoffBracket.tsx +++ b/app/components/scoring/PlayoffBracket.tsx @@ -58,7 +58,7 @@ export function groupMatchesByRound(matches: Match[]): Map { const byRound = new Map(); for (const match of matches) { if (!byRound.has(match.round)) byRound.set(match.round, []); - byRound.get(match.round)!.push(match); + byRound.get(match.round)?.push(match); } for (const group of byRound.values()) { group.sort((a, b) => a.matchNumber - b.matchNumber); @@ -153,7 +153,7 @@ export function computeEliminatedByRound( // Double-chance survivor: participant appeared in a LATER round — not yet eliminated here. if (loserLatestAppear > lossRoundIndex) continue; if (!result.has(match.round)) result.set(match.round, []); - result.get(match.round)!.push(match.loser.id); + result.get(match.round)?.push(match.loser.id); } return result; } @@ -211,7 +211,7 @@ export function PlayoffBracket({ : match.participant2Score; if (loserScore) _hasScore = true; if (!losersByRound.has(match.round)) losersByRound.set(match.round, []); - losersByRound.get(match.round)!.push({ + losersByRound.get(match.round)?.push({ participant: match.loser, score: loserScore, ownership: ownershipMap.get(match.loser.id) || null, @@ -331,8 +331,8 @@ export function PlayoffBracket({ const p1IsTbd = !match.participant1Id; const p2IsTbd = !match.participant2Id; - const p1IsOwned = !p1IsTbd && userParticipantSet.has(match.participant1Id!); - const p2IsOwned = !p2IsTbd && userParticipantSet.has(match.participant2Id!); + const p1IsOwned = !p1IsTbd && userParticipantSet.has(match.participant1Id ?? ""); + const p2IsOwned = !p2IsTbd && userParticipantSet.has(match.participant2Id ?? ""); const matchHasOwned = p1IsOwned || p2IsOwned; const p1Ownership = diff --git a/app/components/scoring/SeasonStandings.tsx b/app/components/scoring/SeasonStandings.tsx index 8f0378c..8af7da5 100644 --- a/app/components/scoring/SeasonStandings.tsx +++ b/app/components/scoring/SeasonStandings.tsx @@ -55,6 +55,43 @@ interface SeasonStandingsProps { * - Movement indicators (position changes) * - Handles ties (same points = same position) */ +function getMovementIndicator( + currentPosition: number, + previousPosition?: number | null +) { + if (!previousPosition) return null; + + const change = previousPosition - currentPosition; // Positive means moved up + + if (change > 0) { + return ( +
+ + +{change} +
+ ); + } else if (change < 0) { + return ( +
+ + {change} +
+ ); + } else { + return ( +
+ +
+ ); + } +} + +function getPositionBadge(position: number, isTied: boolean) { + const suffix = position === 1 ? "st" : position === 2 ? "nd" : position === 3 ? "rd" : "th"; + const positionText = isTied ? `T${position}` : `${position}${suffix}`; + return {positionText}; +} + export function SeasonStandings({ standings, teamOwnerships = [], @@ -77,45 +114,6 @@ export function SeasonStandings({ return ownershipMap.get(participantId) || null; }; - // Get movement indicator - const getMovementIndicator = ( - currentPosition: number, - previousPosition?: number | null - ) => { - if (!previousPosition) return null; - - const change = previousPosition - currentPosition; // Positive means moved up - - if (change > 0) { - return ( -
- - +{change} -
- ); - } else if (change < 0) { - return ( -
- - {change} -
- ); - } else { - return ( -
- -
- ); - } - }; - - // Get position display - const getPositionBadge = (position: number, isTied: boolean) => { - const suffix = position === 1 ? "st" : position === 2 ? "nd" : position === 3 ? "rd" : "th"; - const positionText = isTied ? `T${position}` : `${position}${suffix}`; - return {positionText}; - }; - // Check if multiple participants share the same position const checkIfTied = (standing: SeasonStanding): boolean => { return standings.some( diff --git a/app/components/sport-season/RegularSeasonStandings.tsx b/app/components/sport-season/RegularSeasonStandings.tsx index 765b069..f5a1f2b 100644 --- a/app/components/sport-season/RegularSeasonStandings.tsx +++ b/app/components/sport-season/RegularSeasonStandings.tsx @@ -111,7 +111,7 @@ function buildFlatSections( for (const row of standings) { const conf = row.conference ?? "Other"; if (!confMap.has(conf)) confMap.set(conf, []); - confMap.get(conf)!.push(row); + confMap.get(conf)?.push(row); } return Array.from(confMap.entries()).map(([conference, rows]) => ({ @@ -138,7 +138,7 @@ function buildNhlSections( for (const row of standings) { const conf = row.conference ?? "Other"; if (!confMap.has(conf)) confMap.set(conf, []); - confMap.get(conf)!.push(row); + confMap.get(conf)?.push(row); } return Array.from(confMap.entries()).map(([conference, rows]) => { @@ -146,7 +146,7 @@ function buildNhlSections( for (const row of rows) { const div = row.division ?? ""; if (!divMap.has(div)) divMap.set(div, []); - divMap.get(div)!.push(row); + divMap.get(div)?.push(row); } const divisions = Array.from(divMap.entries()) diff --git a/app/components/standings/PointProgressionChart.tsx b/app/components/standings/PointProgressionChart.tsx index aa2f3eb..ec867bc 100644 --- a/app/components/standings/PointProgressionChart.tsx +++ b/app/components/standings/PointProgressionChart.tsx @@ -30,6 +30,11 @@ const COLORS = [ '#6366f1', // indigo ]; +function formatDate(dateStr: string) { + const date = new Date(dateStr); + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + export function PointProgressionChart({ chartData, teams }: PointProgressionChartProps) { if (chartData.length === 0) { return ( @@ -48,12 +53,6 @@ export function PointProgressionChart({ chartData, teams }: PointProgressionChar ); } - // Format date for display - const formatDate = (dateStr: string) => { - const date = new Date(dateStr); - return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); - }; - return ( diff --git a/app/components/standings/TeamScoreBreakdown.tsx b/app/components/standings/TeamScoreBreakdown.tsx index dde5e39..8416ea4 100644 --- a/app/components/standings/TeamScoreBreakdown.tsx +++ b/app/components/standings/TeamScoreBreakdown.tsx @@ -139,7 +139,7 @@ export function TeamScoreBreakdown({ (pick.finalPosition ?? 0) === 0 ? ( Did Not Score ) : ( - + ) ) : ( Pending diff --git a/app/hooks/useDraftNotifications.ts b/app/hooks/useDraftNotifications.ts index 7952c81..036eea0 100644 --- a/app/hooks/useDraftNotifications.ts +++ b/app/hooks/useDraftNotifications.ts @@ -44,18 +44,20 @@ export function useDraftNotifications(seasonId: string, userId: string) { // would still be null at that point without the flag). let aborted = false; let permissionStatus: PermissionStatus | null = null; + let changeHandler: (() => void) | null = null; navigator.permissions .query({ name: "notifications" }) .then((status) => { if (aborted) return; permissionStatus = status; - status.onchange = () => { + changeHandler = () => { setPermissionState(status.state as NotificationPermission); // If permission was revoked, disable notifications if (status.state !== "granted") { setEnabledState(false); } }; + status.addEventListener("change", changeHandler); }) .catch(() => { // Permissions API not available in all environments; silently ignore @@ -63,8 +65,8 @@ export function useDraftNotifications(seasonId: string, userId: string) { return () => { aborted = true; - if (permissionStatus) { - permissionStatus.onchange = null; + if (permissionStatus && changeHandler) { + permissionStatus.removeEventListener("change", changeHandler); } }; }, [seasonId, userId]); @@ -120,7 +122,7 @@ export function useDraftNotifications(seasonId: string, userId: string) { body, tag: `draft-${seasonId}`, }); - n.onclick = () => window.focus(); + n.addEventListener("click", () => window.focus()); }, [enabled, seasonId] ); diff --git a/app/lib/__tests__/date-utils.test.ts b/app/lib/__tests__/date-utils.test.ts index 117ba70..0a4f81b 100644 --- a/app/lib/__tests__/date-utils.test.ts +++ b/app/lib/__tests__/date-utils.test.ts @@ -26,13 +26,13 @@ describe("localDateTimeToUtcIso", () => { const input = "2026-03-11T17:00"; const result = localDateTimeToUtcIso(input); expect(result).not.toBeNull(); - expect(new Date(result!).getTime()).toBe(new Date(input).getTime()); + expect(new Date(result ?? "").getTime()).toBe(new Date(input).getTime()); }); it("returns a string ending with 'Z' (UTC designator)", () => { const result = localDateTimeToUtcIso("2026-03-11T10:00"); expect(result).not.toBeNull(); - expect(result!.endsWith("Z")).toBe(true); + expect((result ?? "").endsWith("Z")).toBe(true); }); it("returns a full ISO-8601 string with milliseconds", () => { diff --git a/app/lib/__tests__/utils.test.ts b/app/lib/__tests__/utils.test.ts index 6c8b98b..9b16715 100644 --- a/app/lib/__tests__/utils.test.ts +++ b/app/lib/__tests__/utils.test.ts @@ -1,6 +1,32 @@ import { describe, it, expect } from 'vitest'; import { cn } from '~/lib/utils'; +function formatTime(seconds: number | undefined): string { + if (seconds === undefined) return '--:--'; + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, '0')}`; +} + +function isValidLeagueName(name: string): boolean { + return name.trim().length >= 3 && name.trim().length <= 50; +} + +type DraftSpeed = 'fast' | 'standard' | 'slow' | 'very-slow'; + +function getDraftTimes(speed: DraftSpeed): { initial: number; increment: number } { + switch (speed) { + case 'fast': + return { initial: 60, increment: 10 }; + case 'standard': + return { initial: 120, increment: 15 }; + case 'slow': + return { initial: 28800, increment: 3600 }; + case 'very-slow': + return { initial: 43200, increment: 3600 }; + } +} + describe('Utils', () => { describe('cn (className merger)', () => { it('should merge class names', () => { @@ -32,13 +58,6 @@ describe('Utils', () => { }); describe('Draft Time Formatting', () => { - const formatTime = (seconds: number | undefined): string => { - if (seconds === undefined) return '--:--'; - const mins = Math.floor(seconds / 60); - const secs = seconds % 60; - return `${mins}:${secs.toString().padStart(2, '0')}`; - }; - it('should format time correctly', () => { expect(formatTime(120)).toBe('2:00'); expect(formatTime(90)).toBe('1:30'); @@ -57,10 +76,6 @@ describe('Draft Time Formatting', () => { }); describe('League Name Validation', () => { - const isValidLeagueName = (name: string): boolean => { - return name.trim().length >= 3 && name.trim().length <= 50; - }; - it('should accept valid league names', () => { expect(isValidLeagueName('My League')).toBe(true); expect(isValidLeagueName('Test League 2025')).toBe(true); @@ -85,21 +100,6 @@ describe('League Name Validation', () => { }); describe('Draft Speed Presets', () => { - type DraftSpeed = 'fast' | 'standard' | 'slow' | 'very-slow'; - - const getDraftTimes = (speed: DraftSpeed): { initial: number; increment: number } => { - switch (speed) { - case 'fast': - return { initial: 60, increment: 10 }; - case 'standard': - return { initial: 120, increment: 15 }; - case 'slow': - return { initial: 28800, increment: 3600 }; - case 'very-slow': - return { initial: 43200, increment: 3600 }; - } - }; - it('should return correct times for fast speed', () => { const times = getDraftTimes('fast'); expect(times.initial).toBe(60); diff --git a/app/models/__tests__/auto-pick.test.ts b/app/models/__tests__/auto-pick.test.ts index e1dc588..f432448 100644 --- a/app/models/__tests__/auto-pick.test.ts +++ b/app/models/__tests__/auto-pick.test.ts @@ -406,6 +406,10 @@ describe("autoPickForTeam – queueOnly constraint (AC2 & AC3)", () => { }); }); +function makeSeasonQueues(entries: [string, { id: string; participantId: string; queuePosition: number }[]][]) { + return new Map(entries.map(([teamId, items]) => [teamId, items as any])); +} + describe("pruneIneligibleQueueItems", () => { beforeEach(() => { vi.clearAllMocks(); @@ -415,11 +419,6 @@ describe("pruneIneligibleQueueItems", () => { vi.mocked(getSeasonSportsSimple).mockResolvedValue([]); }); - // Helper to build the Map returned by getAllQueuesForSeason - function makeSeasonQueues(entries: [string, { id: string; participantId: string; queuePosition: number }[]][]) { - return new Map(entries.map(([teamId, items]) => [teamId, items as any])); - } - it("removes queued snooker player when snooker is no longer eligible for a team", async () => { vi.mocked(getDraftPicksWithSports).mockResolvedValue([]); vi.mocked(getParticipantsForSeasonWithSports).mockResolvedValue([ diff --git a/app/models/__tests__/ncaa-68-bracket.test.ts b/app/models/__tests__/ncaa-68-bracket.test.ts index 4aba622..830a3a4 100644 --- a/app/models/__tests__/ncaa-68-bracket.test.ts +++ b/app/models/__tests__/ncaa-68-bracket.test.ts @@ -157,7 +157,7 @@ describe("NCAA 68 Bracket Template - Phase 2.8", () => { it("has 8 teams in Elite Eight (top 8 scoring)", () => { const eliteEightRound = NCAA_68.rounds.find((r) => r.name === "Elite Eight"); - const teamsInEliteEight = eliteEightRound!.matchCount * 2; + const teamsInEliteEight = (eliteEightRound?.matchCount ?? 0) * 2; expect(teamsInEliteEight).toBe(8); }); @@ -216,32 +216,32 @@ describe("NCAA 68 Bracket Template - Phase 2.8", () => { }); it("has regions in order: East, South, West, Midwest", () => { - const names = NCAA_68.regions!.map((r) => r.name); + const names = (NCAA_68.regions ?? []).map((r) => r.name); expect(names).toEqual(["East", "South", "West", "Midwest"]); }); it("East has 16 direct seeds and no play-ins", () => { - const east = NCAA_68.regions![0]; + const east = (NCAA_68.regions ?? [])[0]; expect(east.directSeeds).toHaveLength(16); expect(east.playIns).toHaveLength(0); }); it("South has 15 direct seeds and one 16-seed play-in", () => { - const south = NCAA_68.regions![1]; + const south = (NCAA_68.regions ?? [])[1]; expect(south.directSeeds).toHaveLength(15); expect(south.playIns).toHaveLength(1); expect(south.playIns[0].seedSlot).toBe(16); }); it("West has 15 direct seeds and one 11-seed play-in", () => { - const west = NCAA_68.regions![2]; + const west = (NCAA_68.regions ?? [])[2]; expect(west.directSeeds).toHaveLength(15); expect(west.playIns).toHaveLength(1); expect(west.playIns[0].seedSlot).toBe(11); }); it("Midwest has 14 direct seeds and two play-ins (11 and 16)", () => { - const midwest = NCAA_68.regions![3]; + const midwest = (NCAA_68.regions ?? [])[3]; expect(midwest.directSeeds).toHaveLength(14); expect(midwest.playIns).toHaveLength(2); expect(midwest.playIns[0].seedSlot).toBe(11); @@ -249,12 +249,12 @@ describe("NCAA 68 Bracket Template - Phase 2.8", () => { }); it("total direct seeds across all regions equals 60", () => { - const total = NCAA_68.regions!.reduce((sum, r) => sum + r.directSeeds.length, 0); + const total = (NCAA_68.regions ?? []).reduce((sum, r) => sum + r.directSeeds.length, 0); expect(total).toBe(60); }); it("total play-in teams across all regions equals 8", () => { - const total = NCAA_68.regions!.reduce( + const total = (NCAA_68.regions ?? []).reduce( (sum, r) => sum + r.playIns.reduce((s, pi) => s + pi.teams, 0), 0 ); @@ -262,8 +262,8 @@ describe("NCAA 68 Bracket Template - Phase 2.8", () => { }); it("direct + play-in teams sum to 68", () => { - const direct = NCAA_68.regions!.reduce((sum, r) => sum + r.directSeeds.length, 0); - const playIn = NCAA_68.regions!.reduce( + const direct = (NCAA_68.regions ?? []).reduce((sum, r) => sum + r.directSeeds.length, 0); + const playIn = (NCAA_68.regions ?? []).reduce( (sum, r) => sum + r.playIns.reduce((s, pi) => s + pi.teams, 0), 0 ); @@ -272,7 +272,7 @@ describe("NCAA 68 Bracket Template - Phase 2.8", () => { }); describe("buildNCAA68SlotMap", () => { - const slotMap = buildNCAA68SlotMap(NCAA_68.regions!); + const slotMap = buildNCAA68SlotMap(NCAA_68.regions ?? []); it("East starts at index 0", () => { expect(slotMap.directOffsets[0]).toBe(0); @@ -357,7 +357,7 @@ describe("NCAA 68 Bracket Template - Phase 2.8", () => { }); describe("First Four → Round of 64 match number derivation", () => { - const slotMap = buildNCAA68SlotMap(NCAA_68.regions!); + const slotMap = buildNCAA68SlotMap(NCAA_68.regions ?? []); // r64MatchNumber = regionIndex * 8 + matchIndexForSeedSlot(seedSlot) + 1 it("FF1 (South region index 1, seed 16) → R64 match 9", () => { diff --git a/app/models/__tests__/nfl-bracket.test.ts b/app/models/__tests__/nfl-bracket.test.ts index 43c7400..2c6eca1 100644 --- a/app/models/__tests__/nfl-bracket.test.ts +++ b/app/models/__tests__/nfl-bracket.test.ts @@ -96,7 +96,7 @@ describe("NFL 14 Bracket Template - Phase 2.9", () => { expect(wildCard?.matchCount).toBe(6); // 6 matches × 2 teams = 12 teams // Total teams (14) - Wild Card teams (12) = 2 bye teams - const byeTeams = NFL_14.totalTeams - (wildCard!.matchCount * 2); + const byeTeams = NFL_14.totalTeams - ((wildCard?.matchCount ?? 0) * 2); expect(byeTeams).toBe(2); }); @@ -104,9 +104,9 @@ describe("NFL 14 Bracket Template - Phase 2.9", () => { const wildCard = NFL_14.rounds.find((r) => r.name === "Wild Card"); const divisional = NFL_14.rounds.find((r) => r.name === "Divisional"); - const wildCardWinners = wildCard!.matchCount; // 6 winners + const wildCardWinners = wildCard?.matchCount ?? 0; // 6 winners const byeTeams = 2; - const divisionalTeams = divisional!.matchCount * 2; // 4 matches × 2 = 8 teams + const divisionalTeams = (divisional?.matchCount ?? 0) * 2; // 4 matches × 2 = 8 teams expect(wildCardWinners + byeTeams).toBe(divisionalTeams); }); @@ -131,7 +131,7 @@ describe("NFL 14 Bracket Template - Phase 2.9", () => { it("verifies only 8 teams score fantasy points (Divisional and beyond)", () => { const divisional = NFL_14.rounds.find((r) => r.name === "Divisional"); - const divisionalTeams = divisional!.matchCount * 2; // 8 teams + const divisionalTeams = (divisional?.matchCount ?? 0) * 2; // 8 teams expect(divisionalTeams).toBe(8); // Matches requirement from Q18 }); diff --git a/app/models/draft-pick.ts b/app/models/draft-pick.ts index db07039..091b1dd 100644 --- a/app/models/draft-pick.ts +++ b/app/models/draft-pick.ts @@ -98,7 +98,7 @@ export async function getDraftedParticipantsBySportsSeason( if (!map.has(row.sportsSeasonId)) { map.set(row.sportsSeasonId, []); } - map.get(row.sportsSeasonId)!.push({ id: row.participantId, name: row.participantName }); + map.get(row.sportsSeasonId)?.push({ id: row.participantId, name: row.participantName }); } return map; } diff --git a/app/models/playoff-match.ts b/app/models/playoff-match.ts index 2451411..feceb03 100644 --- a/app/models/playoff-match.ts +++ b/app/models/playoff-match.ts @@ -422,7 +422,8 @@ async function generateNCAA68Bracket( for (let i = 0; i < slotMap.playInOffsets.length; i++) { const { regionIndex, playInIndex } = slotMap.playInOffsets[i]; if (!regionFFLabels.has(regionIndex)) regionFFLabels.set(regionIndex, []); - regionFFLabels.get(regionIndex)![playInIndex] = `FF${i + 1}`; + const labels = regionFFLabels.get(regionIndex); + if (labels) labels[playInIndex] = `FF${i + 1}`; } // ── First Four ──────────────────────────────────────────────────────────── diff --git a/app/models/qualifying-points.ts b/app/models/qualifying-points.ts index 5ed8b32..8935781 100644 --- a/app/models/qualifying-points.ts +++ b/app/models/qualifying-points.ts @@ -298,7 +298,7 @@ export async function updateFinalRankings( if (!groupedByPoints.has(points)) { groupedByPoints.set(points, []); } - groupedByPoints.get(points)!.push(standing); + groupedByPoints.get(points)?.push(standing); } // Assign rankings, handling ties diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index 3de3e83..07f4b5a 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -166,7 +166,7 @@ export async function processPlayoffEvent( eq(schema.playoffMatches.scoringEventId, eventId), eq(schema.playoffMatches.round, event.playoffRound) ), - orderBy: (matches, { asc }) => [asc(matches.matchNumber)], + orderBy: (m, { asc }) => [asc(m.matchNumber)], }); // Process based on round @@ -593,7 +593,7 @@ export async function finalizeQualifyingPoints( if (!groupedByQP.has(qp)) { groupedByQP.set(qp, []); } - groupedByQP.get(qp)!.push(standing); + groupedByQP.get(qp)?.push(standing); } // Sort groups by QP (descending) @@ -858,7 +858,7 @@ export async function calculateTeamScore( const bracketTemplateCache = new Map(); async function getBracketTemplate(sportsSeasonId: string): Promise { if (bracketTemplateCache.has(sportsSeasonId)) { - return bracketTemplateCache.get(sportsSeasonId)!; + return bracketTemplateCache.get(sportsSeasonId) ?? null; } const event = await db.query.scoringEvents.findFirst({ where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), @@ -952,7 +952,7 @@ export async function calculateTeamProjectedScore( const bracketTemplateCache = new Map(); async function getBracketTemplate(sportsSeasonId: string): Promise { if (bracketTemplateCache.has(sportsSeasonId)) { - return bracketTemplateCache.get(sportsSeasonId)!; + return bracketTemplateCache.get(sportsSeasonId) ?? null; } const event = await db.query.scoringEvents.findFirst({ where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), @@ -1378,8 +1378,8 @@ export async function recalculateAffectedLeagues( scoredMatches = relevant .filter((m) => m.winnerName && m.loserName) .map((m) => ({ - winnerName: m.winnerName!, - loserName: m.loserName!, + winnerName: m.winnerName ?? "", + loserName: m.loserName ?? "", winnerUsername: usernameForParticipant(m.winnerId, true), loserUsername: usernameForParticipant(m.loserId), })); diff --git a/app/models/scoring-event.ts b/app/models/scoring-event.ts index b4c2db1..28478ee 100644 --- a/app/models/scoring-event.ts +++ b/app/models/scoring-event.ts @@ -496,16 +496,20 @@ export async function getUpcomingEventsForDraftedParticipants( gameKeysByEvent.set(row.id, new Set()); } - const pMap = participantsByEvent.get(row.id)!; - if (row.participant1Id && draftedMap.has(row.participant1Id)) { - pMap.set(row.participant1Id, draftedMap.get(row.participant1Id)!); - } - if (row.participant2Id && draftedMap.has(row.participant2Id)) { - pMap.set(row.participant2Id, draftedMap.get(row.participant2Id)!); + const pMap = participantsByEvent.get(row.id); + if (pMap) { + if (row.participant1Id) { + const p1 = draftedMap.get(row.participant1Id); + if (p1) pMap.set(row.participant1Id, p1); + } + if (row.participant2Id) { + const p2 = draftedMap.get(row.participant2Id); + if (p2) pMap.set(row.participant2Id, p2); + } } if (row.round && row.gameNumber !== null) { - gameKeysByEvent.get(row.id)!.add(`${row.round}|${row.gameNumber}`); + gameKeysByEvent.get(row.id)?.add(`${row.round}|${row.gameNumber}`); } if (row.scheduledAt) { @@ -517,13 +521,13 @@ export async function getUpcomingEventsForDraftedParticipants( } for (const [eventId, event] of eventMap) { - event.relevantParticipants = Array.from(participantsByEvent.get(eventId)!.values()); + event.relevantParticipants = Array.from(participantsByEvent.get(eventId)?.values() ?? []); const earliest = earliestTimeByEvent.get(eventId); event.earliestGameTime = earliest ? earliest.toISOString() : null; // Build matchLabel from game keys - const gameKeys = gameKeysByEvent.get(eventId)!; + const gameKeys = gameKeysByEvent.get(eventId) ?? new Set(); if (gameKeys.size === 1) { const [round, gameNumberStr] = [...gameKeys][0].split("|"); // When round name duplicates the event name, omit the round to avoid diff --git a/app/models/sport.ts b/app/models/sport.ts index 05ab849..be39e8e 100644 --- a/app/models/sport.ts +++ b/app/models/sport.ts @@ -36,22 +36,22 @@ export async function findSportBySlug(slug: string): Promise export async function findAllSports(): Promise { const db = database(); return await db.query.sports.findMany({ - orderBy: (sports, { asc }) => [asc(sports.name)], + orderBy: (s, { asc }) => [asc(s.name)], }); } export async function findAllSportsWithActiveSeasons(): Promise { const db = database(); const sports = await db.query.sports.findMany({ - orderBy: (sports, { asc }) => [asc(sports.name)], + orderBy: (s, { asc }) => [asc(s.name)], with: { sportsSeasons: { - where: (sportsSeasons, { eq, or }) => + where: (ss, { or }) => or( - eq(sportsSeasons.status, "active"), - eq(sportsSeasons.status, "upcoming") + eq(ss.status, "active"), + eq(ss.status, "upcoming") ), - orderBy: (sportsSeasons, { desc }) => [desc(sportsSeasons.year)], + orderBy: (ss, { desc }) => [desc(ss.year)], }, }, }); @@ -70,7 +70,7 @@ export async function findSportsByType(type: SportType): Promise { const db = database(); return await db.query.sports.findMany({ where: eq(schema.sports.type, type), - orderBy: (sports, { asc }) => [asc(sports.name)], + orderBy: (s, { asc }) => [asc(s.name)], }); } diff --git a/app/models/sports-season.ts b/app/models/sports-season.ts index 56bc9a1..d2557d0 100644 --- a/app/models/sports-season.ts +++ b/app/models/sports-season.ts @@ -43,10 +43,10 @@ export async function findSportsSeasonsBySportId(sportId: string): Promise { const db = database(); return await db.query.sportsSeasons.findMany({ - where: (sportsSeasons, { eq, and }) => + where: (ss, { and }) => and( - eq(sportsSeasons.sportId, sportId), - eq(sportsSeasons.status, "active") + eq(ss.sportId, sportId), + eq(ss.status, "active") ), orderBy: (sportsSeasons, { desc }) => [desc(sportsSeasons.year)], with: { diff --git a/app/models/standings.ts b/app/models/standings.ts index c7b64b3..4f28553 100644 --- a/app/models/standings.ts +++ b/app/models/standings.ts @@ -158,7 +158,7 @@ export async function getTeamScoreBreakdown( const bracketTemplateCache = new Map(); async function getBracketTemplate(sportsSeasonId: string): Promise { if (bracketTemplateCache.has(sportsSeasonId)) { - return bracketTemplateCache.get(sportsSeasonId)!; + return bracketTemplateCache.get(sportsSeasonId) ?? null; } const event = await db.query.scoringEvents.findFirst({ where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), @@ -318,7 +318,7 @@ export async function getSevenDayStandingsChange( return current.map((standing) => ({ ...standing, sevenDayRankChange: oldRanks.has(standing.teamId) - ? oldRanks.get(standing.teamId)! - standing.currentRank + ? (oldRanks.get(standing.teamId) ?? 0) - standing.currentRank : 0, sevenDayOldRank: oldRanks.get(standing.teamId) || null, })); @@ -411,7 +411,8 @@ export async function getSeasonPointProgression( dateMap.set(date, { date }); } - const dateData = dateMap.get(date)!; + const dateData = dateMap.get(date); + if (!dateData) continue; dateData[snapshot.team.name] = parseFloat(snapshot.totalPoints); } diff --git a/app/routes/admin._index.tsx b/app/routes/admin._index.tsx index 001f2b9..1dd8759 100644 --- a/app/routes/admin._index.tsx +++ b/app/routes/admin._index.tsx @@ -21,11 +21,14 @@ export function meta(): Route.MetaDescriptors { return [{ title: "Admin - Brackt" }]; } +function toDateStr(d: Date) { + return d.toISOString().split("T")[0]; +} + function getTodayAndTomorrowDates() { const today = new Date(); const tomorrow = new Date(today); tomorrow.setDate(today.getDate() + 1); - const toDateStr = (d: Date) => d.toISOString().split("T")[0]; return { today: toDateStr(today), tomorrow: toDateStr(tomorrow) }; } diff --git a/app/routes/admin.data-sync.tsx b/app/routes/admin.data-sync.tsx index 5cd41b7..69557e8 100644 --- a/app/routes/admin.data-sync.tsx +++ b/app/routes/admin.data-sync.tsx @@ -139,14 +139,14 @@ export default function DataSync({ loaderData }: Route.ComponentProps) { } const reader = new FileReader(); - reader.onload = (event) => { + reader.addEventListener("load", (event) => { const fileData = event.target?.result as string; const formData = new FormData(); formData.append("intent", "import"); formData.append("mode", importMode); formData.append("fileData", fileData); fetcher.submit(formData, { method: "POST" }); - }; + }); reader.readAsText(selectedFile); }; diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx index aff3ba8..76d397e 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx @@ -277,8 +277,8 @@ export default function EventBracket({ // Memoized: Get available rounds in chronological order const availableRounds = useMemo(() => { - const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : undefined; - return getOrderedRoundsFromMatches(matches, template); + const bracketTemplate = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : undefined; + return getOrderedRoundsFromMatches(matches, bracketTemplate); }, [matches, event.bracketTemplateId]); // Check if all matches are complete @@ -414,8 +414,8 @@ export default function EventBracket({ Group {label} - {[...Array(template.groupStage!.teamsPerGroup)].map((_, teamIndex) => { - const seedIndex = groupIndex * template.groupStage!.teamsPerGroup + teamIndex; + {[...Array(template.groupStage?.teamsPerGroup ?? 0)].map((_, teamIndex) => { + const seedIndex = groupIndex * (template.groupStage?.teamsPerGroup ?? 0) + teamIndex; const availableParticipants = getAvailableParticipants(seedIndex); return ( @@ -1051,7 +1051,7 @@ export default function EventBracket({
- +
+ Completed + + ); + } + if (eventType === "schedule_event") { + const isPast = eventStartsAt + ? new Date(eventStartsAt) < new Date() + : eventDate !== null && eventDate !== undefined && eventDate < new Date().toISOString().split("T")[0]; + return isPast ? ( + Results Pending + ) : ( + Upcoming + ); + } + return In Progress; +} + export default function SportsSeasonEvents({ loaderData, actionData, @@ -60,27 +81,6 @@ export default function SportsSeasonEvents({ ? "schedule_event" : "playoff_game"; - const getStatusBadge = (isComplete: boolean, eventType: string, eventDate?: string | null, eventStartsAt?: Date | string | null) => { - if (isComplete) { - return ( - - Completed - - ); - } - if (eventType === "schedule_event") { - const isPast = eventStartsAt - ? new Date(eventStartsAt) < new Date() - : eventDate !== null && eventDate !== undefined && eventDate < new Date().toISOString().split("T")[0]; - return isPast ? ( - Results Pending - ) : ( - Upcoming - ); - } - return In Progress; - }; - return (
diff --git a/app/routes/admin.sports-seasons.$id.expected-values.tsx b/app/routes/admin.sports-seasons.$id.expected-values.tsx index e911c9b..1536f68 100644 --- a/app/routes/admin.sports-seasons.$id.expected-values.tsx +++ b/app/routes/admin.sports-seasons.$id.expected-values.tsx @@ -65,8 +65,10 @@ export default function ExpectedValuesPage({ loaderData }: Route.ComponentProps) // Sum only over participants shown in the table — excludes orphan EV records // from prior simulation runs for participants no longer in this season. const sorted = [...participants].toSorted((a, b) => { - const evA = existingEVs.has(a.id) ? evFromProbs(existingEVs.get(a.id)!) : 0; - const evB = existingEVs.has(b.id) ? evFromProbs(existingEVs.get(b.id)!) : 0; + const evDataA = existingEVs.get(a.id); + const evA = evDataA ? evFromProbs(evDataA) : 0; + const evDataB = existingEVs.get(b.id); + const evB = evDataB ? evFromProbs(evDataB) : 0; return evB - evA; }); diff --git a/app/routes/admin.standings-snapshots.tsx b/app/routes/admin.standings-snapshots.tsx index 7247d41..5f34883 100644 --- a/app/routes/admin.standings-snapshots.tsx +++ b/app/routes/admin.standings-snapshots.tsx @@ -40,7 +40,7 @@ export async function loader() { with: { league: true, }, - orderBy: (seasons, { desc }) => [desc(seasons.createdAt)], + orderBy: (s, { desc }) => [desc(s.createdAt)], }); // For each season, get the most recent snapshot diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index 3ee1996..fea1292 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -494,8 +494,8 @@ export default function DraftRoom() { // Sync timers — the server-side timer state may have changed while // we were disconnected (time bank adjustments, new timers, etc.) if (timers.length > 0) { - setTeamTimers((prev) => { - const updated = { ...prev }; + setTeamTimers((currentTimers) => { + const updated = { ...currentTimers }; timers.forEach((timer) => { updated[timer.teamId] = timer.timeRemaining; }); diff --git a/app/routes/leagues/$leagueId.server.ts b/app/routes/leagues/$leagueId.server.ts index c79b0ff..c96fa2e 100644 --- a/app/routes/leagues/$leagueId.server.ts +++ b/app/routes/leagues/$leagueId.server.ts @@ -110,8 +110,8 @@ export async function loader(args: Route.LoaderArgs) { const calendarDateFrom = today; const calendarDateTo = addDays(today, 30); - const participantsBySportsSeason = myTeam - ? await getDraftedParticipantsBySportsSeason(myTeam.id, season!.id) + const participantsBySportsSeason = myTeam && season + ? await getDraftedParticipantsBySportsSeason(myTeam.id, season.id) : new Map>(); const sportsSeasons = await Promise.all( diff --git a/app/routes/leagues/$leagueId.settings.tsx b/app/routes/leagues/$leagueId.settings.tsx index c5ccf2e..aaae51d 100644 --- a/app/routes/leagues/$leagueId.settings.tsx +++ b/app/routes/leagues/$leagueId.settings.tsx @@ -1103,23 +1103,23 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
{allSportsSeasons.length > 0 ? ( - allSportsSeasons.map((season) => ( -
+ allSportsSeasons.map((ss) => ( +
handleSportToggle(season.id)} + value={ss.id} + checked={selectedSports.has(ss.id)} + onCheckedChange={() => handleSportToggle(ss.id)} disabled={!canEditSports} />
diff --git a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts index 32c8ea3..5fba189 100644 --- a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts +++ b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts @@ -163,8 +163,7 @@ export async function loader(args: Route.LoaderArgs) { if (events.length > 0) { const eventIds = events.map((e) => e.id); const matches = await db.query.playoffMatches.findMany({ - where: (playoffMatches, { inArray }) => - inArray(playoffMatches.scoringEventId, eventIds), + where: (pm) => inArray(pm.scoringEventId, eventIds), with: { participant1: true, participant2: true, @@ -219,7 +218,7 @@ export async function loader(args: Route.LoaderArgs) { }) .map((r) => ({ participantId: r.participantId, - points: calculateBracketPoints(r.finalPosition!, scoringRules, templateId), + points: calculateBracketPoints(r.finalPosition ?? 0, scoringRules, templateId), })); // Track which participants have provisional (floor) scores — still competing diff --git a/app/routes/leagues/__tests__/draft-reconnection-sync.test.ts b/app/routes/leagues/__tests__/draft-reconnection-sync.test.ts index 20c19a6..dd5dfa7 100644 --- a/app/routes/leagues/__tests__/draft-reconnection-sync.test.ts +++ b/app/routes/leagues/__tests__/draft-reconnection-sync.test.ts @@ -628,7 +628,8 @@ describe("full mobile reconnection scenario", () => { const serverPicks = Array.from({ length: 24 }, (_, i) => { const pickNum = i + 1; const teamIdx = getTeamForPick(pickNum, draftSlots); - return makePick(pickNum, teamIdx!.team.id, `player-${pickNum}`); + if (!teamIdx) throw new Error(`No team for pick ${pickNum}`); + return makePick(pickNum, teamIdx.team.id, `player-${pickNum}`); }); // Simulate draft-state-sync handler diff --git a/app/routes/leagues/new.tsx b/app/routes/leagues/new.tsx index 99b7167..3d77e8a 100644 --- a/app/routes/leagues/new.tsx +++ b/app/routes/leagues/new.tsx @@ -182,9 +182,9 @@ export async function action(args: Route.ActionArgs) { // Create teams with fun random names const joinAsPlayer = formData.get("joinAsPlayer") === "on"; const teamNames = generateUniqueTeamNames(teamCountNum); - const teams = teamNames.map((name, i) => ({ + const teams = teamNames.map((teamName, i) => ({ seasonId: season.id, - name, + name: teamName, ownerId: joinAsPlayer && i === 0 ? userId : null, })); diff --git a/app/services/__tests__/bracket-simulator.test.ts b/app/services/__tests__/bracket-simulator.test.ts index 3419977..cf04d82 100644 --- a/app/services/__tests__/bracket-simulator.test.ts +++ b/app/services/__tests__/bracket-simulator.test.ts @@ -51,8 +51,9 @@ describe('bracket-simulator', () => { const results = simulateBracketSync(teams, 'nhl-8', 10000); - const strongDist = results.get('strong')!; - const weakDist = results.get('weak')!; + const strongDist = results.get('strong'); + const weakDist = results.get('weak'); + if (!strongDist || !weakDist) throw new Error('strong or weak result not found'); // Strong team should have higher championship probability expect(strongDist[0]).toBeGreaterThan(weakDist[0]); @@ -76,7 +77,8 @@ describe('bracket-simulator', () => { const results = simulateBracketSync(teams, 'nhl-8', 10000); - const championDist = results.get('champion')!; + const championDist = results.get('champion'); + if (!championDist) throw new Error('champion result not found'); // Dominant team should win very often expect(championDist[0]).toBeGreaterThan(0.6); // >60% championship probability @@ -139,8 +141,9 @@ describe('bracket-simulator', () => { const results2 = simulateBracketSync(teams, 'nhl-8', 1000); // Results won't be identical due to randomness, but should be in same ballpark - const dist1 = results1.get('1')!; - const dist2 = results2.get('1')!; + const dist1 = results1.get('1'); + const dist2 = results2.get('1'); + if (!dist1 || !dist2) throw new Error('Result for participant 1 not found'); // Championship probabilities should be within 10% of each other expect(Math.abs(dist1[0] - dist2[0])).toBeLessThan(0.1); @@ -257,8 +260,9 @@ describe('bracket-simulator', () => { const results = await simulateBracket(teams, 'nhl-8', 10000); - const colDist = results.get('COL')!; - const nyiDist = results.get('NYI')!; + const colDist = results.get('COL'); + const nyiDist = results.get('NYI'); + if (!colDist || !nyiDist) throw new Error('COL or NYI result not found'); // Colorado should have highest championship probability expect(colDist[0]).toBeGreaterThan(0.15); // >15% diff --git a/app/services/__tests__/icm-calculator.test.ts b/app/services/__tests__/icm-calculator.test.ts index 8993140..e7e2ad4 100644 --- a/app/services/__tests__/icm-calculator.test.ts +++ b/app/services/__tests__/icm-calculator.test.ts @@ -45,8 +45,11 @@ describe('icm-calculator', () => { const results = calculateICM(participants); - const strongProbs = icmResultToArray(results.get('strong')!); - const weakProbs = icmResultToArray(results.get('weak')!); + const strongResult = results.get('strong'); + const weakResult = results.get('weak'); + if (!strongResult || !weakResult) throw new Error('Expected results not found'); + const strongProbs = icmResultToArray(strongResult); + const weakProbs = icmResultToArray(weakResult); // Strong team should have higher P(1st) than weak team expect(strongProbs[0]).toBeGreaterThan(weakProbs[0]); @@ -81,11 +84,15 @@ describe('icm-calculator', () => { expect(results.size).toBe(32); // Colorado (favorite) should have highest P(1st) - const colProbs = icmResultToArray(results.get('COL')!); + const colResult32 = results.get('COL'); + if (!colResult32) throw new Error('COL result not found'); + const colProbs = icmResultToArray(colResult32); expect(colProbs[0]).toBeGreaterThan(0.05); // Should have >5% chance of 1st // Even the worst team should have some probability for all placements - const worstProbs = icmResultToArray(results.get('TEAM32')!); + const worstResult = results.get('TEAM32'); + if (!worstResult) throw new Error('TEAM32 result not found'); + const worstProbs = icmResultToArray(worstResult); worstProbs.forEach(p => { expect(p).toBeGreaterThan(0); // Not zero expect(p).toBeLessThan(1); // Valid probability @@ -111,7 +118,9 @@ describe('icm-calculator', () => { expect(results.size).toBe(1); - const probs = icmResultToArray(results.get('1')!); + const singleResult = results.get('1'); + if (!singleResult) throw new Error('Result for participant 1 not found'); + const probs = icmResultToArray(singleResult); // With 1 participant, only 1st place is filled (removed from pool after) expect(probs[0]).toBeCloseTo(1.0, 1); // 100% chance of 1st const sum = probs.reduce((acc, p) => acc + p, 0); @@ -167,8 +176,11 @@ describe('icm-calculator', () => { } // Participant 1 (higher prob) should have higher P(1st) - const probs1 = icmResultToArray(results.get('1')!); - const probs2 = icmResultToArray(results.get('2')!); + const r1 = results.get('1'); + const r2 = results.get('2'); + if (!r1 || !r2) throw new Error('Results for participants 1 and 2 not found'); + const probs1 = icmResultToArray(r1); + const probs2 = icmResultToArray(r2); expect(probs1[0]).toBeGreaterThan(probs2[0]); }); @@ -187,10 +199,15 @@ describe('icm-calculator', () => { const results = calculateICM(participants); - const first = icmResultToArray(results.get('1st')!); - const second = icmResultToArray(results.get('2nd')!); - const third = icmResultToArray(results.get('3rd')!); - const fourth = icmResultToArray(results.get('4th')!); + const rFirst = results.get('1st'); + const rSecond = results.get('2nd'); + const rThird = results.get('3rd'); + const rFourth = results.get('4th'); + if (!rFirst || !rSecond || !rThird || !rFourth) throw new Error('Expected results not found'); + const first = icmResultToArray(rFirst); + const second = icmResultToArray(rSecond); + const third = icmResultToArray(rThird); + const fourth = icmResultToArray(rFourth); // P(1st place) should be ordered expect(first[0]).toBeGreaterThan(second[0]); @@ -212,8 +229,11 @@ describe('icm-calculator', () => { expect(results.size).toBe(3); // Colorado should have better odds than Arizona - const colProbs = icmResultToArray(results.get('COL')!); - const ariProbs = icmResultToArray(results.get('ARI')!); + const colResultOdds = results.get('COL'); + const ariResult = results.get('ARI'); + if (!colResultOdds || !ariResult) throw new Error('COL or ARI result not found'); + const colProbs = icmResultToArray(colResultOdds); + const ariProbs = icmResultToArray(ariResult); expect(colProbs[0]).toBeGreaterThan(ariProbs[0]); @@ -229,8 +249,11 @@ describe('icm-calculator', () => { const results = calculateICMFromOdds(odds); - const favProbs = icmResultToArray(results.get('FAV')!); - const dogProbs = icmResultToArray(results.get('DOG')!); + const favResult = results.get('FAV'); + const dogResult = results.get('DOG'); + if (!favResult || !dogResult) throw new Error('FAV or DOG result not found'); + const favProbs = icmResultToArray(favResult); + const dogProbs = icmResultToArray(dogResult); // Favorite should have higher P(1st) expect(favProbs[0]).toBeGreaterThan(dogProbs[0]); @@ -303,17 +326,21 @@ describe('icm-calculator', () => { expect(results.size).toBe(32); // Favorite (Colorado) should have reasonable championship probability - const colProbs = icmResultToArray(results.get('COL')!); + const colResultInteg = results.get('COL'); + const detResult = results.get('DET'); + const longResult = results.get('TEAM32'); + if (!colResultInteg || !detResult || !longResult) throw new Error('Expected results not found'); + const colProbs = icmResultToArray(colResultInteg); expect(colProbs[0]).toBeGreaterThan(0.05); // >5% for 1st expect(colProbs[0]).toBeLessThan(0.35); // <35% for 1st (not guaranteed) // Middle team (Detroit) should have middling probabilities - const detProbs = icmResultToArray(results.get('DET')!); + const detProbs = icmResultToArray(detResult); expect(detProbs[0]).toBeGreaterThan(0); // Some chance expect(detProbs[0]).toBeLessThan(0.10); // But not high // Longshot should have very small but non-zero probabilities - const longProbs = icmResultToArray(results.get('TEAM32')!); + const longProbs = icmResultToArray(longResult); expect(longProbs[0]).toBeGreaterThan(0); // Not impossible expect(longProbs[0]).toBeLessThan(0.05); // But unlikely (with 32 teams, even worst has ~3% uniform) diff --git a/app/services/__tests__/probability-engine.test.ts b/app/services/__tests__/probability-engine.test.ts index a3495e9..2917801 100644 --- a/app/services/__tests__/probability-engine.test.ts +++ b/app/services/__tests__/probability-engine.test.ts @@ -200,8 +200,8 @@ describe('probability-engine', () => { const eloRatings = convertFuturesToElo(odds, 'american'); expect(eloRatings.size).toBe(3); - expect(eloRatings.get('1')).toBeGreaterThan(eloRatings.get('2')!); - expect(eloRatings.get('2')).toBeGreaterThan(eloRatings.get('3')!); + expect(eloRatings.get('1')).toBeGreaterThan(eloRatings.get('2') ?? 0); + expect(eloRatings.get('2')).toBeGreaterThan(eloRatings.get('3') ?? 0); // Strongest team should be near max Elo expect(eloRatings.get('1')).toBeGreaterThan(1600); @@ -220,8 +220,8 @@ describe('probability-engine', () => { const eloRatings = convertFuturesToElo(odds, 'decimal'); expect(eloRatings.size).toBe(3); - expect(eloRatings.get('1')).toBeGreaterThan(eloRatings.get('2')!); - expect(eloRatings.get('2')).toBeGreaterThan(eloRatings.get('3')!); + expect(eloRatings.get('1')).toBeGreaterThan(eloRatings.get('2') ?? 0); + expect(eloRatings.get('2')).toBeGreaterThan(eloRatings.get('3') ?? 0); }); it('uses custom calibration parameters', () => { @@ -302,8 +302,8 @@ describe('probability-engine', () => { ]; const eloRatings = convertFuturesToElo(odds, 'american'); - const colElo = eloRatings.get('col')!; - const nyiElo = eloRatings.get('nyi')!; + const colElo = eloRatings.get('col') ?? 1500; + const nyiElo = eloRatings.get('nyi') ?? 1500; // Calculate predicted game line const predicted = eloWinProbability(colElo, nyiElo); diff --git a/app/services/bracket-simulator.ts b/app/services/bracket-simulator.ts index ac5ba93..4b605cd 100644 --- a/app/services/bracket-simulator.ts +++ b/app/services/bracket-simulator.ts @@ -132,7 +132,8 @@ function simulate8TeamBracket(teams: TeamForSimulation[]): SimulationResult { // Finals: 2 → 1 const champion = simulateMatchup(finalists[0], finalists[1]); - const runnerUp = finalists.find(t => t.participantId !== champion.participantId)!; + const runnerUp = finalists.find(t => t.participantId !== champion.participantId); + if (!runnerUp) throw new Error("Runner-up not found in finalists"); placements.set(champion.participantId, 1); placements.set(runnerUp.participantId, 2); diff --git a/app/services/icm-calculator.ts b/app/services/icm-calculator.ts index 470b9c8..0e01faa 100644 --- a/app/services/icm-calculator.ts +++ b/app/services/icm-calculator.ts @@ -133,7 +133,8 @@ export function calculateICM( // Record results for (let pos = 0; pos < placements.length; pos++) { - counts.get(placements[pos])![pos]++; + const participantCounts = counts.get(placements[pos]); + if (participantCounts) participantCounts[pos]++; } // Progress logging every 10,000 iterations @@ -149,7 +150,7 @@ export function calculateICM( const results = new Map(); for (const p of normalized) { - const participantCounts = counts.get(p.participantId)!; + const participantCounts = counts.get(p.participantId) ?? Array(scoringPlaces).fill(0); const probabilities = participantCounts.map(count => count / iterations); results.set(p.participantId, { diff --git a/app/services/probability-updater.ts b/app/services/probability-updater.ts index 359f19f..ab0c894 100644 --- a/app/services/probability-updater.ts +++ b/app/services/probability-updater.ts @@ -127,7 +127,7 @@ export async function updateProbabilitiesAfterResult( const finishedMap = new Map( results .filter(r => r.finalPosition !== null) - .map(r => [r.participantId, r.finalPosition!]) + .map(r => [r.participantId, r.finalPosition ?? 0]) ); // Update finished participants @@ -263,7 +263,7 @@ export async function previewProbabilityUpdate( const finishedMap = new Map( results .filter(r => r.finalPosition !== null) - .map(r => [r.participantId, r.finalPosition!]) + .map(r => [r.participantId, r.finalPosition ?? 0]) ); const comparisons: ProbabilityComparison[] = []; diff --git a/app/services/simulations/__tests__/ucl-simulator.test.ts b/app/services/simulations/__tests__/ucl-simulator.test.ts index cbafde7..45b65a1 100644 --- a/app/services/simulations/__tests__/ucl-simulator.test.ts +++ b/app/services/simulations/__tests__/ucl-simulator.test.ts @@ -306,18 +306,18 @@ describe("UCLSimulator.simulate()", () => { // Actually: QF1: team-1 beats team-4; QF2: team-6 beats team-8; etc. const qfMatches = Array.from({ length: 4 }, (_, i) => { const matchNum = i + 1; - const r16w1 = r16Matches[(matchNum - 1) * 2].winnerId!; - const r16w2 = r16Matches[(matchNum - 1) * 2 + 1].winnerId!; + const r16w1 = r16Matches[(matchNum - 1) * 2].winnerId ?? ""; + const r16w2 = r16Matches[(matchNum - 1) * 2 + 1].winnerId ?? ""; const winner = matchNum === 1 ? r16w1 : r16w2; const loser = matchNum === 1 ? r16w2 : r16w1; return makeQFMatch(matchNum, { winnerId: winner, loserId: loser, isComplete: true }); }); // SF: QF1 winner (team-1) + QF2 winner → SF1: team-1 wins - const sf1Winner = qfMatches[0].winnerId!; - const sf1Loser = qfMatches[1].winnerId!; - const sf2Winner = qfMatches[2].winnerId!; - const sf2Loser = qfMatches[3].winnerId!; + const sf1Winner = qfMatches[0].winnerId ?? ""; + const sf1Loser = qfMatches[1].winnerId ?? ""; + const sf2Winner = qfMatches[2].winnerId ?? ""; + const sf2Loser = qfMatches[3].winnerId ?? ""; const sfMatches = [ makeSFMatch(1, { winnerId: sf1Winner, loserId: sf1Loser, isComplete: true }), makeSFMatch(2, { winnerId: sf2Winner, loserId: sf2Loser, isComplete: true }), @@ -335,8 +335,9 @@ describe("UCLSimulator.simulate()", () => { const sim = new UCLSimulator(); const results = await sim.simulate("season-1"); - const championResult = results.find((r) => r.participantId === actualChampion)!; - const finalistResult = results.find((r) => r.participantId === actualFinalist)!; + const championResult = results.find((r) => r.participantId === actualChampion); + const finalistResult = results.find((r) => r.participantId === actualFinalist); + if (!championResult || !finalistResult) throw new Error('Champion or finalist result not found'); // Champion must have probFirst = 1.0 expect(championResult.probabilities.probFirst).toBeCloseTo(1.0, 3); @@ -347,8 +348,9 @@ describe("UCLSimulator.simulate()", () => { expect(finalistResult.probabilities.probSecond).toBeCloseTo(1.0, 3); // R16 losers (teams that lost in R16) must have all probs = 0 - const r16LoserId = r16Matches[0].loserId!; // team-2 lost in R16 match 1 - const r16LoserResult = results.find((r) => r.participantId === r16LoserId)!; + const r16LoserId = r16Matches[0].loserId ?? ""; // team-2 lost in R16 match 1 + const r16LoserResult = results.find((r) => r.participantId === r16LoserId); + if (!r16LoserResult) throw new Error('R16 loser result not found'); const r16Sum = Object.values(r16LoserResult.probabilities).reduce((a, b) => a + b, 0); expect(r16Sum).toBeCloseTo(0, 5); diff --git a/app/services/simulations/auto-racing-simulator.ts b/app/services/simulations/auto-racing-simulator.ts index 0aec9db..ee73f14 100644 --- a/app/services/simulations/auto-racing-simulator.ts +++ b/app/services/simulations/auto-racing-simulator.ts @@ -174,7 +174,8 @@ export class AutoRacingSimulator implements Simulator { for (let sim = 0; sim < NUM_SIMULATIONS; sim++) { const finishOrder = weightedDrawWithoutReplacement(ids, weights); for (let rank = 0; rank < Math.min(8, finishOrder.length); rank++) { - rankCounts.get(finishOrder[rank])![rank]++; + const counts = rankCounts.get(finishOrder[rank]); + if (counts) counts[rank]++; } } } else { @@ -219,26 +220,30 @@ export class AutoRacingSimulator implements Simulator { .map(([id]) => id); for (let rank = 0; rank < Math.min(8, finalOrder.length); rank++) { - rankCounts.get(finalOrder[rank])![rank]++; + const counts = rankCounts.get(finalOrder[rank]); + if (counts) counts[rank]++; } } } // 8. Convert counts → probability distributions - const results: SimulationResult[] = participants.map((p) => ({ - participantId: p.id, - probabilities: { - probFirst: rankCounts.get(p.id)![0] / NUM_SIMULATIONS, - probSecond: rankCounts.get(p.id)![1] / NUM_SIMULATIONS, - probThird: rankCounts.get(p.id)![2] / NUM_SIMULATIONS, - probFourth: rankCounts.get(p.id)![3] / NUM_SIMULATIONS, - probFifth: rankCounts.get(p.id)![4] / NUM_SIMULATIONS, - probSixth: rankCounts.get(p.id)![5] / NUM_SIMULATIONS, - probSeventh: rankCounts.get(p.id)![6] / NUM_SIMULATIONS, - probEighth: rankCounts.get(p.id)![7] / NUM_SIMULATIONS, - }, - source: this.source, - })); + const results: SimulationResult[] = participants.map((p) => { + const counts = rankCounts.get(p.id) ?? [0,0,0,0,0,0,0,0]; + return { + participantId: p.id, + probabilities: { + probFirst: counts[0] / NUM_SIMULATIONS, + probSecond: counts[1] / NUM_SIMULATIONS, + probThird: counts[2] / NUM_SIMULATIONS, + probFourth: counts[3] / NUM_SIMULATIONS, + probFifth: counts[4] / NUM_SIMULATIONS, + probSixth: counts[5] / NUM_SIMULATIONS, + probSeventh: counts[6] / NUM_SIMULATIONS, + probEighth: counts[7] / NUM_SIMULATIONS, + }, + source: this.source, + }; + }); // 9. Per-position normalization: each column should sum to exactly 1.0 but // floating-point division (count / 10000) accumulates small errors across diff --git a/app/services/simulations/bracket-simulator.ts b/app/services/simulations/bracket-simulator.ts index 83b9f9d..80cd30b 100644 --- a/app/services/simulations/bracket-simulator.ts +++ b/app/services/simulations/bracket-simulator.ts @@ -47,7 +47,7 @@ export class BracketSimulator implements Simulator { if (hasOdds) { const oddsInput = evRows .filter((r) => r.sourceOdds !== null) - .map((r) => ({ participantId: r.participantId, odds: r.sourceOdds! })); + .map((r) => ({ participantId: r.participantId, odds: r.sourceOdds ?? 0 })); eloMap = convertFuturesToElo(oddsInput); } else { // Fall back: treat probFirst (as %) as championship win probability, @@ -71,7 +71,7 @@ export class BracketSimulator implements Simulator { .filter((r) => eloMap.has(r.participantId)) .map((r) => ({ participantId: r.participantId, - elo: eloMap.get(r.participantId)!, + elo: eloMap.get(r.participantId) ?? 1500, })); if (teamsForSimulation.length === 0) { diff --git a/app/services/simulations/nba-simulator.ts b/app/services/simulations/nba-simulator.ts index 147ecfc..89a658d 100644 --- a/app/services/simulations/nba-simulator.ts +++ b/app/services/simulations/nba-simulator.ts @@ -240,6 +240,12 @@ interface TeamEntry { data: NbaTeamData | undefined; } +/** Get Elo for a team entry. + * Fallback 1400 = conservative below-average estimate for unknown/unrecognized teams. */ +function elo(entry: TeamEntry): number { + return entry.data?.elo ?? 1400; +} + // ─── Simulator ──────────────────────────────────────────────────────────────── export class NBASimulator implements Simulator { @@ -297,10 +303,6 @@ export class NBASimulator implements Simulator { return 11; // Missed playoffs }; - /** Get Elo for a team entry. - * Fallback 1400 = conservative below-average estimate for unknown/unrecognized teams. */ - const elo = (entry: TeamEntry): number => entry.data?.elo ?? 1400; - /** Simulate a single playoff game. Returns the winner. */ const simGame = (a: TeamEntry, b: TeamEntry): TeamEntry => Math.random() < eloWinProbability(elo(a), elo(b)) ? a : b; @@ -383,12 +385,12 @@ export class NBASimulator implements Simulator { const { winner: champion, loser: finalist } = simSeries(eastChamp, westChamp); // ── Record counts (maps are pre-populated so .get() is always defined) ─── - championCounts.set(champion.id, championCounts.get(champion.id)! + 1); - finalistCounts.set(finalist.id, finalistCounts.get(finalist.id)! + 1); - confFinalLoserCounts.set(eastCFLoser.id, confFinalLoserCounts.get(eastCFLoser.id)! + 1); - confFinalLoserCounts.set(westCFLoser.id, confFinalLoserCounts.get(westCFLoser.id)! + 1); + championCounts.set(champion.id, (championCounts.get(champion.id) ?? 0) + 1); + finalistCounts.set(finalist.id, (finalistCounts.get(finalist.id) ?? 0) + 1); + confFinalLoserCounts.set(eastCFLoser.id, (confFinalLoserCounts.get(eastCFLoser.id) ?? 0) + 1); + confFinalLoserCounts.set(westCFLoser.id, (confFinalLoserCounts.get(westCFLoser.id) ?? 0) + 1); for (const loser of [...eastR2Losers, ...westR2Losers]) { - confSemiLoserCounts.set(loser.id, confSemiLoserCounts.get(loser.id)! + 1); + confSemiLoserCounts.set(loser.id, (confSemiLoserCounts.get(loser.id) ?? 0) + 1); } // Round 1 losers are not counted (0 points per scoring rules). } @@ -400,10 +402,10 @@ export class NBASimulator implements Simulator { // probFifth–Eighth → confSemiLoserCounts / (4*N) — 4 conf semi losers per sim const N = NUM_SIMULATIONS; const results: SimulationResult[] = participantIds.map((participantId) => { - const c = championCounts.get(participantId)!; - const f = finalistCounts.get(participantId)!; - const cf = confFinalLoserCounts.get(participantId)!; - const cs = confSemiLoserCounts.get(participantId)!; + const c = championCounts.get(participantId) ?? 0; + const f = finalistCounts.get(participantId) ?? 0; + const cf = confFinalLoserCounts.get(participantId) ?? 0; + const cs = confSemiLoserCounts.get(participantId) ?? 0; return { participantId, probabilities: { diff --git a/app/services/simulations/ncaam-simulator.ts b/app/services/simulations/ncaam-simulator.ts index 12d4aba..eb35850 100644 --- a/app/services/simulations/ncaam-simulator.ts +++ b/app/services/simulations/ncaam-simulator.ts @@ -296,6 +296,10 @@ export function kenpomWinProbability(netrtgA: number, netrtgB: number): number { return 1 / (1 + Math.exp(-(netrtgA - netrtgB) / KENPOM_SCALE_FACTOR)); } +function sortByMatchNumber(matches: T[]): T[] { + return [...matches].toSorted((a, b) => a.matchNumber - b.matchNumber); +} + // ─── Simulator ──────────────────────────────────────────────────────────────── export class NCAAMSimulator implements Simulator { @@ -339,19 +343,24 @@ export class NCAAMSimulator implements Simulator { const byRound = new Map(); for (const m of allMatches) { if (!byRound.has(m.round)) byRound.set(m.round, []); - byRound.get(m.round)!.push(m); + byRound.get(m.round)?.push(m); } - const sortByMatchNumber = (matches: typeof allMatches) => - [...matches].toSorted((a, b) => a.matchNumber - b.matchNumber); + const rawFirstFour = byRound.get("First Four"); + const rawR64 = byRound.get("Round of 64"); + const rawR32 = byRound.get("Round of 32"); + const rawS16 = byRound.get("Sweet Sixteen"); + const rawE8 = byRound.get("Elite Eight"); + const rawFF = byRound.get("Final Four"); + const rawChamp = byRound.get("Championship"); - const firstFourMatches = byRound.has("First Four") ? sortByMatchNumber(byRound.get("First Four")!) : []; - const r64Matches = byRound.has("Round of 64") ? sortByMatchNumber(byRound.get("Round of 64")!) : null; - const r32Matches = byRound.has("Round of 32") ? sortByMatchNumber(byRound.get("Round of 32")!) : null; - const s16Matches = byRound.has("Sweet Sixteen") ? sortByMatchNumber(byRound.get("Sweet Sixteen")!) : null; - const e8Matches = byRound.has("Elite Eight") ? sortByMatchNumber(byRound.get("Elite Eight")!) : null; - const ffMatches = byRound.has("Final Four") ? sortByMatchNumber(byRound.get("Final Four")!) : null; - const champMatches = byRound.has("Championship") ? sortByMatchNumber(byRound.get("Championship")!) : null; + const firstFourMatches = rawFirstFour ? sortByMatchNumber(rawFirstFour) : []; + const r64Matches = rawR64 ? sortByMatchNumber(rawR64) : null; + const r32Matches = rawR32 ? sortByMatchNumber(rawR32) : null; + const s16Matches = rawS16 ? sortByMatchNumber(rawS16) : null; + const e8Matches = rawE8 ? sortByMatchNumber(rawE8) : null; + const ffMatches = rawFF ? sortByMatchNumber(rawFF) : null; + const champMatches = rawChamp ? sortByMatchNumber(rawChamp) : null; if (!r64Matches || !r32Matches || !s16Matches || !e8Matches || !ffMatches || !champMatches) { const found = [...byRound.keys()].join(", "); @@ -403,7 +412,7 @@ export class NCAAMSimulator implements Simulator { const regions = (bracketEvent.bracketRegionConfig as BracketRegion[] | null) ?? // Fall back to the template's built-in regions — single source of truth - NCAA_68.regions!; + NCAA_68.regions ?? []; const slotMap = buildNCAA68SlotMap(regions); for (let i = 0; i < slotMap.playInOffsets.length; i++) { @@ -532,23 +541,25 @@ export class NCAAMSimulator implements Simulator { // ffSimWinners: Map for the null participant2Id slots const ffSimWinners = new Map(); for (const [ffMatchNum, r64MatchNum] of ffToR64) { - const m = firstFourByNum.get(ffMatchNum)!; + const m = firstFourByNum.get(ffMatchNum); + if (!m) continue; const winner = m.isComplete && m.winnerId ? m.winnerId - : simMatch(m.participant1Id!, m.participant2Id!).winner; + : simMatch(m.participant1Id ?? "", m.participant2Id ?? "").winner; ffSimWinners.set(r64MatchNum, winner); } // ── Round of 64 (32 matches → 32 winners) ──────────────────────────── const r64Winners: string[] = []; for (let i = 1; i <= 32; i++) { - const m = r64ByNum.get(i)!; + const m = r64ByNum.get(i); + if (!m) continue; // participant2Id may be null if this slot is filled by a First Four winner const p2 = m.participant2Id ?? ffSimWinners.get(i) ?? null; if (m.isComplete && m.winnerId) { r64Winners.push(m.winnerId); } else { - r64Winners.push(simMatch(m.participant1Id!, p2!).winner); + r64Winners.push(simMatch(m.participant1Id ?? "", p2 ?? "").winner); } } @@ -590,8 +601,8 @@ export class NCAAMSimulator implements Simulator { // Derive loser from stored participants if loserId is missing loser = dbMatch.loserId ?? (dbMatch.participant1Id === dbMatch.winnerId - ? dbMatch.participant2Id! - : dbMatch.participant1Id!); + ? dbMatch.participant2Id ?? "" + : dbMatch.participant1Id ?? ""); } else { const p1 = s16Winners[(i - 1) * 2]; const p2 = s16Winners[(i - 1) * 2 + 1]; @@ -611,8 +622,8 @@ export class NCAAMSimulator implements Simulator { winner = dbMatch.winnerId; loser = dbMatch.loserId ?? (dbMatch.participant1Id === dbMatch.winnerId - ? dbMatch.participant2Id! - : dbMatch.participant1Id!); + ? dbMatch.participant2Id ?? "" + : dbMatch.participant1Id ?? ""); } else { const p1 = e8Winners[(i - 1) * 2]; const p2 = e8Winners[(i - 1) * 2 + 1]; @@ -629,8 +640,8 @@ export class NCAAMSimulator implements Simulator { champion = champMatch.winnerId; finalist = champMatch.loserId ?? (champMatch.participant1Id === champMatch.winnerId - ? champMatch.participant2Id! - : champMatch.participant1Id!); + ? champMatch.participant2Id ?? "" + : champMatch.participant1Id ?? ""); } else { ({ winner: champion, loser: finalist } = simMatch(ffWinners[0], ffWinners[1])); } @@ -645,10 +656,10 @@ export class NCAAMSimulator implements Simulator { // probFifth–Eighth → e8LoserCounts / (4*N) → 4 losers * 1/(4N) = 1 const N = NUM_SIMULATIONS; const results: SimulationResult[] = participantIds.map((participantId) => { - const c = championCounts.get(participantId)!; - const f = finalistCounts.get(participantId)!; - const ff = ffLoserCounts.get(participantId)!; - const e8 = e8LoserCounts.get(participantId)!; + const c = championCounts.get(participantId) ?? 0; + const f = finalistCounts.get(participantId) ?? 0; + const ff = ffLoserCounts.get(participantId) ?? 0; + const e8 = e8LoserCounts.get(participantId) ?? 0; return { participantId, probabilities: { diff --git a/app/services/simulations/ncaaw-simulator.ts b/app/services/simulations/ncaaw-simulator.ts index 78452f8..e0769db 100644 --- a/app/services/simulations/ncaaw-simulator.ts +++ b/app/services/simulations/ncaaw-simulator.ts @@ -276,6 +276,10 @@ export function barthagWinProbability(barthagA: number, barthagB: number): numbe return pA / total; } +function sortByMatchNumber(matches: T[]): T[] { + return [...matches].toSorted((a, b) => a.matchNumber - b.matchNumber); +} + // ─── Simulator ──────────────────────────────────────────────────────────────── export class NCAAWSimulator implements Simulator { @@ -316,19 +320,24 @@ export class NCAAWSimulator implements Simulator { const byRound = new Map(); for (const m of allMatches) { if (!byRound.has(m.round)) byRound.set(m.round, []); - byRound.get(m.round)!.push(m); + byRound.get(m.round)?.push(m); } - const sortByMatchNumber = (matches: typeof allMatches) => - [...matches].toSorted((a, b) => a.matchNumber - b.matchNumber); + const rawFirstFour = byRound.get("First Four"); + const rawR64 = byRound.get("Round of 64"); + const rawR32 = byRound.get("Round of 32"); + const rawS16 = byRound.get("Sweet Sixteen"); + const rawE8 = byRound.get("Elite Eight"); + const rawFF = byRound.get("Final Four"); + const rawChamp = byRound.get("Championship"); - const firstFourMatches = byRound.has("First Four") ? sortByMatchNumber(byRound.get("First Four")!) : []; - const r64Matches = byRound.has("Round of 64") ? sortByMatchNumber(byRound.get("Round of 64")!) : null; - const r32Matches = byRound.has("Round of 32") ? sortByMatchNumber(byRound.get("Round of 32")!) : null; - const s16Matches = byRound.has("Sweet Sixteen") ? sortByMatchNumber(byRound.get("Sweet Sixteen")!) : null; - const e8Matches = byRound.has("Elite Eight") ? sortByMatchNumber(byRound.get("Elite Eight")!) : null; - const ffMatches = byRound.has("Final Four") ? sortByMatchNumber(byRound.get("Final Four")!) : null; - const champMatches = byRound.has("Championship") ? sortByMatchNumber(byRound.get("Championship")!) : null; + const firstFourMatches = rawFirstFour ? sortByMatchNumber(rawFirstFour) : []; + const r64Matches = rawR64 ? sortByMatchNumber(rawR64) : null; + const r32Matches = rawR32 ? sortByMatchNumber(rawR32) : null; + const s16Matches = rawS16 ? sortByMatchNumber(rawS16) : null; + const e8Matches = rawE8 ? sortByMatchNumber(rawE8) : null; + const ffMatches = rawFF ? sortByMatchNumber(rawFF) : null; + const champMatches = rawChamp ? sortByMatchNumber(rawChamp) : null; if (!r64Matches || !r32Matches || !s16Matches || !e8Matches || !ffMatches || !champMatches) { const found = [...byRound.keys()].join(", "); @@ -370,7 +379,7 @@ export class NCAAWSimulator implements Simulator { const regions = (bracketEvent.bracketRegionConfig as BracketRegion[] | null) ?? // Fall back to the template's built-in regions — single source of truth - NCAA_68.regions!; + NCAA_68.regions ?? []; const slotMap = buildNCAA68SlotMap(regions); for (let i = 0; i < slotMap.playInOffsets.length; i++) { @@ -494,22 +503,24 @@ export class NCAAWSimulator implements Simulator { // ── First Four ──────────────────────────────────────────────────────── const ffSimWinners = new Map(); for (const [ffMatchNum, r64MatchNum] of ffToR64) { - const m = firstFourByNum.get(ffMatchNum)!; + const m = firstFourByNum.get(ffMatchNum); + if (!m) continue; const winner = m.isComplete && m.winnerId ? m.winnerId - : simMatch(m.participant1Id!, m.participant2Id!).winner; + : simMatch(m.participant1Id ?? "", m.participant2Id ?? "").winner; ffSimWinners.set(r64MatchNum, winner); } // ── Round of 64 ─────────────────────────────────────────────────────── const r64Winners: string[] = []; for (let i = 1; i <= 32; i++) { - const m = r64ByNum.get(i)!; + const m = r64ByNum.get(i); + if (!m) continue; const p2 = m.participant2Id ?? ffSimWinners.get(i) ?? null; if (m.isComplete && m.winnerId) { r64Winners.push(m.winnerId); } else { - r64Winners.push(simMatch(m.participant1Id!, p2!).winner); + r64Winners.push(simMatch(m.participant1Id ?? "", p2 ?? "").winner); } } @@ -550,8 +561,8 @@ export class NCAAWSimulator implements Simulator { // Derive loser from stored participants if loserId is missing loser = dbMatch.loserId ?? (dbMatch.participant1Id === dbMatch.winnerId - ? dbMatch.participant2Id! - : dbMatch.participant1Id!); + ? dbMatch.participant2Id ?? "" + : dbMatch.participant1Id ?? ""); } else { const p1 = s16Winners[(i - 1) * 2]; const p2 = s16Winners[(i - 1) * 2 + 1]; @@ -571,8 +582,8 @@ export class NCAAWSimulator implements Simulator { winner = dbMatch.winnerId; loser = dbMatch.loserId ?? (dbMatch.participant1Id === dbMatch.winnerId - ? dbMatch.participant2Id! - : dbMatch.participant1Id!); + ? dbMatch.participant2Id ?? "" + : dbMatch.participant1Id ?? ""); } else { const p1 = e8Winners[(i - 1) * 2]; const p2 = e8Winners[(i - 1) * 2 + 1]; @@ -589,8 +600,8 @@ export class NCAAWSimulator implements Simulator { champion = champMatch.winnerId; finalist = champMatch.loserId ?? (champMatch.participant1Id === champMatch.winnerId - ? champMatch.participant2Id! - : champMatch.participant1Id!); + ? champMatch.participant2Id ?? "" + : champMatch.participant1Id ?? ""); } else { ({ winner: champion, loser: finalist } = simMatch(ffWinners[0], ffWinners[1])); } @@ -601,10 +612,10 @@ export class NCAAWSimulator implements Simulator { // 10. Convert integer counts to probability distributions. const N = NUM_SIMULATIONS; const results: SimulationResult[] = participantIds.map((participantId) => { - const c = championCounts.get(participantId)!; - const f = finalistCounts.get(participantId)!; - const ff = ffLoserCounts.get(participantId)!; - const e8 = e8LoserCounts.get(participantId)!; + const c = championCounts.get(participantId) ?? 0; + const f = finalistCounts.get(participantId) ?? 0; + const ff = ffLoserCounts.get(participantId) ?? 0; + const e8 = e8LoserCounts.get(participantId) ?? 0; return { participantId, probabilities: { diff --git a/app/services/simulations/nhl-simulator.ts b/app/services/simulations/nhl-simulator.ts index e296af3..9b042c1 100644 --- a/app/services/simulations/nhl-simulator.ts +++ b/app/services/simulations/nhl-simulator.ts @@ -291,6 +291,37 @@ type DivisionSeedings = { third: TeamEntry; }; +/** Get Elo for a team entry. Fallback 1400 for unknown teams. */ +function elo(entry: TeamEntry): number { + return entry.data?.elo ?? 1400; +} + +/** + * Weighted pick without replacement. + * Draws one team from `pool` using the given seeding weight key, excluding `excluded`. + * Returns undefined if no eligible team has positive weight — the caller should + * treat that as a degenerate draw and skip the iteration. + */ +function weightedPick( + pool: TeamEntry[], + weightKey: "p_div1" | "p_div2" | "p_div3" | "p_wc1" | "p_wc2", + excluded: Set +): TeamEntry | undefined { + const eligible = pool.filter((t) => !excluded.has(t)); + if (eligible.length === 0) return undefined; + + const weights = eligible.map((t) => t.data?.[weightKey] ?? 0); + const total = weights.reduce((s, w) => s + w, 0); + if (total === 0) return undefined; + + let r = Math.random() * total; + for (let i = 0; i < eligible.length; i++) { + r -= weights[i]; + if (r <= 0) return eligible[i]; + } + return eligible[eligible.length - 1]; +} + // ─── Simulator ──────────────────────────────────────────────────────────────── export class NHLSimulator implements Simulator { @@ -348,9 +379,6 @@ export class NHLSimulator implements Simulator { // ─── Helpers (defined once, outside the hot loop) ───────────────────────── - /** Get Elo for a team entry. Fallback 1400 for unknown teams. */ - const elo = (entry: TeamEntry): number => entry.data?.elo ?? 1400; - /** Simulate a best-of-7 series. Returns winner and loser. */ const simSeries = (a: TeamEntry, b: TeamEntry): { winner: TeamEntry; loser: TeamEntry } => { const winProb = eloWinProbability(elo(a), elo(b)); @@ -362,32 +390,6 @@ export class NHLSimulator implements Simulator { return winsA === 4 ? { winner: a, loser: b } : { winner: b, loser: a }; }; - /** - * Weighted pick without replacement. - * Draws one team from `pool` using the given seeding weight key, excluding `excluded`. - * Returns undefined if no eligible team has positive weight — the caller should - * treat that as a degenerate draw and skip the iteration. - */ - const weightedPick = ( - pool: TeamEntry[], - weightKey: "p_div1" | "p_div2" | "p_div3" | "p_wc1" | "p_wc2", - excluded: Set - ): TeamEntry | undefined => { - const eligible = pool.filter((t) => !excluded.has(t)); - if (eligible.length === 0) return undefined; - - const weights = eligible.map((t) => t.data?.[weightKey] ?? 0); - const total = weights.reduce((s, w) => s + w, 0); - if (total === 0) return undefined; // no team has positive weight for this slot - - let r = Math.random() * total; - for (let i = 0; i < eligible.length; i++) { - r -= weights[i]; - if (r <= 0) return eligible[i]; - } - return eligible[eligible.length - 1]; - }; - /** * Draw the top-3 playoff seeds for a single division. * Returns undefined for each position if no team has weight for that slot @@ -491,7 +493,7 @@ export class NHLSimulator implements Simulator { // Conference Final. const eastCF = simSeries(eastR2m1.winner, eastR2m2.winner); const eastChamp = eastCF.winner; - confFinalLoserCounts.set(eastCF.loser.id, confFinalLoserCounts.get(eastCF.loser.id)! + 1); + confFinalLoserCounts.set(eastCF.loser.id, (confFinalLoserCounts.get(eastCF.loser.id) ?? 0) + 1); // ── Western Conference ──────────────────────────────────────────────────── const westR1Winners = westBracket.map(([a, b]) => simSeries(a, b).winner); @@ -499,16 +501,16 @@ export class NHLSimulator implements Simulator { const westR2m2 = simSeries(westR1Winners[1], westR1Winners[3]); const westCF = simSeries(westR2m1.winner, westR2m2.winner); const westChamp = westCF.winner; - confFinalLoserCounts.set(westCF.loser.id, confFinalLoserCounts.get(westCF.loser.id)! + 1); + confFinalLoserCounts.set(westCF.loser.id, (confFinalLoserCounts.get(westCF.loser.id) ?? 0) + 1); // ── Stanley Cup Final ───────────────────────────────────────────────────── const final = simSeries(eastChamp, westChamp); - championCounts.set(final.winner.id, championCounts.get(final.winner.id)! + 1); - finalistCounts.set(final.loser.id, finalistCounts.get(final.loser.id)! + 1); + championCounts.set(final.winner.id, (championCounts.get(final.winner.id) ?? 0) + 1); + finalistCounts.set(final.loser.id, (finalistCounts.get(final.loser.id) ?? 0) + 1); // Round 2 losers (4 per sim). Round 1 losers are not counted (0 points). for (const loser of [eastR2m1.loser, eastR2m2.loser, westR2m1.loser, westR2m2.loser]) { - r2LoserCounts.set(loser.id, r2LoserCounts.get(loser.id)! + 1); + r2LoserCounts.set(loser.id, (r2LoserCounts.get(loser.id) ?? 0) + 1); } } @@ -527,10 +529,10 @@ export class NHLSimulator implements Simulator { // const N = effectiveN; const results: SimulationResult[] = participantIds.map((participantId) => { - const c = championCounts.get(participantId)!; - const f = finalistCounts.get(participantId)!; - const cf = confFinalLoserCounts.get(participantId)!; - const r2 = r2LoserCounts.get(participantId)!; + const c = championCounts.get(participantId) ?? 0; + const f = finalistCounts.get(participantId) ?? 0; + const cf = confFinalLoserCounts.get(participantId) ?? 0; + const r2 = r2LoserCounts.get(participantId) ?? 0; return { participantId, probabilities: { diff --git a/app/services/simulations/ucl-simulator.ts b/app/services/simulations/ucl-simulator.ts index c5af2db..18e6008 100644 --- a/app/services/simulations/ucl-simulator.ts +++ b/app/services/simulations/ucl-simulator.ts @@ -99,7 +99,7 @@ export class UCLSimulator implements Simulator { const byRound = new Map(); for (const m of allMatches) { if (!byRound.has(m.round)) byRound.set(m.round, []); - byRound.get(m.round)!.push(m); + byRound.get(m.round)?.push(m); } const sortedRoundMatches = [...byRound.values()] @@ -139,7 +139,7 @@ export class UCLSimulator implements Simulator { // r16Matches is sorted by matchNumber, so participantIds[0..1] = match 1, [2..3] = match 2, … const participantIds: string[] = []; for (const m of r16Matches) { - participantIds.push(m.participant1Id!, m.participant2Id!); + participantIds.push(m.participant1Id ?? "", m.participant2Id ?? ""); } const participantSet = new Set(participantIds); const fallbackProb = 1 / participantIds.length; @@ -164,7 +164,7 @@ export class UCLSimulator implements Simulator { if (hasOdds) { const oddsInput = evRows .filter((r) => r.sourceOdds !== null && participantSet.has(r.participantId)) - .map((r) => ({ participantId: r.participantId, odds: r.sourceOdds! })); + .map((r) => ({ participantId: r.participantId, odds: r.sourceOdds ?? 0 })); eloMap = convertFuturesToElo(oddsInput, "american"); } else { // No odds stored — all teams get equal Elo (coin-flip bracket) @@ -227,11 +227,12 @@ export class UCLSimulator implements Simulator { // R16 losers: no count added (0 points per scoring rules) const r16Winners: string[] = []; for (let i = 1; i <= 8; i++) { - const m = r16ByNum.get(i)!; + const m = r16ByNum.get(i); + if (!m) continue; if (m.isComplete && m.winnerId) { r16Winners.push(m.winnerId); } else { - const { winner } = simMatch(m.participant1Id!, m.participant2Id!); + const { winner } = simMatch(m.participant1Id ?? "", m.participant2Id ?? ""); r16Winners.push(winner); } } @@ -255,9 +256,7 @@ export class UCLSimulator implements Simulator { } qfWinners.push(winner); - if (qfLoserCounts.has(loser)) { - qfLoserCounts.set(loser, qfLoserCounts.get(loser)! + 1); - } + qfLoserCounts.set(loser, (qfLoserCounts.get(loser) ?? 0) + 1); } // ── Semifinals ─────────────────────────────────────────────────────── @@ -278,9 +277,7 @@ export class UCLSimulator implements Simulator { } sfWinners.push(winner); - if (sfLoserCounts.has(loser)) { - sfLoserCounts.set(loser, sfLoserCounts.get(loser)! + 1); - } + sfLoserCounts.set(loser, (sfLoserCounts.get(loser) ?? 0) + 1); } // ── Final ───────────────────────────────────────────────────────────── @@ -294,12 +291,8 @@ export class UCLSimulator implements Simulator { ({ winner: champion, loser: finalist } = simMatch(sfWinners[0], sfWinners[1])); } - if (championCounts.has(champion)) { - championCounts.set(champion, championCounts.get(champion)! + 1); - } - if (finalistCounts.has(finalist)) { - finalistCounts.set(finalist, finalistCounts.get(finalist)! + 1); - } + championCounts.set(champion, (championCounts.get(champion) ?? 0) + 1); + finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1); } // 11. Convert counts to probability distributions. @@ -309,10 +302,10 @@ export class UCLSimulator implements Simulator { // probFifth–Eighth → qfLoserCounts / (4*N) — 4 QF losers per sim const N = NUM_SIMULATIONS; const results: SimulationResult[] = participantIds.map((participantId) => { - const c = championCounts.get(participantId)!; - const f = finalistCounts.get(participantId)!; - const sf = sfLoserCounts.get(participantId)!; - const qf = qfLoserCounts.get(participantId)!; + const c = championCounts.get(participantId) ?? 0; + const f = finalistCounts.get(participantId) ?? 0; + const sf = sfLoserCounts.get(participantId) ?? 0; + const qf = qfLoserCounts.get(participantId) ?? 0; return { participantId, probabilities: { diff --git a/app/services/standings-sync/__tests__/nba.test.ts b/app/services/standings-sync/__tests__/nba.test.ts index 39a5e70..ad8aef0 100644 --- a/app/services/standings-sync/__tests__/nba.test.ts +++ b/app/services/standings-sync/__tests__/nba.test.ts @@ -95,7 +95,8 @@ describe("NbaStandingsAdapter", () => { expect(records).toHaveLength(3); - const okc = records.find((r) => r.teamName === "Oklahoma City Thunder")!; + const okc = records.find((r) => r.teamName === "Oklahoma City Thunder"); + if (!okc) throw new Error("Oklahoma City Thunder record not found"); expect(okc).toBeDefined(); expect(okc.wins).toBe(58); expect(okc.losses).toBe(10); @@ -113,7 +114,8 @@ describe("NbaStandingsAdapter", () => { const adapter = new NbaStandingsAdapter(); const records = await adapter.fetchStandings(); - const bos = records.find((r) => r.teamName === "Boston Celtics")!; + const bos = records.find((r) => r.teamName === "Boston Celtics"); + if (!bos) throw new Error("Boston Celtics record not found"); expect(bos.conference).toBe("Eastern Conference"); expect(bos.division).toBe("Atlantic Division"); }); @@ -127,10 +129,12 @@ describe("NbaStandingsAdapter", () => { const adapter = new NbaStandingsAdapter(); const records = await adapter.fetchStandings(); - const bos = records.find((r) => r.teamName === "Boston Celtics")!; + const bos = records.find((r) => r.teamName === "Boston Celtics"); + if (!bos) throw new Error("Boston Celtics record not found"); expect(bos.streak).toBe("W5"); - const tor = records.find((r) => r.teamName === "Toronto Raptors")!; + const tor = records.find((r) => r.teamName === "Toronto Raptors"); + if (!tor) throw new Error("Toronto Raptors record not found"); expect(tor.streak).toBe("L2"); }); diff --git a/app/services/standings-sync/index.ts b/app/services/standings-sync/index.ts index 9ee3093..5720e3d 100644 --- a/app/services/standings-sync/index.ts +++ b/app/services/standings-sync/index.ts @@ -70,7 +70,7 @@ export async function syncStandings(sportsSeasonId: string): Promise const participantByName = new Map(participants.map((p) => [p.name, p])); // Build a map of externalId → participant for ID-first matching const participantByExternalId = new Map( - participants.filter((p) => p.externalId).map((p) => [p.externalId!, p]) + participants.filter((p) => p.externalId).map((p) => [p.externalId ?? "", p]) ); const toUpsert: Parameters[0] = []; @@ -84,9 +84,9 @@ export async function syncStandings(sportsSeasonId: string): Promise // Fall back to name matching const matchedName = findMatchingTeamName(record.teamName, participantNames); if (matchedName) { - participant = participantByName.get(matchedName)!; + participant = participantByName.get(matchedName) ?? null; // Write-back: persist externalId so future syncs skip name matching for this team - if (!participant.externalId) { + if (participant && !participant.externalId) { await updateParticipant(participant.id, { externalId: record.externalTeamId }); } } diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index e8d4e2d..beb10f7 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -19,4 +19,3 @@ declare global { } } -export {}; diff --git a/server/socket.ts b/server/socket.ts index c44fe8a..9633867 100644 --- a/server/socket.ts +++ b/server/socket.ts @@ -163,7 +163,8 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer { } // Get current connected teams for this season - const seasonConnectedTeams = connectedTeams.get(seasonId)!; + const seasonConnectedTeams = connectedTeams.get(seasonId) ?? new Set(); + if (!connectedTeams.has(seasonId)) connectedTeams.set(seasonId, seasonConnectedTeams); // Send the list of already-connected teams to this socket socket.emit("connected-teams-list", { teamIds: Array.from(seasonConnectedTeams) }); @@ -172,7 +173,7 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer { seasonConnectedTeams.add(teamId); // Broadcast to everyone else that this team connected - io!.to(`draft-${seasonId}`).emit("team-connected", { teamId }); + io?.to(`draft-${seasonId}`).emit("team-connected", { teamId }); console.log(`Team ${teamId} connected to draft-${seasonId}. Total connected: ${seasonConnectedTeams.size}`); } @@ -269,7 +270,7 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer { } } - io!.to(`draft-${seasonId}`).emit("team-disconnected", { teamId: currentTeamId }); + io?.to(`draft-${seasonId}`).emit("team-disconnected", { teamId: currentTeamId }); console.log(`Team ${currentTeamId} disconnected from draft-${seasonId}`); } }); @@ -304,7 +305,7 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer { } } - io!.to(`draft-${currentSeasonId}`).emit("team-disconnected", { teamId: currentTeamId }); + io?.to(`draft-${currentSeasonId}`).emit("team-disconnected", { teamId: currentTeamId }); console.log(`Team ${currentTeamId} disconnected from draft-${currentSeasonId}`); } });