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": "^_" } { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }
], ],
"typescript/no-explicit-any": "error", "typescript/no-explicit-any": "error",
"typescript/no-non-null-assertion": "warn", "typescript/no-non-null-assertion": "error",
"typescript/consistent-type-imports": [ "typescript/consistent-type-imports": [
"error", "error",
{ "prefer": "type-imports" } { "prefer": "type-imports" }
@ -36,7 +36,15 @@
"react/no-array-index-key": "error", "react/no-array-index-key": "error",
"react/self-closing-comp": "warn", "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": [ "overrides": [
{ {

View file

@ -32,6 +32,33 @@ interface StandingsTableProps {
showPlacementBreakdown?: boolean; 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({ export function StandingsTable({
standings, standings,
showMovement = true, 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 ( return (
<Table> <Table>
<TableHeader> <TableHeader>

View file

@ -145,7 +145,7 @@ describe('AutodraftSettings Component', () => {
describe('Optimistic UI', () => { describe('Optimistic UI', () => {
it('does not disable buttons while a fetch is in flight', async () => { 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; })); (global.fetch as any).mockReturnValueOnce(new Promise((r) => { resolve = r; }));
render(<AutodraftSettings {...defaultProps} />); render(<AutodraftSettings {...defaultProps} />);
@ -154,7 +154,7 @@ describe('AutodraftSettings Component', () => {
// Buttons stay enabled — optimistic UI does not block interaction // Buttons stay enabled — optimistic UI does not block interaction
expect(screen.getByRole('button', { name: 'Off' })).not.toBeDisabled(); 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 () => { 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 { DraftGrid } from '../DraftGrid';
import { mockDraftSlots } from '~/test/fixtures/team'; 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', () => { describe('DraftGrid Component', () => {
const mockGrid = [ const mockGrid = [
[ [
@ -117,13 +124,6 @@ describe('DraftGrid Component', () => {
}); });
it('should display timers when provided', () => { 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( render(
<DraftGrid <DraftGrid
draftSlots={mockDraftSlots} draftSlots={mockDraftSlots}

View file

@ -49,11 +49,12 @@ export const TeamsDraftedGrid = memo(function TeamsDraftedGrid({
if (!map.has(pick.team.id)) { if (!map.has(pick.team.id)) {
map.set(pick.team.id, new Map()); 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)) { if (!teamMap.has(pick.sport.id)) {
teamMap.set(pick.sport.id, []); teamMap.set(pick.sport.id, []);
} }
teamMap.get(pick.sport.id)!.push(pick); teamMap.get(pick.sport.id)?.push(pick);
}); });
return map; return map;

View file

@ -11,9 +11,10 @@ export function groupPicksByTeamAndSport(
const map = new Map<string, Map<string, DraftPick[]>>(); const map = new Map<string, Map<string, DraftPick[]>>();
picks.forEach((pick) => { picks.forEach((pick) => {
if (!map.has(pick.team.id)) map.set(pick.team.id, new Map()); 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, []); 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; return map;
} }

View file

@ -58,7 +58,7 @@ export function groupMatchesByRound(matches: Match[]): Map<string, Match[]> {
const byRound = new Map<string, Match[]>(); const byRound = new Map<string, Match[]>();
for (const match of matches) { for (const match of matches) {
if (!byRound.has(match.round)) byRound.set(match.round, []); 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()) { for (const group of byRound.values()) {
group.sort((a, b) => a.matchNumber - b.matchNumber); 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. // Double-chance survivor: participant appeared in a LATER round — not yet eliminated here.
if (loserLatestAppear > lossRoundIndex) continue; if (loserLatestAppear > lossRoundIndex) continue;
if (!result.has(match.round)) result.set(match.round, []); 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; return result;
} }
@ -211,7 +211,7 @@ export function PlayoffBracket({
: match.participant2Score; : match.participant2Score;
if (loserScore) _hasScore = true; if (loserScore) _hasScore = true;
if (!losersByRound.has(match.round)) losersByRound.set(match.round, []); if (!losersByRound.has(match.round)) losersByRound.set(match.round, []);
losersByRound.get(match.round)!.push({ losersByRound.get(match.round)?.push({
participant: match.loser, participant: match.loser,
score: loserScore, score: loserScore,
ownership: ownershipMap.get(match.loser.id) || null, ownership: ownershipMap.get(match.loser.id) || null,
@ -331,8 +331,8 @@ export function PlayoffBracket({
const p1IsTbd = !match.participant1Id; const p1IsTbd = !match.participant1Id;
const p2IsTbd = !match.participant2Id; const p2IsTbd = !match.participant2Id;
const p1IsOwned = !p1IsTbd && userParticipantSet.has(match.participant1Id!); const p1IsOwned = !p1IsTbd && userParticipantSet.has(match.participant1Id ?? "");
const p2IsOwned = !p2IsTbd && userParticipantSet.has(match.participant2Id!); const p2IsOwned = !p2IsTbd && userParticipantSet.has(match.participant2Id ?? "");
const matchHasOwned = p1IsOwned || p2IsOwned; const matchHasOwned = p1IsOwned || p2IsOwned;
const p1Ownership = const p1Ownership =

View file

@ -55,6 +55,43 @@ interface SeasonStandingsProps {
* - Movement indicators (position changes) * - Movement indicators (position changes)
* - Handles ties (same points = same position) * - 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 (
<div className="flex items-center gap-1 text-emerald-400">
<TrendingUp className="h-3 w-3" />
<span className="text-xs">+{change}</span>
</div>
);
} else if (change < 0) {
return (
<div className="flex items-center gap-1 text-coral-accent">
<TrendingDown className="h-3 w-3" />
<span className="text-xs">{change}</span>
</div>
);
} else {
return (
<div className="flex items-center gap-1 text-muted-foreground">
<Minus className="h-3 w-3" />
</div>
);
}
}
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({ export function SeasonStandings({
standings, standings,
teamOwnerships = [], teamOwnerships = [],
@ -77,45 +114,6 @@ export function SeasonStandings({
return ownershipMap.get(participantId) || null; 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 (
<div className="flex items-center gap-1 text-emerald-400">
<TrendingUp className="h-3 w-3" />
<span className="text-xs">+{change}</span>
</div>
);
} else if (change < 0) {
return (
<div className="flex items-center gap-1 text-coral-accent">
<TrendingDown className="h-3 w-3" />
<span className="text-xs">{change}</span>
</div>
);
} else {
return (
<div className="flex items-center gap-1 text-muted-foreground">
<Minus className="h-3 w-3" />
</div>
);
}
};
// 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 <span className="font-medium">{positionText}</span>;
};
// Check if multiple participants share the same position // Check if multiple participants share the same position
const checkIfTied = (standing: SeasonStanding): boolean => { const checkIfTied = (standing: SeasonStanding): boolean => {
return standings.some( return standings.some(

View file

@ -111,7 +111,7 @@ function buildFlatSections(
for (const row of standings) { for (const row of standings) {
const conf = row.conference ?? "Other"; const conf = row.conference ?? "Other";
if (!confMap.has(conf)) confMap.set(conf, []); 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]) => ({ return Array.from(confMap.entries()).map(([conference, rows]) => ({
@ -138,7 +138,7 @@ function buildNhlSections(
for (const row of standings) { for (const row of standings) {
const conf = row.conference ?? "Other"; const conf = row.conference ?? "Other";
if (!confMap.has(conf)) confMap.set(conf, []); 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]) => { return Array.from(confMap.entries()).map(([conference, rows]) => {
@ -146,7 +146,7 @@ function buildNhlSections(
for (const row of rows) { for (const row of rows) {
const div = row.division ?? ""; const div = row.division ?? "";
if (!divMap.has(div)) divMap.set(div, []); if (!divMap.has(div)) divMap.set(div, []);
divMap.get(div)!.push(row); divMap.get(div)?.push(row);
} }
const divisions = Array.from(divMap.entries()) const divisions = Array.from(divMap.entries())

View file

@ -30,6 +30,11 @@ const COLORS = [
'#6366f1', // indigo '#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) { export function PointProgressionChart({ chartData, teams }: PointProgressionChartProps) {
if (chartData.length === 0) { if (chartData.length === 0) {
return ( 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 ( return (
<Card> <Card>
<CardHeader> <CardHeader>

View file

@ -139,7 +139,7 @@ export function TeamScoreBreakdown({
(pick.finalPosition ?? 0) === 0 ? ( (pick.finalPosition ?? 0) === 0 ? (
<Badge variant="secondary">Did Not Score</Badge> <Badge variant="secondary">Did Not Score</Badge>
) : ( ) : (
<PlacementBadge position={pick.finalPosition!} /> <PlacementBadge position={pick.finalPosition ?? 0} />
) )
) : ( ) : (
<Badge variant="outline">Pending</Badge> <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). // would still be null at that point without the flag).
let aborted = false; let aborted = false;
let permissionStatus: PermissionStatus | null = null; let permissionStatus: PermissionStatus | null = null;
let changeHandler: (() => void) | null = null;
navigator.permissions navigator.permissions
.query({ name: "notifications" }) .query({ name: "notifications" })
.then((status) => { .then((status) => {
if (aborted) return; if (aborted) return;
permissionStatus = status; permissionStatus = status;
status.onchange = () => { changeHandler = () => {
setPermissionState(status.state as NotificationPermission); setPermissionState(status.state as NotificationPermission);
// If permission was revoked, disable notifications // If permission was revoked, disable notifications
if (status.state !== "granted") { if (status.state !== "granted") {
setEnabledState(false); setEnabledState(false);
} }
}; };
status.addEventListener("change", changeHandler);
}) })
.catch(() => { .catch(() => {
// Permissions API not available in all environments; silently ignore // Permissions API not available in all environments; silently ignore
@ -63,8 +65,8 @@ export function useDraftNotifications(seasonId: string, userId: string) {
return () => { return () => {
aborted = true; aborted = true;
if (permissionStatus) { if (permissionStatus && changeHandler) {
permissionStatus.onchange = null; permissionStatus.removeEventListener("change", changeHandler);
} }
}; };
}, [seasonId, userId]); }, [seasonId, userId]);
@ -120,7 +122,7 @@ export function useDraftNotifications(seasonId: string, userId: string) {
body, body,
tag: `draft-${seasonId}`, tag: `draft-${seasonId}`,
}); });
n.onclick = () => window.focus(); n.addEventListener("click", () => window.focus());
}, },
[enabled, seasonId] [enabled, seasonId]
); );

View file

@ -26,13 +26,13 @@ describe("localDateTimeToUtcIso", () => {
const input = "2026-03-11T17:00"; const input = "2026-03-11T17:00";
const result = localDateTimeToUtcIso(input); const result = localDateTimeToUtcIso(input);
expect(result).not.toBeNull(); 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)", () => { it("returns a string ending with 'Z' (UTC designator)", () => {
const result = localDateTimeToUtcIso("2026-03-11T10:00"); const result = localDateTimeToUtcIso("2026-03-11T10:00");
expect(result).not.toBeNull(); 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", () => { it("returns a full ISO-8601 string with milliseconds", () => {

View file

@ -1,6 +1,32 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { cn } from '~/lib/utils'; 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('Utils', () => {
describe('cn (className merger)', () => { describe('cn (className merger)', () => {
it('should merge class names', () => { it('should merge class names', () => {
@ -32,13 +58,6 @@ describe('Utils', () => {
}); });
describe('Draft Time Formatting', () => { 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', () => { it('should format time correctly', () => {
expect(formatTime(120)).toBe('2:00'); expect(formatTime(120)).toBe('2:00');
expect(formatTime(90)).toBe('1:30'); expect(formatTime(90)).toBe('1:30');
@ -57,10 +76,6 @@ describe('Draft Time Formatting', () => {
}); });
describe('League Name Validation', () => { describe('League Name Validation', () => {
const isValidLeagueName = (name: string): boolean => {
return name.trim().length >= 3 && name.trim().length <= 50;
};
it('should accept valid league names', () => { it('should accept valid league names', () => {
expect(isValidLeagueName('My League')).toBe(true); expect(isValidLeagueName('My League')).toBe(true);
expect(isValidLeagueName('Test League 2025')).toBe(true); expect(isValidLeagueName('Test League 2025')).toBe(true);
@ -85,21 +100,6 @@ describe('League Name Validation', () => {
}); });
describe('Draft Speed Presets', () => { 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', () => { it('should return correct times for fast speed', () => {
const times = getDraftTimes('fast'); const times = getDraftTimes('fast');
expect(times.initial).toBe(60); 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", () => { describe("pruneIneligibleQueueItems", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@ -415,11 +419,6 @@ describe("pruneIneligibleQueueItems", () => {
vi.mocked(getSeasonSportsSimple).mockResolvedValue([]); 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 () => { it("removes queued snooker player when snooker is no longer eligible for a team", async () => {
vi.mocked(getDraftPicksWithSports).mockResolvedValue([]); vi.mocked(getDraftPicksWithSports).mockResolvedValue([]);
vi.mocked(getParticipantsForSeasonWithSports).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)", () => { it("has 8 teams in Elite Eight (top 8 scoring)", () => {
const eliteEightRound = NCAA_68.rounds.find((r) => r.name === "Elite Eight"); 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); 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", () => { 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"]); expect(names).toEqual(["East", "South", "West", "Midwest"]);
}); });
it("East has 16 direct seeds and no play-ins", () => { 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.directSeeds).toHaveLength(16);
expect(east.playIns).toHaveLength(0); expect(east.playIns).toHaveLength(0);
}); });
it("South has 15 direct seeds and one 16-seed play-in", () => { 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.directSeeds).toHaveLength(15);
expect(south.playIns).toHaveLength(1); expect(south.playIns).toHaveLength(1);
expect(south.playIns[0].seedSlot).toBe(16); expect(south.playIns[0].seedSlot).toBe(16);
}); });
it("West has 15 direct seeds and one 11-seed play-in", () => { 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.directSeeds).toHaveLength(15);
expect(west.playIns).toHaveLength(1); expect(west.playIns).toHaveLength(1);
expect(west.playIns[0].seedSlot).toBe(11); expect(west.playIns[0].seedSlot).toBe(11);
}); });
it("Midwest has 14 direct seeds and two play-ins (11 and 16)", () => { 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.directSeeds).toHaveLength(14);
expect(midwest.playIns).toHaveLength(2); expect(midwest.playIns).toHaveLength(2);
expect(midwest.playIns[0].seedSlot).toBe(11); 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", () => { 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); expect(total).toBe(60);
}); });
it("total play-in teams across all regions equals 8", () => { 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), (sum, r) => sum + r.playIns.reduce((s, pi) => s + pi.teams, 0),
0 0
); );
@ -262,8 +262,8 @@ describe("NCAA 68 Bracket Template - Phase 2.8", () => {
}); });
it("direct + play-in teams sum to 68", () => { it("direct + play-in teams sum to 68", () => {
const direct = NCAA_68.regions!.reduce((sum, r) => sum + r.directSeeds.length, 0); const direct = (NCAA_68.regions ?? []).reduce((sum, r) => sum + r.directSeeds.length, 0);
const playIn = NCAA_68.regions!.reduce( const playIn = (NCAA_68.regions ?? []).reduce(
(sum, r) => sum + r.playIns.reduce((s, pi) => s + pi.teams, 0), (sum, r) => sum + r.playIns.reduce((s, pi) => s + pi.teams, 0),
0 0
); );
@ -272,7 +272,7 @@ describe("NCAA 68 Bracket Template - Phase 2.8", () => {
}); });
describe("buildNCAA68SlotMap", () => { describe("buildNCAA68SlotMap", () => {
const slotMap = buildNCAA68SlotMap(NCAA_68.regions!); const slotMap = buildNCAA68SlotMap(NCAA_68.regions ?? []);
it("East starts at index 0", () => { it("East starts at index 0", () => {
expect(slotMap.directOffsets[0]).toBe(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", () => { 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 // r64MatchNumber = regionIndex * 8 + matchIndexForSeedSlot(seedSlot) + 1
it("FF1 (South region index 1, seed 16) → R64 match 9", () => { 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 expect(wildCard?.matchCount).toBe(6); // 6 matches × 2 teams = 12 teams
// Total teams (14) - Wild Card teams (12) = 2 bye 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); 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 wildCard = NFL_14.rounds.find((r) => r.name === "Wild Card");
const divisional = NFL_14.rounds.find((r) => r.name === "Divisional"); 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 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); 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)", () => { it("verifies only 8 teams score fantasy points (Divisional and beyond)", () => {
const divisional = NFL_14.rounds.find((r) => r.name === "Divisional"); 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 expect(divisionalTeams).toBe(8); // Matches requirement from Q18
}); });

View file

@ -98,7 +98,7 @@ export async function getDraftedParticipantsBySportsSeason(
if (!map.has(row.sportsSeasonId)) { if (!map.has(row.sportsSeasonId)) {
map.set(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; return map;
} }

View file

@ -422,7 +422,8 @@ async function generateNCAA68Bracket(
for (let i = 0; i < slotMap.playInOffsets.length; i++) { for (let i = 0; i < slotMap.playInOffsets.length; i++) {
const { regionIndex, playInIndex } = slotMap.playInOffsets[i]; const { regionIndex, playInIndex } = slotMap.playInOffsets[i];
if (!regionFFLabels.has(regionIndex)) regionFFLabels.set(regionIndex, []); 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 ──────────────────────────────────────────────────────────── // ── First Four ────────────────────────────────────────────────────────────

View file

@ -298,7 +298,7 @@ export async function updateFinalRankings(
if (!groupedByPoints.has(points)) { if (!groupedByPoints.has(points)) {
groupedByPoints.set(points, []); groupedByPoints.set(points, []);
} }
groupedByPoints.get(points)!.push(standing); groupedByPoints.get(points)?.push(standing);
} }
// Assign rankings, handling ties // Assign rankings, handling ties

View file

@ -166,7 +166,7 @@ export async function processPlayoffEvent(
eq(schema.playoffMatches.scoringEventId, eventId), eq(schema.playoffMatches.scoringEventId, eventId),
eq(schema.playoffMatches.round, event.playoffRound) eq(schema.playoffMatches.round, event.playoffRound)
), ),
orderBy: (matches, { asc }) => [asc(matches.matchNumber)], orderBy: (m, { asc }) => [asc(m.matchNumber)],
}); });
// Process based on round // Process based on round
@ -593,7 +593,7 @@ export async function finalizeQualifyingPoints(
if (!groupedByQP.has(qp)) { if (!groupedByQP.has(qp)) {
groupedByQP.set(qp, []); groupedByQP.set(qp, []);
} }
groupedByQP.get(qp)!.push(standing); groupedByQP.get(qp)?.push(standing);
} }
// Sort groups by QP (descending) // Sort groups by QP (descending)
@ -858,7 +858,7 @@ export async function calculateTeamScore(
const bracketTemplateCache = new Map<string, string | null>(); const bracketTemplateCache = new Map<string, string | null>();
async function getBracketTemplate(sportsSeasonId: string): Promise<string | null> { async function getBracketTemplate(sportsSeasonId: string): Promise<string | null> {
if (bracketTemplateCache.has(sportsSeasonId)) { if (bracketTemplateCache.has(sportsSeasonId)) {
return bracketTemplateCache.get(sportsSeasonId)!; return bracketTemplateCache.get(sportsSeasonId) ?? null;
} }
const event = await db.query.scoringEvents.findFirst({ const event = await db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
@ -952,7 +952,7 @@ export async function calculateTeamProjectedScore(
const bracketTemplateCache = new Map<string, string | null>(); const bracketTemplateCache = new Map<string, string | null>();
async function getBracketTemplate(sportsSeasonId: string): Promise<string | null> { async function getBracketTemplate(sportsSeasonId: string): Promise<string | null> {
if (bracketTemplateCache.has(sportsSeasonId)) { if (bracketTemplateCache.has(sportsSeasonId)) {
return bracketTemplateCache.get(sportsSeasonId)!; return bracketTemplateCache.get(sportsSeasonId) ?? null;
} }
const event = await db.query.scoringEvents.findFirst({ const event = await db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
@ -1378,8 +1378,8 @@ export async function recalculateAffectedLeagues(
scoredMatches = relevant scoredMatches = relevant
.filter((m) => m.winnerName && m.loserName) .filter((m) => m.winnerName && m.loserName)
.map((m) => ({ .map((m) => ({
winnerName: m.winnerName!, winnerName: m.winnerName ?? "",
loserName: m.loserName!, loserName: m.loserName ?? "",
winnerUsername: usernameForParticipant(m.winnerId, true), winnerUsername: usernameForParticipant(m.winnerId, true),
loserUsername: usernameForParticipant(m.loserId), loserUsername: usernameForParticipant(m.loserId),
})); }));

View file

@ -496,16 +496,20 @@ export async function getUpcomingEventsForDraftedParticipants(
gameKeysByEvent.set(row.id, new Set()); gameKeysByEvent.set(row.id, new Set());
} }
const pMap = participantsByEvent.get(row.id)!; const pMap = participantsByEvent.get(row.id);
if (row.participant1Id && draftedMap.has(row.participant1Id)) { if (pMap) {
pMap.set(row.participant1Id, draftedMap.get(row.participant1Id)!); if (row.participant1Id) {
} const p1 = draftedMap.get(row.participant1Id);
if (row.participant2Id && draftedMap.has(row.participant2Id)) { if (p1) pMap.set(row.participant1Id, p1);
pMap.set(row.participant2Id, draftedMap.get(row.participant2Id)!); }
if (row.participant2Id) {
const p2 = draftedMap.get(row.participant2Id);
if (p2) pMap.set(row.participant2Id, p2);
}
} }
if (row.round && row.gameNumber !== null) { 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) { if (row.scheduledAt) {
@ -517,13 +521,13 @@ export async function getUpcomingEventsForDraftedParticipants(
} }
for (const [eventId, event] of eventMap) { 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); const earliest = earliestTimeByEvent.get(eventId);
event.earliestGameTime = earliest ? earliest.toISOString() : null; event.earliestGameTime = earliest ? earliest.toISOString() : null;
// Build matchLabel from game keys // Build matchLabel from game keys
const gameKeys = gameKeysByEvent.get(eventId)!; const gameKeys = gameKeysByEvent.get(eventId) ?? new Set<string>();
if (gameKeys.size === 1) { if (gameKeys.size === 1) {
const [round, gameNumberStr] = [...gameKeys][0].split("|"); const [round, gameNumberStr] = [...gameKeys][0].split("|");
// When round name duplicates the event name, omit the round to avoid // 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[]> { export async function findAllSports(): Promise<Sport[]> {
const db = database(); const db = database();
return await db.query.sports.findMany({ return await db.query.sports.findMany({
orderBy: (sports, { asc }) => [asc(sports.name)], orderBy: (s, { asc }) => [asc(s.name)],
}); });
} }
export async function findAllSportsWithActiveSeasons(): Promise<SportWithActiveSeasons[]> { export async function findAllSportsWithActiveSeasons(): Promise<SportWithActiveSeasons[]> {
const db = database(); const db = database();
const sports = await db.query.sports.findMany({ const sports = await db.query.sports.findMany({
orderBy: (sports, { asc }) => [asc(sports.name)], orderBy: (s, { asc }) => [asc(s.name)],
with: { with: {
sportsSeasons: { sportsSeasons: {
where: (sportsSeasons, { eq, or }) => where: (ss, { or }) =>
or( or(
eq(sportsSeasons.status, "active"), eq(ss.status, "active"),
eq(sportsSeasons.status, "upcoming") 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(); const db = database();
return await db.query.sports.findMany({ return await db.query.sports.findMany({
where: eq(schema.sports.type, type), 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[]> { export async function findActiveSportsSeasonsBySportId(sportId: string): Promise<SportsSeason[]> {
const db = database(); const db = database();
return await db.query.sportsSeasons.findMany({ return await db.query.sportsSeasons.findMany({
where: (sportsSeasons, { eq, and }) => where: (ss, { and }) =>
and( and(
eq(sportsSeasons.sportId, sportId), eq(ss.sportId, sportId),
eq(sportsSeasons.status, "active") eq(ss.status, "active")
), ),
orderBy: (sportsSeasons, { desc }) => [desc(sportsSeasons.year)], orderBy: (sportsSeasons, { desc }) => [desc(sportsSeasons.year)],
with: { with: {

View file

@ -158,7 +158,7 @@ export async function getTeamScoreBreakdown(
const bracketTemplateCache = new Map<string, string | null>(); const bracketTemplateCache = new Map<string, string | null>();
async function getBracketTemplate(sportsSeasonId: string): Promise<string | null> { async function getBracketTemplate(sportsSeasonId: string): Promise<string | null> {
if (bracketTemplateCache.has(sportsSeasonId)) { if (bracketTemplateCache.has(sportsSeasonId)) {
return bracketTemplateCache.get(sportsSeasonId)!; return bracketTemplateCache.get(sportsSeasonId) ?? null;
} }
const event = await db.query.scoringEvents.findFirst({ const event = await db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
@ -318,7 +318,7 @@ export async function getSevenDayStandingsChange(
return current.map((standing) => ({ return current.map((standing) => ({
...standing, ...standing,
sevenDayRankChange: oldRanks.has(standing.teamId) sevenDayRankChange: oldRanks.has(standing.teamId)
? oldRanks.get(standing.teamId)! - standing.currentRank ? (oldRanks.get(standing.teamId) ?? 0) - standing.currentRank
: 0, : 0,
sevenDayOldRank: oldRanks.get(standing.teamId) || null, sevenDayOldRank: oldRanks.get(standing.teamId) || null,
})); }));
@ -411,7 +411,8 @@ export async function getSeasonPointProgression(
dateMap.set(date, { date }); dateMap.set(date, { date });
} }
const dateData = dateMap.get(date)!; const dateData = dateMap.get(date);
if (!dateData) continue;
dateData[snapshot.team.name] = parseFloat(snapshot.totalPoints); dateData[snapshot.team.name] = parseFloat(snapshot.totalPoints);
} }

View file

@ -21,11 +21,14 @@ export function meta(): Route.MetaDescriptors {
return [{ title: "Admin - Brackt" }]; return [{ title: "Admin - Brackt" }];
} }
function toDateStr(d: Date) {
return d.toISOString().split("T")[0];
}
function getTodayAndTomorrowDates() { function getTodayAndTomorrowDates() {
const today = new Date(); const today = new Date();
const tomorrow = new Date(today); const tomorrow = new Date(today);
tomorrow.setDate(today.getDate() + 1); tomorrow.setDate(today.getDate() + 1);
const toDateStr = (d: Date) => d.toISOString().split("T")[0];
return { today: toDateStr(today), tomorrow: toDateStr(tomorrow) }; return { today: toDateStr(today), tomorrow: toDateStr(tomorrow) };
} }

View file

@ -139,14 +139,14 @@ export default function DataSync({ loaderData }: Route.ComponentProps) {
} }
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (event) => { reader.addEventListener("load", (event) => {
const fileData = event.target?.result as string; const fileData = event.target?.result as string;
const formData = new FormData(); const formData = new FormData();
formData.append("intent", "import"); formData.append("intent", "import");
formData.append("mode", importMode); formData.append("mode", importMode);
formData.append("fileData", fileData); formData.append("fileData", fileData);
fetcher.submit(formData, { method: "POST" }); fetcher.submit(formData, { method: "POST" });
}; });
reader.readAsText(selectedFile); reader.readAsText(selectedFile);
}; };

View file

@ -277,8 +277,8 @@ export default function EventBracket({
// Memoized: Get available rounds in chronological order // Memoized: Get available rounds in chronological order
const availableRounds = useMemo(() => { const availableRounds = useMemo(() => {
const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : undefined; const bracketTemplate = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : undefined;
return getOrderedRoundsFromMatches(matches, template); return getOrderedRoundsFromMatches(matches, bracketTemplate);
}, [matches, event.bracketTemplateId]); }, [matches, event.bracketTemplateId]);
// Check if all matches are complete // Check if all matches are complete
@ -414,8 +414,8 @@ export default function EventBracket({
<CardTitle className="text-base">Group {label}</CardTitle> <CardTitle className="text-base">Group {label}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="px-4 pb-4 space-y-2"> <CardContent className="px-4 pb-4 space-y-2">
{[...Array(template.groupStage!.teamsPerGroup)].map((_, teamIndex) => { {[...Array(template.groupStage?.teamsPerGroup ?? 0)].map((_, teamIndex) => {
const seedIndex = groupIndex * template.groupStage!.teamsPerGroup + teamIndex; const seedIndex = groupIndex * (template.groupStage?.teamsPerGroup ?? 0) + teamIndex;
const availableParticipants = getAvailableParticipants(seedIndex); const availableParticipants = getAvailableParticipants(seedIndex);
return ( return (
@ -1051,7 +1051,7 @@ export default function EventBracket({
<Form key={participant.id} method="post" className="flex gap-2 items-end mb-2"> <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="intent" value="upsert-odds" />
<input type="hidden" name="matchId" value={match.id} /> <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"> <div className="flex-1">
<Label className="text-xs">{participant.name ?? "TBD"}</Label> <Label className="text-xs">{participant.name ?? "TBD"}</Label>
<Input <Input

View file

@ -45,6 +45,27 @@ export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
export { loader, action }; export { loader, action };
function getStatusBadge(isComplete: boolean, eventType: string, eventDate?: string | null, eventStartsAt?: Date | string | null) {
if (isComplete) {
return (
<Badge variant="default" className="bg-emerald-500">
Completed
</Badge>
);
}
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 ? (
<Badge variant="outline" className="text-muted-foreground">Results Pending</Badge>
) : (
<Badge variant="outline">Upcoming</Badge>
);
}
return <Badge variant="secondary">In Progress</Badge>;
}
export default function SportsSeasonEvents({ export default function SportsSeasonEvents({
loaderData, loaderData,
actionData, actionData,
@ -60,27 +81,6 @@ export default function SportsSeasonEvents({
? "schedule_event" ? "schedule_event"
: "playoff_game"; : "playoff_game";
const getStatusBadge = (isComplete: boolean, eventType: string, eventDate?: string | null, eventStartsAt?: Date | string | null) => {
if (isComplete) {
return (
<Badge variant="default" className="bg-emerald-500">
Completed
</Badge>
);
}
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 ? (
<Badge variant="outline" className="text-muted-foreground">Results Pending</Badge>
) : (
<Badge variant="outline">Upcoming</Badge>
);
}
return <Badge variant="secondary">In Progress</Badge>;
};
return ( return (
<div className="p-8"> <div className="p-8">
<div className="max-w-4xl"> <div className="max-w-4xl">

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 // Sum only over participants shown in the table — excludes orphan EV records
// from prior simulation runs for participants no longer in this season. // from prior simulation runs for participants no longer in this season.
const sorted = [...participants].toSorted((a, b) => { const sorted = [...participants].toSorted((a, b) => {
const evA = existingEVs.has(a.id) ? evFromProbs(existingEVs.get(a.id)!) : 0; const evDataA = existingEVs.get(a.id);
const evB = existingEVs.has(b.id) ? evFromProbs(existingEVs.get(b.id)!) : 0; const evA = evDataA ? evFromProbs(evDataA) : 0;
const evDataB = existingEVs.get(b.id);
const evB = evDataB ? evFromProbs(evDataB) : 0;
return evB - evA; return evB - evA;
}); });

View file

@ -40,7 +40,7 @@ export async function loader() {
with: { with: {
league: true, league: true,
}, },
orderBy: (seasons, { desc }) => [desc(seasons.createdAt)], orderBy: (s, { desc }) => [desc(s.createdAt)],
}); });
// For each season, get the most recent snapshot // 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 // Sync timers — the server-side timer state may have changed while
// we were disconnected (time bank adjustments, new timers, etc.) // we were disconnected (time bank adjustments, new timers, etc.)
if (timers.length > 0) { if (timers.length > 0) {
setTeamTimers((prev) => { setTeamTimers((currentTimers) => {
const updated = { ...prev }; const updated = { ...currentTimers };
timers.forEach((timer) => { timers.forEach((timer) => {
updated[timer.teamId] = timer.timeRemaining; updated[timer.teamId] = timer.timeRemaining;
}); });

View file

@ -110,8 +110,8 @@ export async function loader(args: Route.LoaderArgs) {
const calendarDateFrom = today; const calendarDateFrom = today;
const calendarDateTo = addDays(today, 30); const calendarDateTo = addDays(today, 30);
const participantsBySportsSeason = myTeam const participantsBySportsSeason = myTeam && season
? await getDraftedParticipantsBySportsSeason(myTeam.id, season!.id) ? await getDraftedParticipantsBySportsSeason(myTeam.id, season.id)
: new Map<string, Array<{ id: string; name: string }>>(); : new Map<string, Array<{ id: string; name: string }>>();
const sportsSeasons = await Promise.all( const sportsSeasons = await Promise.all(

View file

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

View file

@ -163,8 +163,7 @@ export async function loader(args: Route.LoaderArgs) {
if (events.length > 0) { if (events.length > 0) {
const eventIds = events.map((e) => e.id); const eventIds = events.map((e) => e.id);
const matches = await db.query.playoffMatches.findMany({ const matches = await db.query.playoffMatches.findMany({
where: (playoffMatches, { inArray }) => where: (pm) => inArray(pm.scoringEventId, eventIds),
inArray(playoffMatches.scoringEventId, eventIds),
with: { with: {
participant1: true, participant1: true,
participant2: true, participant2: true,
@ -219,7 +218,7 @@ export async function loader(args: Route.LoaderArgs) {
}) })
.map((r) => ({ .map((r) => ({
participantId: r.participantId, 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 // 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 serverPicks = Array.from({ length: 24 }, (_, i) => {
const pickNum = i + 1; const pickNum = i + 1;
const teamIdx = getTeamForPick(pickNum, draftSlots); 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 // Simulate draft-state-sync handler

View file

@ -182,9 +182,9 @@ export async function action(args: Route.ActionArgs) {
// Create teams with fun random names // Create teams with fun random names
const joinAsPlayer = formData.get("joinAsPlayer") === "on"; const joinAsPlayer = formData.get("joinAsPlayer") === "on";
const teamNames = generateUniqueTeamNames(teamCountNum); const teamNames = generateUniqueTeamNames(teamCountNum);
const teams = teamNames.map((name, i) => ({ const teams = teamNames.map((teamName, i) => ({
seasonId: season.id, seasonId: season.id,
name, name: teamName,
ownerId: joinAsPlayer && i === 0 ? userId : null, ownerId: joinAsPlayer && i === 0 ? userId : null,
})); }));

View file

@ -51,8 +51,9 @@ describe('bracket-simulator', () => {
const results = simulateBracketSync(teams, 'nhl-8', 10000); const results = simulateBracketSync(teams, 'nhl-8', 10000);
const strongDist = results.get('strong')!; const strongDist = results.get('strong');
const weakDist = results.get('weak')!; const weakDist = results.get('weak');
if (!strongDist || !weakDist) throw new Error('strong or weak result not found');
// Strong team should have higher championship probability // Strong team should have higher championship probability
expect(strongDist[0]).toBeGreaterThan(weakDist[0]); expect(strongDist[0]).toBeGreaterThan(weakDist[0]);
@ -76,7 +77,8 @@ describe('bracket-simulator', () => {
const results = simulateBracketSync(teams, 'nhl-8', 10000); 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 // Dominant team should win very often
expect(championDist[0]).toBeGreaterThan(0.6); // >60% championship probability expect(championDist[0]).toBeGreaterThan(0.6); // >60% championship probability
@ -139,8 +141,9 @@ describe('bracket-simulator', () => {
const results2 = simulateBracketSync(teams, 'nhl-8', 1000); const results2 = simulateBracketSync(teams, 'nhl-8', 1000);
// Results won't be identical due to randomness, but should be in same ballpark // Results won't be identical due to randomness, but should be in same ballpark
const dist1 = results1.get('1')!; const dist1 = results1.get('1');
const dist2 = results2.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 // Championship probabilities should be within 10% of each other
expect(Math.abs(dist1[0] - dist2[0])).toBeLessThan(0.1); 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 results = await simulateBracket(teams, 'nhl-8', 10000);
const colDist = results.get('COL')!; const colDist = results.get('COL');
const nyiDist = results.get('NYI')!; const nyiDist = results.get('NYI');
if (!colDist || !nyiDist) throw new Error('COL or NYI result not found');
// Colorado should have highest championship probability // Colorado should have highest championship probability
expect(colDist[0]).toBeGreaterThan(0.15); // >15% expect(colDist[0]).toBeGreaterThan(0.15); // >15%

View file

@ -45,8 +45,11 @@ describe('icm-calculator', () => {
const results = calculateICM(participants); const results = calculateICM(participants);
const strongProbs = icmResultToArray(results.get('strong')!); const strongResult = results.get('strong');
const weakProbs = icmResultToArray(results.get('weak')!); 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 // Strong team should have higher P(1st) than weak team
expect(strongProbs[0]).toBeGreaterThan(weakProbs[0]); expect(strongProbs[0]).toBeGreaterThan(weakProbs[0]);
@ -81,11 +84,15 @@ describe('icm-calculator', () => {
expect(results.size).toBe(32); expect(results.size).toBe(32);
// Colorado (favorite) should have highest P(1st) // 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 expect(colProbs[0]).toBeGreaterThan(0.05); // Should have >5% chance of 1st
// Even the worst team should have some probability for all placements // 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 => { worstProbs.forEach(p => {
expect(p).toBeGreaterThan(0); // Not zero expect(p).toBeGreaterThan(0); // Not zero
expect(p).toBeLessThan(1); // Valid probability expect(p).toBeLessThan(1); // Valid probability
@ -111,7 +118,9 @@ describe('icm-calculator', () => {
expect(results.size).toBe(1); 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) // With 1 participant, only 1st place is filled (removed from pool after)
expect(probs[0]).toBeCloseTo(1.0, 1); // 100% chance of 1st expect(probs[0]).toBeCloseTo(1.0, 1); // 100% chance of 1st
const sum = probs.reduce((acc, p) => acc + p, 0); 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) // Participant 1 (higher prob) should have higher P(1st)
const probs1 = icmResultToArray(results.get('1')!); const r1 = results.get('1');
const probs2 = icmResultToArray(results.get('2')!); 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]); expect(probs1[0]).toBeGreaterThan(probs2[0]);
}); });
@ -187,10 +199,15 @@ describe('icm-calculator', () => {
const results = calculateICM(participants); const results = calculateICM(participants);
const first = icmResultToArray(results.get('1st')!); const rFirst = results.get('1st');
const second = icmResultToArray(results.get('2nd')!); const rSecond = results.get('2nd');
const third = icmResultToArray(results.get('3rd')!); const rThird = results.get('3rd');
const fourth = icmResultToArray(results.get('4th')!); 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 // P(1st place) should be ordered
expect(first[0]).toBeGreaterThan(second[0]); expect(first[0]).toBeGreaterThan(second[0]);
@ -212,8 +229,11 @@ describe('icm-calculator', () => {
expect(results.size).toBe(3); expect(results.size).toBe(3);
// Colorado should have better odds than Arizona // Colorado should have better odds than Arizona
const colProbs = icmResultToArray(results.get('COL')!); const colResultOdds = results.get('COL');
const ariProbs = icmResultToArray(results.get('ARI')!); 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]); expect(colProbs[0]).toBeGreaterThan(ariProbs[0]);
@ -229,8 +249,11 @@ describe('icm-calculator', () => {
const results = calculateICMFromOdds(odds); const results = calculateICMFromOdds(odds);
const favProbs = icmResultToArray(results.get('FAV')!); const favResult = results.get('FAV');
const dogProbs = icmResultToArray(results.get('DOG')!); 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) // Favorite should have higher P(1st)
expect(favProbs[0]).toBeGreaterThan(dogProbs[0]); expect(favProbs[0]).toBeGreaterThan(dogProbs[0]);
@ -303,17 +326,21 @@ describe('icm-calculator', () => {
expect(results.size).toBe(32); expect(results.size).toBe(32);
// Favorite (Colorado) should have reasonable championship probability // 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]).toBeGreaterThan(0.05); // >5% for 1st
expect(colProbs[0]).toBeLessThan(0.35); // <35% for 1st (not guaranteed) expect(colProbs[0]).toBeLessThan(0.35); // <35% for 1st (not guaranteed)
// Middle team (Detroit) should have middling probabilities // 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]).toBeGreaterThan(0); // Some chance
expect(detProbs[0]).toBeLessThan(0.10); // But not high expect(detProbs[0]).toBeLessThan(0.10); // But not high
// Longshot should have very small but non-zero probabilities // 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]).toBeGreaterThan(0); // Not impossible
expect(longProbs[0]).toBeLessThan(0.05); // But unlikely (with 32 teams, even worst has ~3% uniform) 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'); const eloRatings = convertFuturesToElo(odds, 'american');
expect(eloRatings.size).toBe(3); expect(eloRatings.size).toBe(3);
expect(eloRatings.get('1')).toBeGreaterThan(eloRatings.get('2')!); expect(eloRatings.get('1')).toBeGreaterThan(eloRatings.get('2') ?? 0);
expect(eloRatings.get('2')).toBeGreaterThan(eloRatings.get('3')!); expect(eloRatings.get('2')).toBeGreaterThan(eloRatings.get('3') ?? 0);
// Strongest team should be near max Elo // Strongest team should be near max Elo
expect(eloRatings.get('1')).toBeGreaterThan(1600); expect(eloRatings.get('1')).toBeGreaterThan(1600);
@ -220,8 +220,8 @@ describe('probability-engine', () => {
const eloRatings = convertFuturesToElo(odds, 'decimal'); const eloRatings = convertFuturesToElo(odds, 'decimal');
expect(eloRatings.size).toBe(3); expect(eloRatings.size).toBe(3);
expect(eloRatings.get('1')).toBeGreaterThan(eloRatings.get('2')!); expect(eloRatings.get('1')).toBeGreaterThan(eloRatings.get('2') ?? 0);
expect(eloRatings.get('2')).toBeGreaterThan(eloRatings.get('3')!); expect(eloRatings.get('2')).toBeGreaterThan(eloRatings.get('3') ?? 0);
}); });
it('uses custom calibration parameters', () => { it('uses custom calibration parameters', () => {
@ -302,8 +302,8 @@ describe('probability-engine', () => {
]; ];
const eloRatings = convertFuturesToElo(odds, 'american'); const eloRatings = convertFuturesToElo(odds, 'american');
const colElo = eloRatings.get('col')!; const colElo = eloRatings.get('col') ?? 1500;
const nyiElo = eloRatings.get('nyi')!; const nyiElo = eloRatings.get('nyi') ?? 1500;
// Calculate predicted game line // Calculate predicted game line
const predicted = eloWinProbability(colElo, nyiElo); const predicted = eloWinProbability(colElo, nyiElo);

View file

@ -132,7 +132,8 @@ function simulate8TeamBracket(teams: TeamForSimulation[]): SimulationResult {
// Finals: 2 → 1 // Finals: 2 → 1
const champion = simulateMatchup(finalists[0], finalists[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(champion.participantId, 1);
placements.set(runnerUp.participantId, 2); placements.set(runnerUp.participantId, 2);

View file

@ -133,7 +133,8 @@ export function calculateICM(
// Record results // Record results
for (let pos = 0; pos < placements.length; pos++) { 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 // Progress logging every 10,000 iterations
@ -149,7 +150,7 @@ export function calculateICM(
const results = new Map<string, ICMResult>(); const results = new Map<string, ICMResult>();
for (const p of normalized) { 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); const probabilities = participantCounts.map(count => count / iterations);
results.set(p.participantId, { results.set(p.participantId, {

View file

@ -127,7 +127,7 @@ export async function updateProbabilitiesAfterResult(
const finishedMap = new Map( const finishedMap = new Map(
results results
.filter(r => r.finalPosition !== null) .filter(r => r.finalPosition !== null)
.map(r => [r.participantId, r.finalPosition!]) .map(r => [r.participantId, r.finalPosition ?? 0])
); );
// Update finished participants // Update finished participants
@ -263,7 +263,7 @@ export async function previewProbabilityUpdate(
const finishedMap = new Map( const finishedMap = new Map(
results results
.filter(r => r.finalPosition !== null) .filter(r => r.finalPosition !== null)
.map(r => [r.participantId, r.finalPosition!]) .map(r => [r.participantId, r.finalPosition ?? 0])
); );
const comparisons: ProbabilityComparison[] = []; 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. // Actually: QF1: team-1 beats team-4; QF2: team-6 beats team-8; etc.
const qfMatches = Array.from({ length: 4 }, (_, i) => { const qfMatches = Array.from({ length: 4 }, (_, i) => {
const matchNum = i + 1; const matchNum = i + 1;
const r16w1 = r16Matches[(matchNum - 1) * 2].winnerId!; const r16w1 = r16Matches[(matchNum - 1) * 2].winnerId ?? "";
const r16w2 = r16Matches[(matchNum - 1) * 2 + 1].winnerId!; const r16w2 = r16Matches[(matchNum - 1) * 2 + 1].winnerId ?? "";
const winner = matchNum === 1 ? r16w1 : r16w2; const winner = matchNum === 1 ? r16w1 : r16w2;
const loser = matchNum === 1 ? r16w2 : r16w1; const loser = matchNum === 1 ? r16w2 : r16w1;
return makeQFMatch(matchNum, { winnerId: winner, loserId: loser, isComplete: true }); return makeQFMatch(matchNum, { winnerId: winner, loserId: loser, isComplete: true });
}); });
// SF: QF1 winner (team-1) + QF2 winner → SF1: team-1 wins // SF: QF1 winner (team-1) + QF2 winner → SF1: team-1 wins
const sf1Winner = qfMatches[0].winnerId!; const sf1Winner = qfMatches[0].winnerId ?? "";
const sf1Loser = qfMatches[1].winnerId!; const sf1Loser = qfMatches[1].winnerId ?? "";
const sf2Winner = qfMatches[2].winnerId!; const sf2Winner = qfMatches[2].winnerId ?? "";
const sf2Loser = qfMatches[3].winnerId!; const sf2Loser = qfMatches[3].winnerId ?? "";
const sfMatches = [ const sfMatches = [
makeSFMatch(1, { winnerId: sf1Winner, loserId: sf1Loser, isComplete: true }), makeSFMatch(1, { winnerId: sf1Winner, loserId: sf1Loser, isComplete: true }),
makeSFMatch(2, { winnerId: sf2Winner, loserId: sf2Loser, isComplete: true }), makeSFMatch(2, { winnerId: sf2Winner, loserId: sf2Loser, isComplete: true }),
@ -335,8 +335,9 @@ describe("UCLSimulator.simulate()", () => {
const sim = new UCLSimulator(); const sim = new UCLSimulator();
const results = await sim.simulate("season-1"); const results = await sim.simulate("season-1");
const championResult = results.find((r) => r.participantId === actualChampion)!; const championResult = results.find((r) => r.participantId === actualChampion);
const finalistResult = results.find((r) => r.participantId === actualFinalist)!; 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 // Champion must have probFirst = 1.0
expect(championResult.probabilities.probFirst).toBeCloseTo(1.0, 3); expect(championResult.probabilities.probFirst).toBeCloseTo(1.0, 3);
@ -347,8 +348,9 @@ describe("UCLSimulator.simulate()", () => {
expect(finalistResult.probabilities.probSecond).toBeCloseTo(1.0, 3); expect(finalistResult.probabilities.probSecond).toBeCloseTo(1.0, 3);
// R16 losers (teams that lost in R16) must have all probs = 0 // 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 r16LoserId = r16Matches[0].loserId ?? ""; // team-2 lost in R16 match 1
const r16LoserResult = results.find((r) => r.participantId === r16LoserId)!; 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); const r16Sum = Object.values(r16LoserResult.probabilities).reduce((a, b) => a + b, 0);
expect(r16Sum).toBeCloseTo(0, 5); expect(r16Sum).toBeCloseTo(0, 5);

View file

@ -174,7 +174,8 @@ export class AutoRacingSimulator implements Simulator {
for (let sim = 0; sim < NUM_SIMULATIONS; sim++) { for (let sim = 0; sim < NUM_SIMULATIONS; sim++) {
const finishOrder = weightedDrawWithoutReplacement(ids, weights); const finishOrder = weightedDrawWithoutReplacement(ids, weights);
for (let rank = 0; rank < Math.min(8, finishOrder.length); rank++) { 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 { } else {
@ -219,26 +220,30 @@ export class AutoRacingSimulator implements Simulator {
.map(([id]) => id); .map(([id]) => id);
for (let rank = 0; rank < Math.min(8, finalOrder.length); rank++) { 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 // 8. Convert counts → probability distributions
const results: SimulationResult[] = participants.map((p) => ({ const results: SimulationResult[] = participants.map((p) => {
participantId: p.id, const counts = rankCounts.get(p.id) ?? [0,0,0,0,0,0,0,0];
probabilities: { return {
probFirst: rankCounts.get(p.id)![0] / NUM_SIMULATIONS, participantId: p.id,
probSecond: rankCounts.get(p.id)![1] / NUM_SIMULATIONS, probabilities: {
probThird: rankCounts.get(p.id)![2] / NUM_SIMULATIONS, probFirst: counts[0] / NUM_SIMULATIONS,
probFourth: rankCounts.get(p.id)![3] / NUM_SIMULATIONS, probSecond: counts[1] / NUM_SIMULATIONS,
probFifth: rankCounts.get(p.id)![4] / NUM_SIMULATIONS, probThird: counts[2] / NUM_SIMULATIONS,
probSixth: rankCounts.get(p.id)![5] / NUM_SIMULATIONS, probFourth: counts[3] / NUM_SIMULATIONS,
probSeventh: rankCounts.get(p.id)![6] / NUM_SIMULATIONS, probFifth: counts[4] / NUM_SIMULATIONS,
probEighth: rankCounts.get(p.id)![7] / NUM_SIMULATIONS, probSixth: counts[5] / NUM_SIMULATIONS,
}, probSeventh: counts[6] / NUM_SIMULATIONS,
source: this.source, probEighth: counts[7] / NUM_SIMULATIONS,
})); },
source: this.source,
};
});
// 9. Per-position normalization: each column should sum to exactly 1.0 but // 9. Per-position normalization: each column should sum to exactly 1.0 but
// floating-point division (count / 10000) accumulates small errors across // floating-point division (count / 10000) accumulates small errors across

View file

@ -47,7 +47,7 @@ export class BracketSimulator implements Simulator {
if (hasOdds) { if (hasOdds) {
const oddsInput = evRows const oddsInput = evRows
.filter((r) => r.sourceOdds !== null) .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); eloMap = convertFuturesToElo(oddsInput);
} else { } else {
// Fall back: treat probFirst (as %) as championship win probability, // Fall back: treat probFirst (as %) as championship win probability,
@ -71,7 +71,7 @@ export class BracketSimulator implements Simulator {
.filter((r) => eloMap.has(r.participantId)) .filter((r) => eloMap.has(r.participantId))
.map((r) => ({ .map((r) => ({
participantId: r.participantId, participantId: r.participantId,
elo: eloMap.get(r.participantId)!, elo: eloMap.get(r.participantId) ?? 1500,
})); }));
if (teamsForSimulation.length === 0) { if (teamsForSimulation.length === 0) {

View file

@ -240,6 +240,12 @@ interface TeamEntry {
data: NbaTeamData | undefined; 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 ──────────────────────────────────────────────────────────────── // ─── Simulator ────────────────────────────────────────────────────────────────
export class NBASimulator implements Simulator { export class NBASimulator implements Simulator {
@ -297,10 +303,6 @@ export class NBASimulator implements Simulator {
return 11; // Missed playoffs 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. */ /** Simulate a single playoff game. Returns the winner. */
const simGame = (a: TeamEntry, b: TeamEntry): TeamEntry => const simGame = (a: TeamEntry, b: TeamEntry): TeamEntry =>
Math.random() < eloWinProbability(elo(a), elo(b)) ? a : b; 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); const { winner: champion, loser: finalist } = simSeries(eastChamp, westChamp);
// ── Record counts (maps are pre-populated so .get() is always defined) ─── // ── Record counts (maps are pre-populated so .get() is always defined) ───
championCounts.set(champion.id, championCounts.get(champion.id)! + 1); championCounts.set(champion.id, (championCounts.get(champion.id) ?? 0) + 1);
finalistCounts.set(finalist.id, finalistCounts.get(finalist.id)! + 1); finalistCounts.set(finalist.id, (finalistCounts.get(finalist.id) ?? 0) + 1);
confFinalLoserCounts.set(eastCFLoser.id, confFinalLoserCounts.get(eastCFLoser.id)! + 1); confFinalLoserCounts.set(eastCFLoser.id, (confFinalLoserCounts.get(eastCFLoser.id) ?? 0) + 1);
confFinalLoserCounts.set(westCFLoser.id, confFinalLoserCounts.get(westCFLoser.id)! + 1); confFinalLoserCounts.set(westCFLoser.id, (confFinalLoserCounts.get(westCFLoser.id) ?? 0) + 1);
for (const loser of [...eastR2Losers, ...westR2Losers]) { 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). // 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 // probFifthEighth → confSemiLoserCounts / (4*N) — 4 conf semi losers per sim
const N = NUM_SIMULATIONS; const N = NUM_SIMULATIONS;
const results: SimulationResult[] = participantIds.map((participantId) => { const results: SimulationResult[] = participantIds.map((participantId) => {
const c = championCounts.get(participantId)!; const c = championCounts.get(participantId) ?? 0;
const f = finalistCounts.get(participantId)!; const f = finalistCounts.get(participantId) ?? 0;
const cf = confFinalLoserCounts.get(participantId)!; const cf = confFinalLoserCounts.get(participantId) ?? 0;
const cs = confSemiLoserCounts.get(participantId)!; const cs = confSemiLoserCounts.get(participantId) ?? 0;
return { return {
participantId, participantId,
probabilities: { 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)); 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 ──────────────────────────────────────────────────────────────── // ─── Simulator ────────────────────────────────────────────────────────────────
export class NCAAMSimulator implements Simulator { export class NCAAMSimulator implements Simulator {
@ -339,19 +343,24 @@ export class NCAAMSimulator implements Simulator {
const byRound = new Map<string, typeof allMatches>(); const byRound = new Map<string, typeof allMatches>();
for (const m of allMatches) { for (const m of allMatches) {
if (!byRound.has(m.round)) byRound.set(m.round, []); 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) => const rawFirstFour = byRound.get("First Four");
[...matches].toSorted((a, b) => a.matchNumber - b.matchNumber); 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 firstFourMatches = rawFirstFour ? sortByMatchNumber(rawFirstFour) : [];
const r64Matches = byRound.has("Round of 64") ? sortByMatchNumber(byRound.get("Round of 64")!) : null; const r64Matches = rawR64 ? sortByMatchNumber(rawR64) : null;
const r32Matches = byRound.has("Round of 32") ? sortByMatchNumber(byRound.get("Round of 32")!) : null; const r32Matches = rawR32 ? sortByMatchNumber(rawR32) : null;
const s16Matches = byRound.has("Sweet Sixteen") ? sortByMatchNumber(byRound.get("Sweet Sixteen")!) : null; const s16Matches = rawS16 ? sortByMatchNumber(rawS16) : null;
const e8Matches = byRound.has("Elite Eight") ? sortByMatchNumber(byRound.get("Elite Eight")!) : null; const e8Matches = rawE8 ? sortByMatchNumber(rawE8) : null;
const ffMatches = byRound.has("Final Four") ? sortByMatchNumber(byRound.get("Final Four")!) : null; const ffMatches = rawFF ? sortByMatchNumber(rawFF) : null;
const champMatches = byRound.has("Championship") ? sortByMatchNumber(byRound.get("Championship")!) : null; const champMatches = rawChamp ? sortByMatchNumber(rawChamp) : null;
if (!r64Matches || !r32Matches || !s16Matches || !e8Matches || !ffMatches || !champMatches) { if (!r64Matches || !r32Matches || !s16Matches || !e8Matches || !ffMatches || !champMatches) {
const found = [...byRound.keys()].join(", "); const found = [...byRound.keys()].join(", ");
@ -403,7 +412,7 @@ export class NCAAMSimulator implements Simulator {
const regions = const regions =
(bracketEvent.bracketRegionConfig as BracketRegion[] | null) ?? (bracketEvent.bracketRegionConfig as BracketRegion[] | null) ??
// Fall back to the template's built-in regions — single source of truth // Fall back to the template's built-in regions — single source of truth
NCAA_68.regions!; NCAA_68.regions ?? [];
const slotMap = buildNCAA68SlotMap(regions); const slotMap = buildNCAA68SlotMap(regions);
for (let i = 0; i < slotMap.playInOffsets.length; i++) { 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 // ffSimWinners: Map<r64MatchNumber, winnerId> for the null participant2Id slots
const ffSimWinners = new Map<number, string>(); const ffSimWinners = new Map<number, string>();
for (const [ffMatchNum, r64MatchNum] of ffToR64) { 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 const winner = m.isComplete && m.winnerId
? m.winnerId ? m.winnerId
: simMatch(m.participant1Id!, m.participant2Id!).winner; : simMatch(m.participant1Id ?? "", m.participant2Id ?? "").winner;
ffSimWinners.set(r64MatchNum, winner); ffSimWinners.set(r64MatchNum, winner);
} }
// ── Round of 64 (32 matches → 32 winners) ──────────────────────────── // ── Round of 64 (32 matches → 32 winners) ────────────────────────────
const r64Winners: string[] = []; const r64Winners: string[] = [];
for (let i = 1; i <= 32; i++) { 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 // participant2Id may be null if this slot is filled by a First Four winner
const p2 = m.participant2Id ?? ffSimWinners.get(i) ?? null; const p2 = m.participant2Id ?? ffSimWinners.get(i) ?? null;
if (m.isComplete && m.winnerId) { if (m.isComplete && m.winnerId) {
r64Winners.push(m.winnerId); r64Winners.push(m.winnerId);
} else { } 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 // Derive loser from stored participants if loserId is missing
loser = dbMatch.loserId ?? loser = dbMatch.loserId ??
(dbMatch.participant1Id === dbMatch.winnerId (dbMatch.participant1Id === dbMatch.winnerId
? dbMatch.participant2Id! ? dbMatch.participant2Id ?? ""
: dbMatch.participant1Id!); : dbMatch.participant1Id ?? "");
} else { } else {
const p1 = s16Winners[(i - 1) * 2]; const p1 = s16Winners[(i - 1) * 2];
const p2 = s16Winners[(i - 1) * 2 + 1]; const p2 = s16Winners[(i - 1) * 2 + 1];
@ -611,8 +622,8 @@ export class NCAAMSimulator implements Simulator {
winner = dbMatch.winnerId; winner = dbMatch.winnerId;
loser = dbMatch.loserId ?? loser = dbMatch.loserId ??
(dbMatch.participant1Id === dbMatch.winnerId (dbMatch.participant1Id === dbMatch.winnerId
? dbMatch.participant2Id! ? dbMatch.participant2Id ?? ""
: dbMatch.participant1Id!); : dbMatch.participant1Id ?? "");
} else { } else {
const p1 = e8Winners[(i - 1) * 2]; const p1 = e8Winners[(i - 1) * 2];
const p2 = e8Winners[(i - 1) * 2 + 1]; const p2 = e8Winners[(i - 1) * 2 + 1];
@ -629,8 +640,8 @@ export class NCAAMSimulator implements Simulator {
champion = champMatch.winnerId; champion = champMatch.winnerId;
finalist = champMatch.loserId ?? finalist = champMatch.loserId ??
(champMatch.participant1Id === champMatch.winnerId (champMatch.participant1Id === champMatch.winnerId
? champMatch.participant2Id! ? champMatch.participant2Id ?? ""
: champMatch.participant1Id!); : champMatch.participant1Id ?? "");
} else { } else {
({ winner: champion, loser: finalist } = simMatch(ffWinners[0], ffWinners[1])); ({ 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 // probFifthEighth → e8LoserCounts / (4*N) → 4 losers * 1/(4N) = 1
const N = NUM_SIMULATIONS; const N = NUM_SIMULATIONS;
const results: SimulationResult[] = participantIds.map((participantId) => { const results: SimulationResult[] = participantIds.map((participantId) => {
const c = championCounts.get(participantId)!; const c = championCounts.get(participantId) ?? 0;
const f = finalistCounts.get(participantId)!; const f = finalistCounts.get(participantId) ?? 0;
const ff = ffLoserCounts.get(participantId)!; const ff = ffLoserCounts.get(participantId) ?? 0;
const e8 = e8LoserCounts.get(participantId)!; const e8 = e8LoserCounts.get(participantId) ?? 0;
return { return {
participantId, participantId,
probabilities: { probabilities: {

View file

@ -276,6 +276,10 @@ export function barthagWinProbability(barthagA: number, barthagB: number): numbe
return pA / total; return pA / total;
} }
function sortByMatchNumber<T extends { matchNumber: number }>(matches: T[]): T[] {
return [...matches].toSorted((a, b) => a.matchNumber - b.matchNumber);
}
// ─── Simulator ──────────────────────────────────────────────────────────────── // ─── Simulator ────────────────────────────────────────────────────────────────
export class NCAAWSimulator implements Simulator { export class NCAAWSimulator implements Simulator {
@ -316,19 +320,24 @@ export class NCAAWSimulator implements Simulator {
const byRound = new Map<string, typeof allMatches>(); const byRound = new Map<string, typeof allMatches>();
for (const m of allMatches) { for (const m of allMatches) {
if (!byRound.has(m.round)) byRound.set(m.round, []); 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) => const rawFirstFour = byRound.get("First Four");
[...matches].toSorted((a, b) => a.matchNumber - b.matchNumber); 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 firstFourMatches = rawFirstFour ? sortByMatchNumber(rawFirstFour) : [];
const r64Matches = byRound.has("Round of 64") ? sortByMatchNumber(byRound.get("Round of 64")!) : null; const r64Matches = rawR64 ? sortByMatchNumber(rawR64) : null;
const r32Matches = byRound.has("Round of 32") ? sortByMatchNumber(byRound.get("Round of 32")!) : null; const r32Matches = rawR32 ? sortByMatchNumber(rawR32) : null;
const s16Matches = byRound.has("Sweet Sixteen") ? sortByMatchNumber(byRound.get("Sweet Sixteen")!) : null; const s16Matches = rawS16 ? sortByMatchNumber(rawS16) : null;
const e8Matches = byRound.has("Elite Eight") ? sortByMatchNumber(byRound.get("Elite Eight")!) : null; const e8Matches = rawE8 ? sortByMatchNumber(rawE8) : null;
const ffMatches = byRound.has("Final Four") ? sortByMatchNumber(byRound.get("Final Four")!) : null; const ffMatches = rawFF ? sortByMatchNumber(rawFF) : null;
const champMatches = byRound.has("Championship") ? sortByMatchNumber(byRound.get("Championship")!) : null; const champMatches = rawChamp ? sortByMatchNumber(rawChamp) : null;
if (!r64Matches || !r32Matches || !s16Matches || !e8Matches || !ffMatches || !champMatches) { if (!r64Matches || !r32Matches || !s16Matches || !e8Matches || !ffMatches || !champMatches) {
const found = [...byRound.keys()].join(", "); const found = [...byRound.keys()].join(", ");
@ -370,7 +379,7 @@ export class NCAAWSimulator implements Simulator {
const regions = const regions =
(bracketEvent.bracketRegionConfig as BracketRegion[] | null) ?? (bracketEvent.bracketRegionConfig as BracketRegion[] | null) ??
// Fall back to the template's built-in regions — single source of truth // Fall back to the template's built-in regions — single source of truth
NCAA_68.regions!; NCAA_68.regions ?? [];
const slotMap = buildNCAA68SlotMap(regions); const slotMap = buildNCAA68SlotMap(regions);
for (let i = 0; i < slotMap.playInOffsets.length; i++) { for (let i = 0; i < slotMap.playInOffsets.length; i++) {
@ -494,22 +503,24 @@ export class NCAAWSimulator implements Simulator {
// ── First Four ──────────────────────────────────────────────────────── // ── First Four ────────────────────────────────────────────────────────
const ffSimWinners = new Map<number, string>(); const ffSimWinners = new Map<number, string>();
for (const [ffMatchNum, r64MatchNum] of ffToR64) { 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 const winner = m.isComplete && m.winnerId
? m.winnerId ? m.winnerId
: simMatch(m.participant1Id!, m.participant2Id!).winner; : simMatch(m.participant1Id ?? "", m.participant2Id ?? "").winner;
ffSimWinners.set(r64MatchNum, winner); ffSimWinners.set(r64MatchNum, winner);
} }
// ── Round of 64 ─────────────────────────────────────────────────────── // ── Round of 64 ───────────────────────────────────────────────────────
const r64Winners: string[] = []; const r64Winners: string[] = [];
for (let i = 1; i <= 32; i++) { 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; const p2 = m.participant2Id ?? ffSimWinners.get(i) ?? null;
if (m.isComplete && m.winnerId) { if (m.isComplete && m.winnerId) {
r64Winners.push(m.winnerId); r64Winners.push(m.winnerId);
} else { } 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 // Derive loser from stored participants if loserId is missing
loser = dbMatch.loserId ?? loser = dbMatch.loserId ??
(dbMatch.participant1Id === dbMatch.winnerId (dbMatch.participant1Id === dbMatch.winnerId
? dbMatch.participant2Id! ? dbMatch.participant2Id ?? ""
: dbMatch.participant1Id!); : dbMatch.participant1Id ?? "");
} else { } else {
const p1 = s16Winners[(i - 1) * 2]; const p1 = s16Winners[(i - 1) * 2];
const p2 = s16Winners[(i - 1) * 2 + 1]; const p2 = s16Winners[(i - 1) * 2 + 1];
@ -571,8 +582,8 @@ export class NCAAWSimulator implements Simulator {
winner = dbMatch.winnerId; winner = dbMatch.winnerId;
loser = dbMatch.loserId ?? loser = dbMatch.loserId ??
(dbMatch.participant1Id === dbMatch.winnerId (dbMatch.participant1Id === dbMatch.winnerId
? dbMatch.participant2Id! ? dbMatch.participant2Id ?? ""
: dbMatch.participant1Id!); : dbMatch.participant1Id ?? "");
} else { } else {
const p1 = e8Winners[(i - 1) * 2]; const p1 = e8Winners[(i - 1) * 2];
const p2 = e8Winners[(i - 1) * 2 + 1]; const p2 = e8Winners[(i - 1) * 2 + 1];
@ -589,8 +600,8 @@ export class NCAAWSimulator implements Simulator {
champion = champMatch.winnerId; champion = champMatch.winnerId;
finalist = champMatch.loserId ?? finalist = champMatch.loserId ??
(champMatch.participant1Id === champMatch.winnerId (champMatch.participant1Id === champMatch.winnerId
? champMatch.participant2Id! ? champMatch.participant2Id ?? ""
: champMatch.participant1Id!); : champMatch.participant1Id ?? "");
} else { } else {
({ winner: champion, loser: finalist } = simMatch(ffWinners[0], ffWinners[1])); ({ 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. // 10. Convert integer counts to probability distributions.
const N = NUM_SIMULATIONS; const N = NUM_SIMULATIONS;
const results: SimulationResult[] = participantIds.map((participantId) => { const results: SimulationResult[] = participantIds.map((participantId) => {
const c = championCounts.get(participantId)!; const c = championCounts.get(participantId) ?? 0;
const f = finalistCounts.get(participantId)!; const f = finalistCounts.get(participantId) ?? 0;
const ff = ffLoserCounts.get(participantId)!; const ff = ffLoserCounts.get(participantId) ?? 0;
const e8 = e8LoserCounts.get(participantId)!; const e8 = e8LoserCounts.get(participantId) ?? 0;
return { return {
participantId, participantId,
probabilities: { probabilities: {

View file

@ -291,6 +291,37 @@ type DivisionSeedings = {
third: TeamEntry; 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 ──────────────────────────────────────────────────────────────── // ─── Simulator ────────────────────────────────────────────────────────────────
export class NHLSimulator implements Simulator { export class NHLSimulator implements Simulator {
@ -348,9 +379,6 @@ export class NHLSimulator implements Simulator {
// ─── Helpers (defined once, outside the hot loop) ───────────────────────── // ─── 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. */ /** Simulate a best-of-7 series. Returns winner and loser. */
const simSeries = (a: TeamEntry, b: TeamEntry): { winner: TeamEntry; loser: TeamEntry } => { const simSeries = (a: TeamEntry, b: TeamEntry): { winner: TeamEntry; loser: TeamEntry } => {
const winProb = eloWinProbability(elo(a), elo(b)); 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 }; 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. * Draw the top-3 playoff seeds for a single division.
* Returns undefined for each position if no team has weight for that slot * Returns undefined for each position if no team has weight for that slot
@ -491,7 +493,7 @@ export class NHLSimulator implements Simulator {
// Conference Final. // Conference Final.
const eastCF = simSeries(eastR2m1.winner, eastR2m2.winner); const eastCF = simSeries(eastR2m1.winner, eastR2m2.winner);
const eastChamp = eastCF.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 ──────────────────────────────────────────────────── // ── Western Conference ────────────────────────────────────────────────────
const westR1Winners = westBracket.map(([a, b]) => simSeries(a, b).winner); 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 westR2m2 = simSeries(westR1Winners[1], westR1Winners[3]);
const westCF = simSeries(westR2m1.winner, westR2m2.winner); const westCF = simSeries(westR2m1.winner, westR2m2.winner);
const westChamp = westCF.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 ───────────────────────────────────────────────────── // ── Stanley Cup Final ─────────────────────────────────────────────────────
const final = simSeries(eastChamp, westChamp); const final = simSeries(eastChamp, westChamp);
championCounts.set(final.winner.id, championCounts.get(final.winner.id)! + 1); championCounts.set(final.winner.id, (championCounts.get(final.winner.id) ?? 0) + 1);
finalistCounts.set(final.loser.id, finalistCounts.get(final.loser.id)! + 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). // 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]) { 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 N = effectiveN;
const results: SimulationResult[] = participantIds.map((participantId) => { const results: SimulationResult[] = participantIds.map((participantId) => {
const c = championCounts.get(participantId)!; const c = championCounts.get(participantId) ?? 0;
const f = finalistCounts.get(participantId)!; const f = finalistCounts.get(participantId) ?? 0;
const cf = confFinalLoserCounts.get(participantId)!; const cf = confFinalLoserCounts.get(participantId) ?? 0;
const r2 = r2LoserCounts.get(participantId)!; const r2 = r2LoserCounts.get(participantId) ?? 0;
return { return {
participantId, participantId,
probabilities: { probabilities: {

View file

@ -99,7 +99,7 @@ export class UCLSimulator implements Simulator {
const byRound = new Map<string, typeof allMatches>(); const byRound = new Map<string, typeof allMatches>();
for (const m of allMatches) { for (const m of allMatches) {
if (!byRound.has(m.round)) byRound.set(m.round, []); 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()] 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, … // r16Matches is sorted by matchNumber, so participantIds[0..1] = match 1, [2..3] = match 2, …
const participantIds: string[] = []; const participantIds: string[] = [];
for (const m of r16Matches) { for (const m of r16Matches) {
participantIds.push(m.participant1Id!, m.participant2Id!); participantIds.push(m.participant1Id ?? "", m.participant2Id ?? "");
} }
const participantSet = new Set(participantIds); const participantSet = new Set(participantIds);
const fallbackProb = 1 / participantIds.length; const fallbackProb = 1 / participantIds.length;
@ -164,7 +164,7 @@ export class UCLSimulator implements Simulator {
if (hasOdds) { if (hasOdds) {
const oddsInput = evRows const oddsInput = evRows
.filter((r) => r.sourceOdds !== null && participantSet.has(r.participantId)) .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"); eloMap = convertFuturesToElo(oddsInput, "american");
} else { } else {
// No odds stored — all teams get equal Elo (coin-flip bracket) // 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) // R16 losers: no count added (0 points per scoring rules)
const r16Winners: string[] = []; const r16Winners: string[] = [];
for (let i = 1; i <= 8; i++) { 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) { if (m.isComplete && m.winnerId) {
r16Winners.push(m.winnerId); r16Winners.push(m.winnerId);
} else { } else {
const { winner } = simMatch(m.participant1Id!, m.participant2Id!); const { winner } = simMatch(m.participant1Id ?? "", m.participant2Id ?? "");
r16Winners.push(winner); r16Winners.push(winner);
} }
} }
@ -255,9 +256,7 @@ export class UCLSimulator implements Simulator {
} }
qfWinners.push(winner); qfWinners.push(winner);
if (qfLoserCounts.has(loser)) { qfLoserCounts.set(loser, (qfLoserCounts.get(loser) ?? 0) + 1);
qfLoserCounts.set(loser, qfLoserCounts.get(loser)! + 1);
}
} }
// ── Semifinals ─────────────────────────────────────────────────────── // ── Semifinals ───────────────────────────────────────────────────────
@ -278,9 +277,7 @@ export class UCLSimulator implements Simulator {
} }
sfWinners.push(winner); sfWinners.push(winner);
if (sfLoserCounts.has(loser)) { sfLoserCounts.set(loser, (sfLoserCounts.get(loser) ?? 0) + 1);
sfLoserCounts.set(loser, sfLoserCounts.get(loser)! + 1);
}
} }
// ── Final ───────────────────────────────────────────────────────────── // ── Final ─────────────────────────────────────────────────────────────
@ -294,12 +291,8 @@ export class UCLSimulator implements Simulator {
({ winner: champion, loser: finalist } = simMatch(sfWinners[0], sfWinners[1])); ({ winner: champion, loser: finalist } = simMatch(sfWinners[0], sfWinners[1]));
} }
if (championCounts.has(champion)) { championCounts.set(champion, (championCounts.get(champion) ?? 0) + 1);
championCounts.set(champion, championCounts.get(champion)! + 1); finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1);
}
if (finalistCounts.has(finalist)) {
finalistCounts.set(finalist, finalistCounts.get(finalist)! + 1);
}
} }
// 11. Convert counts to probability distributions. // 11. Convert counts to probability distributions.
@ -309,10 +302,10 @@ export class UCLSimulator implements Simulator {
// probFifthEighth → qfLoserCounts / (4*N) — 4 QF losers per sim // probFifthEighth → qfLoserCounts / (4*N) — 4 QF losers per sim
const N = NUM_SIMULATIONS; const N = NUM_SIMULATIONS;
const results: SimulationResult[] = participantIds.map((participantId) => { const results: SimulationResult[] = participantIds.map((participantId) => {
const c = championCounts.get(participantId)!; const c = championCounts.get(participantId) ?? 0;
const f = finalistCounts.get(participantId)!; const f = finalistCounts.get(participantId) ?? 0;
const sf = sfLoserCounts.get(participantId)!; const sf = sfLoserCounts.get(participantId) ?? 0;
const qf = qfLoserCounts.get(participantId)!; const qf = qfLoserCounts.get(participantId) ?? 0;
return { return {
participantId, participantId,
probabilities: { probabilities: {

View file

@ -95,7 +95,8 @@ describe("NbaStandingsAdapter", () => {
expect(records).toHaveLength(3); 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).toBeDefined();
expect(okc.wins).toBe(58); expect(okc.wins).toBe(58);
expect(okc.losses).toBe(10); expect(okc.losses).toBe(10);
@ -113,7 +114,8 @@ describe("NbaStandingsAdapter", () => {
const adapter = new NbaStandingsAdapter(); const adapter = new NbaStandingsAdapter();
const records = await adapter.fetchStandings(); 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.conference).toBe("Eastern Conference");
expect(bos.division).toBe("Atlantic Division"); expect(bos.division).toBe("Atlantic Division");
}); });
@ -127,10 +129,12 @@ describe("NbaStandingsAdapter", () => {
const adapter = new NbaStandingsAdapter(); const adapter = new NbaStandingsAdapter();
const records = await adapter.fetchStandings(); 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"); 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"); 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])); const participantByName = new Map(participants.map((p) => [p.name, p]));
// Build a map of externalId → participant for ID-first matching // Build a map of externalId → participant for ID-first matching
const participantByExternalId = new Map( 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] = []; const toUpsert: Parameters<typeof upsertRegularSeasonStandings>[0] = [];
@ -84,9 +84,9 @@ export async function syncStandings(sportsSeasonId: string): Promise<SyncResult>
// Fall back to name matching // Fall back to name matching
const matchedName = findMatchingTeamName(record.teamName, participantNames); const matchedName = findMatchingTeamName(record.teamName, participantNames);
if (matchedName) { if (matchedName) {
participant = participantByName.get(matchedName)!; participant = participantByName.get(matchedName) ?? null;
// Write-back: persist externalId so future syncs skip name matching for this team // 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 }); 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 // 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 // Send the list of already-connected teams to this socket
socket.emit("connected-teams-list", { teamIds: Array.from(seasonConnectedTeams) }); socket.emit("connected-teams-list", { teamIds: Array.from(seasonConnectedTeams) });
@ -172,7 +173,7 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
seasonConnectedTeams.add(teamId); seasonConnectedTeams.add(teamId);
// Broadcast to everyone else that this team connected // 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}`); 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}`); 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}`); console.log(`Team ${currentTeamId} disconnected from draft-${currentSeasonId}`);
} }
}); });