import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq, and, desc } from "drizzle-orm"; import type { TeamStanding, TeamStandingSnapshot } from "~/types/standings"; // Re-export types from shared types file export type { TeamStanding, TeamStandingSnapshot } 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.sort((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, }; // Calculate points and projected points for each pick const pickBreakdown = await Promise.all( picks.map(async (pick) => { const result = pick.participant.results[0]; let points = 0; let projectedPoints: number | null = null; if (result && result.finalPosition) { // Calculate points based on placement const pointsMap: Record = { 1: season.pointsFor1st, 2: season.pointsFor2nd, 3: season.pointsFor3rd, 4: season.pointsFor4th, 5: season.pointsFor5th, 6: season.pointsFor6th, 7: season.pointsFor7th, 8: season.pointsFor8th, }; points = pointsMap[result.finalPosition] || 0; projectedPoints = points; // Finished = projected equals actual } else { // Participant is unfinished - get EV 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) { const probabilities = { 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), }; projectedPoints = calculateEV(probabilities, scoringRules); } } 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, // A participant is complete if they have a result record (even if finalPosition is 0) isComplete: !!result, }; }) ); // Group by sport for easier display // Structure: { sportName: { sportsSeasonId, picks } } const bySport: Record = {}; for (const pick of pickBreakdown) { const sport = pick.participant.sport; if (!bySport[sport]) { bySport[sport] = { sportsSeasonId: pick.participant.sportsSeasonId, picks: [], }; } bySport[sport].picks.push(pick); } 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, bySport, totalPoints: pickBreakdown.reduce((sum, p) => sum + p.points, 0), actualPoints, projectedPoints: projectedTotalPoints, completedCount: pickBreakdown.filter((p) => p.isComplete).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 ) { 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.toISOString().split("T")[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 today = new Date().toISOString().split("T")[0]; const standings = await db.query.teamStandings.findMany({ where: eq(schema.teamStandings.seasonId, seasonId), }); for (const standing of standings) { // Check if snapshot already exists for today const existing = await db.query.teamStandingsSnapshots.findFirst({ where: and( eq(schema.teamStandingsSnapshots.teamId, standing.teamId), eq(schema.teamStandingsSnapshots.seasonId, seasonId), eq(schema.teamStandingsSnapshots.snapshotDate, today) ), }); if (!existing) { await db.insert(schema.teamStandingsSnapshots).values({ teamId: standing.teamId, seasonId, snapshotDate: today, 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, // Phase 5.4: Copy projected points tracking actualPoints: standing.actualPoints, projectedPoints: standing.projectedPoints, participantsFinished: standing.participantsFinished, }); } } console.log(`[Standings] Created 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()).sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime() ); return { chartData, teams: teams.map(t => ({ id: t.id, name: t.name })), }; }