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:
parent
180aad1520
commit
4bffa40606
56 changed files with 521 additions and 429 deletions
|
|
@ -10,6 +10,16 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"Stop": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "npm run typecheck 2>&1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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": [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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([
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 ────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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%
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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, {
|
||||||
|
|
|
||||||
|
|
@ -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[] = [];
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
// probFifth–Eighth → confSemiLoserCounts / (4*N) — 4 conf semi losers per sim
|
// probFifth–Eighth → 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: {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
// probFifth–Eighth → e8LoserCounts / (4*N) → 4 losers * 1/(4N) = 1
|
// probFifth–Eighth → 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: {
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
// probFifth–Eighth → qfLoserCounts / (4*N) — 4 QF losers per sim
|
// probFifth–Eighth → 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: {
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,4 +19,3 @@ declare global {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {};
|
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue