import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq, and, desc, sql } from "drizzle-orm"; /** * Default qualifying point values (as specified in scoring-system.md) */ export const DEFAULT_QP_VALUES = [ { placement: 1, points: 20 }, { placement: 2, points: 14 }, { placement: 3, points: 10 }, { placement: 4, points: 8 }, { placement: 5, points: 5 }, { placement: 6, points: 5 }, { placement: 7, points: 3 }, { placement: 8, points: 3 }, { placement: 9, points: 2 }, { placement: 10, points: 2 }, { placement: 11, points: 2 }, { placement: 12, points: 2 }, { placement: 13, points: 1 }, { placement: 14, points: 1 }, { placement: 15, points: 1 }, { placement: 16, points: 1 }, ]; export interface QualifyingPointConfigData { placement: number; points: number; } export function roundQualifyingPoints(points: number): number { return Math.round((points + Number.EPSILON) * 100) / 100; } export function calculateSplitQualifyingPoints( placement: number, tieCount: number, pointsByPlacement: Map ): number { if (placement <= 0 || tieCount <= 0) return 0; let totalQP = 0; for (let pos = placement; pos < placement + tieCount; pos++) { totalQP += pointsByPlacement.get(pos) ?? 0; } return roundQualifyingPoints(totalQP / tieCount); } export function hasProcessedQualifyingPlacement( results: Array<{ placement?: number | null; qualifyingPointsAwarded?: string | null; }> ): boolean { return results.some( (result) => result.placement !== null && result.placement !== undefined && result.qualifyingPointsAwarded !== null && result.qualifyingPointsAwarded !== undefined ); } /** * Initialize default qualifying point configuration for a sports season */ export async function initializeQPConfig( sportsSeasonId: string, providedDb?: ReturnType ) { const db = providedDb || database(); // Check if config already exists const existing = await db.query.qualifyingPointConfig.findMany({ where: eq(schema.qualifyingPointConfig.sportsSeasonId, sportsSeasonId), }); if (existing.length > 0) { return existing; } // Insert default config const configs = await db .insert(schema.qualifyingPointConfig) .values( DEFAULT_QP_VALUES.map((qp) => ({ sportsSeasonId, placement: qp.placement, points: qp.points.toString(), })) ) .returning(); return configs; } /** * Get qualifying point configuration for a sports season */ export async function getQPConfig( sportsSeasonId: string, providedDb?: ReturnType ) { const db = providedDb || database(); const configs = await db.query.qualifyingPointConfig.findMany({ where: eq(schema.qualifyingPointConfig.sportsSeasonId, sportsSeasonId), orderBy: [schema.qualifyingPointConfig.placement], }); // If no config exists, initialize with defaults if (configs.length === 0) { return await initializeQPConfig(sportsSeasonId, db); } return configs; } /** * Update qualifying point configuration for a sports season */ export async function updateQPConfig( sportsSeasonId: string, configData: QualifyingPointConfigData[], providedDb?: ReturnType ) { const db = providedDb || database(); // Delete existing config await db .delete(schema.qualifyingPointConfig) .where(eq(schema.qualifyingPointConfig.sportsSeasonId, sportsSeasonId)); // Insert new config const configs = await db .insert(schema.qualifyingPointConfig) .values( configData.map((qp) => ({ sportsSeasonId, placement: qp.placement, points: qp.points.toString(), })) ) .returning(); return configs; } /** * Get qualifying points awarded for a specific placement */ export async function getQPForPlacement( sportsSeasonId: string, placement: number, providedDb?: ReturnType ): Promise { const db = providedDb || database(); const config = await db.query.qualifyingPointConfig.findFirst({ where: and( eq(schema.qualifyingPointConfig.sportsSeasonId, sportsSeasonId), eq(schema.qualifyingPointConfig.placement, placement) ), }); return config ? parseFloat(config.points) : 0; } /** * Get or create participant qualifying total record */ export async function getOrCreateParticipantQPTotal( participantId: string, sportsSeasonId: string, providedDb?: ReturnType ) { const db = providedDb || database(); let total = await db.query.seasonParticipantQualifyingTotals.findFirst({ where: and( eq(schema.seasonParticipantQualifyingTotals.participantId, participantId), eq(schema.seasonParticipantQualifyingTotals.sportsSeasonId, sportsSeasonId) ), }); if (!total) { const [created] = await db .insert(schema.seasonParticipantQualifyingTotals) .values({ participantId, sportsSeasonId, totalQualifyingPoints: "0", eventsScored: 0, }) .returning(); total = created; } return total; } /** * Add qualifying points to a participant's total * NOTE: eventsScored is NOT updated here - it should be recalculated after processing events */ export async function addQualifyingPoints( participantId: string, sportsSeasonId: string, pointsToAdd: number, providedDb?: ReturnType ) { const db = providedDb || database(); // Get or create the total record const total = await getOrCreateParticipantQPTotal(participantId, sportsSeasonId, db); // Update with new points (do NOT increment eventsScored here) const [updated] = await db .update(schema.seasonParticipantQualifyingTotals) .set({ totalQualifyingPoints: (parseFloat(total.totalQualifyingPoints) + pointsToAdd).toString(), updatedAt: new Date(), }) .where(eq(schema.seasonParticipantQualifyingTotals.id, total.id)) .returning(); return updated; } /** * Recalculate total QP and eventsScored for a participant from scratch * This is the source of truth - sums all event_results with QP awarded */ export async function recalculateParticipantQP( participantId: string, sportsSeasonId: string, providedDb?: ReturnType ) { const db = providedDb || database(); // Get all event results for this participant in this sports season const eventResults = await db.query.eventResults.findMany({ where: eq(schema.eventResults.seasonParticipantId, participantId), with: { scoringEvent: true, }, }); // Calculate totals from event_results let totalQP = 0; const uniqueEvents = new Set(); for (const result of eventResults) { if ( result.scoringEvent.sportsSeasonId === sportsSeasonId && result.qualifyingPointsAwarded ) { const qp = parseFloat(result.qualifyingPointsAwarded); if (qp > 0) { totalQP += qp; uniqueEvents.add(result.scoringEvent.id); } } } const eventsScored = uniqueEvents.size; // Get or create the participant's QP total record const total = await getOrCreateParticipantQPTotal(participantId, sportsSeasonId, db); // Update with recalculated values await db .update(schema.seasonParticipantQualifyingTotals) .set({ totalQualifyingPoints: totalQP.toString(), eventsScored, updatedAt: new Date(), }) .where(eq(schema.seasonParticipantQualifyingTotals.id, total.id)); return { totalQP, eventsScored }; } /** * Recalculate eventsScored for a participant by counting unique events with QP awarded * @deprecated Use recalculateParticipantQP instead for more accurate recalculation */ export async function recalculateEventsScored( participantId: string, sportsSeasonId: string, providedDb?: ReturnType ) { const result = await recalculateParticipantQP(participantId, sportsSeasonId, providedDb); return result.eventsScored; } /** * Get all qualifying totals for a sports season, ordered by points (highest first) */ export async function getQPStandings( sportsSeasonId: string, providedDb?: ReturnType ) { const db = providedDb || database(); return await db.query.seasonParticipantQualifyingTotals.findMany({ where: eq(schema.seasonParticipantQualifyingTotals.sportsSeasonId, sportsSeasonId), orderBy: [desc(sql`CAST(${schema.seasonParticipantQualifyingTotals.totalQualifyingPoints} AS DECIMAL)`)], with: { participant: true, }, }); } /** * Update final rankings for qualifying point standings (after finalization) */ export async function updateFinalRankings( sportsSeasonId: string, providedDb?: ReturnType ) { const db = providedDb || database(); const standings = await getQPStandings(sportsSeasonId, db); // Group participants by QP total to handle ties const groupedByPoints = new Map(); for (const standing of standings) { const points = standing.totalQualifyingPoints; if (!groupedByPoints.has(points)) { groupedByPoints.set(points, []); } groupedByPoints.get(points)?.push(standing); } // Assign rankings, handling ties let currentRank = 1; const updates = []; // Sort by points descending const sortedGroups = Array.from(groupedByPoints.entries()).toSorted((a, b) => { return parseFloat(b[0]) - parseFloat(a[0]); }); for (const [_, group] of sortedGroups) { // All participants in this group get the same rank for (const standing of group) { updates.push({ id: standing.id, finalRanking: currentRank, }); } // Increment rank by the size of the group currentRank += group.length; } // Update all rankings for (const update of updates) { await db .update(schema.seasonParticipantQualifyingTotals) .set({ finalRanking: update.finalRanking, updatedAt: new Date() }) .where(eq(schema.seasonParticipantQualifyingTotals.id, update.id)); } return updates; } /** * Reset qualifying points for a sports season (useful for testing or corrections) */ export async function resetQualifyingPoints( sportsSeasonId: string, providedDb?: ReturnType ) { const db = providedDb || database(); await db .delete(schema.seasonParticipantQualifyingTotals) .where(eq(schema.seasonParticipantQualifyingTotals.sportsSeasonId, sportsSeasonId)); }