diff --git a/app/models/event-result.ts b/app/models/event-result.ts new file mode 100644 index 0000000..7d453ef --- /dev/null +++ b/app/models/event-result.ts @@ -0,0 +1,243 @@ +import { database } from "~/database/context"; +import * as schema from "~/database/schema"; +import { eq, and, inArray } from "drizzle-orm"; + +export interface CreateEventResultData { + scoringEventId: string; + participantId: string; + placement?: number; + qualifyingPointsAwarded?: number; + eliminated?: boolean; + rawScore?: number; +} + +export interface UpdateEventResultData { + placement?: number; + qualifyingPointsAwarded?: number; + eliminated?: boolean; + rawScore?: number; +} + +/** + * Create an event result for a participant + */ +export async function createEventResult( + data: CreateEventResultData, + providedDb?: ReturnType +) { + const db = providedDb || database(); + + const [result] = await db + .insert(schema.eventResults) + .values({ + scoringEventId: data.scoringEventId, + participantId: data.participantId, + placement: data.placement, + qualifyingPointsAwarded: data.qualifyingPointsAwarded?.toString(), + eliminated: data.eliminated, + rawScore: data.rawScore?.toString(), + }) + .returning(); + + return result; +} + +/** + * Create multiple event results in bulk + * Useful for entering all results for a tournament at once + */ +export async function createEventResultsBulk( + results: CreateEventResultData[], + providedDb?: ReturnType +) { + const db = providedDb || database(); + + const inserted = await db + .insert(schema.eventResults) + .values( + results.map((r) => ({ + scoringEventId: r.scoringEventId, + participantId: r.participantId, + placement: r.placement, + qualifyingPointsAwarded: r.qualifyingPointsAwarded?.toString(), + eliminated: r.eliminated, + rawScore: r.rawScore?.toString(), + })) + ) + .returning(); + + return inserted; +} + +/** + * Get an event result by ID + */ +export async function getEventResultById( + resultId: string, + providedDb?: ReturnType +) { + const db = providedDb || database(); + + return await db.query.eventResults.findFirst({ + where: eq(schema.eventResults.id, resultId), + with: { + participant: { + with: { + sportsSeason: { + with: { + sport: true, + }, + }, + }, + }, + scoringEvent: true, + }, + }); +} + +/** + * Get all results for a scoring event + */ +export async function getEventResults( + eventId: string, + providedDb?: ReturnType +) { + const db = providedDb || database(); + + return await db.query.eventResults.findMany({ + where: eq(schema.eventResults.scoringEventId, eventId), + orderBy: schema.eventResults.placement, + with: { + participant: { + with: { + sportsSeason: { + with: { + sport: true, + }, + }, + }, + }, + }, + }); +} + +/** + * Get all event results for a participant across all events + */ +export async function getParticipantEventResults( + participantId: string, + providedDb?: ReturnType +) { + const db = providedDb || database(); + + return await db.query.eventResults.findMany({ + where: eq(schema.eventResults.participantId, participantId), + with: { + scoringEvent: true, + }, + orderBy: schema.eventResults.createdAt, + }); +} + +/** + * Update an event result + */ +export async function updateEventResult( + resultId: string, + data: UpdateEventResultData, + providedDb?: ReturnType +) { + const db = providedDb || database(); + + const [updated] = await db + .update(schema.eventResults) + .set({ + placement: data.placement, + qualifyingPointsAwarded: data.qualifyingPointsAwarded?.toString(), + eliminated: data.eliminated, + rawScore: data.rawScore?.toString(), + updatedAt: new Date(), + }) + .where(eq(schema.eventResults.id, resultId)) + .returning(); + + return updated; +} + +/** + * Delete an event result + */ +export async function deleteEventResult( + resultId: string, + providedDb?: ReturnType +) { + const db = providedDb || database(); + + await db.delete(schema.eventResults).where(eq(schema.eventResults.id, resultId)); +} + +/** + * Delete all results for a scoring event + */ +export async function deleteEventResults( + eventId: string, + providedDb?: ReturnType +) { + const db = providedDb || database(); + + await db + .delete(schema.eventResults) + .where(eq(schema.eventResults.scoringEventId, eventId)); +} + +/** + * Check if a participant has a result for a specific event + */ +export async function hasParticipantResult( + eventId: string, + participantId: string, + providedDb?: ReturnType +): Promise { + const db = providedDb || database(); + + const result = await db.query.eventResults.findFirst({ + where: and( + eq(schema.eventResults.scoringEventId, eventId), + eq(schema.eventResults.participantId, participantId) + ), + }); + + return !!result; +} + +/** + * Get results for multiple participants in a specific event + * Useful for checking drafted participants' performance + */ +export async function getEventResultsForParticipants( + eventId: string, + participantIds: string[], + providedDb?: ReturnType +) { + const db = providedDb || database(); + + if (participantIds.length === 0) return []; + + return await db.query.eventResults.findMany({ + where: and( + eq(schema.eventResults.scoringEventId, eventId), + inArray(schema.eventResults.participantId, participantIds) + ), + with: { + participant: { + with: { + sportsSeason: { + with: { + sport: true, + }, + }, + }, + }, + }, + }); +} diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts new file mode 100644 index 0000000..f58fe44 --- /dev/null +++ b/app/models/scoring-calculator.ts @@ -0,0 +1,269 @@ +import { database } from "~/database/context"; +import * as schema from "~/database/schema"; +import { eq, and, inArray } from "drizzle-orm"; +import { getScoringRules, calculateFantasyPoints, calculateAveragedPoints } from "./scoring-rules"; + +/** + * Core scoring calculation engine + * This file contains the logic for calculating fantasy points based on participant results + */ + +export type ScoringPattern = + | "single_elimination_playoff" + | "page_playoff" + | "season_standings" + | "qualifying_points"; + +/** + * Process a playoff event completion and assign final placements + * Implementation will be added in Phase 2 + */ +export async function processPlayoffEvent( + eventId: string, + providedDb?: ReturnType +): Promise { + const db = providedDb || database(); + + // TODO: Implement in Phase 2 + // 1. Get event and determine round (QF, SF, Finals) + // 2. Get matches and determine winners/losers + // 3. Assign shared placements based on elimination round + // 4. Update participantResults with final placements + // 5. Trigger recalculation for affected leagues + + console.log(`[ScoringCalculator] processPlayoffEvent not yet implemented: ${eventId}`); +} + +/** + * Process a qualifying event completion and update QP totals + * Implementation will be added in Phase 3 + */ +export async function processQualifyingEvent( + eventId: string, + providedDb?: ReturnType +): Promise { + const db = providedDb || database(); + + // TODO: Implement in Phase 3 + // 1. Get event results + // 2. Award qualifying points based on placement + // 3. Update participant_qualifying_totals + // 4. Check if all majors complete + // 5. Update QP standings displays + + console.log(`[ScoringCalculator] processQualifyingEvent not yet implemented: ${eventId}`); +} + +/** + * Finalize qualifying points and convert to fantasy placements + * Called manually by admin when all majors are complete + * Implementation will be added in Phase 3 + */ +export async function finalizeQualifyingPoints( + sportsSeasonId: string, + providedDb?: ReturnType +): Promise { + const db = providedDb || database(); + + // TODO: Implement in Phase 3 + // 1. Get all participants ranked by total QP + // 2. Handle ties in QP (share placements) + // 3. Assign final placements (1-8) based on QP ranking + // 4. Update participantResults with final placements + // 5. Mark sports season as finalized + // 6. Trigger recalculation for all affected leagues + + console.log( + `[ScoringCalculator] finalizeQualifyingPoints not yet implemented: ${sportsSeasonId}` + ); +} + +/** + * Process season standings (F1) and assign final placements + * Implementation will be added in Phase 3 + */ +export async function processSeasonStandings( + sportsSeasonId: string, + providedDb?: ReturnType +): Promise { + const db = providedDb || database(); + + // TODO: Implement in Phase 3 + // 1. Get final season standings from participant_season_results + // 2. Assign final placements (1-8) based on standings + // 3. Update participantResults with final placements + // 4. Trigger recalculation for all affected leagues + + console.log( + `[ScoringCalculator] processSeasonStandings not yet implemented: ${sportsSeasonId}` + ); +} + +/** + * Calculate total fantasy points for a team in a specific season + * Sums points from all drafted participants based on their final placements + */ +export async function calculateTeamScore( + teamId: string, + seasonId: string, + providedDb?: ReturnType +): Promise<{ + totalPoints: number; + placementCounts: Record; + participantsCompleted: number; + participantsTotal: number; +}> { + const db = providedDb || database(); + + // Get season scoring rules + const scoringRules = await getScoringRules(seasonId, db); + if (!scoringRules) { + throw new Error(`Season ${seasonId} not found`); + } + + // Get all draft picks for this team + const picks = await db.query.draftPicks.findMany({ + where: and( + eq(schema.draftPicks.teamId, teamId), + eq(schema.draftPicks.seasonId, seasonId) + ), + with: { + participant: { + with: { + results: true, + }, + }, + }, + }); + + let totalPoints = 0; + const placementCounts: Record = { + 1: 0, + 2: 0, + 3: 0, + 4: 0, + 5: 0, + 6: 0, + 7: 0, + 8: 0, + }; + let participantsCompleted = 0; + + for (const pick of picks) { + const result = pick.participant.results[0]; // Should only be one result per participant + + if (result && result.finalPosition) { + const points = calculateFantasyPoints(result.finalPosition, scoringRules); + totalPoints += points; + participantsCompleted++; + + // Track placement for tiebreakers + if (result.finalPosition >= 1 && result.finalPosition <= 8) { + placementCounts[result.finalPosition]++; + } + } + } + + return { + totalPoints, + placementCounts, + participantsCompleted, + participantsTotal: picks.length, + }; +} + +/** + * Recalculate standings for all teams in a season + * Called after any scoring event completes or participant results change + * Implementation will be completed in Phase 4 + */ +export async function recalculateStandings( + seasonId: string, + providedDb?: ReturnType +): Promise { + const db = providedDb || database(); + + // TODO: Complete implementation in Phase 4 + // For now, calculate basic scores without rankings + + const teams = await db.query.teams.findMany({ + where: eq(schema.teams.seasonId, seasonId), + }); + + for (const team of teams) { + const teamScore = await calculateTeamScore(team.id, seasonId, db); + + // Upsert team standings + const existing = await db.query.teamStandings.findFirst({ + where: and( + eq(schema.teamStandings.teamId, team.id), + eq(schema.teamStandings.seasonId, seasonId) + ), + }); + + if (existing) { + await db + .update(schema.teamStandings) + .set({ + totalPoints: teamScore.totalPoints.toString(), + firstPlaceCount: teamScore.placementCounts[1], + secondPlaceCount: teamScore.placementCounts[2], + thirdPlaceCount: teamScore.placementCounts[3], + fourthPlaceCount: teamScore.placementCounts[4], + fifthPlaceCount: teamScore.placementCounts[5], + sixthPlaceCount: teamScore.placementCounts[6], + seventhPlaceCount: teamScore.placementCounts[7], + eighthPlaceCount: teamScore.placementCounts[8], + participantsRemaining: + teamScore.participantsTotal - teamScore.participantsCompleted, + calculatedAt: new Date(), + }) + .where(eq(schema.teamStandings.id, existing.id)); + } else { + await db.insert(schema.teamStandings).values({ + teamId: team.id, + seasonId, + totalPoints: teamScore.totalPoints.toString(), + currentRank: 1, // Will be updated when we add ranking logic + firstPlaceCount: teamScore.placementCounts[1], + secondPlaceCount: teamScore.placementCounts[2], + thirdPlaceCount: teamScore.placementCounts[3], + fourthPlaceCount: teamScore.placementCounts[4], + fifthPlaceCount: teamScore.placementCounts[5], + sixthPlaceCount: teamScore.placementCounts[6], + seventhPlaceCount: teamScore.placementCounts[7], + eighthPlaceCount: teamScore.placementCounts[8], + participantsRemaining: teamScore.participantsTotal - teamScore.participantsCompleted, + }); + } + } + + console.log(`[ScoringCalculator] Recalculated standings for season ${seasonId}`); +} + +/** + * Recalculate standings for all leagues that include a specific sports season + * Called after a sports season event completes + */ +export async function recalculateAffectedLeagues( + sportsSeasonId: string, + providedDb?: ReturnType +): Promise { + const db = providedDb || database(); + + // Get all fantasy seasons that include this sports season + const seasonSports = await db.query.seasonSports.findMany({ + where: eq(schema.seasonSports.sportsSeasonId, sportsSeasonId), + }); + + const seasonIds = seasonSports.map((ss) => ss.seasonId); + + // Recalculate each affected season + for (const seasonId of seasonIds) { + await recalculateStandings(seasonId, db); + } + + console.log( + `[ScoringCalculator] Recalculated ${seasonIds.length} affected season(s) for sports season ${sportsSeasonId}` + ); +} diff --git a/app/models/scoring-event.ts b/app/models/scoring-event.ts new file mode 100644 index 0000000..d1d526a --- /dev/null +++ b/app/models/scoring-event.ts @@ -0,0 +1,197 @@ +import { database } from "~/database/context"; +import * as schema from "~/database/schema"; +import { eq, and, desc } from "drizzle-orm"; + +export type EventType = "playoff_game" | "major_tournament" | "race" | "final_standings"; + +export interface CreateScoringEventData { + sportsSeasonId: string; + name: string; + eventDate?: Date; + eventType: EventType; + playoffRound?: string; // "Quarterfinals", "Semifinals", "Finals" + isQualifyingEvent?: boolean; +} + +export interface UpdateScoringEventData { + name?: string; + eventDate?: Date; + isComplete?: boolean; + completedAt?: Date; +} + +/** + * Create a new scoring event + */ +export async function createScoringEvent( + data: CreateScoringEventData, + providedDb?: ReturnType +) { + const db = providedDb || database(); + + const [event] = await db + .insert(schema.scoringEvents) + .values({ + sportsSeasonId: data.sportsSeasonId, + name: data.name, + eventDate: data.eventDate ? data.eventDate.toISOString().split('T')[0] : undefined, + eventType: data.eventType, + playoffRound: data.playoffRound, + isQualifyingEvent: data.isQualifyingEvent || false, + isComplete: false, + }) + .returning(); + + return event; +} + +/** + * Get a scoring event by ID + */ +export async function getScoringEventById( + eventId: string, + providedDb?: ReturnType +) { + const db = providedDb || database(); + + return await db.query.scoringEvents.findFirst({ + where: eq(schema.scoringEvents.id, eventId), + with: { + sportsSeason: { + with: { + sport: true, + }, + }, + }, + }); +} + +/** + * Get all scoring events for a sports season + */ +export async function getScoringEventsForSportsSeason( + sportsSeasonId: string, + providedDb?: ReturnType +) { + const db = providedDb || database(); + + return await db.query.scoringEvents.findMany({ + where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), + orderBy: [desc(schema.scoringEvents.eventDate), desc(schema.scoringEvents.createdAt)], + with: { + eventResults: { + with: { + participant: true, + }, + }, + }, + }); +} + +/** + * Get incomplete scoring events for a sports season + */ +export async function getIncompleteScoringEvents( + sportsSeasonId: string, + providedDb?: ReturnType +) { + const db = providedDb || database(); + + return await db.query.scoringEvents.findMany({ + where: and( + eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), + eq(schema.scoringEvents.isComplete, false) + ), + orderBy: [desc(schema.scoringEvents.eventDate), desc(schema.scoringEvents.createdAt)], + }); +} + +/** + * Get qualifying events for a sports season + */ +export async function getQualifyingEvents( + sportsSeasonId: string, + providedDb?: ReturnType +) { + const db = providedDb || database(); + + return await db.query.scoringEvents.findMany({ + where: and( + eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), + eq(schema.scoringEvents.isQualifyingEvent, true) + ), + orderBy: [desc(schema.scoringEvents.eventDate), desc(schema.scoringEvents.createdAt)], + }); +} + +/** + * Update a scoring event + */ +export async function updateScoringEvent( + eventId: string, + data: UpdateScoringEventData, + providedDb?: ReturnType +) { + const db = providedDb || database(); + + const updateData: any = { updatedAt: new Date() }; + + if (data.name !== undefined) updateData.name = data.name; + if (data.eventDate !== undefined) { + updateData.eventDate = data.eventDate.toISOString().split('T')[0]; + } + if (data.isComplete !== undefined) updateData.isComplete = data.isComplete; + if (data.completedAt !== undefined) updateData.completedAt = data.completedAt; + + const [updated] = await db + .update(schema.scoringEvents) + .set(updateData) + .where(eq(schema.scoringEvents.id, eventId)) + .returning(); + + return updated; +} + +/** + * Mark a scoring event as complete + */ +export async function completeScoringEvent( + eventId: string, + providedDb?: ReturnType +) { + const db = providedDb || database(); + + return await updateScoringEvent( + eventId, + { + isComplete: true, + completedAt: new Date(), + }, + db + ); +} + +/** + * Delete a scoring event + */ +export async function deleteScoringEvent( + eventId: string, + providedDb?: ReturnType +) { + const db = providedDb || database(); + + await db.delete(schema.scoringEvents).where(eq(schema.scoringEvents.id, eventId)); +} + +/** + * Check if all events for a sports season are complete + */ +export async function areAllEventsComplete( + sportsSeasonId: string, + providedDb?: ReturnType +): Promise { + const db = providedDb || database(); + + const incompleteEvents = await getIncompleteScoringEvents(sportsSeasonId, db); + return incompleteEvents.length === 0; +} diff --git a/app/models/scoring-rules.ts b/app/models/scoring-rules.ts new file mode 100644 index 0000000..f4ea1ae --- /dev/null +++ b/app/models/scoring-rules.ts @@ -0,0 +1,147 @@ +import { database } from "~/database/context"; +import * as schema from "~/database/schema"; +import { eq } from "drizzle-orm"; + +/** + * Scoring rules configuration for a season + * These define the points awarded for each placement (1st through 8th) + */ +export interface ScoringRules { + pointsFor1st: number; + pointsFor2nd: number; + pointsFor3rd: number; + pointsFor4th: number; + pointsFor5th: number; + pointsFor6th: number; + pointsFor7th: number; + pointsFor8th: number; +} + +/** + * Default scoring rules used when creating new seasons + */ +export const DEFAULT_SCORING_RULES: ScoringRules = { + pointsFor1st: 100, + pointsFor2nd: 70, + pointsFor3rd: 50, + pointsFor4th: 40, + pointsFor5th: 25, + pointsFor6th: 25, + pointsFor7th: 15, + pointsFor8th: 15, +}; + +/** + * Get scoring rules for a season + */ +export async function getScoringRules( + seasonId: string, + providedDb?: ReturnType +): Promise { + const db = providedDb || database(); + + const season = await db.query.seasons.findFirst({ + where: eq(schema.seasons.id, seasonId), + }); + + if (!season) { + return null; + } + + return { + pointsFor1st: season.pointsFor1st, + pointsFor2nd: season.pointsFor2nd, + pointsFor3rd: season.pointsFor3rd, + pointsFor4th: season.pointsFor4th, + pointsFor5th: season.pointsFor5th, + pointsFor6th: season.pointsFor6th, + pointsFor7th: season.pointsFor7th, + pointsFor8th: season.pointsFor8th, + }; +} + +/** + * Update scoring rules for a season + */ +export async function updateScoringRules( + seasonId: string, + rules: Partial, + providedDb?: ReturnType +): Promise { + const db = providedDb || database(); + + const [updated] = await db + .update(schema.seasons) + .set(rules) + .where(eq(schema.seasons.id, seasonId)) + .returning({ + pointsFor1st: schema.seasons.pointsFor1st, + pointsFor2nd: schema.seasons.pointsFor2nd, + pointsFor3rd: schema.seasons.pointsFor3rd, + pointsFor4th: schema.seasons.pointsFor4th, + pointsFor5th: schema.seasons.pointsFor5th, + pointsFor6th: schema.seasons.pointsFor6th, + pointsFor7th: schema.seasons.pointsFor7th, + pointsFor8th: schema.seasons.pointsFor8th, + }); + + return updated; +} + +/** + * Calculate fantasy points for a given placement based on season scoring rules + */ +export function calculateFantasyPoints( + placement: number, + rules: ScoringRules +): number { + const pointsMap: Record = { + 1: rules.pointsFor1st, + 2: rules.pointsFor2nd, + 3: rules.pointsFor3rd, + 4: rules.pointsFor4th, + 5: rules.pointsFor5th, + 6: rules.pointsFor6th, + 7: rules.pointsFor7th, + 8: rules.pointsFor8th, + }; + + return pointsMap[placement] || 0; +} + +/** + * Calculate averaged points for shared placements (e.g., playoff ties) + * Used when multiple participants share positions + * + * Example: 4 teams lose in quarterfinals, they share positions 5-8 + * Average = (25 + 25 + 15 + 15) / 4 = 20 points each + */ +export function calculateAveragedPoints( + placements: number[], + rules: ScoringRules +): number { + if (placements.length === 0) return 0; + + const total = placements.reduce((sum, placement) => { + return sum + calculateFantasyPoints(placement, rules); + }, 0); + + return total / placements.length; +} + +/** + * Get points array as a simple ordered list [1st, 2nd, 3rd, ..., 8th] + * Useful for display purposes + */ +export function getScoringRulesArray(rules: ScoringRules): number[] { + return [ + rules.pointsFor1st, + rules.pointsFor2nd, + rules.pointsFor3rd, + rules.pointsFor4th, + rules.pointsFor5th, + rules.pointsFor6th, + rules.pointsFor7th, + rules.pointsFor8th, + ]; +} diff --git a/app/models/standings.ts b/app/models/standings.ts new file mode 100644 index 0000000..47361a7 --- /dev/null +++ b/app/models/standings.ts @@ -0,0 +1,329 @@ +import { database } from "~/database/context"; +import * as schema from "~/database/schema"; +import { eq, and, desc } from "drizzle-orm"; + +export interface TeamStanding { + teamId: string; + teamName: string; + totalPoints: number; + currentRank: number; + previousRank: number | null; + rankChange: number; // Positive = moved up, negative = moved down + placementCounts: { + first: number; + second: number; + third: number; + fourth: number; + fifth: number; + sixth: number; + seventh: number; + eighth: number; + }; + participantsRemaining: number; + calculatedAt: Date; +} + +export interface TeamStandingSnapshot { + date: Date; + rank: number; + totalPoints: number; +} + +/** + * 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), + orderBy: [ + desc(schema.teamStandings.currentRank), // Lower rank number = better + desc(schema.teamStandings.totalPoints), + ], + with: { + team: true, + }, + }); + + return standings.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, + })); +} + +/** + * 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, + }; +} + +/** + * 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, + }, + }, + }, + }); + + // 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 = { + 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, + }, + finalPosition: result?.finalPosition || null, + points, + isComplete: !!result?.finalPosition, + }; + }); + + // Group by sport for easier display + const bySport: Record = {}; + for (const pick of pickBreakdown) { + const sport = pick.participant.sport; + if (!bySport[sport]) { + bySport[sport] = []; + } + bySport[sport].push(pick); + } + + 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 +): 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, + }); + } + } + + console.log(`[Standings] Created daily snapshot for season ${seasonId}`); +} diff --git a/database/schema.ts b/database/schema.ts index 1593d01..e87328b 100644 --- a/database/schema.ts +++ b/database/schema.ts @@ -585,6 +585,21 @@ export const draftSlotsRelations = relations(draftSlots, ({ one }) => ({ }), })); +export const draftPicksRelations = relations(draftPicks, ({ one }) => ({ + season: one(seasons, { + fields: [draftPicks.seasonId], + references: [seasons.id], + }), + team: one(teams, { + fields: [draftPicks.teamId], + references: [teams.id], + }), + participant: one(participants, { + fields: [draftPicks.participantId], + references: [participants.id], + }), +})); + export const draftQueueRelations = relations(draftQueue, ({ one }) => ({ season: one(seasons, { fields: [draftQueue.seasonId], diff --git a/plans/scoring-system.md b/plans/scoring-system.md index fad799d..933dfb1 100644 --- a/plans/scoring-system.md +++ b/plans/scoring-system.md @@ -923,27 +923,32 @@ All clarification questions (Q1-Q21) have been answered and confirmed: ### Phase 1: Core Scoring Infrastructure **Goal**: Set up database schema and basic scoring configuration -- [ ] **1.1** Update database schema - - [ ] Add 8 point fields to `seasons` table - - [ ] Add `scoringPattern` enum to replace/augment `scoringType` - - [ ] Add `totalMajors`, `majorsCompleted`, `qualifyingPointsFinalized` to `sportsSeasons` - - [ ] Create `scoring_events` table - - [ ] Create `event_results` table - - [ ] Create `playoff_matches` table - - [ ] Create `participant_qualifying_totals` table - - [ ] Create `team_sport_scores` table - - [ ] Create `team_standings` table - - [ ] Create `team_standings_snapshots` table (for daily tracking) - - [ ] Create `participant_expected_values` table - - [ ] Create `participant_season_results` table (for F1 current points) - - [ ] Run migration +- [x] **1.1** Update database schema ✅ *Completed* + - [x] Add 8 point fields to `seasons` table + - [x] Add `scoringPattern` enum to replace/augment `scoringType` + - [x] Add `totalMajors`, `majorsCompleted`, `qualifyingPointsFinalized` to `sportsSeasons` + - [x] Create `scoring_events` table + - [x] Create `event_results` table + - [x] Create `playoff_matches` table + - [x] Create `participant_qualifying_totals` table + - [x] Create `team_sport_scores` table + - [x] Create `team_standings` table + - [x] Create `team_standings_snapshots` table (for daily tracking) + - [x] Create `participant_expected_values` table + - [x] Create `participant_season_results` table (for F1 current points) + - [x] Create `qualifying_point_config` table (configurable QP values) + - [x] Add all relations for new tables + - [x] Run migration + - [x] Update test fixtures with new scoring fields -- [ ] **1.2** Create model functions (basic structure) - - [ ] `app/models/scoring-rules.ts` - Get/update season scoring config - - [ ] `app/models/scoring-event.ts` - CRUD for scoring events - - [ ] `app/models/event-result.ts` - Record event results - - [ ] `app/models/scoring-calculator.ts` - Calculate points and standings - - [ ] `app/models/standings.ts` - Get standings and team breakdowns +- [x] **1.2** Create model functions (basic structure) ✅ *Completed* + - [x] `app/models/scoring-rules.ts` - Get/update season scoring config + - [x] `app/models/scoring-event.ts` - CRUD for scoring events + - [x] `app/models/event-result.ts` - Record event results + - [x] `app/models/scoring-calculator.ts` - Calculate points and standings (core logic placeholder) + - [x] `app/models/standings.ts` - Get standings and team breakdowns + - [x] Add draftPicks relations to schema + - [x] All TypeScript compilation passes - [ ] **1.3** Update season settings UI - [ ] Add scoring rules editor component