2025-10-28 23:50:50 -07:00
|
|
|
import { database } from "~/database/context";
|
|
|
|
|
import * as schema from "~/database/schema";
|
|
|
|
|
import { eq, and, desc } from "drizzle-orm";
|
2025-11-13 13:24:03 -08:00
|
|
|
import type { TeamStanding, TeamStandingSnapshot } from "~/types/standings";
|
2025-10-28 23:50:50 -07:00
|
|
|
|
2025-11-13 13:24:03 -08:00
|
|
|
// Re-export types from shared types file
|
|
|
|
|
export type { TeamStanding, TeamStandingSnapshot } from "~/types/standings";
|
2025-10-28 23:50:50 -07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2025-11-13 13:24:03 -08:00
|
|
|
// 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) => ({
|
2025-10-28 23:50:50 -07:00
|
|
|
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,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Calculate points for each pick
|
|
|
|
|
const pickBreakdown = picks.map((pick) => {
|
|
|
|
|
const result = pick.participant.results[0];
|
|
|
|
|
let points = 0;
|
|
|
|
|
|
|
|
|
|
if (result && result.finalPosition) {
|
|
|
|
|
// Calculate points based on placement
|
|
|
|
|
const pointsMap: Record<number, number> = {
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
pickNumber: pick.pickNumber,
|
|
|
|
|
round: pick.round,
|
|
|
|
|
participant: {
|
|
|
|
|
id: pick.participant.id,
|
|
|
|
|
name: pick.participant.name,
|
|
|
|
|
sport: pick.participant.sportsSeason.sport.name,
|
2025-11-14 20:01:21 -08:00
|
|
|
sportsSeasonId: pick.participant.sportsSeasonId,
|
2025-10-28 23:50:50 -07:00
|
|
|
},
|
2025-11-14 20:01:21 -08:00
|
|
|
finalPosition: result?.finalPosition ?? null,
|
2025-10-28 23:50:50 -07:00
|
|
|
points,
|
2025-11-14 20:01:21 -08:00
|
|
|
// A participant is complete if they have a result record (even if finalPosition is 0)
|
|
|
|
|
isComplete: !!result,
|
2025-10-28 23:50:50 -07:00
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Group by sport for easier display
|
2025-11-14 20:01:21 -08:00
|
|
|
// Structure: { sportName: { sportsSeasonId, picks } }
|
|
|
|
|
const bySport: Record<string, { sportsSeasonId: string; picks: typeof pickBreakdown }> = {};
|
2025-10-28 23:50:50 -07:00
|
|
|
for (const pick of pickBreakdown) {
|
|
|
|
|
const sport = pick.participant.sport;
|
|
|
|
|
if (!bySport[sport]) {
|
2025-11-14 20:01:21 -08:00
|
|
|
bySport[sport] = {
|
|
|
|
|
sportsSeasonId: pick.participant.sportsSeasonId,
|
|
|
|
|
picks: [],
|
|
|
|
|
};
|
2025-10-28 23:50:50 -07:00
|
|
|
}
|
2025-11-14 20:01:21 -08:00
|
|
|
bySport[sport].picks.push(pick);
|
2025-10-28 23:50:50 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
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<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>
|
|
|
|
|
) {
|
|
|
|
|
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<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 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,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(`[Standings] Created daily snapshot for season ${seasonId}`);
|
|
|
|
|
}
|