feat: Add scoring event and results management with comprehensive scoring rules and standings tracking

This commit is contained in:
Chris Parsons 2025-10-28 23:50:50 -07:00
parent 8e422c6503
commit e49bb3df8b
7 changed files with 1225 additions and 20 deletions

243
app/models/event-result.ts Normal file
View file

@ -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<typeof database>
) {
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<typeof database>
) {
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<typeof database>
) {
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<typeof database>
) {
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<typeof database>
) {
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<typeof database>
) {
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<typeof database>
) {
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<typeof database>
) {
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<typeof database>
): Promise<boolean> {
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<typeof database>
) {
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,
},
},
},
},
},
});
}

View file

@ -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<typeof database>
): Promise<void> {
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<typeof database>
): Promise<void> {
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<typeof database>
): Promise<void> {
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<typeof database>
): Promise<void> {
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<typeof database>
): Promise<{
totalPoints: number;
placementCounts: Record<number, number>;
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<number, number> = {
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<typeof database>
): Promise<void> {
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<typeof database>
): Promise<void> {
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}`
);
}

197
app/models/scoring-event.ts Normal file
View file

@ -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<typeof database>
) {
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<typeof database>
) {
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<typeof database>
) {
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<typeof database>
) {
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<typeof database>
) {
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<typeof database>
) {
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<typeof database>
) {
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<typeof database>
) {
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<typeof database>
): Promise<boolean> {
const db = providedDb || database();
const incompleteEvents = await getIncompleteScoringEvents(sportsSeasonId, db);
return incompleteEvents.length === 0;
}

147
app/models/scoring-rules.ts Normal file
View file

@ -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<typeof database>
): Promise<ScoringRules | null> {
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<ScoringRules>,
providedDb?: ReturnType<typeof database>
): Promise<ScoringRules> {
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<number, number> = {
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,
];
}

329
app/models/standings.ts Normal file
View file

@ -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<typeof database>
): Promise<TeamStanding[]> {
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<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,
},
finalPosition: result?.finalPosition || null,
points,
isComplete: !!result?.finalPosition,
};
});
// Group by sport for easier display
const bySport: Record<string, typeof pickBreakdown> = {};
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<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}`);
}

View file

@ -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],

View file

@ -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