feat: Add scoring event and results management with comprehensive scoring rules and standings tracking
This commit is contained in:
parent
8e422c6503
commit
e49bb3df8b
7 changed files with 1225 additions and 20 deletions
243
app/models/event-result.ts
Normal file
243
app/models/event-result.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
269
app/models/scoring-calculator.ts
Normal file
269
app/models/scoring-calculator.ts
Normal 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
197
app/models/scoring-event.ts
Normal 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
147
app/models/scoring-rules.ts
Normal 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
329
app/models/standings.ts
Normal 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}`);
|
||||
}
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue