From d926486934f04567c26e01b68a4f9fd3cd0a8127 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Sat, 8 Nov 2025 22:35:07 -0800 Subject: [PATCH] feat: Implement season standings processing and participant result management for final standings events --- app/models/__tests__/season-standings.test.ts | 176 +++++++++++++ app/models/participant-season-result.ts | 244 ++++++++++++++++++ app/models/scoring-calculator.ts | 94 ++++++- ...orts-seasons.$id.events.$eventId.server.ts | 85 ++++++ ...min.sports-seasons.$id.events.$eventId.tsx | 116 ++++++++- app/routes/admin.sports-seasons.$id.tsx | 2 +- plans/scoring-system.md | 67 +++-- 7 files changed, 755 insertions(+), 29 deletions(-) create mode 100644 app/models/__tests__/season-standings.test.ts create mode 100644 app/models/participant-season-result.ts diff --git a/app/models/__tests__/season-standings.test.ts b/app/models/__tests__/season-standings.test.ts new file mode 100644 index 0000000..d3d08bc --- /dev/null +++ b/app/models/__tests__/season-standings.test.ts @@ -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); + }); + }); +}); diff --git a/app/models/participant-season-result.ts b/app/models/participant-season-result.ts new file mode 100644 index 0000000..f96de82 --- /dev/null +++ b/app/models/participant-season-result.ts @@ -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 +) { + 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 +) { + 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 +) { + 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 +) { + 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 +) { + 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 +) { + 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 +) { + 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 +): Promise { + 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; +} diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index f2ceb62..81b9fbd 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -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 { 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 = {}; + + 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}` ); } diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.server.ts b/app/routes/admin.sports-seasons.$id.events.$eventId.server.ts index db7b58a..debb7ef 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.server.ts +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.server.ts @@ -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" }; } diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.tsx b/app/routes/admin.sports-seasons.$id.events.$eventId.tsx index 17e0edb..9b49069 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.tsx +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.tsx @@ -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({ )} - {/* Add Result Form */} - {!event.isComplete && ( + {/* Season Standings Table for final_standings events */} + {event.eventType === "final_standings" && !event.isComplete && ( + <> + + + Season Standings Tracker + + 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. + + + +
+ + + + + + Participant + Championship Points + + + + {participants.map((participant: { id: string; name: string }) => { + const result = seasonResultsMap.get(participant.id); + const currentPoints = result?.currentPoints || ""; + + return ( + + + {participant.name} + + + setHasChanges(true)} + /> + + + ); + })} + +
+ +
+

+ {hasChanges + ? "You have unsaved changes" + : "Update standings after each race/event during the season"} +

+ +
+
+
+
+ + {/* Complete Season Button */} + + + + + Finalize Season + + + When the season is complete, mark this event as complete to: +
    +
  • Convert the top 8 participants (by position) to fantasy placements 1-8
  • +
  • Award fantasy points based on league scoring rules
  • +
  • Update all league standings
  • +
+

+ ⚠️ Make sure all standings are saved before completing +

+
+
+ +
+ + +
+
+
+ + )} + + {/* Regular Result Entry for other event types */} + {event.eventType !== "final_standings" && !event.isComplete && ( Add Result @@ -193,7 +299,7 @@ export default function EventResults({ results - {!event.isComplete && results.length > 0 && ( + {!event.isComplete && results.length > 0 && event.eventType !== "final_standings" && (