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 }) => ({
|
export const draftQueueRelations = relations(draftQueue, ({ one }) => ({
|
||||||
season: one(seasons, {
|
season: one(seasons, {
|
||||||
fields: [draftQueue.seasonId],
|
fields: [draftQueue.seasonId],
|
||||||
|
|
|
||||||
|
|
@ -923,27 +923,32 @@ All clarification questions (Q1-Q21) have been answered and confirmed:
|
||||||
### Phase 1: Core Scoring Infrastructure
|
### Phase 1: Core Scoring Infrastructure
|
||||||
**Goal**: Set up database schema and basic scoring configuration
|
**Goal**: Set up database schema and basic scoring configuration
|
||||||
|
|
||||||
- [ ] **1.1** Update database schema
|
- [x] **1.1** Update database schema ✅ *Completed*
|
||||||
- [ ] Add 8 point fields to `seasons` table
|
- [x] Add 8 point fields to `seasons` table
|
||||||
- [ ] Add `scoringPattern` enum to replace/augment `scoringType`
|
- [x] Add `scoringPattern` enum to replace/augment `scoringType`
|
||||||
- [ ] Add `totalMajors`, `majorsCompleted`, `qualifyingPointsFinalized` to `sportsSeasons`
|
- [x] Add `totalMajors`, `majorsCompleted`, `qualifyingPointsFinalized` to `sportsSeasons`
|
||||||
- [ ] Create `scoring_events` table
|
- [x] Create `scoring_events` table
|
||||||
- [ ] Create `event_results` table
|
- [x] Create `event_results` table
|
||||||
- [ ] Create `playoff_matches` table
|
- [x] Create `playoff_matches` table
|
||||||
- [ ] Create `participant_qualifying_totals` table
|
- [x] Create `participant_qualifying_totals` table
|
||||||
- [ ] Create `team_sport_scores` table
|
- [x] Create `team_sport_scores` table
|
||||||
- [ ] Create `team_standings` table
|
- [x] Create `team_standings` table
|
||||||
- [ ] Create `team_standings_snapshots` table (for daily tracking)
|
- [x] Create `team_standings_snapshots` table (for daily tracking)
|
||||||
- [ ] Create `participant_expected_values` table
|
- [x] Create `participant_expected_values` table
|
||||||
- [ ] Create `participant_season_results` table (for F1 current points)
|
- [x] Create `participant_season_results` table (for F1 current points)
|
||||||
- [ ] Run migration
|
- [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)
|
- [x] **1.2** Create model functions (basic structure) ✅ *Completed*
|
||||||
- [ ] `app/models/scoring-rules.ts` - Get/update season scoring config
|
- [x] `app/models/scoring-rules.ts` - Get/update season scoring config
|
||||||
- [ ] `app/models/scoring-event.ts` - CRUD for scoring events
|
- [x] `app/models/scoring-event.ts` - CRUD for scoring events
|
||||||
- [ ] `app/models/event-result.ts` - Record event results
|
- [x] `app/models/event-result.ts` - Record event results
|
||||||
- [ ] `app/models/scoring-calculator.ts` - Calculate points and standings
|
- [x] `app/models/scoring-calculator.ts` - Calculate points and standings (core logic placeholder)
|
||||||
- [ ] `app/models/standings.ts` - Get standings and team breakdowns
|
- [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
|
- [ ] **1.3** Update season settings UI
|
||||||
- [ ] Add scoring rules editor component
|
- [ ] Add scoring rules editor component
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue