feat: Implement season standings processing and participant result management for final standings events
This commit is contained in:
parent
5d0e6b999c
commit
d926486934
7 changed files with 755 additions and 29 deletions
176
app/models/__tests__/season-standings.test.ts
Normal file
176
app/models/__tests__/season-standings.test.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { calculateFantasyPoints, calculateAveragedPoints, type ScoringRules } from "../scoring-rules";
|
||||
|
||||
const DEFAULT_SCORING: ScoringRules = {
|
||||
pointsFor1st: 100,
|
||||
pointsFor2nd: 70,
|
||||
pointsFor3rd: 50,
|
||||
pointsFor4th: 40,
|
||||
pointsFor5th: 25,
|
||||
pointsFor6th: 25,
|
||||
pointsFor7th: 15,
|
||||
pointsFor8th: 15,
|
||||
};
|
||||
|
||||
describe("Season Standings (F1 Pattern)", () => {
|
||||
describe("Basic Placement Scoring", () => {
|
||||
it("should calculate points for 1st place finisher", () => {
|
||||
const points = calculateFantasyPoints(1, DEFAULT_SCORING);
|
||||
expect(points).toBe(100);
|
||||
});
|
||||
|
||||
it("should calculate points for 8th place finisher", () => {
|
||||
const points = calculateFantasyPoints(8, DEFAULT_SCORING);
|
||||
expect(points).toBe(15);
|
||||
});
|
||||
|
||||
it("should return 0 points for finishers beyond 8th place", () => {
|
||||
const points = calculateFantasyPoints(0, DEFAULT_SCORING);
|
||||
expect(points).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tie Scenarios", () => {
|
||||
it("should handle 2-way tie for 3rd place", () => {
|
||||
// Two drivers tied for 3rd get average of 3rd and 4th place points
|
||||
const points = calculateAveragedPoints([3, 4], DEFAULT_SCORING);
|
||||
// (50 + 40) / 2 = 45
|
||||
expect(points).toBe(45);
|
||||
});
|
||||
|
||||
it("should handle 3-way tie for 5th place", () => {
|
||||
// Three drivers tied for 5th share 5th, 6th, and 7th place points
|
||||
const points = calculateAveragedPoints([5, 6, 7], DEFAULT_SCORING);
|
||||
// (25 + 25 + 15) / 3 = 21.67
|
||||
expect(points).toBeCloseTo(21.67, 2);
|
||||
});
|
||||
|
||||
it("should handle 4-way tie for 1st place", () => {
|
||||
// Four drivers tied for 1st (unlikely but possible)
|
||||
const points = calculateAveragedPoints([1, 2, 3, 4], DEFAULT_SCORING);
|
||||
// (100 + 70 + 50 + 40) / 4 = 65
|
||||
expect(points).toBe(65);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Season Standings Simulation", () => {
|
||||
it("should correctly score a complete F1-style season (no ties)", () => {
|
||||
// Simulate final standings positions 1-10
|
||||
const standings = [
|
||||
{ driver: "Max Verstappen", position: 1, fantasyPoints: 100 },
|
||||
{ driver: "Lewis Hamilton", position: 2, fantasyPoints: 70 },
|
||||
{ driver: "Charles Leclerc", position: 3, fantasyPoints: 50 },
|
||||
{ driver: "Lando Norris", position: 4, fantasyPoints: 40 },
|
||||
{ driver: "Carlos Sainz", position: 5, fantasyPoints: 25 },
|
||||
{ driver: "George Russell", position: 6, fantasyPoints: 25 },
|
||||
{ driver: "Sergio Perez", position: 7, fantasyPoints: 15 },
|
||||
{ driver: "Fernando Alonso", position: 8, fantasyPoints: 15 },
|
||||
{ driver: "Oscar Piastri", position: 9, fantasyPoints: 0 }, // Beyond top 8
|
||||
{ driver: "Pierre Gasly", position: 10, fantasyPoints: 0 }, // Beyond top 8
|
||||
];
|
||||
|
||||
standings.forEach((entry) => {
|
||||
const points = calculateFantasyPoints(entry.position, DEFAULT_SCORING);
|
||||
expect(points).toBe(entry.fantasyPoints);
|
||||
});
|
||||
|
||||
// Total points if one team drafted all top 8
|
||||
const totalTop8 = 100 + 70 + 50 + 40 + 25 + 25 + 15 + 15;
|
||||
expect(totalTop8).toBe(340);
|
||||
});
|
||||
|
||||
it("should correctly score season with tied positions", () => {
|
||||
// Simulate standings where 3rd, 4th, and 5th are tied
|
||||
const standings = [
|
||||
{ position: 1, expected: 100 },
|
||||
{ position: 2, expected: 70 },
|
||||
// Three tied for 3rd place - each gets placement 3
|
||||
// When scoring, these will share 3rd, 4th, 5th
|
||||
{ position: 3, base: 50 }, // Will be averaged: (50+40+25)/3
|
||||
{ position: 3, base: 50 },
|
||||
{ position: 3, base: 50 },
|
||||
{ position: 6, expected: 25 },
|
||||
{ position: 7, expected: 15 },
|
||||
{ position: 8, expected: 15 },
|
||||
];
|
||||
|
||||
// Verify first two positions
|
||||
expect(calculateFantasyPoints(1, DEFAULT_SCORING)).toBe(100);
|
||||
expect(calculateFantasyPoints(2, DEFAULT_SCORING)).toBe(70);
|
||||
|
||||
// Verify tied positions get averaged
|
||||
const tiedPoints = calculateAveragedPoints([3, 4, 5], DEFAULT_SCORING);
|
||||
expect(tiedPoints).toBeCloseTo(38.33, 2); // (50 + 40 + 25) / 3
|
||||
|
||||
// Verify remaining positions
|
||||
expect(calculateFantasyPoints(6, DEFAULT_SCORING)).toBe(25);
|
||||
expect(calculateFantasyPoints(7, DEFAULT_SCORING)).toBe(15);
|
||||
expect(calculateFantasyPoints(8, DEFAULT_SCORING)).toBe(15);
|
||||
});
|
||||
|
||||
it("should handle ties that cross the 8th place boundary", () => {
|
||||
// Simulate where 7th, 8th, and 9th are tied
|
||||
// In practice, 7th and 8th would score, 9th would get 0
|
||||
|
||||
// First 7 and 8 get points
|
||||
expect(calculateFantasyPoints(7, DEFAULT_SCORING)).toBe(15);
|
||||
expect(calculateFantasyPoints(8, DEFAULT_SCORING)).toBe(15);
|
||||
|
||||
// If they share 7th and 8th place
|
||||
const shared7and8 = calculateAveragedPoints([7, 8], DEFAULT_SCORING);
|
||||
expect(shared7and8).toBe(15); // (15 + 15) / 2 = 15
|
||||
|
||||
// 9th gets 0
|
||||
expect(calculateFantasyPoints(0, DEFAULT_SCORING)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Custom Scoring Rules", () => {
|
||||
it("should work with custom F1 league scoring rules", () => {
|
||||
const customScoring: ScoringRules = {
|
||||
pointsFor1st: 150,
|
||||
pointsFor2nd: 100,
|
||||
pointsFor3rd: 75,
|
||||
pointsFor4th: 60,
|
||||
pointsFor5th: 50,
|
||||
pointsFor6th: 40,
|
||||
pointsFor7th: 30,
|
||||
pointsFor8th: 20,
|
||||
};
|
||||
|
||||
expect(calculateFantasyPoints(1, customScoring)).toBe(150);
|
||||
expect(calculateFantasyPoints(8, customScoring)).toBe(20);
|
||||
|
||||
// Tied positions
|
||||
const tied3rd = calculateAveragedPoints([3, 4], customScoring);
|
||||
expect(tied3rd).toBe(67.5); // (75 + 60) / 2
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle all 8 positions tied", () => {
|
||||
// Extremely unlikely but mathematically possible
|
||||
const allTied = calculateAveragedPoints([1, 2, 3, 4, 5, 6, 7, 8], DEFAULT_SCORING);
|
||||
// (100 + 70 + 50 + 40 + 25 + 25 + 15 + 15) / 8 = 42.5
|
||||
expect(allTied).toBe(42.5);
|
||||
});
|
||||
|
||||
it("should handle only top 4 finishing (others DNF/DQ)", () => {
|
||||
// If only 4 participants finish the season
|
||||
expect(calculateFantasyPoints(1, DEFAULT_SCORING)).toBe(100);
|
||||
expect(calculateFantasyPoints(2, DEFAULT_SCORING)).toBe(70);
|
||||
expect(calculateFantasyPoints(3, DEFAULT_SCORING)).toBe(50);
|
||||
expect(calculateFantasyPoints(4, DEFAULT_SCORING)).toBe(40);
|
||||
|
||||
// Positions 5-8 would have no participants
|
||||
// Any non-finishers get 0
|
||||
expect(calculateFantasyPoints(0, DEFAULT_SCORING)).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle single participant season", () => {
|
||||
// Only 1 participant completes the season
|
||||
const points = calculateFantasyPoints(1, DEFAULT_SCORING);
|
||||
expect(points).toBe(100);
|
||||
});
|
||||
});
|
||||
});
|
||||
244
app/models/participant-season-result.ts
Normal file
244
app/models/participant-season-result.ts
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq, and, inArray, desc } from "drizzle-orm";
|
||||
|
||||
export interface UpsertSeasonResultData {
|
||||
participantId: string;
|
||||
sportsSeasonId: string;
|
||||
currentPoints?: number;
|
||||
currentPosition?: number;
|
||||
}
|
||||
|
||||
export interface UpdateSeasonResultData {
|
||||
currentPoints?: number;
|
||||
currentPosition?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a participant's season result (for F1, etc.)
|
||||
* Creates if doesn't exist, updates if it does
|
||||
*/
|
||||
export async function upsertParticipantSeasonResult(
|
||||
data: UpsertSeasonResultData,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
) {
|
||||
const db = providedDb || database();
|
||||
|
||||
// Check if result exists
|
||||
const existing = await db.query.participantSeasonResults.findFirst({
|
||||
where: and(
|
||||
eq(schema.participantSeasonResults.participantId, data.participantId),
|
||||
eq(schema.participantSeasonResults.sportsSeasonId, data.sportsSeasonId)
|
||||
),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
// Update existing
|
||||
const [updated] = await db
|
||||
.update(schema.participantSeasonResults)
|
||||
.set({
|
||||
currentPoints: data.currentPoints?.toString(),
|
||||
currentPosition: data.currentPosition,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.participantSeasonResults.id, existing.id))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
} else {
|
||||
// Insert new
|
||||
const [created] = await db
|
||||
.insert(schema.participantSeasonResults)
|
||||
.values({
|
||||
participantId: data.participantId,
|
||||
sportsSeasonId: data.sportsSeasonId,
|
||||
currentPoints: data.currentPoints?.toString() || "0",
|
||||
currentPosition: data.currentPosition,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return created;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk upsert season results for multiple participants
|
||||
* Useful for entering standings for an entire F1 grid at once
|
||||
*/
|
||||
export async function upsertSeasonResultsBulk(
|
||||
results: UpsertSeasonResultData[],
|
||||
providedDb?: ReturnType<typeof database>
|
||||
) {
|
||||
const db = providedDb || database();
|
||||
|
||||
const upserted = [];
|
||||
for (const data of results) {
|
||||
const result = await upsertParticipantSeasonResult(data, db);
|
||||
upserted.push(result);
|
||||
}
|
||||
|
||||
return upserted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a participant's season result
|
||||
*/
|
||||
export async function getParticipantSeasonResult(
|
||||
participantId: string,
|
||||
sportsSeasonId: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
) {
|
||||
const db = providedDb || database();
|
||||
|
||||
return await db.query.participantSeasonResults.findFirst({
|
||||
where: and(
|
||||
eq(schema.participantSeasonResults.participantId, participantId),
|
||||
eq(schema.participantSeasonResults.sportsSeasonId, sportsSeasonId)
|
||||
),
|
||||
with: {
|
||||
participant: {
|
||||
with: {
|
||||
sportsSeason: {
|
||||
with: {
|
||||
sport: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all season results for a sports season
|
||||
* Returns participants ordered by current position (or points if position not set)
|
||||
*/
|
||||
export async function getSeasonResults(
|
||||
sportsSeasonId: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
) {
|
||||
const db = providedDb || database();
|
||||
|
||||
const results = await db.query.participantSeasonResults.findMany({
|
||||
where: eq(schema.participantSeasonResults.sportsSeasonId, sportsSeasonId),
|
||||
with: {
|
||||
participant: {
|
||||
with: {
|
||||
sportsSeason: {
|
||||
with: {
|
||||
sport: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Sort by position (nulls last), then by points descending
|
||||
return results.sort((a, b) => {
|
||||
if (a.currentPosition !== null && b.currentPosition !== null) {
|
||||
return a.currentPosition - b.currentPosition;
|
||||
}
|
||||
if (a.currentPosition !== null) return -1;
|
||||
if (b.currentPosition !== null) return 1;
|
||||
|
||||
// Both null positions, sort by points
|
||||
const aPoints = parseFloat(a.currentPoints || "0");
|
||||
const bPoints = parseFloat(b.currentPoints || "0");
|
||||
return bPoints - aPoints;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get season results for specific participants
|
||||
*/
|
||||
export async function getSeasonResultsForParticipants(
|
||||
sportsSeasonId: string,
|
||||
participantIds: string[],
|
||||
providedDb?: ReturnType<typeof database>
|
||||
) {
|
||||
const db = providedDb || database();
|
||||
|
||||
if (participantIds.length === 0) return [];
|
||||
|
||||
return await db.query.participantSeasonResults.findMany({
|
||||
where: and(
|
||||
eq(schema.participantSeasonResults.sportsSeasonId, sportsSeasonId),
|
||||
inArray(schema.participantSeasonResults.participantId, participantIds)
|
||||
),
|
||||
with: {
|
||||
participant: {
|
||||
with: {
|
||||
sportsSeason: {
|
||||
with: {
|
||||
sport: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a participant's season result
|
||||
*/
|
||||
export async function updateParticipantSeasonResult(
|
||||
participantId: string,
|
||||
sportsSeasonId: string,
|
||||
data: UpdateSeasonResultData,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
) {
|
||||
const db = providedDb || database();
|
||||
|
||||
const [updated] = await db
|
||||
.update(schema.participantSeasonResults)
|
||||
.set({
|
||||
currentPoints: data.currentPoints?.toString(),
|
||||
currentPosition: data.currentPosition,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(schema.participantSeasonResults.participantId, participantId),
|
||||
eq(schema.participantSeasonResults.sportsSeasonId, sportsSeasonId)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all season results for a sports season
|
||||
*/
|
||||
export async function deleteSeasonResults(
|
||||
sportsSeasonId: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
) {
|
||||
const db = providedDb || database();
|
||||
|
||||
await db
|
||||
.delete(schema.participantSeasonResults)
|
||||
.where(eq(schema.participantSeasonResults.sportsSeasonId, sportsSeasonId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a participant has a season result
|
||||
*/
|
||||
export async function hasParticipantSeasonResult(
|
||||
participantId: string,
|
||||
sportsSeasonId: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<boolean> {
|
||||
const db = providedDb || database();
|
||||
|
||||
const result = await db.query.participantSeasonResults.findFirst({
|
||||
where: and(
|
||||
eq(schema.participantSeasonResults.participantId, participantId),
|
||||
eq(schema.participantSeasonResults.sportsSeasonId, sportsSeasonId)
|
||||
),
|
||||
});
|
||||
|
||||
return !!result;
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ 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";
|
||||
import { getSeasonResults } from "./participant-season-result";
|
||||
|
||||
/**
|
||||
* Core scoring calculation engine
|
||||
|
|
@ -227,7 +228,11 @@ export async function finalizeQualifyingPoints(
|
|||
|
||||
/**
|
||||
* Process season standings (F1) and assign final placements
|
||||
* Implementation will be added in Phase 3
|
||||
*
|
||||
* Q7: For F1, we show current F1 points during the season, then assign fantasy points at the end
|
||||
* Q8: Just final standings, not individual races
|
||||
* Q14: Store current running total, manual update with API future
|
||||
* Q13/Q19: Ties handled by admin entering same position, system auto-shares placements
|
||||
*/
|
||||
export async function processSeasonStandings(
|
||||
sportsSeasonId: string,
|
||||
|
|
@ -235,14 +240,89 @@ export async function processSeasonStandings(
|
|||
): 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
|
||||
// Get all season results for this sports season, sorted by standings
|
||||
const seasonResults = await getSeasonResults(sportsSeasonId, db);
|
||||
|
||||
// Group participants by position to handle ties
|
||||
const positionGroups: Record<number, string[]> = {};
|
||||
|
||||
for (const result of seasonResults) {
|
||||
const position = result.currentPosition;
|
||||
if (position === null) continue; // Skip participants without a position
|
||||
|
||||
if (!positionGroups[position]) {
|
||||
positionGroups[position] = [];
|
||||
}
|
||||
positionGroups[position].push(result.participantId);
|
||||
}
|
||||
|
||||
// Get sorted positions
|
||||
const positions = Object.keys(positionGroups)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
// Assign fantasy placements (1-8) based on standings
|
||||
let currentPlacement = 1;
|
||||
|
||||
for (const position of positions) {
|
||||
const participantIds = positionGroups[position];
|
||||
|
||||
// Stop if we've gone beyond the top 8 fantasy placements
|
||||
if (currentPlacement > 8) break;
|
||||
|
||||
// Calculate how many placements this group shares
|
||||
const participantsInGroup = participantIds.length;
|
||||
|
||||
// If this group would exceed position 8, only some participants get points
|
||||
const maxPlacementForGroup = Math.min(8, currentPlacement + participantsInGroup - 1);
|
||||
const actualParticipantsScoring = maxPlacementForGroup - currentPlacement + 1;
|
||||
|
||||
// Determine if we should assign placements to this group
|
||||
if (currentPlacement <= 8) {
|
||||
// All participants tied at this position get the first placement in the range
|
||||
// (e.g., if 4 people tie for 5th place, they all get placement 5)
|
||||
// The scoring system will handle averaging the points for positions 5, 6, 7, 8
|
||||
for (let i = 0; i < Math.min(participantIds.length, actualParticipantsScoring); i++) {
|
||||
const participantId = participantIds[i];
|
||||
await upsertParticipantResult(
|
||||
participantId,
|
||||
sportsSeasonId,
|
||||
currentPlacement,
|
||||
db
|
||||
);
|
||||
}
|
||||
|
||||
// If there are more participants than scoring positions, assign 0 to the rest
|
||||
for (let i = actualParticipantsScoring; i < participantIds.length; i++) {
|
||||
const participantId = participantIds[i];
|
||||
await upsertParticipantResult(
|
||||
participantId,
|
||||
sportsSeasonId,
|
||||
0, // Beyond top 8
|
||||
db
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Position is beyond top 8, assign 0 points
|
||||
for (const participantId of participantIds) {
|
||||
await upsertParticipantResult(
|
||||
participantId,
|
||||
sportsSeasonId,
|
||||
0,
|
||||
db
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Move to next placement group
|
||||
currentPlacement += participantsInGroup;
|
||||
}
|
||||
|
||||
// Trigger recalculation for all affected leagues
|
||||
await recalculateAffectedLeagues(sportsSeasonId, db);
|
||||
|
||||
console.log(
|
||||
`[ScoringCalculator] processSeasonStandings not yet implemented: ${sportsSeasonId}`
|
||||
`[ScoringCalculator] Processed season standings for sports season ${sportsSeasonId}`
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,11 @@ import {
|
|||
type UpdateEventResultData,
|
||||
} from "~/models/event-result";
|
||||
import { findParticipantResultsBySportsSeasonId } from "~/models/participant-result";
|
||||
import {
|
||||
upsertParticipantSeasonResult,
|
||||
getSeasonResults,
|
||||
} from "~/models/participant-season-result";
|
||||
import { processSeasonStandings } from "~/models/scoring-calculator";
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
const sportsSeason = await findSportsSeasonById(params.id);
|
||||
|
|
@ -32,6 +37,12 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
const results = await getEventResults(params.eventId);
|
||||
const participantResults = await findParticipantResultsBySportsSeasonId(params.id);
|
||||
|
||||
// For final_standings events, also get season results
|
||||
let seasonResults = null;
|
||||
if (event.eventType === "final_standings") {
|
||||
seasonResults = await getSeasonResults(params.id);
|
||||
}
|
||||
|
||||
return {
|
||||
sportsSeason: sportsSeason as typeof sportsSeason & {
|
||||
sport: { id: string; name: string; type: string; slug: string };
|
||||
|
|
@ -40,6 +51,7 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
participants,
|
||||
results,
|
||||
participantResults,
|
||||
seasonResults,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -49,7 +61,19 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
|
||||
if (intent === "complete") {
|
||||
try {
|
||||
const event = await getScoringEventById(params.eventId);
|
||||
if (!event) {
|
||||
return { error: "Event not found" };
|
||||
}
|
||||
|
||||
await completeScoringEvent(params.eventId);
|
||||
|
||||
// If this is a final_standings event, process season standings
|
||||
if (event.eventType === "final_standings") {
|
||||
await processSeasonStandings(params.id);
|
||||
return { success: "Event completed and fantasy placements assigned to top 8!" };
|
||||
}
|
||||
|
||||
return { success: "Event marked as completed" };
|
||||
} catch (error) {
|
||||
console.error("Error completing event:", error);
|
||||
|
|
@ -135,5 +159,66 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
}
|
||||
}
|
||||
|
||||
if (intent === "update-standings") {
|
||||
// Bulk update season standings for final_standings events
|
||||
try {
|
||||
// Parse all form fields and collect participants with points
|
||||
const participantPoints: Array<{ participantId: string; points: number }> = [];
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (key.startsWith("points-")) {
|
||||
const participantId = key.replace("points-", "");
|
||||
const points = value ? parseFloat(value as string) : 0;
|
||||
|
||||
if (points > 0) {
|
||||
participantPoints.push({ participantId, points });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by points descending to determine positions
|
||||
participantPoints.sort((a, b) => b.points - a.points);
|
||||
|
||||
// Assign positions based on sorted order and update
|
||||
for (let i = 0; i < participantPoints.length; i++) {
|
||||
const { participantId, points } = participantPoints[i];
|
||||
const position = i + 1; // Position is 1-based
|
||||
|
||||
await upsertParticipantSeasonResult(
|
||||
{
|
||||
participantId,
|
||||
sportsSeasonId: params.id,
|
||||
currentPoints: points,
|
||||
currentPosition: position,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Also update participants with 0 or no points (they don't have a position)
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (key.startsWith("points-")) {
|
||||
const participantId = key.replace("points-", "");
|
||||
const points = value ? parseFloat(value as string) : 0;
|
||||
|
||||
if (points === 0) {
|
||||
await upsertParticipantSeasonResult(
|
||||
{
|
||||
participantId,
|
||||
sportsSeasonId: params.id,
|
||||
currentPoints: 0,
|
||||
currentPosition: undefined,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: `Updated standings for ${participantPoints.length} participants (positions auto-calculated from points)` };
|
||||
} catch (error) {
|
||||
console.error("Error updating season standings:", error);
|
||||
return { error: "Failed to update season standings" };
|
||||
}
|
||||
}
|
||||
|
||||
return { error: "Invalid action" };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import {
|
|||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { ArrowLeft, Trophy, CheckCircle2, Pencil, Trash2, Brackets } from "lucide-react";
|
||||
import { ArrowLeft, Trophy, CheckCircle2, Pencil, Trash2, Brackets, Save } from "lucide-react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
|
|
@ -28,6 +28,7 @@ import {
|
|||
TableHeader,
|
||||
TableRow,
|
||||
} from "~/components/ui/table";
|
||||
import { useState } from "react";
|
||||
|
||||
export { loader, action };
|
||||
|
||||
|
|
@ -35,7 +36,8 @@ export default function EventResults({
|
|||
loaderData,
|
||||
actionData,
|
||||
}: Route.ComponentProps) {
|
||||
const { sportsSeason, event, participants, results, participantResults } = loaderData;
|
||||
const { sportsSeason, event, participants, results, participantResults, seasonResults } = loaderData;
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
// Create a map of participants with results for easy lookup
|
||||
const participantResultsMap = new Map(
|
||||
|
|
@ -50,6 +52,13 @@ export default function EventResults({
|
|||
// Sort results by placement
|
||||
const sortedResults = [...results].sort((a, b) => (a.placement || 999) - (b.placement || 999));
|
||||
|
||||
// Create a map of season results for easy lookup
|
||||
const seasonResultsMap = seasonResults
|
||||
? new Map(
|
||||
seasonResults.map((r: { participantId: string }) => [r.participantId, r])
|
||||
)
|
||||
: new Map();
|
||||
|
||||
const getEventTypeLabel = (type: string) => {
|
||||
switch (type) {
|
||||
case "playoff_game":
|
||||
|
|
@ -116,8 +125,105 @@ export default function EventResults({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Result Form */}
|
||||
{!event.isComplete && (
|
||||
{/* Season Standings Table for final_standings events */}
|
||||
{event.eventType === "final_standings" && !event.isComplete && (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Season Standings Tracker</CardTitle>
|
||||
<CardDescription>
|
||||
Enter championship points for each participant. Positions are automatically calculated based on points (highest = 1st).
|
||||
When the season ends, mark this event as complete to assign fantasy points to the top 8.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form method="post" className="space-y-4">
|
||||
<input type="hidden" name="intent" value="update-standings" />
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Participant</TableHead>
|
||||
<TableHead className="text-right">Championship Points</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{participants.map((participant: { id: string; name: string }) => {
|
||||
const result = seasonResultsMap.get(participant.id);
|
||||
const currentPoints = result?.currentPoints || "";
|
||||
|
||||
return (
|
||||
<TableRow key={participant.id}>
|
||||
<TableCell className="font-medium">
|
||||
{participant.name}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Input
|
||||
type="number"
|
||||
name={`points-${participant.id}`}
|
||||
defaultValue={currentPoints}
|
||||
placeholder="e.g., 250"
|
||||
step="0.01"
|
||||
min="0"
|
||||
className="w-32 ml-auto"
|
||||
onChange={() => setHasChanges(true)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className="flex justify-between items-center pt-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{hasChanges
|
||||
? "You have unsaved changes"
|
||||
: "Update standings after each race/event during the season"}
|
||||
</p>
|
||||
<Button type="submit">
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Save Standings
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Complete Season Button */}
|
||||
<Card className="border-orange-200 dark:border-orange-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-orange-600 dark:text-orange-400">
|
||||
<Trophy className="inline mr-2 h-5 w-5" />
|
||||
Finalize Season
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
When the season is complete, mark this event as complete to:
|
||||
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||
<li>Convert the top 8 participants (by position) to fantasy placements 1-8</li>
|
||||
<li>Award fantasy points based on league scoring rules</li>
|
||||
<li>Update all league standings</li>
|
||||
</ul>
|
||||
<p className="mt-2 font-semibold text-orange-600 dark:text-orange-400">
|
||||
⚠️ Make sure all standings are saved before completing
|
||||
</p>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form method="post">
|
||||
<input type="hidden" name="intent" value="complete" />
|
||||
<Button type="submit" variant="default" className="bg-orange-600 hover:bg-orange-700">
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
Mark Season Complete & Assign Fantasy Points
|
||||
</Button>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Regular Result Entry for other event types */}
|
||||
{event.eventType !== "final_standings" && !event.isComplete && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Add Result</CardTitle>
|
||||
|
|
@ -193,7 +299,7 @@ export default function EventResults({
|
|||
results
|
||||
</CardDescription>
|
||||
</div>
|
||||
{!event.isComplete && results.length > 0 && (
|
||||
{!event.isComplete && results.length > 0 && event.eventType !== "final_standings" && (
|
||||
<Form method="post">
|
||||
<input type="hidden" name="intent" value="complete" />
|
||||
<Button type="submit" variant="outline" size="sm">
|
||||
|
|
|
|||
|
|
@ -286,7 +286,7 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create playoff games, tournaments, races, or standings events to track participant results and calculate fantasy points.
|
||||
Create playoff games, tournaments, races, or final standings events to track participant results and calculate fantasy points.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -33,10 +33,15 @@ Based on the answers, we need to support these distinct patterns:
|
|||
- **Placement Logic**: All 8 placements are determined individually (7th, 8th, 5th, 6th, 3rd, 4th, 2nd, 1st)
|
||||
- **Points**: Apply league scoring rules directly (or average if needed)
|
||||
|
||||
#### 3. Season Standings (F1)
|
||||
#### 3. Season Standings (F1) ✅ *Implemented*
|
||||
- **Structure**: Final season standings determine placements
|
||||
- **Placement Logic**: Use the sport's official standings (1st through 8th)
|
||||
- **Placement Logic**:
|
||||
- Championship points are entered throughout the season
|
||||
- Positions automatically calculated from points (highest = 1st)
|
||||
- Top 8 finishers by position get fantasy placements
|
||||
- Ties handled automatically (same points = same position)
|
||||
- **Points**: Apply league scoring rules to final positions
|
||||
- **Implementation**: Managed via "Final Standings" event type in events system
|
||||
|
||||
#### 4. Qualifying Points (Golf, Tennis)
|
||||
- **Structure**: Two-phase scoring system
|
||||
|
|
@ -750,6 +755,11 @@ async function updateAllExpectedValues(seasonId: uuid) {
|
|||
- Mark event complete
|
||||
- For playoffs: Enter bracket matchups and winners
|
||||
- For majors: Enter participant placements
|
||||
- For final_standings (F1, etc.):
|
||||
- Enter championship points for all participants
|
||||
- Positions auto-calculated from points (highest = 1st)
|
||||
- Update standings throughout season
|
||||
- Finalize button to convert top 8 to fantasy placements
|
||||
|
||||
/admin/sports-seasons/$sportsSeasonId/finalize-qp
|
||||
- View current qualifying points standings
|
||||
|
|
@ -813,12 +823,12 @@ async function updateAllExpectedValues(seasonId: uuid) {
|
|||
- Show defaults, allow customization
|
||||
- Preview point distribution
|
||||
|
||||
// app/components/scoring/EventResultEntry.tsx
|
||||
- Admin form to enter event results
|
||||
- Different UI for each scoring pattern
|
||||
- Playoff bracket input
|
||||
- Tournament placement input
|
||||
- Season standings input
|
||||
// app/routes/admin.sports-seasons.$id.events.$eventId.tsx
|
||||
- Admin page to enter event results (implemented inline, not separate component)
|
||||
- Different UI for each event type:
|
||||
- playoff_game: Link to bracket management
|
||||
- major_tournament: Participant placement input
|
||||
- final_standings: Championship points table with auto-calculated positions
|
||||
|
||||
// app/components/scoring/PlayoffBracket.tsx
|
||||
- Visual bracket display
|
||||
|
|
@ -1153,12 +1163,22 @@ scoring_events {
|
|||
### Phase 3: Other Scoring Patterns
|
||||
**Goal**: Implement season standings (F1) and qualifying points (Golf/Tennis)
|
||||
|
||||
- [ ] **3.1** Season standings (F1)
|
||||
- [ ] UI for entering current season points
|
||||
- [ ] Store running totals in `participant_season_results`
|
||||
- [ ] Display current standings on sport season page
|
||||
- [ ] Final standings entry (end of season)
|
||||
- [ ] Convert final standings to fantasy placements
|
||||
- [x] **3.1** Season standings (F1) ✅ *Completed*
|
||||
- [x] Create `participant_season_results` model functions (app/models/participant-season-result.ts)
|
||||
- [x] Implement `processSeasonStandings` function in scoring-calculator
|
||||
- [x] UI for entering championship points via "Final Standings" event type
|
||||
- [x] Auto-calculate positions from points (highest points = 1st place)
|
||||
- [x] Store running totals in `participant_season_results` table
|
||||
- [x] Bulk update standings throughout season
|
||||
- [x] Finalize season button converts top 8 to fantasy placements
|
||||
- [x] Handle ties automatically (Q13, Q19)
|
||||
- [x] Comprehensive test suite (13 tests covering all scenarios)
|
||||
- [x] All 281 tests passing ✅
|
||||
|
||||
**Implementation Note**: Season standings are managed through the events system using `eventType: "final_standings"`.
|
||||
When viewing a final_standings event, admins see a standings tracker table where they enter championship points
|
||||
after each race/event. Positions are automatically calculated from points. When the season ends, marking the event
|
||||
as complete triggers `processSeasonStandings()` which assigns fantasy placements to the top 8 finishers.
|
||||
|
||||
- [ ] **3.2** Qualifying points (Golf/Tennis)
|
||||
- [ ] QP value configuration per sports season (Q21)
|
||||
|
|
@ -1290,6 +1310,21 @@ scoring_events {
|
|||
|
||||
---
|
||||
|
||||
**STATUS: READY TO BEGIN IMPLEMENTATION** ✅
|
||||
**IMPLEMENTATION STATUS**
|
||||
|
||||
All planning complete. Starting with Phase 1: Core Scoring Infrastructure.
|
||||
- ✅ **Phase 1**: Core Scoring Infrastructure - COMPLETE
|
||||
- ✅ **Phase 2**: Playoff Scoring (Single Elimination) - COMPLETE
|
||||
- Supports 4, 8, 16, 32 team brackets
|
||||
- NCAA 68-team March Madness with First Four
|
||||
- NFL 14-team playoffs with bye weeks
|
||||
- Template-based system for all bracket types
|
||||
- ✅ **Phase 3.1**: Season Standings (F1) - COMPLETE
|
||||
- Event-based workflow for tracking championship points
|
||||
- Auto-calculated positions from points
|
||||
- Finalization converts top 8 to fantasy placements
|
||||
- ⏳ **Phase 3.2-3.4**: Qualifying Points & Page Playoffs - TODO
|
||||
- ⏳ **Phase 4**: Standings & Display - TODO
|
||||
- ⏳ **Phase 5**: Expected Value - TODO
|
||||
- ⏳ **Phase 6**: Polish & Optimization - TODO
|
||||
|
||||
**Current Focus**: Phase 3.2 - Qualifying Points (Golf/Tennis)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue