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>
This commit is contained in:
Chris Parsons 2026-03-21 10:14:54 -07:00
parent 180aad1520
commit 4de6cb23a0
22 changed files with 208 additions and 201 deletions

View file

@ -36,7 +36,10 @@
"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",
"no-shadow": "error",
"unicorn/consistent-function-scoping": "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

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

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

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

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

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

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

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

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

View file

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

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

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

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

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

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 {
@ -342,9 +346,6 @@ export class NCAAMSimulator implements Simulator {
byRound.get(m.round)!.push(m); byRound.get(m.round)!.push(m);
} }
const sortByMatchNumber = (matches: typeof allMatches) =>
[...matches].toSorted((a, b) => a.matchNumber - b.matchNumber);
const firstFourMatches = byRound.has("First Four") ? sortByMatchNumber(byRound.get("First Four")!) : []; const firstFourMatches = byRound.has("First Four") ? sortByMatchNumber(byRound.get("First Four")!) : [];
const r64Matches = byRound.has("Round of 64") ? sortByMatchNumber(byRound.get("Round of 64")!) : null; const r64Matches = byRound.has("Round of 64") ? sortByMatchNumber(byRound.get("Round of 64")!) : null;
const r32Matches = byRound.has("Round of 32") ? sortByMatchNumber(byRound.get("Round of 32")!) : null; const r32Matches = byRound.has("Round of 32") ? sortByMatchNumber(byRound.get("Round of 32")!) : null;

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 {
@ -319,9 +323,6 @@ export class NCAAWSimulator implements Simulator {
byRound.get(m.round)!.push(m); byRound.get(m.round)!.push(m);
} }
const sortByMatchNumber = (matches: typeof allMatches) =>
[...matches].toSorted((a, b) => a.matchNumber - b.matchNumber);
const firstFourMatches = byRound.has("First Four") ? sortByMatchNumber(byRound.get("First Four")!) : []; const firstFourMatches = byRound.has("First Four") ? sortByMatchNumber(byRound.get("First Four")!) : [];
const r64Matches = byRound.has("Round of 64") ? sortByMatchNumber(byRound.get("Round of 64")!) : null; const r64Matches = byRound.has("Round of 64") ? sortByMatchNumber(byRound.get("Round of 64")!) : null;
const r32Matches = byRound.has("Round of 32") ? sortByMatchNumber(byRound.get("Round of 32")!) : null; const r32Matches = byRound.has("Round of 32") ? sortByMatchNumber(byRound.get("Round of 32")!) : null;

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