brackt/app/models/standings.ts
Chris Parsons e2b178221a
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>
2026-03-21 09:44:05 -07:00

427 lines
14 KiB
TypeScript

import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and } from "drizzle-orm";
import type { TeamStanding, TeamStandingSnapshot, TeamStandingWithChange } from "~/types/standings";
import { calculateBracketPoints, calculateFantasyPoints } from "~/models/scoring-rules";
// Re-export types from shared types file
export type { TeamStanding, TeamStandingSnapshot, TeamStandingWithChange } from "~/types/standings";
/**
* Get current standings for a season
*/
export async function getSeasonStandings(
seasonId: string,
providedDb?: ReturnType<typeof database>
): Promise<TeamStanding[]> {
const db = providedDb || database();
const standings = await db.query.teamStandings.findMany({
where: eq(schema.teamStandings.seasonId, seasonId),
with: {
team: true,
},
});
// Sort by currentRank ascending (1 is best, 2 is second, etc.)
const sorted = standings.toSorted((a, b) => a.currentRank - b.currentRank);
return sorted.map((standing) => ({
teamId: standing.teamId,
teamName: standing.team.name,
totalPoints: parseFloat(standing.totalPoints),
currentRank: standing.currentRank,
previousRank: standing.previousRank,
rankChange: standing.previousRank
? standing.previousRank - standing.currentRank
: 0,
placementCounts: {
first: standing.firstPlaceCount,
second: standing.secondPlaceCount,
third: standing.thirdPlaceCount,
fourth: standing.fourthPlaceCount,
fifth: standing.fifthPlaceCount,
sixth: standing.sixthPlaceCount,
seventh: standing.seventhPlaceCount,
eighth: standing.eighthPlaceCount,
},
participantsRemaining: standing.participantsRemaining,
calculatedAt: standing.calculatedAt,
// Phase 5.4: Include projected points
actualPoints: standing.actualPoints ? parseFloat(standing.actualPoints) : null,
projectedPoints: standing.projectedPoints ? parseFloat(standing.projectedPoints) : null,
participantsFinished: standing.participantsFinished,
}));
}
/**
* Get standings for a specific team
*/
export async function getTeamStanding(
teamId: string,
seasonId: string,
providedDb?: ReturnType<typeof database>
): Promise<TeamStanding | null> {
const db = providedDb || database();
const standing = await db.query.teamStandings.findFirst({
where: and(
eq(schema.teamStandings.teamId, teamId),
eq(schema.teamStandings.seasonId, seasonId)
),
with: {
team: true,
},
});
if (!standing) return null;
return {
teamId: standing.teamId,
teamName: standing.team.name,
totalPoints: parseFloat(standing.totalPoints),
currentRank: standing.currentRank,
previousRank: standing.previousRank,
rankChange: standing.previousRank
? standing.previousRank - standing.currentRank
: 0,
placementCounts: {
first: standing.firstPlaceCount,
second: standing.secondPlaceCount,
third: standing.thirdPlaceCount,
fourth: standing.fourthPlaceCount,
fifth: standing.fifthPlaceCount,
sixth: standing.sixthPlaceCount,
seventh: standing.seventhPlaceCount,
eighth: standing.eighthPlaceCount,
},
participantsRemaining: standing.participantsRemaining,
calculatedAt: standing.calculatedAt,
// Phase 5.4: Include projected points
actualPoints: standing.actualPoints ? parseFloat(standing.actualPoints) : null,
projectedPoints: standing.projectedPoints ? parseFloat(standing.projectedPoints) : null,
participantsFinished: standing.participantsFinished,
};
}
/**
* Get detailed team breakdown with all picks and their points
*/
export async function getTeamScoreBreakdown(
teamId: string,
seasonId: string,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
// Get season scoring rules
const season = await db.query.seasons.findFirst({
where: eq(schema.seasons.id, seasonId),
});
if (!season) return null;
// Get all draft picks for this team with participant details
const picks = await db.query.draftPicks.findMany({
where: and(
eq(schema.draftPicks.teamId, teamId),
eq(schema.draftPicks.seasonId, seasonId)
),
orderBy: schema.draftPicks.pickNumber,
with: {
participant: {
with: {
sportsSeason: {
with: {
sport: true,
},
},
results: true,
},
},
},
});
// Get scoring rules for EV calculation
const scoringRules = {
pointsFor1st: season.pointsFor1st,
pointsFor2nd: season.pointsFor2nd,
pointsFor3rd: season.pointsFor3rd,
pointsFor4th: season.pointsFor4th,
pointsFor5th: season.pointsFor5th,
pointsFor6th: season.pointsFor6th,
pointsFor7th: season.pointsFor7th,
pointsFor8th: season.pointsFor8th,
};
// Cache bracket template IDs per sports season (same approach as calculateTeamScore)
const bracketTemplateCache = new Map<string, string | null>();
async function getBracketTemplate(sportsSeasonId: string): Promise<string | null> {
if (bracketTemplateCache.has(sportsSeasonId)) {
return bracketTemplateCache.get(sportsSeasonId)!;
}
const event = await db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
columns: { bracketTemplateId: true },
});
const templateId = event?.bracketTemplateId ?? null;
bracketTemplateCache.set(sportsSeasonId, templateId);
return templateId;
}
// Calculate points and projected points for each pick
const pickBreakdown = await Promise.all(
picks.map(async (pick) => {
const result = pick.participant.results[0];
const isBracket = pick.participant.sportsSeason.scoringPattern === "playoff_bracket";
let points = 0;
let projectedPoints: number | null = null;
const getEV = async () => {
const { getParticipantEV } = await import("./participant-expected-value");
const { calculateEV } = await import("~/services/ev-calculator");
const ev = await getParticipantEV(pick.participant.id, pick.participant.sportsSeasonId);
if (!ev) return null;
return calculateEV(
{
probFirst: parseFloat(ev.probFirst),
probSecond: parseFloat(ev.probSecond),
probThird: parseFloat(ev.probThird),
probFourth: parseFloat(ev.probFourth),
probFifth: parseFloat(ev.probFifth),
probSixth: parseFloat(ev.probSixth),
probSeventh: parseFloat(ev.probSeventh),
probEighth: parseFloat(ev.probEighth),
},
scoringRules
);
};
if (result && result.finalPosition !== null && result.finalPosition > 0) {
// Calculate points using bracket-averaged scoring for bracket sports
if (isBracket) {
const templateId = await getBracketTemplate(pick.participant.sportsSeasonId);
points = calculateBracketPoints(result.finalPosition, scoringRules, templateId);
} else {
points = calculateFantasyPoints(result.finalPosition, scoringRules);
}
if (result.isPartialScore) {
// Still alive with a floor position — use EV for projected since they can advance
projectedPoints = (await getEV()) ?? points;
} else {
projectedPoints = points; // Finalized: projected equals actual
}
} else {
// Participant is unfinished - get EV
projectedPoints = await getEV();
}
return {
pickNumber: pick.pickNumber,
round: pick.round,
participant: {
id: pick.participant.id,
name: pick.participant.name,
sport: pick.participant.sportsSeason.sport.name,
sportsSeasonId: pick.participant.sportsSeasonId,
},
finalPosition: result?.finalPosition ?? null,
points,
projectedPoints,
// isComplete: has a result record (even if partial/floor)
isComplete: !!result,
// isPartialScore: still alive with a provisional floor position
isPartialScore: result?.isPartialScore ?? false,
};
})
);
const actualPoints = pickBreakdown
.filter((p) => p.isComplete)
.reduce((sum, p) => sum + p.points, 0);
const projectedTotalPoints = pickBreakdown.reduce(
(sum, p) => sum + (p.projectedPoints ?? 0),
0
);
return {
team: await db.query.teams.findFirst({
where: eq(schema.teams.id, teamId),
}),
picks: pickBreakdown,
actualPoints,
projectedPoints: projectedTotalPoints,
completedCount: pickBreakdown.filter((p) => p.isComplete && !p.isPartialScore).length,
totalCount: pickBreakdown.length,
};
}
/**
* Get historical standings snapshots for a team
* Used to display point progression over time
*/
export async function getTeamStandingsHistory(
teamId: string,
seasonId: string,
providedDb?: ReturnType<typeof database>
): Promise<TeamStandingSnapshot[]> {
const db = providedDb || database();
const snapshots = await db.query.teamStandingsSnapshots.findMany({
where: and(
eq(schema.teamStandingsSnapshots.teamId, teamId),
eq(schema.teamStandingsSnapshots.seasonId, seasonId)
),
orderBy: schema.teamStandingsSnapshots.snapshotDate,
});
return snapshots.map((snapshot) => ({
date: new Date(snapshot.snapshotDate),
rank: snapshot.rank,
totalPoints: parseFloat(snapshot.totalPoints),
}));
}
/**
* Get standings comparison between current and 7 days ago
* Used for "7-day change" display
*/
export async function getSevenDayStandingsChange(
seasonId: string,
providedDb?: ReturnType<typeof database>
): Promise<TeamStandingWithChange[]> {
const db = providedDb || database();
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
// Get current standings
const current = await getSeasonStandings(seasonId, db);
// Get snapshots from 7 days ago
const snapshots = await db.query.teamStandingsSnapshots.findMany({
where: and(
eq(schema.teamStandingsSnapshots.seasonId, seasonId),
eq(schema.teamStandingsSnapshots.snapshotDate, `${sevenDaysAgo.getFullYear()}-${String(sevenDaysAgo.getMonth() + 1).padStart(2, "0")}-${String(sevenDaysAgo.getDate()).padStart(2, "0")}`)
),
});
// Create a map of team -> old rank
const oldRanks = new Map<string, number>();
for (const snapshot of snapshots) {
oldRanks.set(snapshot.teamId, snapshot.rank);
}
// Add 7-day changes to current standings
return current.map((standing) => ({
...standing,
sevenDayRankChange: oldRanks.has(standing.teamId)
? oldRanks.get(standing.teamId)! - standing.currentRank
: 0,
sevenDayOldRank: oldRanks.get(standing.teamId) || null,
}));
}
/**
* Create a daily standings snapshot
* Should be called by a scheduled job once per day
*/
export async function createDailySnapshot(
seasonId: string,
providedDb?: ReturnType<typeof database>
): Promise<void> {
const db = providedDb || database();
const now = new Date();
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
const standings = await db.query.teamStandings.findMany({
where: eq(schema.teamStandings.seasonId, seasonId),
});
await db.transaction(async (tx) => {
for (const standing of standings) {
const snapshotData = {
totalPoints: standing.totalPoints,
rank: standing.currentRank,
firstPlaceCount: standing.firstPlaceCount,
secondPlaceCount: standing.secondPlaceCount,
thirdPlaceCount: standing.thirdPlaceCount,
fourthPlaceCount: standing.fourthPlaceCount,
fifthPlaceCount: standing.fifthPlaceCount,
sixthPlaceCount: standing.sixthPlaceCount,
seventhPlaceCount: standing.seventhPlaceCount,
eighthPlaceCount: standing.eighthPlaceCount,
participantsRemaining: standing.participantsRemaining,
actualPoints: standing.actualPoints,
projectedPoints: standing.projectedPoints,
participantsFinished: standing.participantsFinished,
};
await tx
.insert(schema.teamStandingsSnapshots)
.values({ teamId: standing.teamId, seasonId, snapshotDate: today, ...snapshotData })
.onConflictDoUpdate({
target: [
schema.teamStandingsSnapshots.teamId,
schema.teamStandingsSnapshots.seasonId,
schema.teamStandingsSnapshots.snapshotDate,
],
set: snapshotData,
});
}
});
console.log(`[Standings] Upserted daily snapshot for season ${seasonId}`);
}
/**
* Get point progression data for all teams in a season
* Returns historical snapshots organized by team for charting
*/
export async function getSeasonPointProgression(
seasonId: string,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
// Get all snapshots for this season
const snapshots = await db.query.teamStandingsSnapshots.findMany({
where: eq(schema.teamStandingsSnapshots.seasonId, seasonId),
orderBy: schema.teamStandingsSnapshots.snapshotDate,
with: {
team: true,
},
});
// Get unique teams
const teams = await db.query.teams.findMany({
where: eq(schema.teams.seasonId, seasonId),
});
// Organize data by date
const dateMap = new Map<string, { date: string; [teamName: string]: string | number }>();
for (const snapshot of snapshots) {
const date = snapshot.snapshotDate;
if (!dateMap.has(date)) {
dateMap.set(date, { date });
}
const dateData = dateMap.get(date)!;
dateData[snapshot.team.name] = parseFloat(snapshot.totalPoints);
}
// Convert to array and sort by date
const chartData = Array.from(dateMap.values()).toSorted((a, b) =>
new Date(a.date).getTime() - new Date(b.date).getTime()
);
return {
chartData,
teams: teams.map(t => ({ id: t.id, name: t.name })),
};
}