Fix oxlint warnings: no-shadow, consistent-function-scoping, no-non-null-assertion, and others (#196)

* Fix no-shadow and consistent-function-scoping lint violations

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Add npm run typecheck as Stop hook in Claude settings

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

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-03-21 10:59:51 -07:00 committed by GitHub
parent 180aad1520
commit 4bffa40606
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 521 additions and 429 deletions

View file

@ -10,6 +10,16 @@
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "npm run typecheck 2>&1"
}
]
}
]
}
}

View file

@ -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": [
{

View file

@ -32,6 +32,33 @@ interface StandingsTableProps {
showPlacementBreakdown?: boolean;
}
function getRankBadge(rank: number) {
if (rank === 1) {
return (
<div className="flex items-center gap-2">
<Trophy className="h-5 w-5 text-yellow-500" />
<span className="font-bold text-lg">{rank}</span>
</div>
);
} else if (rank === 2) {
return (
<div className="flex items-center gap-2">
<Medal className="h-5 w-5 text-gray-400" />
<span className="font-semibold">{rank}</span>
</div>
);
} else if (rank === 3) {
return (
<div className="flex items-center gap-2">
<Award className="h-5 w-5 text-amber-700" />
<span className="font-semibold">{rank}</span>
</div>
);
} else {
return <span className="font-medium">{rank}</span>;
}
}
export function StandingsTable({
standings,
showMovement = true,
@ -66,33 +93,6 @@ export function StandingsTable({
}
};
const getRankBadge = (rank: number) => {
if (rank === 1) {
return (
<div className="flex items-center gap-2">
<Trophy className="h-5 w-5 text-yellow-500" />
<span className="font-bold text-lg">{rank}</span>
</div>
);
} else if (rank === 2) {
return (
<div className="flex items-center gap-2">
<Medal className="h-5 w-5 text-gray-400" />
<span className="font-semibold">{rank}</span>
</div>
);
} else if (rank === 3) {
return (
<div className="flex items-center gap-2">
<Award className="h-5 w-5 text-amber-700" />
<span className="font-semibold">{rank}</span>
</div>
);
} else {
return <span className="font-medium">{rank}</span>;
}
};
return (
<Table>
<TableHeader>

View file

@ -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(<AutodraftSettings {...defaultProps} />);
@ -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 () => {

View file

@ -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(
<DraftGrid
draftSlots={mockDraftSlots}

View file

@ -49,11 +49,12 @@ export const TeamsDraftedGrid = memo(function TeamsDraftedGrid({
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<string, typeof picks>();
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;

View file

@ -11,9 +11,10 @@ export function groupPicksByTeamAndSport(
const map = new Map<string, Map<string, DraftPick[]>>();
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<string, DraftPick[]>();
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;
}

View file

@ -58,7 +58,7 @@ export function groupMatchesByRound(matches: Match[]): Map<string, Match[]> {
const byRound = new Map<string, Match[]>();
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 =

View file

@ -55,33 +55,10 @@ interface SeasonStandingsProps {
* - Movement indicators (position changes)
* - Handles ties (same points = same position)
*/
export function SeasonStandings({
standings,
teamOwnerships = [],
userParticipantIds = [],
showOwnership = true,
isFinalized = false,
title = "Championship Standings",
description: _description,
}: SeasonStandingsProps) {
const userParticipantSet = new Set(userParticipantIds);
// Create ownership map for fast lookup
const ownershipMap = new Map<string, TeamOwnership>();
teamOwnerships.forEach((ownership) => {
ownershipMap.set(ownership.participantId, ownership);
});
// Get ownership info for a participant
const getOwnership = (participantId: string): TeamOwnership | null => {
if (!showOwnership) return null;
return ownershipMap.get(participantId) || null;
};
// Get movement indicator
const getMovementIndicator = (
function getMovementIndicator(
currentPosition: number,
previousPosition?: number | null
) => {
) {
if (!previousPosition) return null;
const change = previousPosition - currentPosition; // Positive means moved up
@ -107,13 +84,34 @@ export function SeasonStandings({
</div>
);
}
};
}
// Get position display
const getPositionBadge = (position: number, isTied: boolean) => {
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 <span className="font-medium">{positionText}</span>;
}
export function SeasonStandings({
standings,
teamOwnerships = [],
userParticipantIds = [],
showOwnership = true,
isFinalized = false,
title = "Championship Standings",
description: _description,
}: SeasonStandingsProps) {
const userParticipantSet = new Set(userParticipantIds);
// Create ownership map for fast lookup
const ownershipMap = new Map<string, TeamOwnership>();
teamOwnerships.forEach((ownership) => {
ownershipMap.set(ownership.participantId, ownership);
});
// Get ownership info for a participant
const getOwnership = (participantId: string): TeamOwnership | null => {
if (!showOwnership) return null;
return ownershipMap.get(participantId) || null;
};
// Check if multiple participants share the same position

View file

@ -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())

View file

@ -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 (
<Card>
<CardHeader>

View file

@ -139,7 +139,7 @@ export function TeamScoreBreakdown({
(pick.finalPosition ?? 0) === 0 ? (
<Badge variant="secondary">Did Not Score</Badge>
) : (
<PlacementBadge position={pick.finalPosition!} />
<PlacementBadge position={pick.finalPosition ?? 0} />
)
) : (
<Badge variant="outline">Pending</Badge>

View file

@ -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]
);

View file

@ -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", () => {

View file

@ -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);

View file

@ -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<teamId, queue[]> 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([

View file

@ -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", () => {

View file

@ -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
});

View file

@ -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;
}

View file

@ -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 ────────────────────────────────────────────────────────────

View file

@ -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

View file

@ -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<string, string | null>();
async function getBracketTemplate(sportsSeasonId: string): Promise<string | null> {
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<string, string | null>();
async function getBracketTemplate(sportsSeasonId: string): Promise<string | null> {
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),
}));

View file

@ -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)!);
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.participant2Id && draftedMap.has(row.participant2Id)) {
pMap.set(row.participant2Id, draftedMap.get(row.participant2Id)!);
}
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<string>();
if (gameKeys.size === 1) {
const [round, gameNumberStr] = [...gameKeys][0].split("|");
// When round name duplicates the event name, omit the round to avoid

View file

@ -36,22 +36,22 @@ export async function findSportBySlug(slug: string): Promise<Sport | undefined>
export async function findAllSports(): Promise<Sport[]> {
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<SportWithActiveSeasons[]> {
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<Sport[]> {
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)],
});
}

View file

@ -43,10 +43,10 @@ export async function findSportsSeasonsBySportId(sportId: string): Promise<Sport
export async function findActiveSportsSeasonsBySportId(sportId: string): Promise<SportsSeason[]> {
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: {

View file

@ -158,7 +158,7 @@ export async function getTeamScoreBreakdown(
const bracketTemplateCache = new Map<string, string | null>();
async function getBracketTemplate(sportsSeasonId: string): Promise<string | null> {
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);
}

View file

@ -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) };
}

View file

@ -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);
};

View file

@ -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({
<CardTitle className="text-base">Group {label}</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 space-y-2">
{[...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({
<Form key={participant.id} method="post" className="flex gap-2 items-end mb-2">
<input type="hidden" name="intent" value="upsert-odds" />
<input type="hidden" name="matchId" value={match.id} />
<input type="hidden" name="participantId" value={participant.id!} />
<input type="hidden" name="participantId" value={participant.id ?? ""} />
<div className="flex-1">
<Label className="text-xs">{participant.name ?? "TBD"}</Label>
<Input

View file

@ -45,22 +45,7 @@ export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
export { loader, action };
export default function SportsSeasonEvents({
loaderData,
actionData,
}: Route.ComponentProps) {
const { sportsSeason, events, qpStandings, scoringRules } = loaderData;
const [createEventStartsAt, setCreateEventStartsAt] = useState("");
const defaultEventType =
sportsSeason.scoringPattern === "qualifying_points"
? "major_tournament"
: sportsSeason.scoringPattern === "season_standings"
? "schedule_event"
: "playoff_game";
const getStatusBadge = (isComplete: boolean, eventType: string, eventDate?: string | null, eventStartsAt?: Date | string | null) => {
function getStatusBadge(isComplete: boolean, eventType: string, eventDate?: string | null, eventStartsAt?: Date | string | null) {
if (isComplete) {
return (
<Badge variant="default" className="bg-emerald-500">
@ -79,7 +64,22 @@ export default function SportsSeasonEvents({
);
}
return <Badge variant="secondary">In Progress</Badge>;
};
}
export default function SportsSeasonEvents({
loaderData,
actionData,
}: Route.ComponentProps) {
const { sportsSeason, events, qpStandings, scoringRules } = loaderData;
const [createEventStartsAt, setCreateEventStartsAt] = useState("");
const defaultEventType =
sportsSeason.scoringPattern === "qualifying_points"
? "major_tournament"
: sportsSeason.scoringPattern === "season_standings"
? "schedule_event"
: "playoff_game";
return (
<div className="p-8">

View file

@ -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;
});

View file

@ -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

View file

@ -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;
});

View file

@ -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<string, Array<{ id: string; name: string }>>();
const sportsSeasons = await Promise.all(

View file

@ -1103,23 +1103,23 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
</Label>
<div className="max-h-96 overflow-y-auto border rounded-md p-3 space-y-2">
{allSportsSeasons.length > 0 ? (
allSportsSeasons.map((season) => (
<div key={season.id} className="flex items-center space-x-2">
allSportsSeasons.map((ss) => (
<div key={ss.id} className="flex items-center space-x-2">
<Checkbox
id={season.id}
id={ss.id}
name="sportsSeasons"
value={season.id}
checked={selectedSports.has(season.id)}
onCheckedChange={() => handleSportToggle(season.id)}
value={ss.id}
checked={selectedSports.has(ss.id)}
onCheckedChange={() => handleSportToggle(ss.id)}
disabled={!canEditSports}
/>
<Label
htmlFor={season.id}
htmlFor={ss.id}
className={`font-normal flex-1 ${canEditSports ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'}`}
>
<span className="font-medium">{season.sport.name}</span> - {season.name} ({season.year})
<span className="font-medium">{ss.sport.name}</span> - {ss.name} ({ss.year})
<Badge variant="outline" className="ml-2 text-xs">
{season.scoringType.replace("_", " ")}
{ss.scoringType.replace("_", " ")}
</Badge>
</Label>
</div>

View file

@ -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

View file

@ -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

View file

@ -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,
}));

View file

@ -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%

View file

@ -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)

View file

@ -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);

View file

@ -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);

View file

@ -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<string, ICMResult>();
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, {

View file

@ -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[] = [];

View file

@ -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);

View file

@ -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) => ({
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: 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,
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

View file

@ -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) {

View file

@ -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 {
// probFifthEighth → 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: {

View file

@ -296,6 +296,10 @@ export function kenpomWinProbability(netrtgA: number, netrtgB: number): number {
return 1 / (1 + Math.exp(-(netrtgA - netrtgB) / KENPOM_SCALE_FACTOR));
}
function sortByMatchNumber<T extends { matchNumber: number }>(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<string, typeof allMatches>();
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<r64MatchNumber, winnerId> for the null participant2Id slots
const ffSimWinners = new Map<number, string>();
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 {
// probFifthEighth → 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: {

View file

@ -276,6 +276,10 @@ export function barthagWinProbability(barthagA: number, barthagB: number): numbe
return pA / total;
}
function sortByMatchNumber<T extends { matchNumber: number }>(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<string, typeof allMatches>();
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<number, string>();
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: {

View file

@ -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>
): 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>
): 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: {

View file

@ -99,7 +99,7 @@ export class UCLSimulator implements Simulator {
const byRound = new Map<string, typeof allMatches>();
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 {
// probFifthEighth → 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: {

View file

@ -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");
});

View file

@ -70,7 +70,7 @@ export async function syncStandings(sportsSeasonId: string): Promise<SyncResult>
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<typeof upsertRegularSeasonStandings>[0] = [];
@ -84,9 +84,9 @@ export async function syncStandings(sportsSeasonId: string): Promise<SyncResult>
// 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 });
}
}

View file

@ -19,4 +19,3 @@ declare global {
}
}
export {};

View file

@ -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<string>();
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}`);
}
});