Add oxlint linting setup with zero errors (#194)

* Add oxlint and fix all lint errors

- Install oxlint, add .oxlintrc.json with rules for TypeScript/React
- Add npm run lint / lint:fix scripts
- Add Claude PostToolUse hook to run oxlint on every edited file
- Fix 101 errors: unused vars/imports, eqeqeq, prefer-const, no-new-array
- Fix no-array-index-key (use stable keys or suppress positional cases)
- Fix exhaustive-deps missing dependency in useEffect
- Promote exhaustive-deps and no-array-index-key to errors
- Fix Map.get() !== null bug in $leagueId.server.ts (should be !== undefined)

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

* Fix no-explicit-any warnings and upgrade tsconfig to ES2023

- Replace all `any` types with proper types or `unknown` across ~20 files
- Add typed socket payload interfaces in draft route and useDraftSocket
- Use any[] with eslint-disable for socket.io callbacks (legitimate escape hatch)
- Bump all tsconfigs from ES2022 → ES2023 to support toSorted/toReversed
- Fix cascading type errors uncovered by removing any: Map.get narrowing,
  participant relation types, ChartDataPoint, Partial<NewSeason> indexing
- Add ParticipantResultWithParticipant type to participant-result model
- Fix test fixtures to match updated interfaces (DraftCell, ParticipantResult)
- Fix duplicate getQPStandings import in sportsSeasonId.server.ts

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

* Promote no-explicit-any to error

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-03-21 09:44:05 -07:00 committed by GitHub
parent bcca8b76fa
commit e2b178221a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
97 changed files with 767 additions and 325 deletions

15
.claude/settings.json Normal file
View file

@ -0,0 +1,15 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path // empty' | xargs -I{} sh -c 'case \"{}\" in *.ts|*.tsx) ./node_modules/.bin/oxlint \"{}\" 2>&1 ;; esac'"
}
]
}
]
}
}

58
.oxlintrc.json Normal file
View file

@ -0,0 +1,58 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "vitest", "unicorn", "import"],
"env": {
"browser": true,
"node": true,
"es2022": true
},
"categories": {
"correctness": "error",
"suspicious": "warn"
},
"rules": {
"no-console": "warn",
"no-var": "error",
"prefer-const": "error",
"eqeqeq": ["error", "always"],
"no-unused-vars": "off",
"no-undef": "off",
"typescript/no-unused-vars": [
"error",
{ "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }
],
"typescript/no-explicit-any": "error",
"typescript/no-non-null-assertion": "warn",
"typescript/consistent-type-imports": [
"error",
{ "prefer": "type-imports" }
],
"react/react-in-jsx-scope": "off",
"react/rules-of-hooks": "error",
"react/exhaustive-deps": "error",
"react/jsx-key": "error",
"react/no-array-index-key": "error",
"react/self-closing-comp": "warn",
"import/no-duplicates": "error"
},
"overrides": [
{
"files": ["**/__tests__/**", "**/*.test.ts", "**/*.test.tsx", "cypress/**"],
"rules": {
"no-console": "off",
"typescript/no-explicit-any": "off"
}
}
],
"ignorePatterns": [
"build/**",
"dist/**",
"node_modules/**",
"drizzle/**",
".react-router/**",
"cypress/fixtures/**"
]
}

View file

