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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-03-21 10:14:54 -07:00
parent 180aad1520
commit 4de6cb23a0
22 changed files with 208 additions and 201 deletions

View file

@ -36,7 +36,10 @@
"react/no-array-index-key": "error",
"react/self-closing-comp": "warn",
"import/no-duplicates": "error"
"import/no-duplicates": "error",
"no-shadow": "error",
"unicorn/consistent-function-scoping": "error"
},
"overrides": [
{

View file

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

View file

@ -3,6 +3,13 @@ import { render, screen } from '@testing-library/react';
import { DraftGrid } from '../DraftGrid';
import { mockDraftSlots } from '~/test/fixtures/team';
function formatTime(seconds: number | undefined) {
if (seconds === undefined) return '--:--';
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
describe('DraftGrid Component', () => {
const mockGrid = [
[
@ -117,13 +124,6 @@ describe('DraftGrid Component', () => {
});
it('should display timers when provided', () => {
const formatTime = (seconds: number | undefined) => {
if (seconds === undefined) return '--:--';
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
render(
<DraftGrid
draftSlots={mockDraftSlots}

View file

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

View file

@ -30,6 +30,11 @@ const COLORS = [
'#6366f1', // indigo
];
function formatDate(dateStr: string) {
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
export function PointProgressionChart({ chartData, teams }: PointProgressionChartProps) {
if (chartData.length === 0) {
return (
@ -48,12 +53,6 @@ export function PointProgressionChart({ chartData, teams }: PointProgressionChar
);
}
// Format date for display
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
};
return (
<Card>
<CardHeader>

View file

@ -1,6 +1,32 @@
import { describe, it, expect } from 'vitest';
import { cn } from '~/lib/utils';
function formatTime(seconds: number | undefined): string {
if (seconds === undefined) return '--:--';
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function isValidLeagueName(name: string): boolean {
return name.trim().length >= 3 && name.trim().length <= 50;
}
type DraftSpeed = 'fast' | 'standard' | 'slow' | 'very-slow';
function getDraftTimes(speed: DraftSpeed): { initial: number; increment: number } {
switch (speed) {
case 'fast':
return { initial: 60, increment: 10 };
case 'standard':
return { initial: 120, increment: 15 };
case 'slow':
return { initial: 28800, increment: 3600 };
case 'very-slow':
return { initial: 43200, increment: 3600 };
}
}
describe('Utils', () => {
describe('cn (className merger)', () => {
it('should merge class names', () => {
@ -32,13 +58,6 @@ describe('Utils', () => {
});
describe('Draft Time Formatting', () => {
const formatTime = (seconds: number | undefined): string => {
if (seconds === undefined) return '--:--';
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
it('should format time correctly', () => {
expect(formatTime(120)).toBe('2:00');
expect(formatTime(90)).toBe('1:30');
@ -57,10 +76,6 @@ describe('Draft Time Formatting', () => {
});
describe('League Name Validation', () => {
const isValidLeagueName = (name: string): boolean => {
return name.trim().length >= 3 && name.trim().length <= 50;
};
it('should accept valid league names', () => {
expect(isValidLeagueName('My League')).toBe(true);
expect(isValidLeagueName('Test League 2025')).toBe(true);
@ -85,21 +100,6 @@ describe('League Name Validation', () => {
});
describe('Draft Speed Presets', () => {
type DraftSpeed = 'fast' | 'standard' | 'slow' | 'very-slow';
const getDraftTimes = (speed: DraftSpeed): { initial: number; increment: number } => {
switch (speed) {
case 'fast':
return { initial: 60, increment: 10 };
case 'standard':
return { initial: 120, increment: 15 };
case 'slow':
return { initial: 28800, increment: 3600 };
case 'very-slow':
return { initial: 43200, increment: 3600 };
}
};
it('should return correct times for fast speed', () => {
const times = getDraftTimes('fast');
expect(times.initial).toBe(60);

View file

@ -406,6 +406,10 @@ describe("autoPickForTeam queueOnly constraint (AC2 & AC3)", () => {
});
});
function makeSeasonQueues(entries: [string, { id: string; participantId: string; queuePosition: number }[]][]) {
return new Map(entries.map(([teamId, items]) => [teamId, items as any]));
}
describe("pruneIneligibleQueueItems", () => {
beforeEach(() => {
vi.clearAllMocks();
@ -415,11 +419,6 @@ describe("pruneIneligibleQueueItems", () => {
vi.mocked(getSeasonSportsSimple).mockResolvedValue([]);
});
// Helper to build the Map<teamId, queue[]> returned by getAllQueuesForSeason
function makeSeasonQueues(entries: [string, { id: string; participantId: string; queuePosition: number }[]][]) {
return new Map(entries.map(([teamId, items]) => [teamId, items as any]));
}
it("removes queued snooker player when snooker is no longer eligible for a team", async () => {
vi.mocked(getDraftPicksWithSports).mockResolvedValue([]);
vi.mocked(getParticipantsForSeasonWithSports).mockResolvedValue([

View file

@ -166,7 +166,7 @@ export async function processPlayoffEvent(
eq(schema.playoffMatches.scoringEventId, eventId),
eq(schema.playoffMatches.round, event.playoffRound)
),
orderBy: (matches, { asc }) => [asc(matches.matchNumber)],
orderBy: (m, { asc }) => [asc(m.matchNumber)],
});
// Process based on round

View file

@ -36,22 +36,22 @@ export async function findSportBySlug(slug: string): Promise<Sport | undefined>
export async function findAllSports(): Promise<Sport[]> {
const db = database();
return await db.query.sports.findMany({
orderBy: (sports, { asc }) => [asc(sports.name)],
orderBy: (s, { asc }) => [asc(s.name)],
});
}
export async function findAllSportsWithActiveSeasons(): Promise<SportWithActiveSeasons[]> {
const db = database();
const sports = await db.query.sports.findMany({
orderBy: (sports, { asc }) => [asc(sports.name)],
orderBy: (s, { asc }) => [asc(s.name)],
with: {
sportsSeasons: {
where: (sportsSeasons, { eq, or }) =>
where: (ss, { or }) =>
or(
eq(sportsSeasons.status, "active"),
eq(sportsSeasons.status, "upcoming")
eq(ss.status, "active"),
eq(ss.status, "upcoming")
),
orderBy: (sportsSeasons, { desc }) => [desc(sportsSeasons.year)],
orderBy: (ss, { desc }) => [desc(ss.year)],
},
},
});
@ -70,7 +70,7 @@ export async function findSportsByType(type: SportType): Promise<Sport[]> {
const db = database();
return await db.query.sports.findMany({
where: eq(schema.sports.type, type),
orderBy: (sports, { asc }) => [asc(sports.name)],
orderBy: (s, { asc }) => [asc(s.name)],
});
}

View file

@ -43,10 +43,10 @@ export async function findSportsSeasonsBySportId(sportId: string): Promise<Sport
export async function findActiveSportsSeasonsBySportId(sportId: string): Promise<SportsSeason[]> {
const db = database();
return await db.query.sportsSeasons.findMany({
where: (sportsSeasons, { eq, and }) =>
where: (ss, { and }) =>
and(
eq(sportsSeasons.sportId, sportId),
eq(sportsSeasons.status, "active")
eq(ss.sportId, sportId),
eq(ss.status, "active")
),
orderBy: (sportsSeasons, { desc }) => [desc(sportsSeasons.year)],
with: {

View file

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

View file

@ -277,8 +277,8 @@ export default function EventBracket({
// Memoized: Get available rounds in chronological order
const availableRounds = useMemo(() => {
const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : undefined;
return getOrderedRoundsFromMatches(matches, template);
const bracketTemplate = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : undefined;
return getOrderedRoundsFromMatches(matches, bracketTemplate);
}, [matches, event.bracketTemplateId]);
// Check if all matches are complete

View file

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

View file

@ -40,7 +40,7 @@ export async function loader() {
with: {
league: true,
},
orderBy: (seasons, { desc }) => [desc(seasons.createdAt)],
orderBy: (s, { desc }) => [desc(s.createdAt)],
});
// For each season, get the most recent snapshot

View file

@ -494,8 +494,8 @@ export default function DraftRoom() {
// Sync timers — the server-side timer state may have changed while
// we were disconnected (time bank adjustments, new timers, etc.)
if (timers.length > 0) {
setTeamTimers((prev) => {
const updated = { ...prev };
setTeamTimers((currentTimers) => {
const updated = { ...currentTimers };
timers.forEach((timer) => {
updated[timer.teamId] = timer.timeRemaining;
});

View file

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

View file

@ -163,8 +163,7 @@ export async function loader(args: Route.LoaderArgs) {
if (events.length > 0) {
const eventIds = events.map((e) => e.id);
const matches = await db.query.playoffMatches.findMany({
where: (playoffMatches, { inArray }) =>
inArray(playoffMatches.scoringEventId, eventIds),
where: (pm) => inArray(pm.scoringEventId, eventIds),
with: {
participant1: true,
participant2: true,

View file

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

View file

@ -240,6 +240,12 @@ interface TeamEntry {
data: NbaTeamData | undefined;
}
/** Get Elo for a team entry.
* Fallback 1400 = conservative below-average estimate for unknown/unrecognized teams. */
function elo(entry: TeamEntry): number {
return entry.data?.elo ?? 1400;
}
// ─── Simulator ────────────────────────────────────────────────────────────────
export class NBASimulator implements Simulator {
@ -297,10 +303,6 @@ export class NBASimulator implements Simulator {
return 11; // Missed playoffs
};
/** Get Elo for a team entry.
* Fallback 1400 = conservative below-average estimate for unknown/unrecognized teams. */
const elo = (entry: TeamEntry): number => entry.data?.elo ?? 1400;
/** Simulate a single playoff game. Returns the winner. */
const simGame = (a: TeamEntry, b: TeamEntry): TeamEntry =>
Math.random() < eloWinProbability(elo(a), elo(b)) ? a : b;

View file

@ -296,6 +296,10 @@ export function kenpomWinProbability(netrtgA: number, netrtgB: number): number {
return 1 / (1 + Math.exp(-(netrtgA - netrtgB) / KENPOM_SCALE_FACTOR));
}
function sortByMatchNumber<T extends { matchNumber: number }>(matches: T[]): T[] {
return [...matches].toSorted((a, b) => a.matchNumber - b.matchNumber);
}
// ─── Simulator ────────────────────────────────────────────────────────────────
export class NCAAMSimulator implements Simulator {
@ -342,9 +346,6 @@ export class NCAAMSimulator implements Simulator {
byRound.get(m.round)!.push(m);
}
const sortByMatchNumber = (matches: typeof allMatches) =>
[...matches].toSorted((a, b) => a.matchNumber - b.matchNumber);
const firstFourMatches = byRound.has("First Four") ? sortByMatchNumber(byRound.get("First Four")!) : [];
const r64Matches = byRound.has("Round of 64") ? sortByMatchNumber(byRound.get("Round of 64")!) : null;
const r32Matches = byRound.has("Round of 32") ? sortByMatchNumber(byRound.get("Round of 32")!) : null;

View file

@ -276,6 +276,10 @@ export function barthagWinProbability(barthagA: number, barthagB: number): numbe
return pA / total;
}
function sortByMatchNumber<T extends { matchNumber: number }>(matches: T[]): T[] {
return [...matches].toSorted((a, b) => a.matchNumber - b.matchNumber);
}
// ─── Simulator ────────────────────────────────────────────────────────────────
export class NCAAWSimulator implements Simulator {
@ -319,9 +323,6 @@ export class NCAAWSimulator implements Simulator {
byRound.get(m.round)!.push(m);
}
const sortByMatchNumber = (matches: typeof allMatches) =>
[...matches].toSorted((a, b) => a.matchNumber - b.matchNumber);
const firstFourMatches = byRound.has("First Four") ? sortByMatchNumber(byRound.get("First Four")!) : [];
const r64Matches = byRound.has("Round of 64") ? sortByMatchNumber(byRound.get("Round of 64")!) : null;
const r32Matches = byRound.has("Round of 32") ? sortByMatchNumber(byRound.get("Round of 32")!) : null;

View file

@ -291,6 +291,37 @@ type DivisionSeedings = {
third: TeamEntry;
};
/** Get Elo for a team entry. Fallback 1400 for unknown teams. */
function elo(entry: TeamEntry): number {
return entry.data?.elo ?? 1400;
}
/**
* Weighted pick without replacement.
* Draws one team from `pool` using the given seeding weight key, excluding `excluded`.
* Returns undefined if no eligible team has positive weight the caller should
* treat that as a degenerate draw and skip the iteration.
*/
function weightedPick(
pool: TeamEntry[],
weightKey: "p_div1" | "p_div2" | "p_div3" | "p_wc1" | "p_wc2",
excluded: Set<TeamEntry>
): TeamEntry | undefined {
const eligible = pool.filter((t) => !excluded.has(t));
if (eligible.length === 0) return undefined;
const weights = eligible.map((t) => t.data?.[weightKey] ?? 0);
const total = weights.reduce((s, w) => s + w, 0);
if (total === 0) return undefined;
let r = Math.random() * total;
for (let i = 0; i < eligible.length; i++) {
r -= weights[i];
if (r <= 0) return eligible[i];
}
return eligible[eligible.length - 1];
}
// ─── Simulator ────────────────────────────────────────────────────────────────
export class NHLSimulator implements Simulator {
@ -348,9 +379,6 @@ export class NHLSimulator implements Simulator {
// ─── Helpers (defined once, outside the hot loop) ─────────────────────────
/** Get Elo for a team entry. Fallback 1400 for unknown teams. */
const elo = (entry: TeamEntry): number => entry.data?.elo ?? 1400;
/** Simulate a best-of-7 series. Returns winner and loser. */
const simSeries = (a: TeamEntry, b: TeamEntry): { winner: TeamEntry; loser: TeamEntry } => {
const winProb = eloWinProbability(elo(a), elo(b));
@ -362,32 +390,6 @@ export class NHLSimulator implements Simulator {
return winsA === 4 ? { winner: a, loser: b } : { winner: b, loser: a };
};
/**
* Weighted pick without replacement.
* Draws one team from `pool` using the given seeding weight key, excluding `excluded`.
* Returns undefined if no eligible team has positive weight the caller should
* treat that as a degenerate draw and skip the iteration.
*/
const weightedPick = (
pool: TeamEntry[],
weightKey: "p_div1" | "p_div2" | "p_div3" | "p_wc1" | "p_wc2",
excluded: Set<TeamEntry>
): TeamEntry | undefined => {
const eligible = pool.filter((t) => !excluded.has(t));
if (eligible.length === 0) return undefined;
const weights = eligible.map((t) => t.data?.[weightKey] ?? 0);
const total = weights.reduce((s, w) => s + w, 0);
if (total === 0) return undefined; // no team has positive weight for this slot
let r = Math.random() * total;
for (let i = 0; i < eligible.length; i++) {
r -= weights[i];
if (r <= 0) return eligible[i];
}
return eligible[eligible.length - 1];
};
/**
* Draw the top-3 playoff seeds for a single division.
* Returns undefined for each position if no team has weight for that slot