brackt/app/models/qualifying-points.ts
Chris Parsons 1089022c09 feat: Implement Qualifying Points Standings component and related logic
- Added `QualifyingPointsStandings` component to display standings based on qualifying points.
- Introduced scoring rules and projected points calculation for participants.
- Implemented tie handling for rankings and displayed appropriate UI elements based on finalization status.
- Created tests for qualifying points configuration, accumulation logic, ranking logic, and scoring workflow.
- Developed scoring calculator tests to validate fantasy points conversion from qualifying points.
- Established qualifying points management functions including initialization, retrieval, updating, and resetting of points.
2025-11-11 10:08:25 -08:00

348 lines
9.3 KiB
TypeScript

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;
}
/**
* Initialize default qualifying point configuration for a sports season
*/
export async function initializeQPConfig(
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
) {
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<typeof database>
) {
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<typeof database>
) {
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<typeof database>
): Promise<number> {
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<typeof database>
) {
const db = providedDb || database();
let total = await db.query.participantQualifyingTotals.findFirst({
where: and(
eq(schema.participantQualifyingTotals.participantId, participantId),
eq(schema.participantQualifyingTotals.sportsSeasonId, sportsSeasonId)
),
});
if (!total) {
const [created] = await db
.insert(schema.participantQualifyingTotals)
.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<typeof database>
) {
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.participantQualifyingTotals)
.set({
totalQualifyingPoints: (parseFloat(total.totalQualifyingPoints) + pointsToAdd).toString(),
updatedAt: new Date(),
})
.where(eq(schema.participantQualifyingTotals.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<typeof database>
) {
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.participantId, participantId),
with: {
scoringEvent: true,
},
});
// Calculate totals from event_results
let totalQP = 0;
const uniqueEvents = new Set<string>();
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.participantQualifyingTotals)
.set({
totalQualifyingPoints: totalQP.toString(),
eventsScored,
updatedAt: new Date(),
})
.where(eq(schema.participantQualifyingTotals.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<typeof database>
) {
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<typeof database>
) {
const db = providedDb || database();
return await db.query.participantQualifyingTotals.findMany({
where: eq(schema.participantQualifyingTotals.sportsSeasonId, sportsSeasonId),
orderBy: [desc(sql`CAST(${schema.participantQualifyingTotals.totalQualifyingPoints} AS DECIMAL)`)],
with: {
participant: true,
},
});
}
/**
* Update final rankings for qualifying point standings (after finalization)
*/
export async function updateFinalRankings(
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
const standings = await getQPStandings(sportsSeasonId, db);
// Group participants by QP total to handle ties
const groupedByPoints = new Map<string, typeof standings>();
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()).sort((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.participantQualifyingTotals)
.set({ finalRanking: update.finalRanking, updatedAt: new Date() })
.where(eq(schema.participantQualifyingTotals.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<typeof database>
) {
const db = providedDb || database();
await db
.delete(schema.participantQualifyingTotals)
.where(eq(schema.participantQualifyingTotals.sportsSeasonId, sportsSeasonId));
}