@ -6,6 +6,13 @@ import {
ContextMenuTrigger, ContextMenuTrigger,
} from "~/components/ui/context-menu"; } from "~/components/ui/context-menu";
interface DraftCell {
pickNumber: number;
participant: { id: string; name: string };
sport: { id: string; name: string };
team: { id: string; name: string };
}
interface DraftGridProps { interface DraftGridProps {
draftSlots: Array<{ draftSlots: Array<{
id: string; id: string;
@ -15,7 +22,7 @@ interface DraftGridProps {
name: string; name: string;
}; };
}>; }>;
draftGrid: any[][]; draftGrid: Array<Array<DraftCell | null>>;
currentPick: number; currentPick: number;
teamTimers?: Record<string, number>; teamTimers?: Record<string, number>;
formatTime?: (seconds: number | undefined) => string; formatTime?: (seconds: number | undefined) => string;
@ -92,11 +99,11 @@ export function DraftGrid({
const round = roundIndex + 1; const round = roundIndex + 1;
const isEvenRound = round % 2 === 0; const isEvenRound = round % 2 === 0;
const displayPicks = isEvenRound const displayPicks = isEvenRound
? [...roundPicks].reverse() ? [...roundPicks].toReversed()
: roundPicks; : roundPicks;
return ( return (
<div key={roundIndex} className="flex gap-2"> <div key={round} className="flex gap-2">
{displayPicks.map((cell, index) => { {displayPicks.map((cell, index) => {
const actualIndex = isEvenRound const actualIndex = isEvenRound
? roundPicks.length - 1 - index ? roundPicks.length - 1 - index

View file

@ -6,12 +6,12 @@ import { mockDraftSlots } from '~/test/fixtures/team';
describe('DraftGrid Component', () => { describe('DraftGrid Component', () => {
const mockGrid = [ const mockGrid = [
[ [
{ participant: { name: 'Player 1' }, sport: { name: 'NFL' } }, { pickNumber: 1, participant: { id: 'p1', name: 'Player 1' }, sport: { id: 's1', name: 'NFL' }, team: { id: 't1', name: 'Team 1' } },
null, null,
], ],
[ [
null, null,
{ participant: { name: 'Player 2' }, sport: { name: 'NBA' } }, { pickNumber: 4, participant: { id: 'p2', name: 'Player 2' }, sport: { id: 's2', name: 'NBA' }, team: { id: 't2', name: 'Team 2' } },
], ],
]; ];
@ -143,12 +143,12 @@ describe('DraftGrid Component', () => {
it('should reverse picks on even rounds', () => { it('should reverse picks on even rounds', () => {
const snakeGrid = [ const snakeGrid = [
[ [
{ participant: { name: 'Pick 1' }, sport: { name: 'NFL' } }, { pickNumber: 1, participant: { id: 'p1', name: 'Pick 1' }, sport: { id: 's1', name: 'NFL' }, team: { id: 't1', name: 'Team 1' } },
{ participant: { name: 'Pick 2' }, sport: { name: 'NBA' } }, { pickNumber: 2, participant: { id: 'p2', name: 'Pick 2' }, sport: { id: 's2', name: 'NBA' }, team: { id: 't2', name: 'Team 2' } },
], ],
[ [
{ participant: { name: 'Pick 4' }, sport: { name: 'MLB' } }, { pickNumber: 3, participant: { id: 'p3', name: 'Pick 4' }, sport: { id: 's1', name: 'MLB' }, team: { id: 't2', name: 'Team 2' } },
{ participant: { name: 'Pick 3' }, sport: { name: 'NHL' } }, { pickNumber: 4, participant: { id: 'p4', name: 'Pick 3' }, sport: { id: 's2', name: 'NHL' }, team: { id: 't1', name: 'Team 1' } },
], ],
]; ];

View file

@ -41,8 +41,8 @@ export function ConnectionOverlay({
</div> </div>
) : ( ) : (
<div className="relative w-16 h-16"> <div className="relative w-16 h-16">
<div className="absolute inset-0 border-4 border-primary/30 rounded-full"></div> <div className="absolute inset-0 border-4 border-primary/30 rounded-full" />
<div className="absolute inset-0 border-4 border-primary border-t-transparent rounded-full animate-spin"></div> <div className="absolute inset-0 border-4 border-primary border-t-transparent rounded-full animate-spin" />
</div> </div>
)} )}
@ -85,15 +85,15 @@ export function ConnectionOverlay({
<div <div
className="w-2 h-2 bg-primary rounded-full animate-bounce" className="w-2 h-2 bg-primary rounded-full animate-bounce"
style={{ animationDelay: "0ms" }} style={{ animationDelay: "0ms" }}
></div> />
<div <div
className="w-2 h-2 bg-primary rounded-full animate-bounce" className="w-2 h-2 bg-primary rounded-full animate-bounce"
style={{ animationDelay: "150ms" }} style={{ animationDelay: "150ms" }}
></div> />
<div <div
className="w-2 h-2 bg-primary rounded-full animate-bounce" className="w-2 h-2 bg-primary rounded-full animate-bounce"
style={{ animationDelay: "300ms" }} style={{ animationDelay: "300ms" }}
></div> />
</div> </div>
)} )}
</div> </div>

View file

@ -168,11 +168,11 @@ export const DraftGridSection = memo(function DraftGridSection({
const round = roundIndex + 1; const round = roundIndex + 1;
const isEvenRound = round % 2 === 0; const isEvenRound = round % 2 === 0;
const displayPicks = isEvenRound const displayPicks = isEvenRound
? [...roundPicks].reverse() ? [...roundPicks].toReversed()
: roundPicks; : roundPicks;
return ( return (
<div key={roundIndex} className="flex gap-2 items-stretch"> <div key={round} className="flex gap-2 items-stretch">
{/* Round label */} {/* Round label */}
<div className="w-8 flex-shrink-0 sticky left-0 z-[5] bg-background/95 flex items-center justify-center"> <div className="w-8 flex-shrink-0 sticky left-0 z-[5] bg-background/95 flex items-center justify-center">
<span className="text-xs font-mono text-muted-foreground">R{round}</span> <span className="text-xs font-mono text-muted-foreground">R{round}</span>

View file

@ -27,7 +27,7 @@ export function RecentPicksSection({ picks }: RecentPicksSectionProps) {
<div className="space-y-2 overflow-y-auto"> <div className="space-y-2 overflow-y-auto">
{picks {picks
.slice() .slice()
.reverse() .toReversed()
.slice(0, 10) .slice(0, 10)
.map((pick) => ( .map((pick) => (
<div <div

View file

@ -29,7 +29,7 @@ export const SidebarRecentPicks = memo(function SidebarRecentPicks({ picks }: Si
<div className="space-y-1.5"> <div className="space-y-1.5">
{picks {picks
.slice() .slice()
.reverse() .toReversed()
.map((pick) => ( .map((pick) => (
<div <div
key={pick.id} key={pick.id}

View file

@ -163,7 +163,7 @@ export function PlayoffBracket({
rounds, rounds,
preEliminatedParticipants = [], preEliminatedParticipants = [],
participantPoints = [], participantPoints = [],
partialScoreParticipantIds = [], partialScoreParticipantIds: _partialScoreParticipantIds = [],
teamOwnerships = [], teamOwnerships = [],
userParticipantIds = [], userParticipantIds = [],
showOwnership = true, showOwnership = true,
@ -189,7 +189,7 @@ export function PlayoffBracket({
// In double-chance brackets (e.g. AFL), a participant may lose one round but // In double-chance brackets (e.g. AFL), a participant may lose one round but
// win a later one — only count them as eliminated at their FINAL losing match. // win a later one — only count them as eliminated at their FINAL losing match.
const losersByRound = new Map<string, Array<{ participant: Participant; score: string | null; ownership: TeamOwnership | null }>>(); const losersByRound = new Map<string, Array<{ participant: Participant; score: string | null; ownership: TeamOwnership | null }>>();
let hasScore = false; let _hasScore = false;
let bracketWinner: Participant | null = null; let bracketWinner: Participant | null = null;
const lastRound = rounds[rounds.length - 1]; const lastRound = rounds[rounds.length - 1];
@ -209,7 +209,7 @@ export function PlayoffBracket({
match.loserId === match.participant1Id match.loserId === match.participant1Id
? match.participant1Score ? match.participant1Score
: 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,
@ -574,12 +574,12 @@ export function PlayoffBracket({
</TableRow> </TableRow>
)} )}
{rankedEntries.filter((entry) => isDraftedOrScoring(entry.participant.id)).map((entry, i) => { {rankedEntries.filter((entry) => isDraftedOrScoring(entry.participant.id)).map((entry) => {
const isOwned = userParticipantSet.has(entry.participant.id); const isOwned = userParticipantSet.has(entry.participant.id);
const pts = pointsMap.get(entry.participant.id); const pts = pointsMap.get(entry.participant.id);
return ( return (
<TableRow <TableRow
key={`${entry.participant.id}-${i}`} key={entry.participant.id}
className={isOwned ? "bg-electric/5 border-l-2 border-l-electric opacity-80" : "opacity-60"} className={isOwned ? "bg-electric/5 border-l-2 border-l-electric opacity-80" : "opacity-60"}
> >
<TableCell className="font-mono text-xs text-muted-foreground"> <TableCell className="font-mono text-xs text-muted-foreground">

View file

@ -62,7 +62,7 @@ export function SeasonStandings({
showOwnership = true, showOwnership = true,
isFinalized = false, isFinalized = false,
title = "Championship Standings", title = "Championship Standings",
description, description: _description,
}: SeasonStandingsProps) { }: SeasonStandingsProps) {
const userParticipantSet = new Set(userParticipantIds); const userParticipantSet = new Set(userParticipantIds);
// Create ownership map for fast lookup // Create ownership map for fast lookup
@ -191,7 +191,7 @@ export function SeasonStandings({
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{standings {standings
.sort((a, b) => a.position - b.position) .toSorted((a, b) => a.position - b.position)
.map((standing) => { .map((standing) => {
const ownership = getOwnership(standing.participant.id); const ownership = getOwnership(standing.participant.id);
const isTied = checkIfTied(standing); const isTied = checkIfTied(standing);

View file

@ -121,7 +121,7 @@ interface SportSeasonDisplayProps {
export function SportSeasonDisplay({ export function SportSeasonDisplay({
scoringPattern, scoringPattern,
sportSeasonName, sportSeasonName,
sportName, sportName: _sportName,
playoffMatches = [], playoffMatches = [],
playoffRounds = [], playoffRounds = [],
preEliminatedParticipants = [], preEliminatedParticipants = [],

View file

@ -110,7 +110,7 @@ export function EventSchedule({ upcomingEvents, recentEvents }: EventSchedulePro
!event.isComplete && (() => { !event.isComplete && (() => {
const isPast = event.eventStartsAt const isPast = event.eventStartsAt
? new Date(event.eventStartsAt) < new Date() ? new Date(event.eventStartsAt) < new Date()
: event.eventDate != null && event.eventDate < new Date().toISOString().split("T")[0]; : event.eventDate !== null && event.eventDate < new Date().toISOString().split("T")[0];
return isPast && ( return isPast && (
<Badge variant="outline" className="text-xs shrink-0 border-amber-500/30 text-amber-400"> <Badge variant="outline" className="text-xs shrink-0 border-amber-500/30 text-amber-400">
Results Pending Results Pending

View file

@ -26,7 +26,7 @@ interface StandingRow {
lastTen: string | null; lastTen: string | null;
homeRecord: string | null; homeRecord: string | null;
awayRecord: string | null; awayRecord: string | null;
syncedAt: string | null; syncedAt: Date | string | null;
participant: { id: string; name: string; shortName?: string | null }; participant: { id: string; name: string; shortName?: string | null };
} }
@ -61,7 +61,7 @@ interface TableSection {
// ─── Formatting ─────────────────────────────────────────────────────────────── // ─── Formatting ───────────────────────────────────────────────────────────────
function formatWinPct(winPct: string | null, wins: number, gamesPlayed: number): string { function formatWinPct(winPct: string | null, wins: number, gamesPlayed: number): string {
if (winPct != null) { if (winPct !== null) {
const pct = parseFloat(winPct); const pct = parseFloat(winPct);
if (!isNaN(pct)) return pct.toFixed(3); if (!isNaN(pct)) return pct.toFixed(3);
} }
@ -70,7 +70,7 @@ function formatWinPct(winPct: string | null, wins: number, gamesPlayed: number):
} }
function formatGB(gamesBack: string | null): string { function formatGB(gamesBack: string | null): string {
if (gamesBack == null) return "—"; if (gamesBack === null) return "—";
const gb = parseFloat(gamesBack); const gb = parseFloat(gamesBack);
if (isNaN(gb) || gb === 0) return "—"; if (isNaN(gb) || gb === 0) return "—";
return gb % 1 === 0 ? gb.toString() : gb.toFixed(1); return gb % 1 === 0 ? gb.toString() : gb.toFixed(1);
@ -96,7 +96,7 @@ function buildFlatSections(
conference: "", conference: "",
sections: [ sections: [
{ {
rows: [...standings].sort( rows: [...standings].toSorted(
(a, b) => (a.leagueRank ?? 99) - (b.leagueRank ?? 99) (a, b) => (a.leagueRank ?? 99) - (b.leagueRank ?? 99)
), ),
rankMode: "conference", rankMode: "conference",
@ -118,7 +118,7 @@ function buildFlatSections(
conference, conference,
sections: [ sections: [
{ {
rows: [...rows].sort( rows: [...rows].toSorted(
(a, b) => (a, b) =>
(a.conferenceRank ?? a.leagueRank ?? 99) - (a.conferenceRank ?? a.leagueRank ?? 99) -
(b.conferenceRank ?? b.leagueRank ?? 99) (b.conferenceRank ?? b.leagueRank ?? 99)
@ -154,9 +154,9 @@ function buildNhlSections(
name, name,
qualifiers: divRows qualifiers: divRows
.filter((r) => (r.divisionRank ?? 99) <= 3) .filter((r) => (r.divisionRank ?? 99) <= 3)
.sort((a, b) => (a.divisionRank ?? 99) - (b.divisionRank ?? 99)), .toSorted((a, b) => (a.divisionRank ?? 99) - (b.divisionRank ?? 99)),
})) }))
.sort( .toSorted(
(a, b) => (a, b) =>
(a.qualifiers[0]?.conferenceRank ?? 99) - (a.qualifiers[0]?.conferenceRank ?? 99) -
(b.qualifiers[0]?.conferenceRank ?? 99) (b.qualifiers[0]?.conferenceRank ?? 99)
@ -164,7 +164,7 @@ function buildNhlSections(
const wildCard = rows const wildCard = rows
.filter((r) => (r.divisionRank ?? 99) > 3) .filter((r) => (r.divisionRank ?? 99) > 3)
.sort( .toSorted(
(a, b) => (a, b) =>
(a.conferenceRank ?? a.leagueRank ?? 99) - (a.conferenceRank ?? a.leagueRank ?? 99) -
(b.conferenceRank ?? b.leagueRank ?? 99) (b.conferenceRank ?? b.leagueRank ?? 99)
@ -243,7 +243,7 @@ function StandingsTable({
if (section.heading) { if (section.heading) {
sectionRows.push( sectionRows.push(
<tr key={`h-${sIdx}`}> <tr key={`h-${section.heading}`}>
<td <td
colSpan={totalCols} colSpan={totalCols}
className={`text-xs font-medium text-muted-foreground/60 uppercase tracking-wider pb-1 ${ className={`text-xs font-medium text-muted-foreground/60 uppercase tracking-wider pb-1 ${
@ -350,7 +350,7 @@ function StandingsTable({
</td> </td>
{hasEvs && ( {hasEvs && (
<td className="py-2 pl-2 text-right tabular-nums"> <td className="py-2 pl-2 text-right tabular-nums">
{ev != null ? ( {ev !== null ? (
<span className="text-blue-500 dark:text-blue-400 font-medium"> <span className="text-blue-500 dark:text-blue-400 font-medium">
{parseFloat(ev).toFixed(1)} {parseFloat(ev).toFixed(1)}
</span> </span>
@ -388,7 +388,7 @@ export function RegularSeasonStandings({
const lastSyncedAt = standings const lastSyncedAt = standings
.map((s) => s.syncedAt) .map((s) => s.syncedAt)
.filter(Boolean) .filter(Boolean)
.sort() .toSorted()
.at(-1); .at(-1);
const groups = const groups =

View file

@ -61,7 +61,7 @@ export function TeamScoreBreakdown({
// Flatten all picks sorted by sport name then pick number // Flatten all picks sorted by sport name then pick number
const allPicks = breakdown.picks const allPicks = breakdown.picks
.slice() .slice()
.sort((a, b) => { .toSorted((a, b) => {
const sportCmp = a.participant.sport.localeCompare(b.participant.sport); const sportCmp = a.participant.sport.localeCompare(b.participant.sport);
return sportCmp !== 0 ? sportCmp : a.pickNumber - b.pickNumber; return sportCmp !== 0 ? sportCmp : a.pickNumber - b.pickNumber;
}); });

View file

@ -21,15 +21,15 @@ async function handleRequest(
routerContext: EntryContext, routerContext: EntryContext,
// If you have middleware enabled: // If you have middleware enabled:
// loadContext: RouterContextProvider // loadContext: RouterContextProvider
loadContext: AppLoadContext _loadContext: AppLoadContext
) { ) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let shellRendered = false; let shellRendered = false;
let userAgent = request.headers.get("user-agent"); const userAgent = request.headers.get("user-agent");
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding // Ensure requests from bots and SPA Mode renders wait for all content to load before responding
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation // https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
let readyOption: keyof RenderToPipeableStreamOptions = const readyOption: keyof RenderToPipeableStreamOptions =
(userAgent && isbot(userAgent)) || routerContext.isSpaMode (userAgent && isbot(userAgent)) || routerContext.isSpaMode
? "onAllReady" ? "onAllReady"
: "onShellReady"; : "onShellReady";

View file

@ -9,9 +9,11 @@ interface UseDraftSocketReturn {
/** Increments each time a new socket instance is created. Include in /** Increments each time a new socket instance is created. Include in
* useEffect deps so handler effects re-register on socket recreation. */ * useEffect deps so handler effects re-register on socket recreation. */
socketVersion: number; socketVersion: number;
// eslint-disable-next-line typescript/no-explicit-any -- socket.io callbacks are untyped at the hook level
on: (event: string, callback: (...args: any[]) => void) => void; on: (event: string, callback: (...args: any[]) => void) => void;
// eslint-disable-next-line typescript/no-explicit-any
off: (event: string, callback?: (...args: any[]) => void) => void; off: (event: string, callback?: (...args: any[]) => void) => void;
emit: (event: string, ...args: any[]) => void; emit: (event: string, ...args: unknown[]) => void;
} }
export function useDraftSocket(seasonId: string, teamId?: string): UseDraftSocketReturn { export function useDraftSocket(seasonId: string, teamId?: string): UseDraftSocketReturn {
@ -131,15 +133,17 @@ export function useDraftSocket(seasonId: string, teamId?: string): UseDraftSocke
// has run (socketRef.current === null) are silently no-ops. Consumers should // has run (socketRef.current === null) are silently no-ops. Consumers should
// only call them inside their own useEffect, not during render or synchronously // only call them inside their own useEffect, not during render or synchronously
// after mount. // after mount.
// eslint-disable-next-line typescript/no-explicit-any -- socket.io callbacks are untyped at the hook level
const on = useCallback((event: string, callback: (...args: any[]) => void) => { const on = useCallback((event: string, callback: (...args: any[]) => void) => {
socketRef.current?.on(event, callback); socketRef.current?.on(event, callback);
}, []); }, []);
// eslint-disable-next-line typescript/no-explicit-any
const off = useCallback((event: string, callback?: (...args: any[]) => void) => { const off = useCallback((event: string, callback?: (...args: any[]) => void) => {
socketRef.current?.off(event, callback); socketRef.current?.off(event, callback);
}, []); }, []);
const emit = useCallback((event: string, ...args: any[]) => { const emit = useCallback((event: string, ...args: unknown[]) => {
socketRef.current?.emit(event, ...args); socketRef.current?.emit(event, ...args);
}, []); }, []);

View file

@ -599,7 +599,7 @@ describe("calculateDraftEligibility", () => {
// Team1 picks NFL // Team1 picks NFL
allPicks = [createPick("team1", "nfl1", "nfl", "NFL")]; allPicks = [createPick("team1", "nfl1", "nfl", "NFL")];
let team2Eligibility = calculateDraftEligibility( const team2Eligibility = calculateDraftEligibility(
"team2", "team2",
[], [],
allPicks, allPicks,
@ -619,7 +619,7 @@ describe("calculateDraftEligibility", () => {
createPick("team1", "nfl1", "nfl", "NFL"), createPick("team1", "nfl1", "nfl", "NFL"),
createPick("team2", "nfl2", "nfl", "NFL"), createPick("team2", "nfl2", "nfl", "NFL"),
]; ];
let team3Eligibility = calculateDraftEligibility( const team3Eligibility = calculateDraftEligibility(
"team3", "team3",
[], [],
allPicks, allPicks,
@ -642,7 +642,7 @@ describe("calculateDraftEligibility", () => {
]; ];
// Now team1 wants a second NFL (flex pick) // Now team1 wants a second NFL (flex pick)
let team1Eligibility = calculateDraftEligibility( const team1Eligibility = calculateDraftEligibility(
"team1", "team1",
[createPick("team1", "nfl1", "nfl", "NFL")], [createPick("team1", "nfl1", "nfl", "NFL")],
allPicks, allPicks,

View file

@ -603,7 +603,7 @@ export function getOrderedRoundsFromMatches(
for (const m of matches) { for (const m of matches) {
roundMatchCounts.set(m.round, (roundMatchCounts.get(m.round) || 0) + 1); roundMatchCounts.set(m.round, (roundMatchCounts.get(m.round) || 0) + 1);
} }
return Array.from(roundMatchCounts.keys()).sort( return Array.from(roundMatchCounts.keys()).toSorted(
(a, b) => (roundMatchCounts.get(b) || 0) - (roundMatchCounts.get(a) || 0) (a, b) => (roundMatchCounts.get(b) || 0) - (roundMatchCounts.get(a) || 0)
); );
} }

View file

@ -15,7 +15,7 @@ export async function buildOwnerMap(
const userByClerkId = new Map( const userByClerkId = new Map(
userRows userRows
.map((u) => [u.clerkId, getUserDisplayName(u)] as const) .map((u) => [u.clerkId, getUserDisplayName(u)] as const)
.filter((entry): entry is [string, string] => entry[1] != null) .filter((entry): entry is [string, string] => entry[1] !== null && entry[1] !== undefined)
); );
const ownerMap: Record<string, string> = {}; const ownerMap: Record<string, string> = {};

View file

@ -1,6 +1,6 @@
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq, and } from "drizzle-orm"; import { eq } from "drizzle-orm";
/** /**
* Check if a season is complete * Check if a season is complete

View file

@ -182,18 +182,6 @@ describe("AFL Finals System - Phase 3.3", () => {
describe("AFL Point Distribution", () => { describe("AFL Point Distribution", () => {
it("has all 8 scoring placements covered", () => { it("has all 8 scoring placements covered", () => {
// Verify that all placements 1-8 are accounted for in AFL system
const placements = {
1: "Grand Final winner",
2: "Grand Final loser",
3: "Preliminary Final loser (shared with 4th)",
4: "Preliminary Final loser (shared with 3rd)",
5: "Semi-Final loser (shared with 6th)",
6: "Semi-Final loser (shared with 5th)",
7: "Elimination Final loser (shared with 8th)",
8: "Elimination Final loser (shared with 7th)",
};
// Count scoring rounds // Count scoring rounds
const scoringRounds = AFL_10.rounds.filter(r => r.isScoring); const scoringRounds = AFL_10.rounds.filter(r => r.isScoring);
expect(scoringRounds).toHaveLength(4); // EF, SF, PF, GF expect(scoringRounds).toHaveLength(4); // EF, SF, PF, GF

View file

@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach } from "vitest"; import { describe, it, expect } from "vitest";
import { SIMPLE_16, SIMPLE_32, NCAA_68 } from "~/lib/bracket-templates"; import { SIMPLE_16, SIMPLE_32, NCAA_68 } from "~/lib/bracket-templates";
import { calculateFantasyPoints, calculateAveragedPoints, type ScoringRules } from "../scoring-rules"; import { calculateFantasyPoints, calculateAveragedPoints, type ScoringRules } from "../scoring-rules";

View file

@ -101,7 +101,7 @@ describe("Bracket Seeding", () => {
it("uses all 16 seeds exactly once", () => { it("uses all 16 seeds exactly once", () => {
const pairs = generateStandardSeeding(16); const pairs = generateStandardSeeding(16);
const allSeeds = pairs.flat().map(s => s + 1).sort((a, b) => a - b); const allSeeds = pairs.flat().map(s => s + 1).toSorted((a, b) => a - b);
expect(allSeeds).toEqual(Array.from({ length: 16 }, (_, i) => i + 1)); expect(allSeeds).toEqual(Array.from({ length: 16 }, (_, i) => i + 1));
}); });
@ -114,7 +114,7 @@ describe("Bracket Seeding", () => {
const seed1 = firstMatch[0] + 1; const seed1 = firstMatch[0] + 1;
const seed2 = firstMatch[1] + 1; const seed2 = firstMatch[1] + 1;
expect([seed1, seed2].sort()).toEqual([1, 16]); expect([seed1, seed2].toSorted()).toEqual([1, 16]);
}); });
it("has proper matchup sums for balance", () => { it("has proper matchup sums for balance", () => {
@ -167,12 +167,12 @@ describe("Bracket Seeding", () => {
const seed1 = firstMatch[0] + 1; const seed1 = firstMatch[0] + 1;
const seed2 = firstMatch[1] + 1; const seed2 = firstMatch[1] + 1;
expect([seed1, seed2].sort()).toEqual([1, 32]); expect([seed1, seed2].toSorted()).toEqual([1, 32]);
}); });
it("uses all 32 seeds exactly once", () => { it("uses all 32 seeds exactly once", () => {
const pairs = generateStandardSeeding(32); const pairs = generateStandardSeeding(32);
const allSeeds = pairs.flat().map(s => s + 1).sort((a, b) => a - b); const allSeeds = pairs.flat().map(s => s + 1).toSorted((a, b) => a - b);
expect(allSeeds).toEqual(Array.from({ length: 32 }, (_, i) => i + 1)); expect(allSeeds).toEqual(Array.from({ length: 32 }, (_, i) => i + 1));
}); });

View file

@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, vi } from "vitest"; import { describe, it, expect } from "vitest";
import { import {
SIMPLE_4, SIMPLE_4,
SIMPLE_8, SIMPLE_8,

View file

@ -12,7 +12,7 @@ import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calcul
*/ */
describe("participant-expected-value model", () => { describe("participant-expected-value model", () => {
const defaultScoring: ScoringRules = { const _defaultScoring: ScoringRules = {
pointsFor1st: 100, pointsFor1st: 100,
pointsFor2nd: 70, pointsFor2nd: 70,
pointsFor3rd: 50, pointsFor3rd: 50,
@ -23,7 +23,7 @@ describe("participant-expected-value model", () => {
pointsFor8th: 15, pointsFor8th: 15,
}; };
const validProbabilities: ProbabilityDistribution = { const _validProbabilities: ProbabilityDistribution = {
probFirst: 20, probFirst: 20,
probSecond: 20, probSecond: 20,
probThird: 15, probThird: 15,

View file

@ -237,7 +237,7 @@ describe("processMatchResult", () => {
it("does not overwrite a finalized row with a partial score", async () => { it("does not overwrite a finalized row with a partial score", async () => {
// findFirst returns a finalized row for the first participant (loser) // findFirst returns a finalized row for the first participant (loser)
const existing = { id: "result-1", isPartialScore: false }; const existing = { id: "result-1", isPartialScore: false };
const { db, insertedRows, updatedRows } = makeDb(existing); const { db, updatedRows } = makeDb(existing);
await processMatchResult( await processMatchResult(
{ ...BASE, round: "Quarterfinals", isScoring: true }, { ...BASE, round: "Quarterfinals", isScoring: true },

View file

@ -93,7 +93,7 @@ describe("Qualifying Points Configuration", () => {
{ name: "Player C", qp: 38 }, { name: "Player C", qp: 38 },
]; ];
const sorted = standings.sort((a, b) => b.qp - a.qp); const sorted = standings.toSorted((a, b) => b.qp - a.qp);
expect(sorted[0].name).toBe("Player B"); // 50 QP expect(sorted[0].name).toBe("Player B"); // 50 QP
expect(sorted[1].name).toBe("Player A"); // 44 QP expect(sorted[1].name).toBe("Player A"); // 44 QP
@ -107,7 +107,7 @@ describe("Qualifying Points Configuration", () => {
{ name: "Player C", qp: 20 }, { name: "Player C", qp: 20 },
]; ];
const sorted = standings.sort((a, b) => b.qp - a.qp); const sorted = standings.toSorted((a, b) => b.qp - a.qp);
// Both players with 30 QP should be before Player C // Both players with 30 QP should be before Player C
expect(sorted[2].name).toBe("Player C"); expect(sorted[2].name).toBe("Player C");

View file

@ -6,11 +6,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
// ── DB mock ────────────────────────────────────────────────────────────────── // ── DB mock ──────────────────────────────────────────────────────────────────
// selectDistinct returns a fluent chain; findMany is used in step 2. // selectDistinct returns a fluent chain; findMany is used in step 2.
const mockFindMany = vi.fn(); const mockFindMany = vi.fn();
const mockSelectChainBase = {
from: vi.fn(),
leftJoin: vi.fn(),
where: vi.fn(),
};
function makeSelectChain(rows: { id: string }[]) { function makeSelectChain(rows: { id: string }[]) {
const chain = { const chain = {

View file

@ -81,7 +81,7 @@ describe("Season Standings (F1 Pattern)", () => {
it("should correctly score season with tied positions", () => { it("should correctly score season with tied positions", () => {
// Simulate standings where 3rd, 4th, and 5th are tied // Simulate standings where 3rd, 4th, and 5th are tied
const standings = [ const _standings = [
{ position: 1, expected: 100 }, { position: 1, expected: 100 },
{ position: 2, expected: 70 }, { position: 2, expected: 70 },
// Three tied for 3rd place - each gets placement 3 // Three tied for 3rd place - each gets placement 3

View file

@ -175,7 +175,7 @@ describe("Standings Snapshots", () => {
]; ];
// Should be sorted by date descending (newest first) // Should be sorted by date descending (newest first)
const sorted = [...snapshots].sort((a, b) => const sorted = [...snapshots].toSorted((a, b) =>
b.date.localeCompare(a.date) b.date.localeCompare(a.date)
); );

View file

@ -60,7 +60,7 @@ function assignRanks(
}> }>
): Map<string, number> { ): Map<string, number> {
// Sort teams by ranking criteria // Sort teams by ranking criteria
const sorted = [...teams].sort(compareTeamsForRanking); const sorted = [...teams].toSorted(compareTeamsForRanking);
const ranks = new Map<string, number>(); const ranks = new Map<string, number>();
let currentRank = 1; let currentRank = 1;

View file

@ -345,7 +345,7 @@ export function getTeamForPick(
pickNumber: number, pickNumber: number,
draftOrder: { teamId: string; draftOrder: number }[] draftOrder: { teamId: string; draftOrder: number }[]
): string | null { ): string | null {
const sortedOrder = [...draftOrder].sort((a, b) => a.draftOrder - b.draftOrder); const sortedOrder = [...draftOrder].toSorted((a, b) => a.draftOrder - b.draftOrder);
const teamCount = sortedOrder.length; const teamCount = sortedOrder.length;
if (teamCount === 0) return null; if (teamCount === 0) return null;

View file

@ -1,4 +1,4 @@
import { eq, desc, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
@ -101,7 +101,7 @@ export async function findLeaguesWithActiveSeasonsByUserId(
for (const league of [...leaguesWithTeam, ...leaguesAsCommissioner]) { for (const league of [...leaguesWithTeam, ...leaguesAsCommissioner]) {
leagueMap.set(league.id, league); leagueMap.set(league.id, league);
} }
return [...leagueMap.values()].sort( return [...leagueMap.values()].toSorted(
(a, b) => b.createdAt.getTime() - a.createdAt.getTime() (a, b) => b.createdAt.getTime() - a.createdAt.getTime()
); );
} }

View file

@ -3,6 +3,9 @@ import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
export type ParticipantResult = typeof schema.participantResults.$inferSelect; export type ParticipantResult = typeof schema.participantResults.$inferSelect;
export type ParticipantResultWithParticipant = ParticipantResult & {
participant: { id: string; name: string } | null;
};
export type NewParticipantResult = typeof schema.participantResults.$inferInsert; export type NewParticipantResult = typeof schema.participantResults.$inferInsert;
export async function createParticipantResult( export async function createParticipantResult(
@ -62,7 +65,7 @@ export async function findParticipantResultByParticipantId(
export async function findParticipantResultsBySportsSeasonId( export async function findParticipantResultsBySportsSeasonId(
sportsSeasonId: string sportsSeasonId: string
): Promise<ParticipantResult[]> { ): Promise<ParticipantResultWithParticipant[]> {
const db = database(); const db = database();
return await db.query.participantResults.findMany({ return await db.query.participantResults.findMany({
where: eq(schema.participantResults.sportsSeasonId, sportsSeasonId), where: eq(schema.participantResults.sportsSeasonId, sportsSeasonId),

View file

@ -1,6 +1,6 @@
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq, and, inArray, desc } from "drizzle-orm"; import { eq, and, inArray } from "drizzle-orm";
export interface UpsertSeasonResultData { export interface UpsertSeasonResultData {
participantId: string; participantId: string;
@ -135,7 +135,7 @@ export async function getSeasonResults(
}); });
// Sort by position (nulls last), then by points descending // Sort by position (nulls last), then by points descending
return results.sort((a, b) => { return results.toSorted((a, b) => {
if (a.currentPosition !== null && b.currentPosition !== null) { if (a.currentPosition !== null && b.currentPosition !== null) {
return a.currentPosition - b.currentPosition; return a.currentPosition - b.currentPosition;
} }

View file

@ -149,7 +149,6 @@ export async function generateSingleEliminationBracket(
eventId: string, eventId: string,
participantIds: string[] participantIds: string[]
): Promise<PlayoffMatch[]> { ): Promise<PlayoffMatch[]> {
const db = database();
const numParticipants = participantIds.length; const numParticipants = participantIds.length;
// Validate that we have 4 or 8 participants for standard bracket // Validate that we have 4 or 8 participants for standard bracket
@ -218,8 +217,6 @@ export async function advanceWinner(
matchId: string, matchId: string,
winnerId: string winnerId: string
): Promise<void> { ): Promise<void> {
const db = database();
// Get the match // Get the match
const match = await findPlayoffMatchById(matchId); const match = await findPlayoffMatchById(matchId);
if (!match) throw new Error("Match not found"); if (!match) throw new Error("Match not found");
@ -523,17 +520,6 @@ async function generateNFL14Bracket(
): Promise<PlayoffMatch[]> { ): Promise<PlayoffMatch[]> {
const matches: NewPlayoffMatch[] = []; const matches: NewPlayoffMatch[] = [];
// Wild Card: 6 games for seeds 3-14 (12 teams)
// Seeds 1-2 get byes and automatically advance to Divisional
const wildCardSeeding = [
[6, 10], // #7 seed vs #11 seed (0-indexed: 6 vs 10)
[5, 11], // #6 seed vs #12 seed
[4, 12], // #5 seed vs #13 seed
[7, 9], // #8 seed vs #10 seed
[3, 13], // #4 seed vs #14 seed
[2, 14], // #3 seed vs #15 seed (Note: 14 is index for seed 15, but we only have 14 teams, so this is 3 vs 14)
];
// Actually for 14 teams (seeds 1-14), Wild Card should be: // Actually for 14 teams (seeds 1-14), Wild Card should be:
// Match 1: #7 (index 6) vs #10 (index 9) // Match 1: #7 (index 6) vs #10 (index 9)
// Match 2: #6 (index 5) vs #11 (index 10) // Match 2: #6 (index 5) vs #11 (index 10)
@ -541,7 +527,7 @@ async function generateNFL14Bracket(
// Match 4: #8 (index 7) vs #9 (index 8) // Match 4: #8 (index 7) vs #9 (index 8)
// Match 5: #4 (index 3) vs #13 (index 12) // Match 5: #4 (index 3) vs #13 (index 12)
// Match 6: #3 (index 2) vs #14 (index 13) // Match 6: #3 (index 2) vs #14 (index 13)
const correctedWildCardSeeding = [ const wildCardSeeding = [
[6, 9], // #7 vs #10 [6, 9], // #7 vs #10
[5, 10], // #6 vs #11 [5, 10], // #6 vs #11
[4, 11], // #5 vs #12 [4, 11], // #5 vs #12
@ -550,8 +536,8 @@ async function generateNFL14Bracket(
[2, 13], // #3 vs #14 [2, 13], // #3 vs #14
]; ];
for (let i = 0; i < correctedWildCardSeeding.length; i++) { for (let i = 0; i < wildCardSeeding.length; i++) {
const [seed1, seed2] = correctedWildCardSeeding[i]; const [seed1, seed2] = wildCardSeeding[i];
matches.push({ matches.push({
scoringEventId: eventId, scoringEventId: eventId,
round: "Wild Card", round: "Wild Card",
@ -572,7 +558,7 @@ async function generateNFL14Bracket(
if (divisionalRound) { if (divisionalRound) {
for (let i = 0; i < divisionalRound.matchCount; i++) { for (let i = 0; i < divisionalRound.matchCount; i++) {
let participant1Id: string | null = null; let participant1Id: string | null = null;
let participant2Id: string | null = null; const participant2Id: string | null = null;
let seedInfo: string | null = null; let seedInfo: string | null = null;
// Assign bye teams to first two Divisional matches // Assign bye teams to first two Divisional matches
@ -768,7 +754,6 @@ async function advanceAFLWinner(
winnerId: string, winnerId: string,
loserId: string loserId: string
): Promise<void> { ): Promise<void> {
const db = database();
const eventId = match.scoringEventId; const eventId = match.scoringEventId;
// Wildcard Round: Winner advances to Elimination Finals // Wildcard Round: Winner advances to Elimination Finals
@ -871,8 +856,6 @@ export async function advanceWinnerTemplate(
winnerId: string, winnerId: string,
template: BracketTemplate template: BracketTemplate
): Promise<void> { ): Promise<void> {
const db = database();
// Get the match // Get the match
const match = await findPlayoffMatchById(matchId); const match = await findPlayoffMatchById(matchId);
if (!match) throw new Error("Match not found"); if (!match) throw new Error("Match not found");

View file

@ -306,7 +306,7 @@ export async function updateFinalRankings(
const updates = []; const updates = [];
// Sort by points descending // Sort by points descending
const sortedGroups = Array.from(groupedByPoints.entries()).sort((a, b) => { const sortedGroups = Array.from(groupedByPoints.entries()).toSorted((a, b) => {
return parseFloat(b[0]) - parseFloat(a[0]); return parseFloat(b[0]) - parseFloat(a[0]);
}); });

View file

@ -46,9 +46,9 @@ export async function upsertRegularSeasonStandings(
losses: r.losses, losses: r.losses,
otLosses: r.otLosses ?? null, otLosses: r.otLosses ?? null,
ties: r.ties ?? null, ties: r.ties ?? null,
winPct: r.winPct != null ? r.winPct.toString() : null, winPct: r.winPct !== null && r.winPct !== undefined ? r.winPct.toString() : null,
gamesPlayed: r.gamesPlayed, gamesPlayed: r.gamesPlayed,
gamesBack: r.gamesBack != null ? r.gamesBack.toString() : null, gamesBack: r.gamesBack !== null && r.gamesBack !== undefined ? r.gamesBack.toString() : null,
conference: r.conference ?? null, conference: r.conference ?? null,
division: r.division ?? null, division: r.division ?? null,
conferenceRank: r.conferenceRank ?? null, conferenceRank: r.conferenceRank ?? null,
@ -133,7 +133,7 @@ export async function getRegularSeasonStandings(
}); });
// Sort: conference → division → divisionRank ?? leagueRank ?? leagueRank, nulls last // Sort: conference → division → divisionRank ?? leagueRank ?? leagueRank, nulls last
return rows.sort((a, b) => { return rows.toSorted((a, b) => {
const confA = a.conference ?? "ZZZ"; const confA = a.conference ?? "ZZZ";
const confB = b.conference ?? "ZZZ"; const confB = b.conference ?? "ZZZ";
if (confA !== confB) return confA.localeCompare(confB); if (confA !== confB) return confA.localeCompare(confB);

View file

@ -1,7 +1,7 @@
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq, and, inArray } from "drizzle-orm"; import { eq, and, inArray } from "drizzle-orm";
import { getScoringRules, calculateFantasyPoints, calculateAveragedPoints, calculateBracketPoints } from "./scoring-rules"; import { getScoringRules, calculateFantasyPoints, calculateBracketPoints } from "./scoring-rules";
import { getSeasonResults } from "./participant-season-result"; import { getSeasonResults } from "./participant-season-result";
import { updateProbabilitiesAfterResult } from "~/services/probability-updater"; import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
import { sendStandingsUpdateNotification, type ScoredMatch } from "~/services/discord"; import { sendStandingsUpdateNotification, type ScoredMatch } from "~/services/discord";
@ -597,7 +597,7 @@ export async function finalizeQualifyingPoints(
} }
// Sort groups by QP (descending) // Sort groups by QP (descending)
const sortedGroups = Array.from(groupedByQP.entries()).sort((a, b) => { const sortedGroups = Array.from(groupedByQP.entries()).toSorted((a, b) => {
return parseFloat(b[0]) - parseFloat(a[0]); return parseFloat(b[0]) - parseFloat(a[0]);
}); });
@ -722,7 +722,7 @@ export async function processSeasonStandings(
// Get sorted positions // Get sorted positions
const positions = Object.keys(positionGroups) const positions = Object.keys(positionGroups)
.map(Number) .map(Number)
.sort((a, b) => a - b); .toSorted((a, b) => a - b);
// Assign fantasy placements (1-8) based on standings // Assign fantasy placements (1-8) based on standings
let currentPlacement = 1; let currentPlacement = 1;
@ -874,7 +874,7 @@ export async function calculateTeamScore(
// If duplicates exist due to data corruption, the first row wins — acceptable trade-off. // If duplicates exist due to data corruption, the first row wins — acceptable trade-off.
const result = pick.participant.results[0]; const result = pick.participant.results[0];
if (result && result.finalPosition != null && result.finalPosition > 0) { if (result && result.finalPosition !== null && result.finalPosition > 0) {
const isBracket = pick.participant.sportsSeason?.scoringPattern === "playoff_bracket"; const isBracket = pick.participant.sportsSeason?.scoringPattern === "playoff_bracket";
let points: number; let points: number;
if (isBracket) { if (isBracket) {
@ -1100,7 +1100,7 @@ function assignRanks(
}> }>
): Map<string, number> { ): Map<string, number> {
// Sort teams by ranking criteria // Sort teams by ranking criteria
const sorted = [...teams].sort(compareTeamsForRanking); const sorted = [...teams].toSorted(compareTeamsForRanking);
const ranks = new Map<string, number>(); const ranks = new Map<string, number>();
let currentRank = 1; let currentRank = 1;
@ -1330,7 +1330,7 @@ export async function recalculateAffectedLeagues(
const usernameByClerkId = new Map( const usernameByClerkId = new Map(
teamOwnerUsers teamOwnerUsers
.map((u) => [u.clerkId, getUserDisplayName(u)]) .map((u) => [u.clerkId, getUserDisplayName(u)])
.filter((entry): entry is [string, string] => entry[1] != null) .filter((entry): entry is [string, string] => entry[1] !== null)
); );
// Build teamId → ownerId map from standings data // Build teamId → ownerId map from standings data

View file

@ -157,7 +157,7 @@ export async function updateScoringEvent(
) { ) {
const db = providedDb || database(); const db = providedDb || database();
const updateData: any = { updatedAt: new Date() }; const updateData: Partial<typeof schema.scoringEvents.$inferInsert> = { updatedAt: new Date() };
if (data.name !== undefined) updateData.name = data.name; if (data.name !== undefined) updateData.name = data.name;
if (data.eventDate !== undefined) { if (data.eventDate !== undefined) {
@ -450,6 +450,7 @@ export async function getUpcomingEventsForDraftedParticipants(
inArray(schema.playoffMatches.participant1Id, draftedIds), inArray(schema.playoffMatches.participant1Id, draftedIds),
// participant2Id is nullable UUID — same underlying type as participant1Id; // participant2Id is nullable UUID — same underlying type as participant1Id;
// the notNull difference only exists at the TypeScript layer // the notNull difference only exists at the TypeScript layer
// oxlint-disable-next-line typescript/no-explicit-any -- participant2Id is nullable UUID; notNull difference only exists at the TypeScript layer
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
inArray(schema.playoffMatches.participant2Id as any, draftedIds) inArray(schema.playoffMatches.participant2Id as any, draftedIds)
), ),
@ -503,7 +504,7 @@ export async function getUpcomingEventsForDraftedParticipants(
pMap.set(row.participant2Id, draftedMap.get(row.participant2Id)!); pMap.set(row.participant2Id, draftedMap.get(row.participant2Id)!);
} }
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}`);
} }
@ -578,7 +579,7 @@ export async function getEventsForDates(
// Build timestamp bounds covering the full span of the requested dates so we // Build timestamp bounds covering the full span of the requested dates so we
// can range-check the playoffMatchGames.scheduledAt timestamp column. // can range-check the playoffMatchGames.scheduledAt timestamp column.
const sortedDates = [...dates].sort(); const sortedDates = [...dates].toSorted();
const dateFromTimestamp = new Date(sortedDates[0] + "T00:00:00.000Z"); const dateFromTimestamp = new Date(sortedDates[0] + "T00:00:00.000Z");
const dateToTimestamp = new Date(sortedDates[sortedDates.length - 1] + "T23:59:59.999Z"); const dateToTimestamp = new Date(sortedDates[sortedDates.length - 1] + "T23:59:59.999Z");

View file

@ -1,6 +1,6 @@
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq, and, desc } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import type { TeamStanding, TeamStandingSnapshot, TeamStandingWithChange } from "~/types/standings"; import type { TeamStanding, TeamStandingSnapshot, TeamStandingWithChange } from "~/types/standings";
import { calculateBracketPoints, calculateFantasyPoints } from "~/models/scoring-rules"; import { calculateBracketPoints, calculateFantasyPoints } from "~/models/scoring-rules";
@ -24,7 +24,7 @@ export async function getSeasonStandings(
}); });
// Sort by currentRank ascending (1 is best, 2 is second, etc.) // Sort by currentRank ascending (1 is best, 2 is second, etc.)
const sorted = standings.sort((a, b) => a.currentRank - b.currentRank); const sorted = standings.toSorted((a, b) => a.currentRank - b.currentRank);
return sorted.map((standing) => ({ return sorted.map((standing) => ({
teamId: standing.teamId, teamId: standing.teamId,
@ -197,7 +197,7 @@ export async function getTeamScoreBreakdown(
); );
}; };
if (result && result.finalPosition != null && result.finalPosition > 0) { if (result && result.finalPosition !== null && result.finalPosition > 0) {
// Calculate points using bracket-averaged scoring for bracket sports // Calculate points using bracket-averaged scoring for bracket sports
if (isBracket) { if (isBracket) {
const templateId = await getBracketTemplate(pick.participant.sportsSeasonId); const templateId = await getBracketTemplate(pick.participant.sportsSeasonId);
@ -402,7 +402,7 @@ export async function getSeasonPointProgression(
}); });
// Organize data by date // Organize data by date
const dateMap = new Map<string, any>(); const dateMap = new Map<string, { date: string; [teamName: string]: string | number }>();
for (const snapshot of snapshots) { for (const snapshot of snapshots) {
const date = snapshot.snapshotDate; const date = snapshot.snapshotDate;
@ -411,12 +411,12 @@ export async function getSeasonPointProgression(
dateMap.set(date, { date }); dateMap.set(date, { date });
} }
const dateData = dateMap.get(date); const dateData = dateMap.get(date)!;
dateData[snapshot.team.name] = parseFloat(snapshot.totalPoints); dateData[snapshot.team.name] = parseFloat(snapshot.totalPoints);
} }
// Convert to array and sort by date // Convert to array and sort by date
const chartData = Array.from(dateMap.values()).sort((a, b) => const chartData = Array.from(dateMap.values()).toSorted((a, b) =>
new Date(a.date).getTime() - new Date(b.date).getTime() new Date(a.date).getTime() - new Date(b.date).getTime()
); );

View file

@ -1,4 +1,4 @@
import { eq, and, inArray } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";

View file

@ -1,5 +1,5 @@
import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket"; import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket";
import { redirect } from "react-router";
import { findSportsSeasonById } from "~/models/sports-season"; import { findSportsSeasonById } from "~/models/sports-season";
import { findParticipantsBySportsSeasonId } from "~/models/participant"; import { findParticipantsBySportsSeasonId } from "~/models/participant";
import { getScoringEventById, updateScoringEvent } from "~/models/scoring-event"; import { getScoringEventById, updateScoringEvent } from "~/models/scoring-event";
@ -42,7 +42,7 @@ import {
} from "~/models/tournament-group"; } from "~/models/tournament-group";
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq, and } from "drizzle-orm"; import { eq } from "drizzle-orm";
export async function loader({ params }: Route.LoaderArgs) { export async function loader({ params }: Route.LoaderArgs) {
const sportsSeason = await findSportsSeasonById(params.id); const sportsSeason = await findSportsSeasonById(params.id);
@ -79,6 +79,8 @@ export async function loader({ params }: Route.LoaderArgs) {
participant2: { id: string; name: string } | null; participant2: { id: string; name: string } | null;
winner: { id: string; name: string } | null; winner: { id: string; name: string } | null;
loser: { id: string; name: string } | null; loser: { id: string; name: string } | null;
games: Array<{ id: string; gameNumber: number; scheduledAt: Date | null; status: string; winner?: { name: string } | null }>;
odds: Array<{ id: string; participantId: string; moneylineOdds: number; impliedProbability: string; oddsSource?: string | null; participant: { id: string; name: string } | null }>;
}>, }>,
tournamentGroups, tournamentGroups,
}; };

View file

@ -66,7 +66,7 @@ export default function EventBracket({
const template = templates.find((t) => t.id === selectedTemplate); const template = templates.find((t) => t.id === selectedTemplate);
// Determine if this is a group-stage event // Determine if this is a group-stage event
const hasGroupStage = template?.groupStage != null; const hasGroupStage = template?.groupStage !== null;
// Determine if this template uses named regions (e.g. NCAA 68) // Determine if this template uses named regions (e.g. NCAA 68)
const hasRegions = (template?.regions?.length ?? 0) > 0; const hasRegions = (template?.regions?.length ?? 0) > 0;
@ -151,8 +151,8 @@ export default function EventBracket({
const knockoutPopulated = useMemo(() => { const knockoutPopulated = useMemo(() => {
if (!isGroupStageEvent) return false; if (!isGroupStageEvent) return false;
// Check if any Round of 32 match has participants assigned // Check if any Round of 32 match has participants assigned
const r32Matches = matches.filter((m: any) => m.round === "Round of 32"); const r32Matches = matches.filter((m) => m.round === "Round of 32");
return r32Matches.some((m: any) => m.participant1Id || m.participant2Id); return r32Matches.some((m) => m.participant1Id || m.participant2Id);
}, [isGroupStageEvent, matches]); }, [isGroupStageEvent, matches]);
// Group stage stats // Group stage stats
@ -161,7 +161,7 @@ export default function EventBracket({
let eliminated = 0; let eliminated = 0;
let total = 0; let total = 0;
for (const group of tournamentGroups) { for (const group of tournamentGroups) {
for (const member of (group as any).members || []) { for (const member of group.members || []) {
total++; total++;
if (member.eliminated) eliminated++; if (member.eliminated) eliminated++;
} }
@ -174,7 +174,7 @@ export default function EventBracket({
if (!isGroupStageEvent) return []; if (!isGroupStageEvent) return [];
const advancing: Array<{ id: string; name: string }> = []; const advancing: Array<{ id: string; name: string }> = [];
for (const group of tournamentGroups) { for (const group of tournamentGroups) {
for (const member of (group as any).members || []) { for (const member of group.members || []) {
if (!member.eliminated && member.participant) { if (!member.eliminated && member.participant) {
advancing.push({ advancing.push({
id: member.participant.id, id: member.participant.id,
@ -258,7 +258,7 @@ export default function EventBracket({
const getPendingWinnersInRound = (round: string) => { const getPendingWinnersInRound = (round: string) => {
return Object.entries(selectedWinners) return Object.entries(selectedWinners)
.filter(([matchId]) => { .filter(([matchId]) => {
const match = matches.find((m: any) => m.id === matchId); const match = matches.find((m) => m.id === matchId);
return match && match.round === round && !match.isComplete; return match && match.round === round && !match.isComplete;
}); });
}; };
@ -283,7 +283,7 @@ export default function EventBracket({
// Check if all matches are complete // Check if all matches are complete
const allMatchesComplete = useMemo(() => { const allMatchesComplete = useMemo(() => {
return matches.length > 0 && matches.every((m: any) => m.isComplete); return matches.length > 0 && matches.every((m) => m.isComplete);
}, [matches]); }, [matches]);
// No bracket and no groups yet = setup phase // No bracket and no groups yet = setup phase
@ -455,6 +455,7 @@ export default function EventBracket({
</p> </p>
</div> </div>
{regionConfig.map((region, r) => ( {regionConfig.map((region, r) => (
// eslint-disable-next-line react/no-array-index-key
<div key={r} className="flex flex-wrap items-center gap-2"> <div key={r} className="flex flex-wrap items-center gap-2">
<Input <Input
className="w-36 h-8 text-sm" className="w-36 h-8 text-sm"
@ -463,6 +464,7 @@ export default function EventBracket({
placeholder={`Region ${r + 1}`} placeholder={`Region ${r + 1}`}
/> />
{region.playInSeeds.map((seed, pi) => ( {region.playInSeeds.map((seed, pi) => (
// eslint-disable-next-line react/no-array-index-key
<div key={pi} className="flex items-center gap-1"> <div key={pi} className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">play-in seed:</span> <span className="text-xs text-muted-foreground">play-in seed:</span>
<Select <Select
@ -505,6 +507,7 @@ export default function EventBracket({
))} ))}
{/* Hidden fields so server receives the region config */} {/* Hidden fields so server receives the region config */}
{regionConfig.map((region, r) => ( {regionConfig.map((region, r) => (
// eslint-disable-next-line react/no-array-index-key
<Fragment key={r}> <Fragment key={r}>
<input type="hidden" name={`regionName${r}`} value={region.name} /> <input type="hidden" name={`regionName${r}`} value={region.name} />
<input type="hidden" name={`regionPlayInSeeds${r}`} value={region.playInSeeds.join(",")} /> <input type="hidden" name={`regionPlayInSeeds${r}`} value={region.playInSeeds.join(",")} />
@ -520,6 +523,7 @@ export default function EventBracket({
{effectiveRegions.map((region, r) => { {effectiveRegions.map((region, r) => {
const directOffset = regionSlotMap.directOffsets[r]; const directOffset = regionSlotMap.directOffsets[r];
return ( return (
// eslint-disable-next-line react/no-array-index-key
<div key={r} className="border rounded-lg p-4 space-y-2"> <div key={r} className="border rounded-lg p-4 space-y-2">
<h4 className="font-semibold text-sm">{region.name || `Region ${r + 1}`}</h4> <h4 className="font-semibold text-sm">{region.name || `Region ${r + 1}`}</h4>
{region.directSeeds.map((seedNum, pos) => { {region.directSeeds.map((seedNum, pos) => {
@ -573,7 +577,7 @@ export default function EventBracket({
const idx1 = pi.startIndex; const idx1 = pi.startIndex;
const idx2 = pi.startIndex + 1; const idx2 = pi.startIndex + 1;
return ( return (
<div key={ffIdx} className="space-y-2"> <div key={`${pi.regionIndex}-${pi.seedSlot}`} className="space-y-2">
<p className="text-xs font-medium text-muted-foreground"> <p className="text-xs font-medium text-muted-foreground">
Play-In {ffIdx + 1} {regionName} {pi.seedSlot}-seed Play-In {ffIdx + 1} {regionName} {pi.seedSlot}-seed
</p> </p>
@ -617,6 +621,7 @@ export default function EventBracket({
const availableParticipants = getAvailableParticipants(i); const availableParticipants = getAvailableParticipants(i);
return ( return (
// eslint-disable-next-line react/no-array-index-key
<div key={i} className="flex items-center gap-2"> <div key={i} className="flex items-center gap-2">
<Label className="w-20 text-sm text-muted-foreground shrink-0"> <Label className="w-20 text-sm text-muted-foreground shrink-0">
Seed {i + 1} Seed {i + 1}
@ -680,13 +685,13 @@ export default function EventBracket({
{/* Group Cards */} {/* Group Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{tournamentGroups.map((group: any) => ( {tournamentGroups.map((group) => (
<Card key={group.id}> <Card key={group.id}>
<CardHeader className="py-3 px-4"> <CardHeader className="py-3 px-4">
<CardTitle className="text-base">Group {group.groupName}</CardTitle> <CardTitle className="text-base">Group {group.groupName}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="px-4 pb-4 space-y-2"> <CardContent className="px-4 pb-4 space-y-2">
{(group.members || []).map((member: any) => ( {(group.members || []).map((member) => (
<div <div
key={member.id} key={member.id}
className={`flex items-center justify-between gap-2 p-2 rounded-md border ${ className={`flex items-center justify-between gap-2 p-2 rounded-md border ${
@ -829,7 +834,7 @@ export default function EventBracket({
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-8"></TableHead> <TableHead className="w-8" />
<TableHead className="w-16">Match</TableHead> <TableHead className="w-16">Match</TableHead>
<TableHead>Participant 1</TableHead> <TableHead>Participant 1</TableHead>
<TableHead>Score</TableHead> <TableHead>Score</TableHead>
@ -924,9 +929,9 @@ export default function EventBracket({
</div> </div>
{/* Existing games */} {/* Existing games */}
{(match as any).games?.length > 0 ? ( {match.games?.length > 0 ? (
<div className="space-y-2 mb-3"> <div className="space-y-2 mb-3">
{(match as any).games.map((game: any) => ( {match.games.map((game) => (
<div key={game.id} className="flex items-center gap-2 text-sm bg-background rounded p-2 border"> <div key={game.id} className="flex items-center gap-2 text-sm bg-background rounded p-2 border">
<span className="font-medium w-12 shrink-0">Game {game.gameNumber}</span> <span className="font-medium w-12 shrink-0">Game {game.gameNumber}</span>
<span className="text-muted-foreground flex-1"> <span className="text-muted-foreground flex-1">
@ -982,7 +987,7 @@ export default function EventBracket({
type="number" type="number"
min={1} min={1}
max={9} max={9}
defaultValue={((match as any).games?.length ?? 0) + 1} defaultValue={(match.games?.length ?? 0) + 1}
className="h-8 w-20" className="h-8 w-20"
required required
/> />
@ -1009,9 +1014,9 @@ export default function EventBracket({
<span className="text-sm font-semibold">Moneyline Odds</span> <span className="text-sm font-semibold">Moneyline Odds</span>
</div> </div>
{(match as any).odds?.length > 0 ? ( {match.odds?.length > 0 ? (
<div className="space-y-2 mb-3"> <div className="space-y-2 mb-3">
{(match as any).odds.map((odd: any) => ( {match.odds.map((odd) => (
<div key={odd.id} className="flex items-center gap-2 text-sm bg-background rounded p-2 border"> <div key={odd.id} className="flex items-center gap-2 text-sm bg-background rounded p-2 border">
<span className="flex-1 font-medium">{odd.participant?.name}</span> <span className="flex-1 font-medium">{odd.participant?.name}</span>
<span className={`font-mono ${odd.moneylineOdds > 0 ? "text-emerald-500" : "text-muted-foreground"}`}> <span className={`font-mono ${odd.moneylineOdds > 0 ? "text-emerald-500" : "text-muted-foreground"}`}>

View file

@ -52,7 +52,7 @@ function EditEventCard({ event }: { event: { name: string; eventDate?: string |
if (event.eventStartsAt) { if (event.eventStartsAt) {
setDisplayValue(format(new Date(event.eventStartsAt), "yyyy-MM-dd'T'HH:mm")); setDisplayValue(format(new Date(event.eventStartsAt), "yyyy-MM-dd'T'HH:mm"));
} }
}, []); }, [event.eventStartsAt]);
return ( return (
<Card> <Card>
@ -107,7 +107,7 @@ export default function EventResults({
); );
// Sort results by placement // Sort results by placement
const sortedResults = [...results].sort((a, b) => (a.placement || 999) - (b.placement || 999)); const sortedResults = [...results].toSorted((a, b) => (a.placement || 999) - (b.placement || 999));
// Create a map of season results for easy lookup // Create a map of season results for easy lookup
const seasonResultsMap = seasonResults const seasonResultsMap = seasonResults
@ -120,7 +120,7 @@ export default function EventResults({
if (event.eventType === "schedule_event") { if (event.eventType === "schedule_event") {
const isPast = event.eventStartsAt const isPast = event.eventStartsAt
? new Date(event.eventStartsAt) < new Date() ? new Date(event.eventStartsAt) < new Date()
: event.eventDate != null && String(event.eventDate).slice(0, 10) < new Date().toISOString().split("T")[0]; : event.eventDate !== null && event.eventDate !== undefined && String(event.eventDate).slice(0, 10) < new Date().toISOString().split("T")[0];
return ( return (
<div className="p-8"> <div className="p-8">
@ -695,12 +695,12 @@ export default function EventResults({
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{[...participantResults] {[...participantResults]
.sort((a: any, b: any) => { .toSorted((a: { finalPosition?: number | null }, b: { finalPosition?: number | null }) => {
const posA = a.finalPosition ?? 999; const posA = a.finalPosition ?? 999;
const posB = b.finalPosition ?? 999; const posB = b.finalPosition ?? 999;
return posA - posB; return posA - posB;
}) })
.map((result: any) => ( .map((result) => (
<TableRow key={result.id}> <TableRow key={result.id}>
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -730,7 +730,7 @@ export default function EventResults({
</span> </span>
</div> </div>
</TableCell> </TableCell>
<TableCell>{result.participant.name}</TableCell> <TableCell>{result.participant?.name}</TableCell>
<TableCell className="text-right font-semibold"> <TableCell className="text-right font-semibold">
{result.qualifyingPoints ? ( {result.qualifyingPoints ? (
<span className={result.qualifyingPoints === "0.00" ? "text-muted-foreground" : "text-emerald-400"}> <span className={result.qualifyingPoints === "0.00" ? "text-muted-foreground" : "text-emerald-400"}>

View file

@ -10,7 +10,7 @@ import {
} from "~/models/scoring-event"; } from "~/models/scoring-event";
import { getQPStandings } from "~/models/qualifying-points"; import { getQPStandings } from "~/models/qualifying-points";
import { finalizeQualifyingPoints } from "~/models/scoring-calculator"; import { finalizeQualifyingPoints } from "~/models/scoring-calculator";
import { getScoringRules } from "~/models/scoring-rules";
export async function loader({ params }: Route.LoaderArgs) { export async function loader({ params }: Route.LoaderArgs) {
const sportsSeason = await findSportsSeasonById(params.id); const sportsSeason = await findSportsSeasonById(params.id);
@ -23,7 +23,7 @@ export async function loader({ params }: Route.LoaderArgs) {
// For qualifying sports seasons, get QP standings // For qualifying sports seasons, get QP standings
let qpStandings = null; let qpStandings = null;
let scoringRules = null; const scoringRules = null;
if (sportsSeason.scoringPattern === "qualifying_points") { if (sportsSeason.scoringPattern === "qualifying_points") {
qpStandings = await getQPStandings(params.id); qpStandings = await getQPStandings(params.id);

View file

@ -71,7 +71,7 @@ export default function SportsSeasonEvents({
if (eventType === "schedule_event") { if (eventType === "schedule_event") {
const isPast = eventStartsAt const isPast = eventStartsAt
? new Date(eventStartsAt) < new Date() ? new Date(eventStartsAt) < new Date()
: eventDate != null && eventDate < new Date().toISOString().split("T")[0]; : eventDate !== null && eventDate !== undefined && eventDate < new Date().toISOString().split("T")[0];
return isPast ? ( return isPast ? (
<Badge variant="outline" className="text-muted-foreground">Results Pending</Badge> <Badge variant="outline" className="text-muted-foreground">Results Pending</Badge>
) : ( ) : (

View file

@ -64,7 +64,7 @@ export default function ExpectedValuesPage({ loaderData }: Route.ComponentProps)
// so the total EV should always equal the sum of scoring values (340). // so the total EV should always equal the sum of scoring values (340).
// 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].sort((a, b) => { const sorted = [...participants].toSorted((a, b) => {
const evA = existingEVs.has(a.id) ? evFromProbs(existingEVs.get(a.id)!) : 0; const evA = existingEVs.has(a.id) ? evFromProbs(existingEVs.get(a.id)!) : 0;
const evB = existingEVs.has(b.id) ? evFromProbs(existingEVs.get(b.id)!) : 0; const evB = existingEVs.has(b.id) ? evFromProbs(existingEVs.get(b.id)!) : 0;
return evB - evA; return evB - evA;

View file

@ -316,8 +316,8 @@ export default function AdminSportsSeasonFuturesOdds() {
Not matched ({parseResults.unmatched.length}) enter manually below Not matched ({parseResults.unmatched.length}) enter manually below
</div> </div>
<div className="rounded-md border border-amber-200 bg-amber-50 divide-y divide-amber-100 text-sm"> <div className="rounded-md border border-amber-200 bg-amber-50 divide-y divide-amber-100 text-sm">
{parseResults.unmatched.map((u, i) => ( {parseResults.unmatched.map((u) => (
<div key={`${u.inputName}-${i}`} className="flex justify-between px-3 py-1.5"> <div key={u.inputName} className="flex justify-between px-3 py-1.5">
<span>{u.inputName}</span> <span>{u.inputName}</span>
<span className="font-medium">{u.odds > 0 ? '+' : ''}{u.odds}</span> <span className="font-medium">{u.odds > 0 ? '+' : ''}{u.odds}</span>
</div> </div>

View file

@ -246,7 +246,7 @@ export default function ManageParticipants({ loaderData, actionData }: Route.Com
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Name</TableHead> <TableHead>Name</TableHead>
<TableHead className="w-[50px]"></TableHead> <TableHead className="w-[50px]" />
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>

View file

@ -43,8 +43,6 @@ export async function loader({ params }: Route.LoaderArgs) {
export async function action({ request, params }: Route.ActionArgs) { export async function action({ request, params }: Route.ActionArgs) {
const formData = await request.formData(); const formData = await request.formData();
const isNhl = formData.get("_isNhl") === "true";
// Parse updates from form fields: wins_<id>, losses_<id>, otLosses_<id>, conference_<id>, division_<id> // Parse updates from form fields: wins_<id>, losses_<id>, otLosses_<id>, conference_<id>, division_<id>
const byId = new Map< const byId = new Map<
string, string,
@ -87,7 +85,7 @@ export async function action({ request, params }: Route.ActionArgs) {
try { try {
for (const [participantId, data] of byId.entries()) { for (const [participantId, data] of byId.entries()) {
if (data.wins == null && data.losses == null) continue; if (data.wins === null && data.losses === null) continue;
const wins = data.wins ?? 0; const wins = data.wins ?? 0;
const losses = data.losses ?? 0; const losses = data.losses ?? 0;
const gamesPlayed = wins + losses + (data.otLosses ?? 0); const gamesPlayed = wins + losses + (data.otLosses ?? 0);
@ -116,7 +114,7 @@ export default function ManageRegularStandings({ loaderData, actionData }: Route
sportsSeason.sport?.name?.toLowerCase().includes("hockey"); sportsSeason.sport?.name?.toLowerCase().includes("hockey");
// Sort participants: those with records first (by wins desc), then unrecorded // Sort participants: those with records first (by wins desc), then unrecorded
const sorted = [...participants].sort((a, b) => { const sorted = [...participants].toSorted((a, b) => {
const recA = standingsMap[a.id]; const recA = standingsMap[a.id];
const recB = standingsMap[b.id]; const recB = standingsMap[b.id];
if (recA && recB) return (recB.wins ?? 0) - (recA.wins ?? 0); if (recA && recB) return (recB.wins ?? 0) - (recA.wins ?? 0);

View file

@ -83,7 +83,7 @@ export default function ManageStandings({ loaderData, actionData }: Route.Compon
const { sportsSeason, participants, resultsMap } = loaderData; const { sportsSeason, participants, resultsMap } = loaderData;
// Sort participants by current position (unranked at bottom) // Sort participants by current position (unranked at bottom)
const sorted = [...participants].sort((a, b) => { const sorted = [...participants].toSorted((a, b) => {
const posA = resultsMap[a.id]?.currentPosition ?? Infinity; const posA = resultsMap[a.id]?.currentPosition ?? Infinity;
const posB = resultsMap[b.id]?.currentPosition ?? Infinity; const posB = resultsMap[b.id]?.currentPosition ?? Infinity;
return posA - posB; return posA - posB;
@ -138,7 +138,7 @@ export default function ManageStandings({ loaderData, actionData }: Route.Compon
<span>Points</span> <span>Points</span>
</div> </div>
{sorted.map((participant, index) => { {sorted.map((participant) => {
const result = resultsMap[participant.id]; const result = resultsMap[participant.id];
return ( return (
<div <div

View file

@ -3,8 +3,7 @@ import { getAuth } from "@clerk/react-router/server";
import { isUserAdminByClerkId } from "~/models/user"; import { isUserAdminByClerkId } from "~/models/user";
import type { Route } from "./+types/admin.sports-seasons.$id"; import type { Route } from "./+types/admin.sports-seasons.$id";
import { findSportsSeasonById, updateSportsSeason, deleteSportsSeason } from "~/models/sports-season"; import { findSportsSeasonById, updateSportsSeason, deleteSportsSeason, type NewSportsSeason } from "~/models/sports-season";
import { findParticipantsBySportsSeasonId } from "~/models/participant";
import { processSeasonStandings, recalculateStandings } from "~/models/scoring-calculator"; import { processSeasonStandings, recalculateStandings } from "~/models/scoring-calculator";
import { createDailySnapshot } from "~/models/standings"; import { createDailySnapshot } from "~/models/standings";
import { database } from "~/database/context"; import { database } from "~/database/context";
@ -12,13 +11,12 @@ import { participantEvSnapshots, seasonSports } from "~/database/schema";
import { eq, desc } from "drizzle-orm"; import { eq, desc } from "drizzle-orm";
import { getSimulatorInfo, type SimulatorType } from "~/services/simulations/registry"; import { getSimulatorInfo, type SimulatorType } from "~/services/simulations/registry";
import { syncStandings } from "~/services/standings-sync/index"; import { syncStandings } from "~/services/standings-sync/index";
import { getLastSyncedAt } from "~/models/regular-season-standings";
import { import {
getPendingStandingsMappings, getPendingStandingsMappings,
deletePendingStandingsMapping, deletePendingStandingsMapping,
} from "~/models/pending-standings-mappings"; } from "~/models/pending-standings-mappings";
import { updateParticipant } from "~/models/participant"; import { findParticipantsBySportsSeasonId, updateParticipant } from "~/models/participant";
import { upsertRegularSeasonStandings } from "~/models/regular-season-standings"; import { getLastSyncedAt, upsertRegularSeasonStandings } from "~/models/regular-season-standings";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
@ -254,7 +252,7 @@ export async function action(args: Route.ActionArgs) {
} }
try { try {
const updateData: any = { const updateData: Partial<NewSportsSeason> = {
name: name.trim(), name: name.trim(),
year: yearNum, year: yearNum,
startDate: typeof startDate === "string" && startDate ? startDate : null, startDate: typeof startDate === "string" && startDate ? startDate : null,
@ -265,7 +263,7 @@ export async function action(args: Route.ActionArgs) {
}; };
if (scoringPattern && typeof scoringPattern === "string") { if (scoringPattern && typeof scoringPattern === "string") {
updateData.scoringPattern = scoringPattern; updateData.scoringPattern = scoringPattern as "playoff_bracket" | "season_standings" | "qualifying_points";
} }
if (totalMajors && typeof totalMajors === "string") { if (totalMajors && typeof totalMajors === "string") {

View file

@ -1,7 +1,7 @@
import { Form, Link, redirect } from "react-router"; import { Form, Link, redirect } from "react-router";
import type { Route } from "./+types/admin.sports-seasons.new"; import type { Route } from "./+types/admin.sports-seasons.new";
import { createSportsSeason } from "~/models/sports-season"; import { createSportsSeason, type NewSportsSeason } from "~/models/sports-season";
import { findAllSports } from "~/models/sport"; import { findAllSports } from "~/models/sport";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
@ -75,7 +75,7 @@ export async function action({ request }: Route.ActionArgs) {
} }
try { try {
const seasonData: any = { const seasonData: Partial<NewSportsSeason> = {
sportId, sportId,
name: name.trim(), name: name.trim(),
year: yearNum, year: yearNum,
@ -86,7 +86,7 @@ export async function action({ request }: Route.ActionArgs) {
}; };
if (scoringPattern && typeof scoringPattern === "string") { if (scoringPattern && typeof scoringPattern === "string") {
seasonData.scoringPattern = scoringPattern; seasonData.scoringPattern = scoringPattern as "playoff_bracket" | "season_standings" | "qualifying_points";
} }
if (totalMajors && typeof totalMajors === "string") { if (totalMajors && typeof totalMajors === "string") {
@ -96,7 +96,7 @@ export async function action({ request }: Route.ActionArgs) {
} }
} }
await createSportsSeason(seasonData); await createSportsSeason(seasonData as NewSportsSeason);
return redirect("/admin/sports-seasons"); return redirect("/admin/sports-seasons");
} catch (error) { } catch (error) {

View file

@ -1,5 +1,4 @@
import { useLoaderData, useActionData, Form } from "react-router"; import { Form } from "react-router";
import { redirect } from "react-router";
import type { Route } from "./+types/admin.standings-snapshots"; import type { Route } from "./+types/admin.standings-snapshots";
import { database } from "~/database/context"; import { database } from "~/database/context";

View file

@ -26,7 +26,7 @@ export async function action({ request }: Route.ActionArgs) {
// Create a new Svix instance with your webhook secret // Create a new Svix instance with your webhook secret
const wh = new Webhook(WEBHOOK_SECRET); const wh = new Webhook(WEBHOOK_SECRET);
let evt: any; let evt: { type: string; data: { id: string; email_addresses?: Array<{ email_address: string }>; username?: string; first_name?: string; last_name?: string; image_url?: string } };
// Verify the webhook // Verify the webhook
try { try {
@ -34,7 +34,7 @@ export async function action({ request }: Route.ActionArgs) {
"svix-id": svix_id, "svix-id": svix_id,
"svix-timestamp": svix_timestamp, "svix-timestamp": svix_timestamp,
"svix-signature": svix_signature, "svix-signature": svix_signature,
}); }) as typeof evt;
} catch (err) { } catch (err) {
console.error("Error verifying webhook:", err); console.error("Error verifying webhook:", err);
return new Response("Error: Verification failed", { status: 400 }); return new Response("Error: Verification failed", { status: 400 });
@ -53,7 +53,7 @@ export async function action({ request }: Route.ActionArgs) {
const user = await findOrCreateUser({ const user = await findOrCreateUser({
id, id,
emailAddresses: emailAddresses:
email_addresses?.map((e: any) => ({ email_addresses?.map((e: { email_address: string }) => ({
emailAddress: e.email_address, emailAddress: e.email_address,
})) || [], })) || [],
username, username,
@ -69,7 +69,7 @@ export async function action({ request }: Route.ActionArgs) {
const user = await findOrCreateUser({ const user = await findOrCreateUser({
id, id,
emailAddresses: emailAddresses:
email_addresses?.map((e: any) => ({ email_addresses?.map((e: { email_address: string }) => ({
emailAddress: e.email_address, emailAddress: e.email_address,
})) || [], })) || [],
username, username,

View file

@ -103,7 +103,7 @@ export async function loader(args: Route.LoaderArgs) {
const upcomingCalendarEvents = leaguesWithData const upcomingCalendarEvents = leaguesWithData
.flatMap((l) => l.calendarEvents) .flatMap((l) => l.calendarEvents)
.sort((a, b) => toEventSortKey(a).localeCompare(toEventSortKey(b))); .toSorted((a, b) => toEventSortKey(a).localeCompare(toEventSortKey(b)));
return { return {
leagues: leaguesWithData, leagues: leaguesWithData,

View file

@ -127,9 +127,11 @@ export default function DraftBoard() {
useEffect(() => { useEffect(() => {
if (season.status !== "draft") return; if (season.status !== "draft") return;
const handlePickMade = (data: any) => { type PickShape = (typeof initialPicks)[number];
setPicks((prev: any) => [...prev, data.pick]); const handlePickMade = (data: unknown) => {
setCurrentPick(data.nextPickNumber); const pickData = data as { pick: PickShape; nextPickNumber: number };
setPicks((prev) => [...prev, pickData.pick]);
setCurrentPick(pickData.nextPickNumber);
}; };
on("pick-made", handlePickMade); on("pick-made", handlePickMade);
@ -142,13 +144,14 @@ export default function DraftBoard() {
// Generate draft grid // Generate draft grid
const totalTeams = draftSlots.length; const totalTeams = draftSlots.length;
const totalRounds = season.draftRounds || 1; const totalRounds = season.draftRounds || 1;
const draftGrid: any[][] = []; type PickItem = (typeof initialPicks)[number];
const draftGrid: Array<Array<PickItem | null>> = [];
for (let round = 0; round < totalRounds; round++) { for (let round = 0; round < totalRounds; round++) {
const roundPicks: any[] = []; const roundPicks: Array<PickItem | null> = [];
for (let teamIndex = 0; teamIndex < totalTeams; teamIndex++) { for (let teamIndex = 0; teamIndex < totalTeams; teamIndex++) {
const pickNumber = round * totalTeams + teamIndex + 1; const pickNumber = round * totalTeams + teamIndex + 1;
const pick = picks.find((p: any) => p.pickNumber === pickNumber); const pick = picks.find((p) => p.pickNumber === pickNumber);
roundPicks.push(pick || null); roundPicks.push(pick || null);
} }
draftGrid.push(roundPicks); draftGrid.push(roundPicks);

View file

@ -5,8 +5,6 @@ import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { useDraftSocket } from "~/hooks/useDraftSocket"; import { useDraftSocket } from "~/hooks/useDraftSocket";
import { useCallback, useEffect, useState, useMemo, useRef } from "react"; import { useCallback, useEffect, useState, useMemo, useRef } from "react";
import { Card } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "~/components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "~/components/ui/dialog";
@ -81,7 +79,7 @@ export async function loader(args: Route.LoaderArgs) {
// Resolve user's team and commissioner status (null userId means neither) // Resolve user's team and commissioner status (null userId means neither)
const userTeam = userId const userTeam = userId
? season.teams.find((team: any) => team.ownerId === userId) ? season.teams.find((team) => team.ownerId === userId)
: undefined; : undefined;
const isCommissioner = userId const isCommissioner = userId
@ -221,7 +219,6 @@ export async function loader(args: Route.LoaderArgs) {
userAutodraftSettings, userAutodraftSettings,
isCommissioner: !!isCommissioner, isCommissioner: !!isCommissioner,
currentUserId: userId, currentUserId: userId,
numFlexPicks: Math.max(0, season.draftRounds - seasonSports.length),
ownerMap, ownerMap,
}; };
} }
@ -239,7 +236,6 @@ export default function DraftRoom() {
userAutodraftSettings, userAutodraftSettings,
isCommissioner, isCommissioner,
currentUserId, currentUserId,
numFlexPicks,
ownerMap, ownerMap,
} = useLoaderData<typeof loader>(); } = useLoaderData<typeof loader>();
const { revalidate, state: revalidatorState } = useRevalidator(); const { revalidate, state: revalidatorState } = useRevalidator();
@ -431,7 +427,7 @@ export default function DraftRoom() {
// (deduplicated by ID) after the DB snapshot lands. // (deduplicated by ID) after the DB snapshot lands.
const revalidatorStateRef = useRef(revalidatorState); const revalidatorStateRef = useRef(revalidatorState);
const isRevalidatingRef = useRef(false); const isRevalidatingRef = useRef(false);
const pendingPicksDuringRevalidationRef = useRef<any[]>([]); const pendingPicksDuringRevalidationRef = useRef<(typeof draftPicks)[number][]>([]);
// Track the draftPicks reference when revalidation starts. If it hasn't // Track the draftPicks reference when revalidation starts. If it hasn't
// changed by the time revalidation completes, the loader didn't return new // changed by the time revalidation completes, the loader didn't return new
// data (e.g. network failure) and we should NOT overwrite state that may // data (e.g. network failure) and we should NOT overwrite state that may
@ -471,9 +467,9 @@ export default function DraftRoom() {
return; return;
} }
const dbPickIds = new Set(draftPicks.map((p: any) => p.id)); const dbPickIds = new Set(draftPicks.map((p) => p.id));
const missedPicks = pendingPicksDuringRevalidationRef.current.filter( const missedPicks = pendingPicksDuringRevalidationRef.current.filter(
(p: any) => !dbPickIds.has(p.id) (p) => !dbPickIds.has(p.id)
); );
pendingPicksDuringRevalidationRef.current = []; pendingPicksDuringRevalidationRef.current = [];
@ -500,7 +496,7 @@ export default function DraftRoom() {
if (timers.length > 0) { if (timers.length > 0) {
setTeamTimers((prev) => { setTeamTimers((prev) => {
const updated = { ...prev }; const updated = { ...prev };
timers.forEach((timer: any) => { timers.forEach((timer) => {
updated[timer.teamId] = timer.timeRemaining; updated[timer.teamId] = timer.timeRemaining;
}); });
return updated; return updated;
@ -510,7 +506,7 @@ export default function DraftRoom() {
// we were away. // we were away.
setAutodraftStatus(() => { setAutodraftStatus(() => {
const status: Record<string, AutodraftStatusEntry> = {}; const status: Record<string, AutodraftStatusEntry> = {};
autodraftSettings.forEach((setting: any) => { autodraftSettings.forEach((setting) => {
status[setting.teamId] = { status[setting.teamId] = {
isEnabled: setting.isEnabled, isEnabled: setting.isEnabled,
mode: setting.mode, mode: setting.mode,
@ -551,7 +547,7 @@ export default function DraftRoom() {
// Track autodraft status for all teams // Track autodraft status for all teams
const [autodraftStatus, setAutodraftStatus] = useState<Record<string, AutodraftStatusEntry>>(() => { const [autodraftStatus, setAutodraftStatus] = useState<Record<string, AutodraftStatusEntry>>(() => {
const status: Record<string, AutodraftStatusEntry> = {}; const status: Record<string, AutodraftStatusEntry> = {};
autodraftSettings.forEach((setting: any) => { autodraftSettings.forEach((setting) => {
status[setting.teamId] = { status[setting.teamId] = {
isEnabled: setting.isEnabled, isEnabled: setting.isEnabled,
mode: setting.mode, mode: setting.mode,
@ -571,7 +567,7 @@ export default function DraftRoom() {
if (timers.length > 0) { if (timers.length > 0) {
// Draft has started, use actual timers // Draft has started, use actual timers
timers.forEach((timer: any) => { timers.forEach((timer) => {
timersMap[timer.teamId] = timer.timeRemaining; timersMap[timer.teamId] = timer.timeRemaining;
}); });
} else { } else {
@ -614,7 +610,7 @@ export default function DraftRoom() {
// Shared transforms for eligibility calculations // Shared transforms for eligibility calculations
const transformedPicks = useMemo( const transformedPicks = useMemo(
() => () =>
picks.map((p: any) => ({ picks.map((p) => ({
teamId: p.team.id, teamId: p.team.id,
participant: { participant: {
id: p.participant.id, id: p.participant.id,
@ -626,7 +622,7 @@ export default function DraftRoom() {
const transformedParticipants = useMemo( const transformedParticipants = useMemo(
() => () =>
availableParticipants.map((p: any) => ({ availableParticipants.map((p) => ({
id: p.id, id: p.id,
sport: { id: p.sport.id, name: p.sport.name }, sport: { id: p.sport.id, name: p.sport.name },
})), })),
@ -640,7 +636,7 @@ export default function DraftRoom() {
sportsMap.set(p.sport.id, { id: p.sport.id, name: p.sport.name }); sportsMap.set(p.sport.id, { id: p.sport.id, name: p.sport.name });
} }
}); });
return Array.from(sportsMap.values()).sort((a, b) => return Array.from(sportsMap.values()).toSorted((a, b) =>
a.name.localeCompare(b.name) a.name.localeCompare(b.name)
); );
}, [availableParticipants]); }, [availableParticipants]);
@ -649,7 +645,7 @@ export default function DraftRoom() {
const eligibility = useMemo(() => { const eligibility = useMemo(() => {
if (!userTeam) return null; if (!userTeam) return null;
const userTeamPicks = transformedPicks.filter( const userTeamPicks = transformedPicks.filter(
(p: any) => p.teamId === userTeam.id (p) => p.teamId === userTeam.id
); );
return calculateDraftEligibility( return calculateDraftEligibility(
userTeam.id, userTeam.id,
@ -666,7 +662,7 @@ export default function DraftRoom() {
const forcePickEligibility = useMemo(() => { const forcePickEligibility = useMemo(() => {
if (!selectedPickSlot) return null; if (!selectedPickSlot) return null;
const selectedTeamPicks = transformedPicks.filter( const selectedTeamPicks = transformedPicks.filter(
(p: any) => p.teamId === selectedPickSlot.teamId (p) => p.teamId === selectedPickSlot.teamId
); );
return calculateDraftEligibility( return calculateDraftEligibility(
selectedPickSlot.teamId, selectedPickSlot.teamId,
@ -683,10 +679,10 @@ export default function DraftRoom() {
const replacePickEligibility = useMemo(() => { const replacePickEligibility = useMemo(() => {
if (!replacePickSlot) return null; if (!replacePickSlot) return null;
const allPicksExcluding = transformedPicks.filter( const allPicksExcluding = transformedPicks.filter(
(p: any) => p.participant.id !== replacePickSlot.oldParticipantId (p) => p.participant.id !== replacePickSlot.oldParticipantId
); );
const selectedTeamPicks = allPicksExcluding.filter( const selectedTeamPicks = allPicksExcluding.filter(
(p: any) => p.teamId === replacePickSlot.teamId (p) => p.teamId === replacePickSlot.teamId
); );
return calculateDraftEligibility( return calculateDraftEligibility(
replacePickSlot.teamId, replacePickSlot.teamId,
@ -701,7 +697,8 @@ export default function DraftRoom() {
// Listen for new picks from other users // Listen for new picks from other users
useEffect(() => { useEffect(() => {
const handlePickMade = (data: any) => { type PickMadePayload = { pick: (typeof draftPicks)[number] & { team?: { id?: string; name?: string }; participant?: { name?: string } }; nextPickNumber: number; isDraftComplete?: boolean };
const handlePickMade = (data: PickMadePayload) => {
if (isRevalidatingRef.current) { if (isRevalidatingRef.current) {
// Buffer this pick; the revalidation-complete handler will merge it // Buffer this pick; the revalidation-complete handler will merge it
// into the DB snapshot so it isn't silently dropped. // into the DB snapshot so it isn't silently dropped.
@ -710,7 +707,7 @@ export default function DraftRoom() {
// pick counter and the picks list in sync with each other. // pick counter and the picks list in sync with each other.
pendingPicksDuringRevalidationRef.current.push(data.pick); pendingPicksDuringRevalidationRef.current.push(data.pick);
} else { } else {
setPicks((prev: any) => [...prev, data.pick]); setPicks((prev) => [...prev, data.pick]);
setCurrentPick(data.nextPickNumber); setCurrentPick(data.nextPickNumber);
} }
@ -739,7 +736,7 @@ export default function DraftRoom() {
} }
}; };
const handleTimerUpdate = (data: any) => { const handleTimerUpdate = (data: { teamId: string; timeRemaining: number; currentPickNumber: number | null }) => {
// Bail out without creating a new object reference if nothing changed. // Bail out without creating a new object reference if nothing changed.
// Without this guard the spread `{ ...prev }` always returns a new object, // Without this guard the spread `{ ...prev }` always returns a new object,
// causing a full re-render of the DraftRoom tree on every 1-second tick. // causing a full re-render of the DraftRoom tree on every 1-second tick.
@ -753,7 +750,7 @@ export default function DraftRoom() {
// if the HTTP revalidation hasn't completed yet. // if the HTTP revalidation hasn't completed yet.
// React's useState already bails out when the new value equals the old // React's useState already bails out when the new value equals the old
// one (Object.is comparison on primitives), so no manual guard needed. // one (Object.is comparison on primitives), so no manual guard needed.
if (data.currentPickNumber != null) { if (data.currentPickNumber !== null) {
setCurrentPick(data.currentPickNumber); setCurrentPick(data.currentPickNumber);
} }
}; };
@ -823,35 +820,35 @@ export default function DraftRoom() {
}; };
const handleParticipantRemovedFromQueues = (data: { participantId: string }) => { const handleParticipantRemovedFromQueues = (data: { participantId: string }) => {
setQueue((prev: any) => setQueue((prev) =>
prev.filter((item: any) => item.participantId !== data.participantId) prev.filter((item) => item.participantId !== data.participantId)
); );
}; };
const handleQueueEligibilityPruned = (data: { teamId: string; removedParticipantIds: string[] }) => { const handleQueueEligibilityPruned = (data: { teamId: string; removedParticipantIds: string[] }) => {
if (data.teamId !== userTeam?.id) return; if (data.teamId !== userTeam?.id) return;
const removed = new Set(data.removedParticipantIds); const removed = new Set(data.removedParticipantIds);
setQueue((prev: any) => prev.filter((item: any) => !removed.has(item.participantId))); setQueue((prev) => prev.filter((item) => !removed.has(item.participantId)));
}; };
// Fired when another window/session changes the user's queue (add/remove/reorder/clear). // Fired when another window/session changes the user's queue (add/remove/reorder/clear).
// Only received by this team's own sockets (emitted to team-${teamId} private room). // Only received by this team's own sockets (emitted to team-${teamId} private room).
// Ignored while this window has in-flight mutations to prevent the server echo // Ignored while this window has in-flight mutations to prevent the server echo
// from clobbering a more-recent optimistic update. // from clobbering a more-recent optimistic update.
const handleQueueUpdated = (data: { queue: any[] }) => { const handleQueueUpdated = (data: { queue: QueueItem[] }) => {
if (pendingQueueMutationsRef.current > 0) return; if (pendingQueueMutationsRef.current > 0) return;
setQueue(data.queue); setQueue(data.queue);
}; };
const handlePickReplaced = (data: any) => { const handlePickReplaced = (data: { pickNumber: number; pick: (typeof draftPicks)[number] }) => {
setPicks((prev: any) => setPicks((prev) =>
prev.map((p: any) => (p.pickNumber === data.pickNumber ? data.pick : p)) prev.map((p) => (p.pickNumber === data.pickNumber ? data.pick : p))
); );
}; };
const handleDraftRolledBack = (data: any) => { const handleDraftRolledBack = (data: { pickNumber: number }) => {
setPicks((prev: any) => setPicks((prev) =>
prev.filter((p: any) => p.pickNumber < data.pickNumber) prev.filter((p) => p.pickNumber < data.pickNumber)
); );
setCurrentPick(data.pickNumber); setCurrentPick(data.pickNumber);
setIsDraftComplete(false); setIsDraftComplete(false);
@ -861,7 +858,7 @@ export default function DraftRoom() {
// Full state sync sent by the server on join-draft. Provides an immediate, // Full state sync sent by the server on join-draft. Provides an immediate,
// socket-based path to bring the client up to date — critical for mobile // socket-based path to bring the client up to date — critical for mobile
// reconnection where the HTTP revalidation may be delayed or fail entirely. // reconnection where the HTTP revalidation may be delayed or fail entirely.
const handleDraftStateSync = (data: any) => { const handleDraftStateSync = (data: { picks: (typeof draftPicks)[number][]; currentPickNumber: number; isPaused: boolean; status: string; timers?: Array<{ teamId: string; timeRemaining: number }>; queue?: QueueItem[] }) => {
// If a revalidation is in-flight, let it complete and handle the merge. // If a revalidation is in-flight, let it complete and handle the merge.
// The revalidation path already buffers picks and deduplicates — layering // The revalidation path already buffers picks and deduplicates — layering
// this on top would risk conflicts. But if NOT revalidating, apply the // this on top would risk conflicts. But if NOT revalidating, apply the
@ -876,7 +873,7 @@ export default function DraftRoom() {
if (data.timers) { if (data.timers) {
setTeamTimers((prev) => { setTeamTimers((prev) => {
const updated = { ...prev }; const updated = { ...prev };
data.timers.forEach((t: any) => { data.timers?.forEach((t) => {
updated[t.teamId] = t.timeRemaining; updated[t.teamId] = t.timeRemaining;
}); });
return updated; return updated;
@ -989,7 +986,7 @@ export default function DraftRoom() {
const error = await response.json(); const error = await response.json();
toast.error(error.error || "Failed to add to queue"); toast.error(error.error || "Failed to add to queue");
} }
} catch (error) { } catch {
// Revert the optimistic update on network error // Revert the optimistic update on network error
setQueue((prev) => prev.filter((item) => item.id !== tempQueueItem.id)); setQueue((prev) => prev.filter((item) => item.id !== tempQueueItem.id));
toast.error("Network error - failed to add to queue"); toast.error("Network error - failed to add to queue");
@ -1218,7 +1215,7 @@ export default function DraftRoom() {
}; };
const handleReplacePickOpen = useCallback((pickNumber: number, teamId: string) => { const handleReplacePickOpen = useCallback((pickNumber: number, teamId: string) => {
const pick = picks.find((p: any) => p.pickNumber === pickNumber); const pick = picks.find((p) => p.pickNumber === pickNumber);
if (!pick) { if (!pick) {
toast.error("Pick not found — try refreshing the page"); toast.error("Pick not found — try refreshing the page");
return; return;
@ -1391,7 +1388,7 @@ export default function DraftRoom() {
round: number; round: number;
pickInRound: number; pickInRound: number;
teamId: string; teamId: string;
pick?: any; pick?: (typeof draftPicks)[number];
}> }>
> = []; > = [];
@ -1405,7 +1402,7 @@ export default function DraftRoom() {
const pickNumber = (round - 1) * teamCount + pickInRound; const pickNumber = (round - 1) * teamCount + pickInRound;
const teamId = draftSlots[teamIndex]?.team.id; const teamId = draftSlots[teamIndex]?.team.id;
const pick = picks.find((p: any) => p.pickNumber === pickNumber); const pick = picks.find((p) => p.pickNumber === pickNumber);
roundPicks.push({ pickNumber, round, pickInRound, teamId, pick }); roundPicks.push({ pickNumber, round, pickInRound, teamId, pick });
} }
@ -1417,7 +1414,7 @@ export default function DraftRoom() {
// Get drafted participant IDs for filtering // Get drafted participant IDs for filtering
const draftedParticipantIds = useMemo( const draftedParticipantIds = useMemo(
() => new Set(picks.map((p: any) => p.participant.id)), () => new Set(picks.map((p) => p.participant.id)),
[picks] [picks]
); );
@ -1426,8 +1423,8 @@ export default function DraftRoom() {
if (!userTeam) return new Set<string>(); if (!userTeam) return new Set<string>();
return new Set( return new Set(
picks picks
.filter((p: any) => p.team.id === userTeam.id) .filter((p) => p.team.id === userTeam.id)
.map((p: any) => p.sport.id) .map((p) => p.sport.id)
); );
}, [picks, userTeam]); }, [picks, userTeam]);
@ -1436,8 +1433,8 @@ export default function DraftRoom() {
if (!userTeam) return new Set<string>(); if (!userTeam) return new Set<string>();
return new Set( return new Set(
picks picks
.filter((p: any) => p.team.id === userTeam.id) .filter((p) => p.team.id === userTeam.id)
.map((p: any) => p.sport.name) .map((p) => p.sport.name)
); );
}, [picks, userTeam]); }, [picks, userTeam]);
@ -1445,7 +1442,7 @@ export default function DraftRoom() {
const uniqueSports = useMemo( const uniqueSports = useMemo(
() => () =>
Array.from( Array.from(
new Set(availableParticipants.map((p: any) => p.sport.name)) new Set(availableParticipants.map((p) => p.sport.name))
).sort(), ).sort(),
[availableParticipants] [availableParticipants]
); );
@ -1453,7 +1450,7 @@ export default function DraftRoom() {
// Filter participants based on search, sport, drafted status, and eligibility // Filter participants based on search, sport, drafted status, and eligibility
const filteredParticipants = useMemo(() => { const filteredParticipants = useMemo(() => {
const sportFilterSet = new Set(sportFilters); const sportFilterSet = new Set(sportFilters);
return availableParticipants.filter((participant: any) => { return availableParticipants.filter((participant) => {
// Drafted filter - hide drafted participants by default // Drafted filter - hide drafted participants by default
if (hideDrafted && draftedParticipantIds.has(participant.id)) { if (hideDrafted && draftedParticipantIds.has(participant.id)) {
return false; return false;
@ -1621,9 +1618,9 @@ export default function DraftRoom() {
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
> >
<line x1="3" y1="12" x2="21" y2="12"></line> <line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="6" x2="21" y2="6"></line> <line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="18" x2="21" y2="18"></line> <line x1="3" y1="18" x2="21" y2="18" />
</svg> </svg>
</Button> </Button>
)} )}
@ -1962,7 +1959,7 @@ export default function DraftRoom() {
<DialogTitle>Roll Back Draft</DialogTitle> <DialogTitle>Roll Back Draft</DialogTitle>
<DialogDescription> <DialogDescription>
{rollbackPickNumber !== null && (() => { {rollbackPickNumber !== null && (() => {
const count = picks.filter((p: any) => p.pickNumber >= rollbackPickNumber).length; const count = picks.filter((p) => p.pickNumber >= rollbackPickNumber).length;
return `This will erase ${count} pick${count === 1 ? "" : "s"} (pick #${rollbackPickNumber} through the most recent) and return the draft to pick #${rollbackPickNumber}. This cannot be undone.`; return `This will erase ${count} pick${count === 1 ? "" : "s"} (pick #${rollbackPickNumber} through the most recent) and return the draft to pick #${rollbackPickNumber}. This cannot be undone.`;
})()} })()}
</DialogDescription> </DialogDescription>

View file

@ -85,14 +85,14 @@ export async function loader(args: Route.LoaderArgs) {
const ownerMap = new Map( const ownerMap = new Map(
ownerIds ownerIds
.map((id) => [id, userByClerkId.get(id)] as const) .map((id) => [id, userByClerkId.get(id)] as const)
.filter((entry): entry is [string, NonNullable<typeof entry[1]>] => entry[1] != null) .filter((entry): entry is [string, NonNullable<typeof entry[1]>] => entry[1] !== undefined)
.map(([id, user]) => [id, getUserDisplayName(user)]) .map(([id, user]) => [id, getUserDisplayName(user)])
); );
const commissionerMap = new Map( const commissionerMap = new Map(
commissionerIds commissionerIds
.map((id) => [id, userByClerkId.get(id)] as const) .map((id) => [id, userByClerkId.get(id)] as const)
.filter((entry): entry is [string, NonNullable<typeof entry[1]>] => entry[1] != null) .filter((entry): entry is [string, NonNullable<typeof entry[1]>] => entry[1] !== undefined)
.map(([id, user]) => [id, getUserDisplayName(user)]) .map(([id, user]) => [id, getUserDisplayName(user)])
); );
@ -149,7 +149,7 @@ export async function loader(args: Route.LoaderArgs) {
sportsSeasonPageUrl: `/leagues/${leagueId}/sports-seasons/${ss.id}`, sportsSeasonPageUrl: `/leagues/${leagueId}/sports-seasons/${ss.id}`,
})) }))
) )
.sort((a, b) => toEventSortKey(a).localeCompare(toEventSortKey(b))); .toSorted((a, b) => toEventSortKey(a).localeCompare(toEventSortKey(b)));
// Check if draft order is set // Check if draft order is set
const isDraftOrderSet = draftSlots.length > 0; const isDraftOrderSet = draftSlots.length > 0;

View file

@ -13,7 +13,7 @@ import {
countCommissionersByLeagueId, countCommissionersByLeagueId,
removeCommissionerByLeagueAndUser, removeCommissionerByLeagueAndUser,
} from "~/models/commissioner"; } from "~/models/commissioner";
import { findCurrentSeasonWithSports, updateSeason } from "~/models/season"; import { findCurrentSeasonWithSports, updateSeason, type NewSeason } from "~/models/season";
import { findTeamsBySeasonId, deleteTeam, removeTeamOwner, claimTeam } from "~/models/team"; import { findTeamsBySeasonId, deleteTeam, removeTeamOwner, claimTeam } from "~/models/team";
import { findAllUsers, findUserByClerkId, findUsersByClerkIds, getUserDisplayName, isUserAdminByClerkId } from "~/models/user"; import { findAllUsers, findUserByClerkId, findUsersByClerkIds, getUserDisplayName, isUserAdminByClerkId } from "~/models/user";
import { unlinkSportFromSeason, linkMultipleSportsToSeason } from "~/models/season-sport"; import { unlinkSportFromSeason, linkMultipleSportsToSeason } from "~/models/season-sport";
@ -281,7 +281,7 @@ export async function action(args: Route.ActionArgs) {
const selectedSports = formData.getAll("sportsSeasons"); const selectedSports = formData.getAll("sportsSeasons");
const currentSportIds = new Set( const currentSportIds = new Set(
season.seasonSports?.map((s: any) => s.sportsSeason.id) || [] season.seasonSports?.map((s) => s.sportsSeason.id) || []
); );
const newSportIds = new Set(selectedSports as string[]); const newSportIds = new Set(selectedSports as string[]);
@ -465,7 +465,7 @@ export async function action(args: Route.ActionArgs) {
// Now handle season-specific updates. // Now handle season-specific updates.
try { try {
// Update season settings // Update season settings
const seasonUpdates: any = {}; const seasonUpdates: Partial<NewSeason> = {};
// Handle draft rounds // Handle draft rounds
if (typeof draftRounds === "string") { if (typeof draftRounds === "string") {
@ -514,7 +514,7 @@ export async function action(args: Route.ActionArgs) {
if (points < 0 || points > 1000) { if (points < 0 || points > 1000) {
return { error: `${field} must be between 0 and 1000 points` }; return { error: `${field} must be between 0 and 1000 points` };
} }
seasonUpdates[field] = points; (seasonUpdates as Record<string, unknown>)[field] = points;
} }
} }
} }
@ -652,7 +652,7 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [timerMode, setTimerMode] = useState<"chess_clock" | "standard">(season?.draftTimerMode ?? "chess_clock"); const [timerMode, setTimerMode] = useState<"chess_clock" | "standard">(season?.draftTimerMode ?? "chess_clock");
const [selectedSports, setSelectedSports] = useState<Set<string>>( const [selectedSports, setSelectedSports] = useState<Set<string>>(
new Set(season?.seasonSports?.map((s: any) => s.sportsSeason.id) || []) new Set(season?.seasonSports?.map((s) => s.sportsSeason.id) || [])
); );
const [draftOrderTeams, setDraftOrderTeams] = useState<string[]>( const [draftOrderTeams, setDraftOrderTeams] = useState<string[]>(
draftSlots.length > 0 draftSlots.length > 0

View file

@ -18,6 +18,7 @@ import {
import { getRegularSeasonStandings } from "~/models/regular-season-standings"; import { getRegularSeasonStandings } from "~/models/regular-season-standings";
import { getAllParticipantEVsForSeason } from "~/models/participant-expected-value"; import { getAllParticipantEVsForSeason } from "~/models/participant-expected-value";
import { getBracketTemplate, getOrderedRoundsFromMatches } from "~/lib/bracket-templates"; import { getBracketTemplate, getOrderedRoundsFromMatches } from "~/lib/bracket-templates";
import type { PlayoffMatch } from "~/models/playoff-match";
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq, and, inArray } from "drizzle-orm"; import { eq, and, inArray } from "drizzle-orm";
@ -136,13 +137,20 @@ export async function loader(args: Route.LoaderArgs) {
type SeasonStanding = { id: string; position: number; championshipPoints: string; participant: { id: string; name: string } }; type SeasonStanding = { id: string; position: number; championshipPoints: string; participant: { id: string; name: string } };
let playoffMatches: any[] = []; type PlayoffMatchWithRelations = PlayoffMatch & {
participant1: { id: string; name: string } | null;
participant2: { id: string; name: string } | null;
winner: { id: string; name: string } | null;
loser: { id: string; name: string } | null;
};
let playoffMatches: PlayoffMatchWithRelations[] = [];
let playoffRounds: string[] = []; let playoffRounds: string[] = [];
let preEliminatedParticipants: { id: string; name: string }[] = []; let preEliminatedParticipants: { id: string; name: string }[] = [];
let participantPoints: { participantId: string; points: number }[] = []; let participantPoints: { participantId: string; points: number }[] = [];
let partialScoreParticipantIds: string[] = []; let partialScoreParticipantIds: string[] = [];
let seasonStandings: SeasonStanding[] = []; let seasonStandings: SeasonStanding[] = [];
let qpStandings: any[] = []; type QPStanding = Awaited<ReturnType<typeof getQPStandings>>[number];
let qpStandings: QPStanding[] = [];
if (scoringPattern === "playoff_bracket") { if (scoringPattern === "playoff_bracket") {
// Fetch playoff matches via scoring events // Fetch playoff matches via scoring events
@ -164,7 +172,7 @@ export async function loader(args: Route.LoaderArgs) {
loser: true, loser: true,
}, },
}); });
playoffMatches = matches; playoffMatches = matches as PlayoffMatchWithRelations[];
templateId = events.find((e) => e.bracketTemplateId)?.bracketTemplateId ?? undefined; templateId = events.find((e) => e.bracketTemplateId)?.bracketTemplateId ?? undefined;
const template = templateId ? getBracketTemplate(templateId) : undefined; const template = templateId ? getBracketTemplate(templateId) : undefined;
@ -186,8 +194,8 @@ export async function loader(args: Route.LoaderArgs) {
]); ]);
preEliminatedParticipants = eliminatedResults preEliminatedParticipants = eliminatedResults
.filter((r) => r.participant) .filter((r): r is typeof r & { participant: NonNullable<typeof r.participant> } => r.participant !== null && r.participant !== undefined)
.map((r) => ({ id: (r as any).participant.id, name: (r as any).participant.name })); .map((r) => ({ id: r.participant.id, name: r.participant.name }));
// Compute per-participant fantasy points directly from participant_results. // Compute per-participant fantasy points directly from participant_results.
// This covers both finalized placements and progressive floor scores for still-alive participants. // This covers both finalized placements and progressive floor scores for still-alive participants.

View file

@ -69,7 +69,7 @@ export default function SportSeasonDetail({
const hasBracket = playoffMatches && playoffMatches.length > 0; const hasBracket = playoffMatches && playoffMatches.length > 0;
const hasStandings = regularSeasonStandings && regularSeasonStandings.length > 0; const hasStandings = regularSeasonStandings && regularSeasonStandings.length > 0;
const showOtLosses = hasStandings && regularSeasonStandings.some((s: any) => s.otLosses != null); const showOtLosses = hasStandings && regularSeasonStandings.some((s) => s.otLosses !== null);
const simulatorType = sportsSeason.sport?.simulatorType; const simulatorType = sportsSeason.sport?.simulatorType;
const standingsDisplayMode = const standingsDisplayMode =
@ -80,7 +80,7 @@ export default function SportSeasonDetail({
// Build ownership map for RegularSeasonStandings // Build ownership map for RegularSeasonStandings
const ownershipMap = Object.fromEntries( const ownershipMap = Object.fromEntries(
teamOwnerships.map((o: any) => [ teamOwnerships.map((o) => [
o.participantId, o.participantId,
{ teamName: o.teamName, ownerName: o.ownerName ?? "", teamId: o.teamId }, { teamName: o.teamName, ownerName: o.ownerName ?? "", teamId: o.teamId },
]) ])
@ -113,12 +113,12 @@ export default function SportSeasonDetail({
{hasStandings && !hasBracket && ( {hasStandings && !hasBracket && (
<div className="mb-6"> <div className="mb-6">
<RegularSeasonStandings <RegularSeasonStandings
standings={regularSeasonStandings as any} standings={regularSeasonStandings}
teamOwnerships={ownershipMap} teamOwnerships={ownershipMap}
userParticipantIds={userParticipantIds} userParticipantIds={userParticipantIds}
showOtLosses={showOtLosses} showOtLosses={showOtLosses}
participantEvs={participantEvs} participantEvs={participantEvs}
displayMode={standingsDisplayMode as any} displayMode={standingsDisplayMode as "flat" | "nhl-divisions"}
playoffSpots={playoffSpots} playoffSpots={playoffSpots}
/> />
</div> </div>
@ -129,25 +129,25 @@ export default function SportSeasonDetail({
(upcomingEvents.length > 0 || recentEvents.length > 0) && ( (upcomingEvents.length > 0 || recentEvents.length > 0) && (
<div className="mb-6"> <div className="mb-6">
<EventSchedule <EventSchedule
upcomingEvents={upcomingEvents as any} upcomingEvents={upcomingEvents}
recentEvents={recentEvents as any} recentEvents={recentEvents}
/> />
</div> </div>
)} )}
{/* Hide bracket display when standings exist but no bracket matches have been set yet */} {/* Hide bracket display when standings exist but no bracket matches have been set yet */}
{!(scoringPattern === "playoff_bracket" && hasStandings && !hasBracket) && <SportSeasonDisplay {!(scoringPattern === "playoff_bracket" && hasStandings && !hasBracket) && <SportSeasonDisplay
scoringPattern={scoringPattern as any} scoringPattern={scoringPattern as "playoff_bracket" | "season_standings" | "qualifying_points"}
sportSeasonName={sportsSeason.name} sportSeasonName={sportsSeason.name}
sportName={sportsSeason.sport.name} sportName={sportsSeason.sport.name}
playoffMatches={playoffMatches as any} playoffMatches={playoffMatches}
playoffRounds={playoffRounds} playoffRounds={playoffRounds}
preEliminatedParticipants={preEliminatedParticipants} preEliminatedParticipants={preEliminatedParticipants}
participantPoints={participantPoints} participantPoints={participantPoints}
partialScoreParticipantIds={partialScoreParticipantIds} partialScoreParticipantIds={partialScoreParticipantIds}
seasonStandings={seasonStandings as any} seasonStandings={seasonStandings}
seasonIsFinalized={seasonIsFinalized} seasonIsFinalized={seasonIsFinalized}
qpStandings={qpStandings as any} qpStandings={qpStandings}
qpIsFinalized={sportsSeason.qualifyingPointsFinalized || false} qpIsFinalized={sportsSeason.qualifyingPointsFinalized || false}
totalMajors={sportsSeason.totalMajors} totalMajors={sportsSeason.totalMajors}
majorsCompleted={sportsSeason.majorsCompleted || 0} majorsCompleted={sportsSeason.majorsCompleted || 0}
@ -166,12 +166,12 @@ export default function SportSeasonDetail({
{hasStandings && hasBracket && ( {hasStandings && hasBracket && (
<div className="mt-8"> <div className="mt-8">
<RegularSeasonStandings <RegularSeasonStandings
standings={regularSeasonStandings as any} standings={regularSeasonStandings}
teamOwnerships={ownershipMap} teamOwnerships={ownershipMap}
userParticipantIds={userParticipantIds} userParticipantIds={userParticipantIds}
showOtLosses={showOtLosses} showOtLosses={showOtLosses}
participantEvs={participantEvs} participantEvs={participantEvs}
displayMode={standingsDisplayMode as any} displayMode={standingsDisplayMode as "flat" | "nhl-divisions"}
playoffSpots={playoffSpots} playoffSpots={playoffSpots}
/> />
</div> </div>

View file

@ -85,12 +85,12 @@ export async function loader(args: Route.LoaderArgs) {
]); ]);
// Build teamId -> ownerName map // Build teamId -> ownerName map
const ownerIds = [...new Set(teams.map((t) => t.ownerId).filter((id): id is string => id != null))]; const ownerIds = [...new Set(teams.map((t) => t.ownerId).filter((id): id is string => id !== null))];
const userRows = await findUsersByClerkIds(ownerIds); const userRows = await findUsersByClerkIds(ownerIds);
const userByClerkId = new Map(userRows.map((u) => [u.clerkId, u])); const userByClerkId = new Map(userRows.map((u) => [u.clerkId, u]));
const ownerNameByTeamId = new Map( const ownerNameByTeamId = new Map(
teams teams
.filter((t): t is typeof t & { ownerId: string } => t.ownerId != null) .filter((t): t is typeof t & { ownerId: string } => t.ownerId !== null)
.map((t) => { .map((t) => {
const user = userByClerkId.get(t.ownerId); const user = userByClerkId.get(t.ownerId);
return [t.id, user ? getUserDisplayName(user) : null] as const; return [t.id, user ? getUserDisplayName(user) : null] as const;

View file

@ -87,7 +87,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
const standingsMap = new Map(standings.map((s) => [s.teamId, s])); const standingsMap = new Map(standings.map((s) => [s.teamId, s]));
// Pre-compute sorted standings list // Pre-compute sorted standings list
const sortedTeams = [...teams].sort((a, b) => { const sortedTeams = [...teams].toSorted((a, b) => {
const rankA = standingsMap.get(a.id)?.currentRank ?? Infinity; const rankA = standingsMap.get(a.id)?.currentRank ?? Infinity;
const rankB = standingsMap.get(b.id)?.currentRank ?? Infinity; const rankB = standingsMap.get(b.id)?.currentRank ?? Infinity;
return rankA - rankB; return rankA - rankB;
@ -95,7 +95,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
// Pre-compute sorted sports seasons: active → upcoming → completed, // Pre-compute sorted sports seasons: active → upcoming → completed,
// season_standings last within active, then alphabetical by sport name // season_standings last within active, then alphabetical by sport name
const sortedSportsSeasons = [...sportsSeasons].sort((a, b) => { const sortedSportsSeasons = [...sportsSeasons].toSorted((a, b) => {
const statusOrder = { active: 0, upcoming: 1, completed: 2 } as const; const statusOrder = { active: 0, upcoming: 1, completed: 2 } as const;
const statusDiff = statusOrder[a.status] - statusOrder[b.status]; const statusDiff = statusOrder[a.status] - statusOrder[b.status];
if (statusDiff !== 0) return statusDiff; if (statusDiff !== 0) return statusDiff;

View file

@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect } from 'vitest';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers

View file

@ -110,7 +110,7 @@ describe("timer-update handler currentPickNumber sync", () => {
...prev, ...prev,
[data.teamId]: data.timeRemaining, [data.teamId]: data.timeRemaining,
})); }));
if (data.currentPickNumber != null) { if (data.currentPickNumber !== null) {
setCurrentPick(data.currentPickNumber); setCurrentPick(data.currentPickNumber);
} }
}; };
@ -132,7 +132,7 @@ describe("timer-update handler currentPickNumber sync", () => {
const setCurrentPick = (value: number) => { currentPick = value; }; const setCurrentPick = (value: number) => { currentPick = value; };
const handleTimerUpdate = (data: any) => { const handleTimerUpdate = (data: any) => {
if (data.currentPickNumber != null) { if (data.currentPickNumber !== null) {
setCurrentPick(data.currentPickNumber); setCurrentPick(data.currentPickNumber);
} }
}; };

View file

@ -1,8 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { updateLeague, findLeagueById } from '~/models/league'; import { updateLeague } from '~/models/league';
import { isCommissioner } from '~/models/commissioner';
import { findCurrentSeasonWithSports, updateSeason } from '~/models/season'; import { findCurrentSeasonWithSports, updateSeason } from '~/models/season';
import { findTeamsBySeasonId } from '~/models/team';
// Mock all models used in the settings update intent // Mock all models used in the settings update intent
vi.mock('~/models/league', () => ({ vi.mock('~/models/league', () => ({
@ -175,7 +173,7 @@ describe('Settings Update - League Save', () => {
let result: { error: string } | undefined; let result: { error: string } | undefined;
try { try {
await updateLeague('league-1', { name: 'Test League', isPublicDraftBoard: false }); await updateLeague('league-1', { name: 'Test League', isPublicDraftBoard: false });
} catch (e) { } catch {
result = { error: 'Failed to update league. Please try again.' }; result = { error: 'Failed to update league. Please try again.' };
} }

View file

@ -57,7 +57,7 @@ export async function loader() {
return { return {
...template, ...template,
sportsSeasonIds: templateWithSports?.seasonTemplateSports?.map( sportsSeasonIds: templateWithSports?.seasonTemplateSports?.map(
(ts: any) => ts.sportsSeason.id (ts: { sportsSeason: { id: string } }) => ts.sportsSeason.id
) || [] ) || []
}; };
}) })

View file

@ -11,7 +11,7 @@ export default function TestSocket() {
useEffect(() => { useEffect(() => {
// Listen for test messages // Listen for test messages
const handleTestMessage = (data: any) => { const handleTestMessage = (data: unknown) => {
console.log("Received test message:", data); console.log("Received test message:", data);
setMessages((prev) => [...prev, JSON.stringify(data)]); setMessages((prev) => [...prev, JSON.stringify(data)]);
}; };
@ -64,6 +64,7 @@ export default function TestSocket() {
) : ( ) : (
<ul className="space-y-2"> <ul className="space-y-2">
{messages.map((msg, idx) => ( {messages.map((msg, idx) => (
// eslint-disable-next-line react/no-array-index-key
<li key={idx} className="text-sm font-mono bg-card p-2 rounded"> <li key={idx} className="text-sm font-mono bg-card p-2 rounded">
{msg} {msg}
</li> </li>

View file

@ -26,7 +26,7 @@ describe('bracket-simulator', () => {
expect(results.size).toBe(8); expect(results.size).toBe(8);
// With equal ratings, each team should have roughly equal probability // With equal ratings, each team should have roughly equal probability
results.forEach((distribution, participantId) => { results.forEach((distribution, _participantId) => {
// Sum of probabilities should be 1.0 // Sum of probabilities should be 1.0
const sum = distribution.reduce((acc, p) => acc + p, 0); const sum = distribution.reduce((acc, p) => acc + p, 0);
expect(sum).toBeCloseTo(1.0, 1); expect(sum).toBeCloseTo(1.0, 1);
@ -96,7 +96,7 @@ describe('bracket-simulator', () => {
const results = simulateBracketSync(teams, 'nhl-8', 5000); const results = simulateBracketSync(teams, 'nhl-8', 5000);
results.forEach((distribution, participantId) => { results.forEach((distribution, _participantId) => {
const sum = distribution.reduce((acc, p) => acc + p, 0); const sum = distribution.reduce((acc, p) => acc + p, 0);
expect(sum).toBeCloseTo(1.0, 1); expect(sum).toBeCloseTo(1.0, 1);
}); });
@ -154,7 +154,7 @@ describe('bracket-simulator', () => {
const results = simulateBracketSync(teams, 'nhl-8', 1000); const results = simulateBracketSync(teams, 'nhl-8', 1000);
results.forEach((distribution, participantId) => { results.forEach((distribution, _participantId) => {
// Should be array of 8 numbers // Should be array of 8 numbers
expect(distribution).toHaveLength(8); expect(distribution).toHaveLength(8);

View file

@ -8,7 +8,6 @@ import {
eloWinProbability, eloWinProbability,
convertFuturesToElo, convertFuturesToElo,
calculatePredictionError, calculatePredictionError,
DEFAULT_CALIBRATION,
} from '../probability-engine'; } from '../probability-engine';
describe('probability-engine', () => { describe('probability-engine', () => {

View file

@ -37,6 +37,7 @@ describe("probability-updater", () => {
notes: null, notes: null,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
participant: null,
}, },
]); ]);
@ -108,6 +109,7 @@ describe("probability-updater", () => {
notes: null, notes: null,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
participant: null,
}, },
{ {
id: "result-2", id: "result-2",
@ -119,6 +121,7 @@ describe("probability-updater", () => {
notes: null, notes: null,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
participant: null,
}, },
]); ]);
@ -195,6 +198,7 @@ describe("probability-updater", () => {
notes: null, notes: null,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
participant: null,
}, },
]); ]);
@ -219,6 +223,7 @@ describe("probability-updater", () => {
notes: null, notes: null,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
participant: null,
}, },
]); ]);

View file

@ -146,7 +146,7 @@ describe("NCAAM bracket advancement path", () => {
it("E8 match i uses 0-indexed S16 winners at (i-1)*2 and (i-1)*2+1", () => { it("E8 match i uses 0-indexed S16 winners at (i-1)*2 and (i-1)*2+1", () => {
for (let i = 1; i <= 4; i++) { for (let i = 1; i <= 4; i++) {
const p1 = (i - 1) * 2; const _p1 = (i - 1) * 2;
const p2 = (i - 1) * 2 + 1; const p2 = (i - 1) * 2 + 1;
expect(p2).toBeLessThan(8); expect(p2).toBeLessThan(8);
} }
@ -154,7 +154,7 @@ describe("NCAAM bracket advancement path", () => {
it("FF match i uses 0-indexed E8 winners at (i-1)*2 and (i-1)*2+1", () => { it("FF match i uses 0-indexed E8 winners at (i-1)*2 and (i-1)*2+1", () => {
for (let i = 1; i <= 2; i++) { for (let i = 1; i <= 2; i++) {
const p1 = (i - 1) * 2; const _p1 = (i - 1) * 2;
const p2 = (i - 1) * 2 + 1; const p2 = (i - 1) * 2 + 1;
expect(p2).toBeLessThan(4); expect(p2).toBeLessThan(4);
} }

View file

@ -161,7 +161,7 @@ describe("NCAAW bracket advancement path", () => {
it("E8 match i uses 0-indexed S16 winners at (i-1)*2 and (i-1)*2+1", () => { it("E8 match i uses 0-indexed S16 winners at (i-1)*2 and (i-1)*2+1", () => {
for (let i = 1; i <= 4; i++) { for (let i = 1; i <= 4; i++) {
const p1 = (i - 1) * 2; const _p1 = (i - 1) * 2;
const p2 = (i - 1) * 2 + 1; const p2 = (i - 1) * 2 + 1;
expect(p2).toBeLessThan(8); expect(p2).toBeLessThan(8);
} }
@ -169,7 +169,7 @@ describe("NCAAW bracket advancement path", () => {
it("FF match i uses 0-indexed E8 winners at (i-1)*2 and (i-1)*2+1", () => { it("FF match i uses 0-indexed E8 winners at (i-1)*2 and (i-1)*2+1", () => {
for (let i = 1; i <= 2; i++) { for (let i = 1; i <= 2; i++) {
const p1 = (i - 1) * 2; const _p1 = (i - 1) * 2;
const p2 = (i - 1) * 2 + 1; const p2 = (i - 1) * 2 + 1;
expect(p2).toBeLessThan(4); expect(p2).toBeLessThan(4);
} }

View file

@ -286,7 +286,7 @@ describe("UCLSimulator.simulate()", () => {
// Full tournament completed: 8 R16 losers, 4 QF losers, 2 SF losers, 1 finalist, 1 champion // Full tournament completed: 8 R16 losers, 4 QF losers, 2 SF losers, 1 finalist, 1 champion
// Use team-1 as champion path: R16m1→QFm1→SFm1→Final // Use team-1 as champion path: R16m1→QFm1→SFm1→Final
const champion = PARTICIPANT_IDS[0]; // team-1, participant1 of R16 match 1 const champion = PARTICIPANT_IDS[0]; // team-1, participant1 of R16 match 1
const finalist = PARTICIPANT_IDS[2]; // team-3, participant1 of R16 match 2 const _finalist = PARTICIPANT_IDS[2]; // team-3, participant1 of R16 match 2
const r16Matches = Array.from({ length: 8 }, (_, i) => { const r16Matches = Array.from({ length: 8 }, (_, i) => {
const matchNum = i + 1; const matchNum = i + 1;
@ -301,8 +301,8 @@ describe("UCLSimulator.simulate()", () => {
// QF: R16 match 1 winner (team-1) + R16 match 2 winner (team-4) → QF match 1: team-1 wins // QF: R16 match 1 winner (team-1) + R16 match 2 winner (team-4) → QF match 1: team-1 wins
// R16 match 3 winner (team-6) + R16 match 4 winner (team-8) → QF match 2: team-6 wins // R16 match 3 winner (team-6) + R16 match 4 winner (team-8) → QF match 2: team-6 wins
// QF match 3: team-10 wins, QF match 4: team-14 wins // QF match 3: team-10 wins, QF match 4: team-14 wins
const qfWinners = [champion, PARTICIPANT_IDS[3], PARTICIPANT_IDS[9], PARTICIPANT_IDS[13]]; const _qfWinners = [champion, PARTICIPANT_IDS[3], PARTICIPANT_IDS[9], PARTICIPANT_IDS[13]];
const qfLosers = [PARTICIPANT_IDS[3], PARTICIPANT_IDS[5], PARTICIPANT_IDS[7], PARTICIPANT_IDS[11]]; const _qfLosers = [PARTICIPANT_IDS[3], PARTICIPANT_IDS[5], PARTICIPANT_IDS[7], PARTICIPANT_IDS[11]];
// 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;

View file

@ -140,7 +140,7 @@ export class AutoRacingSimulator implements Simulator {
for (const p of participants) { for (const p of participants) {
const ev = evMap.get(p.id); const ev = evMap.get(p.id);
rawProbs.set(p.id, ev?.sourceOdds != null ? americanToImpliedProb(ev.sourceOdds) : fallbackProb); rawProbs.set(p.id, ev !== undefined && ev.sourceOdds !== null && ev.sourceOdds !== undefined ? americanToImpliedProb(ev.sourceOdds) : fallbackProb);
} }
// Normalize to remove vig // Normalize to remove vig
@ -164,7 +164,7 @@ export class AutoRacingSimulator implements Simulator {
// rankCounts[id][0..7] = number of times driver finished 1st..8th // rankCounts[id][0..7] = number of times driver finished 1st..8th
const rankCounts = new Map<string, number[]>(); const rankCounts = new Map<string, number[]>();
for (const id of ids) { for (const id of ids) {
rankCounts.set(id, new Array(8).fill(0)); rankCounts.set(id, Array.from({ length: 8 }, () => 0));
} }
if (remainingRaces === 0) { if (remainingRaces === 0) {
@ -215,7 +215,7 @@ export class AutoRacingSimulator implements Simulator {
// 7d. Sort by final championship points, record top-8 finishes // 7d. Sort by final championship points, record top-8 finishes
const finalOrder = [...simPoints.entries()] const finalOrder = [...simPoints.entries()]
.sort((a, b) => b[1] - a[1]) .toSorted((a, b) => b[1] - a[1])
.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++) {

View file

@ -11,8 +11,8 @@
*/ */
import { database } from "~/database/context"; import { database } from "~/database/context";
import { participantExpectedValues, participants } from "~/database/schema"; import { participantExpectedValues } from "~/database/schema";
import { eq, and, isNotNull } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { simulateBracket } from "~/services/bracket-simulator"; import { simulateBracket } from "~/services/bracket-simulator";
import { convertFuturesToElo } from "~/services/probability-engine"; import { convertFuturesToElo } from "~/services/probability-engine";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";

View file

@ -343,7 +343,7 @@ export class NCAAMSimulator implements Simulator {
} }
const sortByMatchNumber = (matches: typeof allMatches) => const sortByMatchNumber = (matches: typeof allMatches) =>
[...matches].sort((a, b) => a.matchNumber - b.matchNumber); [...matches].toSorted((a, b) => a.matchNumber - b.matchNumber);
const firstFourMatches = byRound.has("First Four") ? sortByMatchNumber(byRound.get("First Four")!) : []; const firstFourMatches = byRound.has("First Four") ? sortByMatchNumber(byRound.get("First Four")!) : [];
const r64Matches = byRound.has("Round of 64") ? sortByMatchNumber(byRound.get("Round of 64")!) : null; const r64Matches = byRound.has("Round of 64") ? sortByMatchNumber(byRound.get("Round of 64")!) : null;

View file

@ -320,7 +320,7 @@ export class NCAAWSimulator implements Simulator {
} }
const sortByMatchNumber = (matches: typeof allMatches) => const sortByMatchNumber = (matches: typeof allMatches) =>
[...matches].sort((a, b) => a.matchNumber - b.matchNumber); [...matches].toSorted((a, b) => a.matchNumber - b.matchNumber);
const firstFourMatches = byRound.has("First Four") ? sortByMatchNumber(byRound.get("First Four")!) : []; const firstFourMatches = byRound.has("First Four") ? sortByMatchNumber(byRound.get("First Four")!) : [];
const r64Matches = byRound.has("Round of 64") ? sortByMatchNumber(byRound.get("Round of 64")!) : null; const r64Matches = byRound.has("Round of 64") ? sortByMatchNumber(byRound.get("Round of 64")!) : null;

View file

@ -103,7 +103,7 @@ export class UCLSimulator implements Simulator {
} }
const sortedRoundMatches = [...byRound.values()] const sortedRoundMatches = [...byRound.values()]
.sort((a, b) => b.length - a.length) .toSorted((a, b) => b.length - a.length)
.map((matches) => matches.sort((a, b) => a.matchNumber - b.matchNumber)); .map((matches) => matches.sort((a, b) => a.matchNumber - b.matchNumber));
if (sortedRoundMatches.length < 4) { if (sortedRoundMatches.length < 4) {
@ -178,7 +178,7 @@ export class UCLSimulator implements Simulator {
const ev = evMap.get(id); const ev = evMap.get(id);
rawProbs.set( rawProbs.set(
id, id,
ev?.sourceOdds != null ? americanToImpliedProb(ev.sourceOdds) : fallbackProb ev !== undefined && ev.sourceOdds !== null && ev.sourceOdds !== undefined ? americanToImpliedProb(ev.sourceOdds) : fallbackProb
); );
} }
const rawSum = [...rawProbs.values()].reduce((a, b) => a + b, 0); const rawSum = [...rawProbs.values()].reduce((a, b) => a + b, 0);

View file

@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect } from "vitest";
import { findMatchingTeamName } from "~/lib/normalize-team-name"; import { findMatchingTeamName } from "~/lib/normalize-team-name";
// Pure unit tests for the name-matching logic used by syncStandings. // Pure unit tests for the name-matching logic used by syncStandings.

View file

@ -96,7 +96,7 @@ export class NbaStandingsAdapter implements StandingsSyncAdapter {
} }
// Assign league rank by sorting on wins desc, then losses asc // Assign league rank by sorting on wins desc, then losses asc
const sorted = [...flattened].sort((a, b) => { const sorted = [...flattened].toSorted((a, b) => {
const winsA = statsMap(a.entry.stats).get("wins")?.value ?? 0; const winsA = statsMap(a.entry.stats).get("wins")?.value ?? 0;
const winsB = statsMap(b.entry.stats).get("wins")?.value ?? 0; const winsB = statsMap(b.entry.stats).get("wins")?.value ?? 0;
if (winsB !== winsA) return winsB - winsA; if (winsB !== winsA) return winsB - winsA;
@ -122,7 +122,7 @@ export class NbaStandingsAdapter implements StandingsSyncAdapter {
// Seed within conference — ESPN key is "playoffSeed", value is a float (1.0, 2.0 ...) // Seed within conference — ESPN key is "playoffSeed", value is a float (1.0, 2.0 ...)
const playoffSeedStat = sm.get("playoffSeed"); const playoffSeedStat = sm.get("playoffSeed");
const conferenceRank = const conferenceRank =
playoffSeedStat?.value != null playoffSeedStat !== undefined && playoffSeedStat.value !== null && playoffSeedStat.value !== undefined
? Math.round(playoffSeedStat.value) ? Math.round(playoffSeedStat.value)
: playoffSeedStat?.displayValue : playoffSeedStat?.displayValue
? parseInt(playoffSeedStat.displayValue, 10) || undefined ? parseInt(playoffSeedStat.displayValue, 10) || undefined

View file

@ -13,7 +13,7 @@ process.env.NODE_ENV = 'test';
// Mock Clerk // Mock Clerk
vi.mock('@clerk/react-router/server', () => ({ vi.mock('@clerk/react-router/server', () => ({
getAuth: vi.fn(() => Promise.resolve({ userId: 'test-user-id' })), getAuth: vi.fn(() => Promise.resolve({ userId: 'test-user-id' })),
clerkMiddleware: vi.fn(() => (req: any, res: any, next: any) => next()), clerkMiddleware: vi.fn(() => (req: unknown, res: unknown, next: () => void) => next()),
rootAuthLoader: vi.fn(() => Promise.resolve({ userId: 'test-user-id' })), rootAuthLoader: vi.fn(() => Promise.resolve({ userId: 'test-user-id' })),
})); }));

View file

@ -221,7 +221,7 @@ export async function importSportsDataFromJSON(
importData = ImportDataSchema.parse(JSON.parse(jsonData)); importData = ImportDataSchema.parse(JSON.parse(jsonData));
} catch (err) { } catch (err) {
throw new Error( throw new Error(
`Invalid import file: ${err instanceof Error ? err.message : "unknown validation error"}` `Invalid import file: ${err instanceof Error ? err.message : "unknown validation error"}`, { cause: err }
); );
} }

View file

@ -2,7 +2,7 @@
// The real implementation uses Node.js AsyncLocalStorage which is server-only. // The real implementation uses Node.js AsyncLocalStorage which is server-only.
// This stub is used during the client build to avoid bundling server-only code. // This stub is used during the client build to avoid bundling server-only code.
export const DatabaseContext = null as any; export const DatabaseContext = null as unknown;
export function database(): never { export function database(): never {
throw new Error("database() can only be called on the server"); throw new Error("database() can only be called on the server");

View file

@ -2,7 +2,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import * as schema from "./schema"; import type * as schema from "./schema";
export const DatabaseContext = new AsyncLocalStorage< export const DatabaseContext = new AsyncLocalStorage<
PostgresJsDatabase<typeof schema> PostgresJsDatabase<typeof schema>

369
package-lock.json generated
View file

@ -76,6 +76,7 @@
"dotenv-cli": "^8.0.0", "dotenv-cli": "^8.0.0",
"esbuild": "^0.25.11", "esbuild": "^0.25.11",
"jsdom": "^27.0.1", "jsdom": "^27.0.1",
"oxlint": "^1.56.0",
"shadcn": "^4.0.8", "shadcn": "^4.0.8",
"tailwindcss": "^4.1.4", "tailwindcss": "^4.1.4",
"tsx": "^4.20.6", "tsx": "^4.20.6",
@ -3227,6 +3228,329 @@
"@opentelemetry/api": "^1.1.0" "@opentelemetry/api": "^1.1.0"
} }
}, },
"node_modules/@oxlint/binding-android-arm-eabi": {
"version": "1.56.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.56.0.tgz",
"integrity": "sha512-IyfYPthZyiSKwAv/dLjeO18SaK8MxLI9Yss2JrRDyweQAkuL3LhEy7pwIwI7uA3KQc1Vdn20kdmj3q0oUIQL6A==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-android-arm64": {
"version": "1.56.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.56.0.tgz",
"integrity": "sha512-Ga5zYrzH6vc/VFxhn6MmyUnYEfy9vRpwTIks99mY3j6Nz30yYpIkWryI0QKPCgvGUtDSXVLEaMum5nA+WrNOSg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-darwin-arm64": {
"version": "1.56.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.56.0.tgz",
"integrity": "sha512-ogmbdJysnw/D4bDcpf1sPLpFThZ48lYp4aKYm10Z/6Nh1SON6NtnNhTNOlhEY296tDFItsZUz+2tgcSYqh8Eyw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-darwin-x64": {
"version": "1.56.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.56.0.tgz",
"integrity": "sha512-x8QE1h+RAtQ2g+3KPsP6Fk/tdz6zJQUv5c7fTrJxXV3GHOo+Ry5p/PsogU4U+iUZg0rj6hS+E4xi+mnwwlDCWQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-freebsd-x64": {
"version": "1.56.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.56.0.tgz",
"integrity": "sha512-6G+WMZvwJpMvY7my+/SHEjb7BTk/PFbePqLpmVmUJRIsJMy/UlyYqjpuh0RCgYYkPLcnXm1rUM04kbTk8yS1Yg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-linux-arm-gnueabihf": {
"version": "1.56.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.56.0.tgz",
"integrity": "sha512-YYHBsk/sl7fYwQOok+6W5lBPeUEvisznV/HZD2IfZmF3Bns6cPC3Z0vCtSEOaAWTjYWN3jVsdu55jMxKlsdlhg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-linux-arm-musleabihf": {
"version": "1.56.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.56.0.tgz",
"integrity": "sha512-+AZK8rOUr78y8WT6XkDb04IbMRqauNV+vgT6f8ZLOH8wnpQ9i7Nol0XLxAu+Cq7Sb+J9wC0j6Km5hG8rj47/yQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-linux-arm64-gnu": {
"version": "1.56.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.56.0.tgz",
"integrity": "sha512-urse2SnugwJRojUkGSSeH2LPMaje5Q50yQtvtL9HFckiyeqXzoFwOAZqD5TR29R2lq7UHidfFDM9EGcchcbb8A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-linux-arm64-musl": {
"version": "1.56.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.56.0.tgz",
"integrity": "sha512-rkTZkBfJ4TYLjansjSzL6mgZOdN5IvUnSq3oNJSLwBcNvy3dlgQtpHPrRxrCEbbcp7oQ6If0tkNaqfOsphYZ9g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-linux-ppc64-gnu": {
"version": "1.56.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.56.0.tgz",
"integrity": "sha512-uqL1kMH3u69/e1CH2EJhP3CP28jw2ExLsku4o8RVAZ7fySo9zOyI2fy9pVlTAp4voBLVgzndXi3SgtdyCTa2aA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-linux-riscv64-gnu": {
"version": "1.56.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.56.0.tgz",
"integrity": "sha512-j0CcMBOgV6KsRaBdsebIeiy7hCjEvq2KdEsiULf2LZqAq0v1M1lWjelhCV57LxsqaIGChXFuFJ0RiFrSRHPhSg==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-linux-riscv64-musl": {
"version": "1.56.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.56.0.tgz",
"integrity": "sha512-7VDOiL8cDG3DQ/CY3yKjbV1c4YPvc4vH8qW09Vv+5ukq3l/Kcyr6XGCd5NvxUmxqDb2vjMpM+eW/4JrEEsUetA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-linux-s390x-gnu": {
"version": "1.56.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.56.0.tgz",
"integrity": "sha512-JGRpX0M+ikD3WpwJ7vKcHKV6Kg0dT52BW2Eu2BupXotYeqGXBrbY+QPkAyKO6MNgKozyTNaRh3r7g+VWgyAQYQ==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-linux-x64-gnu": {
"version": "1.56.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.56.0.tgz",
"integrity": "sha512-dNaICPvtmuxFP/VbqdofrLqdS3bM/AKJN3LMJD52si44ea7Be1cBk6NpfIahaysG9Uo+L98QKddU9CD5L8UHnQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-linux-x64-musl": {
"version": "1.56.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.56.0.tgz",
"integrity": "sha512-pF1vOtM+GuXmbklM1hV8WMsn6tCNPvkUzklj/Ej98JhlanbmA2RB1BILgOpwSuCTRTIYx2MXssmEyQQ90QF5aA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-openharmony-arm64": {
"version": "1.56.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.56.0.tgz",
"integrity": "sha512-bp8NQ4RE6fDIFLa4bdBiOA+TAvkNkg+rslR+AvvjlLTYXLy9/uKAYLQudaQouWihLD/hgkrXIKKzXi5IXOewwg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-win32-arm64-msvc": {
"version": "1.56.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.56.0.tgz",
"integrity": "sha512-PxT4OJDfMOQBzo3OlzFb9gkoSD+n8qSBxyVq2wQSZIHFQYGEqIRTo9M0ZStvZm5fdhMqaVYpOnJvH2hUMEDk/g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-win32-ia32-msvc": {
"version": "1.56.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.56.0.tgz",
"integrity": "sha512-PTRy6sIEPqy2x8PTP1baBNReN/BNEFmde0L+mYeHmjXE1Vlcc9+I5nsqENsB2yAm5wLkzPoTNCMY/7AnabT4/A==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-win32-x64-msvc": {
"version": "1.56.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.56.0.tgz",
"integrity": "sha512-ZHa0clocjLmIDr+1LwoWtxRcoYniAvERotvwKUYKhH41NVfl0Y4LNbyQkwMZzwDvKklKGvGZ5+DAG58/Ik47tQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@pkgjs/parseargs": { "node_modules/@pkgjs/parseargs": {
"version": "0.11.0", "version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@ -12391,6 +12715,51 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/oxlint": {
"version": "1.56.0",
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.56.0.tgz",
"integrity": "sha512-Q+5Mj5PVaH/R6/fhMMFzw4dT+KPB+kQW4kaL8FOIq7tfhlnEVp6+3lcWqFruuTNlUo9srZUW3qH7Id4pskeR6g==",
"dev": true,
"license": "MIT",
"bin": {
"oxlint": "bin/oxlint"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/sponsors/Boshen"
},
"optionalDependencies": {
"@oxlint/binding-android-arm-eabi": "1.56.0",
"@oxlint/binding-android-arm64": "1.56.0",
"@oxlint/binding-darwin-arm64": "1.56.0",
"@oxlint/binding-darwin-x64": "1.56.0",
"@oxlint/binding-freebsd-x64": "1.56.0",
"@oxlint/binding-linux-arm-gnueabihf": "1.56.0",
"@oxlint/binding-linux-arm-musleabihf": "1.56.0",
"@oxlint/binding-linux-arm64-gnu": "1.56.0",
"@oxlint/binding-linux-arm64-musl": "1.56.0",
"@oxlint/binding-linux-ppc64-gnu": "1.56.0",
"@oxlint/binding-linux-riscv64-gnu": "1.56.0",
"@oxlint/binding-linux-riscv64-musl": "1.56.0",
"@oxlint/binding-linux-s390x-gnu": "1.56.0",
"@oxlint/binding-linux-x64-gnu": "1.56.0",
"@oxlint/binding-linux-x64-musl": "1.56.0",
"@oxlint/binding-openharmony-arm64": "1.56.0",
"@oxlint/binding-win32-arm64-msvc": "1.56.0",
"@oxlint/binding-win32-ia32-msvc": "1.56.0",
"@oxlint/binding-win32-x64-msvc": "1.56.0"
},
"peerDependencies": {
"oxlint-tsgolint": ">=0.15.0"
},
"peerDependenciesMeta": {
"oxlint-tsgolint": {
"optional": true
}
}
},
"node_modules/p-limit": { "node_modules/p-limit": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",

View file

@ -18,7 +18,9 @@
"test:e2e": "cypress open", "test:e2e": "cypress open",
"test:e2e:headless": "cypress run", "test:e2e:headless": "cypress run",
"test:all": "npm run test:run && npm run test:e2e:headless", "test:all": "npm run test:run && npm run test:e2e:headless",
"typecheck": "react-router typegen && tsc -b && tsc -p tsconfig.server.json --noEmit" "typecheck": "react-router typegen && tsc -b && tsc -p tsconfig.server.json --noEmit",
"lint": "oxlint app/ server/ database/",
"lint:fix": "oxlint app/ server/ database/ --fix"
}, },
"dependencies": { "dependencies": {
"@clerk/react-router": "^2.1.0", "@clerk/react-router": "^2.1.0",
@ -91,6 +93,7 @@
"dotenv-cli": "^8.0.0", "dotenv-cli": "^8.0.0",
"esbuild": "^0.25.11", "esbuild": "^0.25.11",
"jsdom": "^27.0.1", "jsdom": "^27.0.1",
"oxlint": "^1.56.0",
"shadcn": "^4.0.8", "shadcn": "^4.0.8",
"tailwindcss": "^4.1.4", "tailwindcss": "^4.1.4",
"tsx": "^4.20.6", "tsx": "^4.20.6",

View file

@ -12,9 +12,9 @@ export default {
buildEnd: async ( buildEnd: async (
{ {
viteConfig: viteConfig, viteConfig,
reactRouterConfig: reactRouterConfig, reactRouterConfig,
buildManifest: buildManifest buildManifest
} }
) => { ) => {
await sentryOnBuildEnd({ await sentryOnBuildEnd({

View file

@ -2,6 +2,7 @@ import { createRequestHandler } from "@react-router/express";
import { drizzle } from "drizzle-orm/postgres-js"; import { drizzle } from "drizzle-orm/postgres-js";
import express from "express"; import express from "express";
import postgres from "postgres"; import postgres from "postgres";
import type { ServerBuild } from "react-router";
import { RouterContextProvider } from "react-router"; import { RouterContextProvider } from "react-router";
import { DatabaseContext } from "~/database/context"; import { DatabaseContext } from "~/database/context";
@ -18,11 +19,12 @@ app.use((_, __, next) => DatabaseContext.run(db, next));
app.use( app.use(
createRequestHandler({ createRequestHandler({
build: () => import("virtual:react-router/server-build") as any, build: () => import("virtual:react-router/server-build") as unknown as Promise<ServerBuild>,
// @ts-ignore -- RouterContextProvider is the correct runtime type but tsconfig.server.json can't resolve the conditional type statically
getLoadContext() { getLoadContext() {
const provider = new RouterContextProvider(); const provider = new RouterContextProvider();
provider.set(expressValueContext, "Hello from Express"); provider.set(expressValueContext, "Hello from Express");
return provider as any; // Type assertion needed - RouterContextProvider is the context return provider as unknown as RouterContextProvider;
}, },
}), }),
); );

View file

@ -1,7 +1,7 @@
import { drizzle } from "drizzle-orm/postgres-js"; import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres"; import postgres from "postgres";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq, inArray, or } from "drizzle-orm"; import { eq, or } from "drizzle-orm";
import { createDailySnapshot } from "~/models/standings"; import { createDailySnapshot } from "~/models/standings";
// Create a dedicated database connection for the snapshot system // Create a dedicated database connection for the snapshot system

View file

@ -1,4 +1,5 @@
import { Server as SocketIOServer, Socket } from "socket.io"; import type { Socket } from "socket.io";
import { Server as SocketIOServer } from "socket.io";
import type { Server as HTTPServer } from "http"; import type { Server as HTTPServer } from "http";
import { drizzle } from "drizzle-orm/postgres-js"; import { drizzle } from "drizzle-orm/postgres-js";
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
@ -20,12 +21,12 @@ function getSocketDb(): PostgresJsDatabase<typeof schema> {
// Socket event types // Socket event types
interface ServerToClientEvents { interface ServerToClientEvents {
"test-message": (data: { "test-message": (data: {
originalMessage: any; originalMessage: unknown;
serverResponse: string; serverResponse: string;
serverTimestamp: string; serverTimestamp: string;
socketId: string; socketId: string;
}) => void; }) => void;
"pick-made": (data: any) => void; "pick-made": (data: unknown) => void;
"draft-started": (data: { seasonId: string; currentPickNumber: number }) => void; "draft-started": (data: { seasonId: string; currentPickNumber: number }) => void;
"draft-completed": () => void; "draft-completed": () => void;
"timer-update": (data: { "timer-update": (data: {
@ -58,9 +59,9 @@ interface ServerToClientEvents {
round: number; round: number;
pickInRound: number; pickInRound: number;
timeUsed: number; timeUsed: number;
team: any; team: unknown;
participant: any; participant: unknown;
sport: any; sport: unknown;
}>; }>;
timers: Array<{ timers: Array<{
teamId: string; teamId: string;
@ -79,7 +80,7 @@ interface ServerToClientEvents {
interface ClientToServerEvents { interface ClientToServerEvents {
"join-draft": (seasonId: string, teamId?: string) => void; "join-draft": (seasonId: string, teamId?: string) => void;
"leave-draft": (seasonId: string) => void; "leave-draft": (seasonId: string) => void;
"test-event": (data: any) => void; "test-event": (data: unknown) => void;
} }
// Global type augmentation // Global type augmentation
@ -273,7 +274,7 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
} }
}); });
socket.on("test-event", (data: any) => { socket.on("test-event", (data: unknown) => {
console.log("📨 Received test-event from client:", socket.id, data); console.log("📨 Received test-event from client:", socket.id, data);
socket.emit("test-message", { socket.emit("test-message", {

2
server/types.d.ts vendored
View file

@ -4,4 +4,4 @@ declare global {
var __socketIO: SocketIOServer | undefined; var __socketIO: SocketIOServer | undefined;
} }
export {};

View file

@ -17,8 +17,8 @@
"composite": true, "composite": true,
"strict": true, "strict": true,
"types": ["node"], "types": ["node"],
"lib": ["ES2022"], "lib": ["ES2023"],
"target": "ES2022", "target": "ES2023",
"module": "ES2022", "module": "ES2022",
"moduleResolution": "bundler" "moduleResolution": "bundler"
} }

View file

@ -5,10 +5,10 @@
"noEmit": false, "noEmit": false,
"outDir": "./dist/server", "outDir": "./dist/server",
"rootDir": ".", "rootDir": ".",
"target": "ES2022", "target": "ES2023",
"module": "ES2022", "module": "ES2022",
"moduleResolution": "node", "moduleResolution": "node",
"lib": ["ES2022"], "lib": ["ES2023"],
"types": ["node"], "types": ["node"],
"strict": true, "strict": true,
"esModuleInterop": true, "esModuleInterop": true,

View file

@ -14,7 +14,7 @@
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
"strict": true, "strict": true,
"lib": ["DOM", "DOM.Iterable", "ES2022"], "lib": ["DOM", "DOM.Iterable", "ES2023"],
"types": ["vite/client"], "types": ["vite/client"],
"target": "ES2022", "target": "ES2022",
"module": "ES2022", "module": "ES2022",