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 ): Promise { 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 ): Promise { 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 ) { 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(); async function getBracketTemplate(sportsSeasonId: string): Promise { 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 ): Promise { 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 ): Promise { 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(); 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 ): Promise { 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 ) { 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(); 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 })), }; }