From 1089022c099f38034a08046e5fa2e3ed5e20e057 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Tue, 11 Nov 2025 10:08:25 -0800 Subject: [PATCH] feat: Implement Qualifying Points Standings component and related logic - Added `QualifyingPointsStandings` component to display standings based on qualifying points. - Introduced scoring rules and projected points calculation for participants. - Implemented tie handling for rankings and displayed appropriate UI elements based on finalization status. - Created tests for qualifying points configuration, accumulation logic, ranking logic, and scoring workflow. - Developed scoring calculator tests to validate fantasy points conversion from qualifying points. - Established qualifying points management functions including initialization, retrieval, updating, and resetting of points. --- .../scoring/QualifyingPointsStandings.tsx | 259 +++++++++++++ .../__tests__/qualifying-points.test.ts | 339 +++++++++++++++++ .../scoring-calculator-qualifying.test.ts | 199 ++++++++++ app/models/qualifying-points.ts | 348 ++++++++++++++++++ app/models/scoring-calculator.ts | 211 ++++++++++- ...orts-seasons.$id.events.$eventId.server.ts | 56 ++- ...min.sports-seasons.$id.events.$eventId.tsx | 94 ++++- .../admin.sports-seasons.$id.events.server.ts | 39 ++ .../admin.sports-seasons.$id.events.tsx | 50 ++- app/routes/admin.sports-seasons.$id.tsx | 65 +++- app/routes/admin.sports-seasons.new.tsx | 62 +++- plans/scoring-system.md | 67 +++- 12 files changed, 1753 insertions(+), 36 deletions(-) create mode 100644 app/components/scoring/QualifyingPointsStandings.tsx create mode 100644 app/models/__tests__/qualifying-points.test.ts create mode 100644 app/models/__tests__/scoring-calculator-qualifying.test.ts create mode 100644 app/models/qualifying-points.ts diff --git a/app/components/scoring/QualifyingPointsStandings.tsx b/app/components/scoring/QualifyingPointsStandings.tsx new file mode 100644 index 0000000..0b9b636 --- /dev/null +++ b/app/components/scoring/QualifyingPointsStandings.tsx @@ -0,0 +1,259 @@ +import { Form } from "react-router"; +import { Button } from "~/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "~/components/ui/table"; +import { Trophy, CheckCircle2 } from "lucide-react"; + +interface QPStanding { + id: string; + totalQualifyingPoints: string; + eventsScored: number; + finalRanking: number | null; + participant: { + id: string; + name: string; + }; +} + +interface ScoringRules { + pointsFor1st: number; + pointsFor2nd: number; + pointsFor3rd: number; + pointsFor4th: number; + pointsFor5th: number; + pointsFor6th: number; + pointsFor7th: number; + pointsFor8th: number; +} + +interface QualifyingPointsStandingsProps { + standings: QPStanding[]; + scoringRules: ScoringRules | null; + isFinalized: boolean; + totalMajors?: number | null; + majorsCompleted?: number; + canFinalize: boolean; // Whether all majors are complete +} + +export function QualifyingPointsStandings({ + standings, + scoringRules, + isFinalized, + totalMajors, + majorsCompleted = 0, + canFinalize, +}: QualifyingPointsStandingsProps) { + // Calculate projected fantasy points for each standing (Q6) + const calculateProjectedPoints = (currentRank: number): number => { + if (!scoringRules || currentRank < 1 || currentRank > 8) return 0; + + const pointsMap: Record = { + 1: scoringRules.pointsFor1st, + 2: scoringRules.pointsFor2nd, + 3: scoringRules.pointsFor3rd, + 4: scoringRules.pointsFor4th, + 5: scoringRules.pointsFor5th, + 6: scoringRules.pointsFor6th, + 7: scoringRules.pointsFor7th, + 8: scoringRules.pointsFor8th, + }; + + return pointsMap[currentRank] || 0; + }; + + // Assign current ranks with tie handling + const rankedStandings: Array = []; + let previousRank = 0; + let previousQP = -1; + + for (let index = 0; index < standings.length; index++) { + const standing = standings[index]; + const currentQP = parseFloat(standing.totalQualifyingPoints); + + // Check if this is a tie with the previous participant + let currentRank: number; + if (index > 0 && Math.abs(currentQP - previousQP) < 0.001) { + // Same QP as previous = same rank + currentRank = previousRank; + } else { + // Different QP = new rank (1-based position) + currentRank = index + 1; + } + + rankedStandings.push({ + ...standing, + currentRank, + }); + + previousRank = currentRank; + previousQP = currentQP; + } + + return ( + + + + + Qualifying Points Standings + + + {isFinalized ? ( + + + Finalized - Fantasy points have been assigned + + ) : ( + <> + Current standings based on {majorsCompleted} of {totalMajors || '?'} major tournaments completed. + {scoringRules ? ( + + Projected fantasy points show what participants would earn if finalized now. + + ) : ( + + Projected points will be shown when linked to a fantasy season. + + )} + + )} + + + + {standings.length === 0 ? ( +

+ No qualifying points awarded yet. Complete a qualifying event to see standings. +

+ ) : ( + <> + + + + Rank + Participant + Total QP + Events + {scoringRules && !isFinalized && ( + Projected Points + )} + + + + {rankedStandings.map((standing, idx) => { + const projectedPoints = calculateProjectedPoints(standing.currentRank); + const isTop8 = standing.currentRank <= 8; + + // Check if this is a tie (same rank as another participant) + const isTied = rankedStandings.some((other, otherIdx) => + otherIdx !== idx && + other.currentRank === standing.currentRank + ); + + return ( + + +
+ {standing.currentRank === 1 && !isTied && 🥇} + {standing.currentRank === 2 && !isTied && 🥈} + {standing.currentRank === 3 && !isTied && 🥉} + + {isTied && "T"} + {standing.currentRank} + {standing.currentRank === 1 + ? "st" + : standing.currentRank === 2 + ? "nd" + : standing.currentRank === 3 + ? "rd" + : "th"} + +
+
+ + {standing.participant.name} + + + + {parseFloat(standing.totalQualifyingPoints).toFixed(2)} QP + + + + {standing.eventsScored} + + {scoringRules && !isFinalized && ( + + {isTop8 ? ( + + {projectedPoints} pts + + ) : ( + 0 pts + )} + + )} +
+ ); + })} +
+
+ + {!isFinalized && canFinalize && ( +
+
+
+

Ready to finalize?

+

+ All {totalMajors} major tournaments are complete. Finalizing will: +

+
    +
  • Convert the top 8 participants to fantasy placements 1-8
  • +
  • Award fantasy points based on league scoring rules
  • +
  • Update all league standings
  • +
  • Lock the qualifying points (no further changes)
  • +
+
+
+ + +
+
+
+ )} + + {!isFinalized && !canFinalize && ( +
+

+ Not ready to finalize yet. Complete all {totalMajors} major tournaments before finalizing qualifying points. + ({majorsCompleted} of {totalMajors} completed) +

+
+ )} + + )} +
+
+ ); +} diff --git a/app/models/__tests__/qualifying-points.test.ts b/app/models/__tests__/qualifying-points.test.ts new file mode 100644 index 0000000..07abf9f --- /dev/null +++ b/app/models/__tests__/qualifying-points.test.ts @@ -0,0 +1,339 @@ +import { describe, it, expect } from "vitest"; +import { DEFAULT_QP_VALUES } from "../qualifying-points"; + +describe("Qualifying Points Configuration", () => { + describe("DEFAULT_QP_VALUES", () => { + it("should have 16 placement values", () => { + expect(DEFAULT_QP_VALUES).toHaveLength(16); + }); + + it("should have correct QP values for top placements", () => { + expect(DEFAULT_QP_VALUES[0]).toEqual({ placement: 1, points: 20 }); + expect(DEFAULT_QP_VALUES[1]).toEqual({ placement: 2, points: 14 }); + expect(DEFAULT_QP_VALUES[2]).toEqual({ placement: 3, points: 10 }); + expect(DEFAULT_QP_VALUES[3]).toEqual({ placement: 4, points: 8 }); + }); + + it("should have 5 QP for 5th and 6th place", () => { + expect(DEFAULT_QP_VALUES[4]).toEqual({ placement: 5, points: 5 }); + expect(DEFAULT_QP_VALUES[5]).toEqual({ placement: 6, points: 5 }); + }); + + it("should have 3 QP for 7th and 8th place", () => { + expect(DEFAULT_QP_VALUES[6]).toEqual({ placement: 7, points: 3 }); + expect(DEFAULT_QP_VALUES[7]).toEqual({ placement: 8, points: 3 }); + }); + + it("should have 2 QP for 9th-12th place", () => { + expect(DEFAULT_QP_VALUES[8]).toEqual({ placement: 9, points: 2 }); + expect(DEFAULT_QP_VALUES[9]).toEqual({ placement: 10, points: 2 }); + expect(DEFAULT_QP_VALUES[10]).toEqual({ placement: 11, points: 2 }); + expect(DEFAULT_QP_VALUES[11]).toEqual({ placement: 12, points: 2 }); + }); + + it("should have 1 QP for 13th-16th place", () => { + expect(DEFAULT_QP_VALUES[12]).toEqual({ placement: 13, points: 1 }); + expect(DEFAULT_QP_VALUES[13]).toEqual({ placement: 14, points: 1 }); + expect(DEFAULT_QP_VALUES[14]).toEqual({ placement: 15, points: 1 }); + expect(DEFAULT_QP_VALUES[15]).toEqual({ placement: 16, points: 1 }); + }); + + it("should be sorted by placement (ascending)", () => { + for (let i = 0; i < DEFAULT_QP_VALUES.length - 1; i++) { + expect(DEFAULT_QP_VALUES[i].placement).toBeLessThan( + DEFAULT_QP_VALUES[i + 1].placement + ); + } + }); + + it("should have descending point values for higher placements", () => { + expect(DEFAULT_QP_VALUES[0].points).toBeGreaterThan(DEFAULT_QP_VALUES[1].points); + expect(DEFAULT_QP_VALUES[1].points).toBeGreaterThan(DEFAULT_QP_VALUES[2].points); + expect(DEFAULT_QP_VALUES[2].points).toBeGreaterThan(DEFAULT_QP_VALUES[3].points); + }); + }); + + describe("QP Accumulation Logic", () => { + it("should accumulate QP across multiple events", () => { + // Simulating participant earning QP from 4 majors + const event1QP = 20; // 1st place + const event2QP = 14; // 2nd place + const event3QP = 10; // 3rd place + const event4QP = 8; // 4th place + + const totalQP = event1QP + event2QP + event3QP + event4QP; + + expect(totalQP).toBe(52); + }); + + it("should handle decimal QP values", () => { + const qp1 = 20.5; + const qp2 = 14.25; + + const total = qp1 + qp2; + + expect(total).toBe(34.75); + }); + + it("should handle zero QP awards", () => { + const event1QP = 20; + const event2QP = 0; // Finished beyond 16th + + const total = event1QP + event2QP; + + expect(total).toBe(20); + }); + }); + + describe("QP Ranking Logic", () => { + it("should rank participants by total QP (highest first)", () => { + const standings = [ + { name: "Player A", qp: 44 }, + { name: "Player B", qp: 50 }, + { name: "Player C", qp: 38 }, + ]; + + const sorted = standings.sort((a, b) => b.qp - a.qp); + + expect(sorted[0].name).toBe("Player B"); // 50 QP + expect(sorted[1].name).toBe("Player A"); // 44 QP + expect(sorted[2].name).toBe("Player C"); // 38 QP + }); + + it("should handle ties in QP totals", () => { + const standings = [ + { name: "Player A", qp: 30 }, + { name: "Player B", qp: 30 }, + { name: "Player C", qp: 20 }, + ]; + + const sorted = standings.sort((a, b) => b.qp - a.qp); + + // Both players with 30 QP should be before Player C + expect(sorted[2].name).toBe("Player C"); + expect(sorted[0].qp).toBe(30); + expect(sorted[1].qp).toBe(30); + }); + }); + + describe("QP to Fantasy Placement Conversion", () => { + it("should assign top 8 QP leaders to fantasy placements 1-8", () => { + const qpRankings = [50, 44, 38, 30, 25, 20, 15, 10, 5, 2]; + + const top8 = qpRankings.slice(0, 8); + + expect(top8).toHaveLength(8); + expect(top8[0]).toBe(50); // 1st place + expect(top8[7]).toBe(10); // 8th place + }); + + it("should assign 0 fantasy points to participants beyond top 8", () => { + const qpRankings = [50, 44, 38, 30, 25, 20, 15, 10, 5, 2]; + + const beyond8 = qpRankings.slice(8); + + expect(beyond8).toHaveLength(2); + // These would get 0 fantasy points + const fantasyPoints = beyond8.map(() => 0); + expect(fantasyPoints).toEqual([0, 0]); + }); + + it("should handle ties at the cutoff (8th place)", () => { + // Scenario: Two players tied for 8th place QP + const qpRankings = [ + { name: "P1", qp: 50 }, // 1st + { name: "P2", qp: 44 }, // 2nd + { name: "P3", qp: 38 }, // 3rd + { name: "P4", qp: 30 }, // 4th + { name: "P5", qp: 25 }, // 5th + { name: "P6", qp: 20 }, // 6th + { name: "P7", qp: 15 }, // 7th + { name: "P8", qp: 10 }, // Tied for 8th + { name: "P9", qp: 10 }, // Tied for 8th + ]; + + // Both should get placement 8 + const tied8th = qpRankings.filter(p => p.qp === 10); + expect(tied8th).toHaveLength(2); + + // In implementation, both would share 8th place fantasy placement + const placement = 8; + expect(placement).toBe(8); + }); + }); + + describe("Tie Handling During QP Awards", () => { + it("should correctly split QP among tied participants", () => { + // Scenario: 7 players tied for 14th place + // They occupy positions 14-20 + // Positions 14, 15, 16 get 1 QP each = 3 total QP + // Positions 17-20 get 0 QP + // Total: 3 QP divided by 7 players = 3/7 = 0.428571... per player + + const tieCount = 7; + const startPosition = 14; + + // Calculate total QP for positions 14-20 + const qpMap: Record = { + 14: 1, + 15: 1, + 16: 1, + 17: 0, + 18: 0, + 19: 0, + 20: 0, + }; + + let totalQP = 0; + for (let pos = startPosition; pos < startPosition + tieCount; pos++) { + totalQP += qpMap[pos] || 0; + } + + const qpPerPlayer = totalQP / tieCount; + + expect(totalQP).toBe(3); + expect(qpPerPlayer).toBeCloseTo(0.428571, 5); + }); + + it("should handle 2-way tie for 5th place", () => { + // 2 players tied for 5th occupy positions 5 and 6 + // Position 5: 5 QP, Position 6: 5 QP + // Total: 10 QP / 2 players = 5 QP each + + const tieCount = 2; + const startPosition = 5; + const qpMap: Record = { 5: 5, 6: 5 }; + + let totalQP = 0; + for (let pos = startPosition; pos < startPosition + tieCount; pos++) { + totalQP += qpMap[pos]; + } + + const qpPerPlayer = totalQP / tieCount; + + expect(totalQP).toBe(10); + expect(qpPerPlayer).toBe(5); + }); + + it("should handle 3-way tie for 1st place", () => { + // 3 players tied for 1st occupy positions 1, 2, 3 + // Position 1: 20 QP, Position 2: 14 QP, Position 3: 10 QP + // Total: 44 QP / 3 players = 14.666... QP each + + const tieCount = 3; + const startPosition = 1; + const qpMap: Record = { 1: 20, 2: 14, 3: 10 }; + + let totalQP = 0; + for (let pos = startPosition; pos < startPosition + tieCount; pos++) { + totalQP += qpMap[pos]; + } + + const qpPerPlayer = totalQP / tieCount; + + expect(totalQP).toBe(44); + expect(qpPerPlayer).toBeCloseTo(14.666667, 5); + }); + }); + + describe("Reprocessing Events", () => { + it("should handle reprocessing by removing old QP before adding new QP", () => { + // Scenario: Admin enters results, processes QP, then realizes there's an error + // They fix the placements and reprocess + + // Initial processing + const participant1InitialQP = 20; // 1st place + const participant2InitialQP = 14; // 2nd place + + let totalQP1 = participant1InitialQP; + let totalQP2 = participant2InitialQP; + + // Admin realizes participant 1 should have been 3rd, not 1st + // Reprocessing: subtract old QP, add new QP + totalQP1 = totalQP1 - participant1InitialQP + 10; // Remove 20, add 10 (3rd place) + totalQP2 = totalQP2 - participant2InitialQP + 20; // Remove 14, add 20 (moved to 1st) + + expect(totalQP1).toBe(10); // Should have 10 QP (3rd place) + expect(totalQP2).toBe(20); // Should have 20 QP (1st place) + }); + + it("should not increment majorsCompleted when reprocessing", () => { + // First processing + let majorsCompleted = 0; + const wasAlreadyProcessed = false; + + if (!wasAlreadyProcessed) { + majorsCompleted += 1; + } + + expect(majorsCompleted).toBe(1); + + // Reprocessing (wasAlreadyProcessed = true) + const reprocessing = true; + + if (!reprocessing) { + majorsCompleted += 1; + } + + expect(majorsCompleted).toBe(1); // Should still be 1, not 2 + }); + }); + + describe("Scoring Workflow", () => { + it("should process 4 majors and calculate final standings", () => { + // Simulating a full season with 4 majors + const participants = [ + { name: "Tiger Woods", qp: 0, events: 0 }, + { name: "Rory McIlroy", qp: 0, events: 0 }, + { name: "Scottie Scheffler", qp: 0, events: 0 }, + ]; + + // Event 1: The Masters + participants[0].qp += 20; // Tiger wins + participants[0].events++; + participants[1].qp += 14; // Rory 2nd + participants[1].events++; + participants[2].qp += 10; // Scottie 3rd + participants[2].events++; + + // Event 2: PGA Championship + participants[1].qp += 20; // Rory wins + participants[1].events++; + participants[0].qp += 14; // Tiger 2nd + participants[0].events++; + participants[2].qp += 10; // Scottie 3rd + participants[2].events++; + + // Event 3: U.S. Open + participants[2].qp += 20; // Scottie wins + participants[2].events++; + participants[0].qp += 14; // Tiger 2nd + participants[0].events++; + participants[1].qp += 10; // Rory 3rd + participants[1].events++; + + // Event 4: The Open + participants[0].qp += 20; // Tiger wins + participants[0].events++; + participants[1].qp += 14; // Rory 2nd + participants[1].events++; + participants[2].qp += 10; // Scottie 3rd + participants[2].events++; + + // Final standings + participants.sort((a, b) => b.qp - a.qp); + + expect(participants[0].name).toBe("Tiger Woods"); + expect(participants[0].qp).toBe(68); // 20+14+14+20 + expect(participants[0].events).toBe(4); + + expect(participants[1].name).toBe("Rory McIlroy"); + expect(participants[1].qp).toBe(58); // 14+20+10+14 + expect(participants[1].events).toBe(4); + + expect(participants[2].name).toBe("Scottie Scheffler"); + expect(participants[2].qp).toBe(50); // 10+10+20+10 + expect(participants[2].events).toBe(4); + }); + }); +}); diff --git a/app/models/__tests__/scoring-calculator-qualifying.test.ts b/app/models/__tests__/scoring-calculator-qualifying.test.ts new file mode 100644 index 0000000..0744e7f --- /dev/null +++ b/app/models/__tests__/scoring-calculator-qualifying.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect } from "vitest"; +import { calculateFantasyPoints, 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("Qualifying Points - Fantasy Scoring Integration", () => { + describe("QP to Fantasy Points Conversion", () => { + it("should convert 1st place in QP standings to 100 fantasy points", () => { + const points = calculateFantasyPoints(1, DEFAULT_SCORING); + expect(points).toBe(100); + }); + + it("should convert 2nd place in QP standings to 70 fantasy points", () => { + const points = calculateFantasyPoints(2, DEFAULT_SCORING); + expect(points).toBe(70); + }); + + it("should convert 8th place in QP standings to 15 fantasy points", () => { + const points = calculateFantasyPoints(8, DEFAULT_SCORING); + expect(points).toBe(15); + }); + + it("should convert 9th+ place in QP standings to 0 fantasy points", () => { + const points9th = calculateFantasyPoints(9, DEFAULT_SCORING); + const points10th = calculateFantasyPoints(10, DEFAULT_SCORING); + + expect(points9th).toBe(0); + expect(points10th).toBe(0); + }); + }); + + describe("Full Season Workflow Simulation", () => { + it("should calculate correct final fantasy points after 4 majors", () => { + // Simulate 4 major tournaments with 3 participants + // Each major awards QP based on placement + + type Participant = { + name: string; + totalQP: number; + majorResults: number[]; + }; + + const participants: Participant[] = [ + { name: "Tiger Woods", totalQP: 0, majorResults: [1, 2, 2, 1] }, // Wins 2, 2nd in 2 + { name: "Rory McIlroy", totalQP: 0, majorResults: [2, 1, 3, 2] }, // Wins 1, 2nd in 2, 3rd in 1 + { name: "Scottie Scheffler", totalQP: 0, majorResults: [3, 3, 1, 3] }, // Wins 1, 3rd in 3 + ]; + + // QP awards based on placement + const qpMap: Record = { + 1: 20, + 2: 14, + 3: 10, + 4: 8, + 5: 5, + 6: 5, + 7: 3, + 8: 3, + }; + + // Calculate total QP for each participant + for (const participant of participants) { + for (const placement of participant.majorResults) { + participant.totalQP += qpMap[placement] || 0; + } + } + + // Sort by total QP (highest first) + participants.sort((a, b) => b.totalQP - a.totalQP); + + // Verify QP totals + expect(participants[0].name).toBe("Tiger Woods"); + expect(participants[0].totalQP).toBe(68); // 20+14+14+20 + + expect(participants[1].name).toBe("Rory McIlroy"); + expect(participants[1].totalQP).toBe(58); // 14+20+10+14 + + expect(participants[2].name).toBe("Scottie Scheffler"); + expect(participants[2].totalQP).toBe(50); // 10+10+20+10 + + // Assign fantasy placements based on QP rankings + const fantasyPlacements = [1, 2, 3]; // Top 3 get 1st, 2nd, 3rd + + // Calculate fantasy points + const fantasyPoints = fantasyPlacements.map(placement => + calculateFantasyPoints(placement, DEFAULT_SCORING) + ); + + expect(fantasyPoints[0]).toBe(100); // Tiger's fantasy points + expect(fantasyPoints[1]).toBe(70); // Rory's fantasy points + expect(fantasyPoints[2]).toBe(50); // Scottie's fantasy points + }); + + it("should handle ties in QP standings (Q5)", () => { + // Two participants tie with same QP total + type ParticipantWithQP = { + name: string; + totalQP: number; + finalPlacement: number; + }; + + const participants: ParticipantWithQP[] = [ + { name: "Player A", totalQP: 50, finalPlacement: 1 }, + { name: "Player B", totalQP: 30, finalPlacement: 2 }, // Tied for 2nd + { name: "Player C", totalQP: 30, finalPlacement: 2 }, // Tied for 2nd + { name: "Player D", totalQP: 20, finalPlacement: 4 }, // Ranked 4th (skips 3) + ]; + + // Both tied participants get placement 2 + const tied = participants.filter(p => p.totalQP === 30); + expect(tied).toHaveLength(2); + expect(tied[0].finalPlacement).toBe(2); + expect(tied[1].finalPlacement).toBe(2); + + // Both would earn 2nd place fantasy points + const points = calculateFantasyPoints(2, DEFAULT_SCORING); + expect(points).toBe(70); + }); + + it("should handle participants beyond top 8 getting 0 points", () => { + // 10 participants, but only top 8 score + const participants = Array.from({ length: 10 }, (_, i) => ({ + name: `Player ${i + 1}`, + qp: 100 - i * 10, // Descending QP + finalPlacement: i + 1, + })); + + // Top 8 get points, 9th and 10th get 0 + const participant9 = participants[8]; + const participant10 = participants[9]; + + const points9 = calculateFantasyPoints(participant9.finalPlacement, DEFAULT_SCORING); + const points10 = calculateFantasyPoints(participant10.finalPlacement, DEFAULT_SCORING); + + expect(points9).toBe(0); + expect(points10).toBe(0); + }); + }); + + describe("Custom Scoring Rules", () => { + it("should apply custom scoring rules to QP standings", () => { + const customScoring: ScoringRules = { + pointsFor1st: 200, + pointsFor2nd: 150, + pointsFor3rd: 100, + pointsFor4th: 75, + pointsFor5th: 50, + pointsFor6th: 40, + pointsFor7th: 30, + pointsFor8th: 20, + }; + + const points1st = calculateFantasyPoints(1, customScoring); + const points8th = calculateFantasyPoints(8, customScoring); + + expect(points1st).toBe(200); + expect(points8th).toBe(20); + }); + }); + + describe("Edge Cases", () => { + it("should handle all participants tied for 1st in QP", () => { + // Unlikely but possible scenario + const participants = [ + { name: "P1", qp: 40, placement: 1 }, + { name: "P2", qp: 40, placement: 1 }, + { name: "P3", qp: 40, placement: 1 }, + ]; + + // All get 1st place fantasy points + const points = participants.map(p => + calculateFantasyPoints(p.placement, DEFAULT_SCORING) + ); + + expect(points).toEqual([100, 100, 100]); + }); + + it("should handle participant with 0 QP (didn't score in any major)", () => { + const participant = { + name: "No Score Player", + qp: 0, + placement: 20, // Beyond scoring range + }; + + const points = calculateFantasyPoints(participant.placement, DEFAULT_SCORING); + + expect(points).toBe(0); + }); + }); +}); diff --git a/app/models/qualifying-points.ts b/app/models/qualifying-points.ts new file mode 100644 index 0000000..10ab421 --- /dev/null +++ b/app/models/qualifying-points.ts @@ -0,0 +1,348 @@ +import { database } from "~/database/context"; +import * as schema from "~/database/schema"; +import { eq, and, desc, sql } from "drizzle-orm"; + +/** + * Default qualifying point values (as specified in scoring-system.md) + */ +export const DEFAULT_QP_VALUES = [ + { placement: 1, points: 20 }, + { placement: 2, points: 14 }, + { placement: 3, points: 10 }, + { placement: 4, points: 8 }, + { placement: 5, points: 5 }, + { placement: 6, points: 5 }, + { placement: 7, points: 3 }, + { placement: 8, points: 3 }, + { placement: 9, points: 2 }, + { placement: 10, points: 2 }, + { placement: 11, points: 2 }, + { placement: 12, points: 2 }, + { placement: 13, points: 1 }, + { placement: 14, points: 1 }, + { placement: 15, points: 1 }, + { placement: 16, points: 1 }, +]; + +export interface QualifyingPointConfigData { + placement: number; + points: number; +} + +/** + * Initialize default qualifying point configuration for a sports season + */ +export async function initializeQPConfig( + sportsSeasonId: string, + providedDb?: ReturnType +) { + const db = providedDb || database(); + + // Check if config already exists + const existing = await db.query.qualifyingPointConfig.findMany({ + where: eq(schema.qualifyingPointConfig.sportsSeasonId, sportsSeasonId), + }); + + if (existing.length > 0) { + return existing; + } + + // Insert default config + const configs = await db + .insert(schema.qualifyingPointConfig) + .values( + DEFAULT_QP_VALUES.map((qp) => ({ + sportsSeasonId, + placement: qp.placement, + points: qp.points.toString(), + })) + ) + .returning(); + + return configs; +} + +/** + * Get qualifying point configuration for a sports season + */ +export async function getQPConfig( + sportsSeasonId: string, + providedDb?: ReturnType +) { + const db = providedDb || database(); + + const configs = await db.query.qualifyingPointConfig.findMany({ + where: eq(schema.qualifyingPointConfig.sportsSeasonId, sportsSeasonId), + orderBy: [schema.qualifyingPointConfig.placement], + }); + + // If no config exists, initialize with defaults + if (configs.length === 0) { + return await initializeQPConfig(sportsSeasonId, db); + } + + return configs; +} + +/** + * Update qualifying point configuration for a sports season + */ +export async function updateQPConfig( + sportsSeasonId: string, + configData: QualifyingPointConfigData[], + providedDb?: ReturnType +) { + const db = providedDb || database(); + + // Delete existing config + await db + .delete(schema.qualifyingPointConfig) + .where(eq(schema.qualifyingPointConfig.sportsSeasonId, sportsSeasonId)); + + // Insert new config + const configs = await db + .insert(schema.qualifyingPointConfig) + .values( + configData.map((qp) => ({ + sportsSeasonId, + placement: qp.placement, + points: qp.points.toString(), + })) + ) + .returning(); + + return configs; +} + +/** + * Get qualifying points awarded for a specific placement + */ +export async function getQPForPlacement( + sportsSeasonId: string, + placement: number, + providedDb?: ReturnType +): Promise { + const db = providedDb || database(); + + const config = await db.query.qualifyingPointConfig.findFirst({ + where: and( + eq(schema.qualifyingPointConfig.sportsSeasonId, sportsSeasonId), + eq(schema.qualifyingPointConfig.placement, placement) + ), + }); + + return config ? parseFloat(config.points) : 0; +} + +/** + * Get or create participant qualifying total record + */ +export async function getOrCreateParticipantQPTotal( + participantId: string, + sportsSeasonId: string, + providedDb?: ReturnType +) { + const db = providedDb || database(); + + let total = await db.query.participantQualifyingTotals.findFirst({ + where: and( + eq(schema.participantQualifyingTotals.participantId, participantId), + eq(schema.participantQualifyingTotals.sportsSeasonId, sportsSeasonId) + ), + }); + + if (!total) { + const [created] = await db + .insert(schema.participantQualifyingTotals) + .values({ + participantId, + sportsSeasonId, + totalQualifyingPoints: "0", + eventsScored: 0, + }) + .returning(); + total = created; + } + + return total; +} + +/** + * Add qualifying points to a participant's total + * NOTE: eventsScored is NOT updated here - it should be recalculated after processing events + */ +export async function addQualifyingPoints( + participantId: string, + sportsSeasonId: string, + pointsToAdd: number, + providedDb?: ReturnType +) { + const db = providedDb || database(); + + // Get or create the total record + const total = await getOrCreateParticipantQPTotal(participantId, sportsSeasonId, db); + + // Update with new points (do NOT increment eventsScored here) + const [updated] = await db + .update(schema.participantQualifyingTotals) + .set({ + totalQualifyingPoints: (parseFloat(total.totalQualifyingPoints) + pointsToAdd).toString(), + updatedAt: new Date(), + }) + .where(eq(schema.participantQualifyingTotals.id, total.id)) + .returning(); + + return updated; +} + +/** + * Recalculate total QP and eventsScored for a participant from scratch + * This is the source of truth - sums all event_results with QP awarded + */ +export async function recalculateParticipantQP( + participantId: string, + sportsSeasonId: string, + providedDb?: ReturnType +) { + const db = providedDb || database(); + + // Get all event results for this participant in this sports season + const eventResults = await db.query.eventResults.findMany({ + where: eq(schema.eventResults.participantId, participantId), + with: { + scoringEvent: true, + }, + }); + + // Calculate totals from event_results + let totalQP = 0; + const uniqueEvents = new Set(); + + for (const result of eventResults) { + if ( + result.scoringEvent.sportsSeasonId === sportsSeasonId && + result.qualifyingPointsAwarded + ) { + const qp = parseFloat(result.qualifyingPointsAwarded); + if (qp > 0) { + totalQP += qp; + uniqueEvents.add(result.scoringEvent.id); + } + } + } + + const eventsScored = uniqueEvents.size; + + // Get or create the participant's QP total record + const total = await getOrCreateParticipantQPTotal(participantId, sportsSeasonId, db); + + // Update with recalculated values + await db + .update(schema.participantQualifyingTotals) + .set({ + totalQualifyingPoints: totalQP.toString(), + eventsScored, + updatedAt: new Date(), + }) + .where(eq(schema.participantQualifyingTotals.id, total.id)); + + return { totalQP, eventsScored }; +} + +/** + * Recalculate eventsScored for a participant by counting unique events with QP awarded + * @deprecated Use recalculateParticipantQP instead for more accurate recalculation + */ +export async function recalculateEventsScored( + participantId: string, + sportsSeasonId: string, + providedDb?: ReturnType +) { + const result = await recalculateParticipantQP(participantId, sportsSeasonId, providedDb); + return result.eventsScored; +} + +/** + * Get all qualifying totals for a sports season, ordered by points (highest first) + */ +export async function getQPStandings( + sportsSeasonId: string, + providedDb?: ReturnType +) { + const db = providedDb || database(); + + return await db.query.participantQualifyingTotals.findMany({ + where: eq(schema.participantQualifyingTotals.sportsSeasonId, sportsSeasonId), + orderBy: [desc(sql`CAST(${schema.participantQualifyingTotals.totalQualifyingPoints} AS DECIMAL)`)], + with: { + participant: true, + }, + }); +} + +/** + * Update final rankings for qualifying point standings (after finalization) + */ +export async function updateFinalRankings( + sportsSeasonId: string, + providedDb?: ReturnType +) { + const db = providedDb || database(); + + const standings = await getQPStandings(sportsSeasonId, db); + + // Group participants by QP total to handle ties + const groupedByPoints = new Map(); + for (const standing of standings) { + const points = standing.totalQualifyingPoints; + if (!groupedByPoints.has(points)) { + groupedByPoints.set(points, []); + } + groupedByPoints.get(points)!.push(standing); + } + + // Assign rankings, handling ties + let currentRank = 1; + const updates = []; + + // Sort by points descending + const sortedGroups = Array.from(groupedByPoints.entries()).sort((a, b) => { + return parseFloat(b[0]) - parseFloat(a[0]); + }); + + for (const [_, group] of sortedGroups) { + // All participants in this group get the same rank + for (const standing of group) { + updates.push({ + id: standing.id, + finalRanking: currentRank, + }); + } + // Increment rank by the size of the group + currentRank += group.length; + } + + // Update all rankings + for (const update of updates) { + await db + .update(schema.participantQualifyingTotals) + .set({ finalRanking: update.finalRanking, updatedAt: new Date() }) + .where(eq(schema.participantQualifyingTotals.id, update.id)); + } + + return updates; +} + +/** + * Reset qualifying points for a sports season (useful for testing or corrections) + */ +export async function resetQualifyingPoints( + sportsSeasonId: string, + providedDb?: ReturnType +) { + const db = providedDb || database(); + + await db + .delete(schema.participantQualifyingTotals) + .where(eq(schema.participantQualifyingTotals.sportsSeasonId, sportsSeasonId)); +} diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index 81b9fbd..6169e4b 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -184,7 +184,10 @@ async function upsertParticipantResult( /** * Process a qualifying event completion and update QP totals - * Implementation will be added in Phase 3 + * Phase 3.2 Implementation + * + * Q4: Manual finalization after all majors complete + * Q5: Ties in QP handled by sharing placements */ export async function processQualifyingEvent( eventId: string, @@ -192,20 +195,101 @@ export async function processQualifyingEvent( ): Promise { 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 + // Import qualifying points functions + const { getEventResults } = await import("./event-result"); + const { getQPForPlacement, recalculateParticipantQP } = await import("./qualifying-points"); - console.log(`[ScoringCalculator] processQualifyingEvent not yet implemented: ${eventId}`); + // Get the event + const event = await db.query.scoringEvents.findFirst({ + where: eq(schema.scoringEvents.id, eventId), + with: { + sportsSeason: true, + }, + }); + + if (!event) { + throw new Error(`Event ${eventId} not found`); + } + + if (!event.isQualifyingEvent) { + throw new Error(`Event ${eventId} is not a qualifying event`); + } + + // Get all event results for this qualifying event + const results = await getEventResults(eventId, db); + + // Check if this was already processed (for majorsCompleted counter) + const wasAlreadyProcessed = results.some(r => r.qualifyingPointsAwarded && parseFloat(r.qualifyingPointsAwarded) > 0); + + // Group results by placement to handle ties + const placementGroups = new Map(); + for (const result of results) { + if (result.placement) { + const group = placementGroups.get(result.placement) || []; + group.push(result); + placementGroups.set(result.placement, group); + } + } + + // Process each placement group and update event_results with QP awarded + for (const [placement, group] of placementGroups) { + const tieCount = group.length; + + // Calculate total QP for the positions this group occupies + // A tie for Nth place occupies positions N through N + tieCount - 1 + let totalQP = 0; + for (let pos = placement; pos < placement + tieCount; pos++) { + const qpForPos = await getQPForPlacement(event.sportsSeasonId, pos, db); + totalQP += qpForPos; + } + + // Divide equally among tied participants + const qpPerParticipant = totalQP / tieCount; + + // Update the event result with the QP awarded + for (const result of group) { + await db + .update(schema.eventResults) + .set({ + qualifyingPointsAwarded: qpPerParticipant.toString(), + updatedAt: new Date(), + }) + .where(eq(schema.eventResults.id, result.id)); + } + } + + // Recalculate totals for all participants from scratch + // This is the source of truth - sums all their event_results + const participantIds = new Set(results.map(r => r.participantId)); + for (const participantId of participantIds) { + await recalculateParticipantQP(participantId, event.sportsSeasonId, db); + } + + // Increment majorsCompleted counter (only if this is the first time processing) + if (!wasAlreadyProcessed) { + const sportsSeason = event.sportsSeason; + await db + .update(schema.sportsSeasons) + .set({ + majorsCompleted: (sportsSeason.majorsCompleted || 0) + 1, + updatedAt: new Date(), + }) + .where(eq(schema.sportsSeasons.id, event.sportsSeasonId)); + } + + console.log( + `[ScoringCalculator] Processed qualifying event ${eventId}: awarded QP to ${results.length} participants` + ); } /** * Finalize qualifying points and convert to fantasy placements * Called manually by admin when all majors are complete - * Implementation will be added in Phase 3 + * Phase 3.2 Implementation + * + * Q4: Manual finalization (admin button) + * Q5: QP ties handled by sharing placements + * Q19: Tie entry uses same placement, system auto-calculates shared positions */ export async function finalizeQualifyingPoints( sportsSeasonId: string, @@ -213,16 +297,109 @@ export async function finalizeQualifyingPoints( ): Promise { 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 + // Import qualifying points functions + const { getQPStandings, updateFinalRankings } = await import("./qualifying-points"); + + // Get all participants ranked by total QP (highest first) + const standings = await getQPStandings(sportsSeasonId, db); + + if (standings.length === 0) { + throw new Error(`No qualifying points standings found for sports season ${sportsSeasonId}`); + } + + // Update final rankings in participant_qualifying_totals + await updateFinalRankings(sportsSeasonId, db); + + // Group participants by QP total to handle ties (Q5, Q19) + const groupedByQP = new Map(); + for (const standing of standings) { + const qp = standing.totalQualifyingPoints; + if (!groupedByQP.has(qp)) { + groupedByQP.set(qp, []); + } + groupedByQP.get(qp)!.push(standing); + } + + // Sort groups by QP (descending) + const sortedGroups = Array.from(groupedByQP.entries()).sort((a, b) => { + return parseFloat(b[0]) - parseFloat(a[0]); + }); + + // Assign fantasy placements (1-8) based on QP ranking + let currentPlacement = 1; + + for (const [_, group] of sortedGroups) { + // Stop if we've gone beyond the top 8 fantasy placements + if (currentPlacement > 8) break; + + const participantsInGroup = group.length; + + // Calculate how many placements this group shares + const maxPlacementForGroup = Math.min(8, currentPlacement + participantsInGroup - 1); + const actualParticipantsScoring = maxPlacementForGroup - currentPlacement + 1; + + // Assign placements to participants in this group + for (let i = 0; i < Math.min(participantsInGroup, actualParticipantsScoring); i++) { + const standing = group[i]; + await upsertParticipantResult( + standing.participantId, + sportsSeasonId, + currentPlacement, + db + ); + } + + // If there are more participants than scoring positions, assign 0 to the rest + for (let i = actualParticipantsScoring; i < participantsInGroup; i++) { + const standing = group[i]; + await upsertParticipantResult( + standing.participantId, + sportsSeasonId, + 0, // Beyond top 8 + db + ); + } + + // Move to next placement group + currentPlacement += participantsInGroup; + } + + // Assign 0 points to any remaining participants beyond top 8 + if (currentPlacement <= standings.length) { + for (let i = currentPlacement - 1; i < standings.length; i++) { + const standing = standings[i]; + const hasResult = await db.query.participantResults.findFirst({ + where: and( + eq(schema.participantResults.participantId, standing.participantId), + eq(schema.participantResults.sportsSeasonId, sportsSeasonId) + ), + }); + + if (!hasResult) { + await upsertParticipantResult( + standing.participantId, + sportsSeasonId, + 0, + db + ); + } + } + } + + // Mark sports season as finalized + await db + .update(schema.sportsSeasons) + .set({ + qualifyingPointsFinalized: true, + updatedAt: new Date(), + }) + .where(eq(schema.sportsSeasons.id, sportsSeasonId)); + + // Trigger recalculation for all affected leagues + await recalculateAffectedLeagues(sportsSeasonId, db); console.log( - `[ScoringCalculator] finalizeQualifyingPoints not yet implemented: ${sportsSeasonId}` + `[ScoringCalculator] Finalized qualifying points for sports season ${sportsSeasonId}: ${standings.length} participants processed` ); } 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 debb7ef..40ab8a5 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.server.ts +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.server.ts @@ -18,7 +18,11 @@ import { upsertParticipantSeasonResult, getSeasonResults, } from "~/models/participant-season-result"; -import { processSeasonStandings } from "~/models/scoring-calculator"; +import { processSeasonStandings, processQualifyingEvent } from "~/models/scoring-calculator"; +import { getQPStandings, getQPConfig } from "~/models/qualifying-points"; +import { database } from "~/database/context"; +import * as schema from "~/database/schema"; +import { eq } from "drizzle-orm"; export async function loader({ params }: Route.LoaderArgs) { const sportsSeason = await findSportsSeasonById(params.id); @@ -43,6 +47,18 @@ export async function loader({ params }: Route.LoaderArgs) { seasonResults = await getSeasonResults(params.id); } + // For qualifying events, get QP config + let qpConfig = null; + if (event.isQualifyingEvent) { + qpConfig = await getQPConfig(params.id); + } + + // For qualifying sports seasons, get QP standings + let qpStandings = null; + if (sportsSeason.scoringPattern === "qualifying_points") { + qpStandings = await getQPStandings(params.id); + } + return { sportsSeason: sportsSeason as typeof sportsSeason & { sport: { id: string; name: string; type: string; slug: string }; @@ -52,6 +68,8 @@ export async function loader({ params }: Route.LoaderArgs) { results, participantResults, seasonResults, + qpConfig, + qpStandings, }; } @@ -59,6 +77,36 @@ export async function action({ request, params }: Route.ActionArgs) { const formData = await request.formData(); const intent = formData.get("intent"); + if (intent === "mark-qualifying") { + try { + const event = await getScoringEventById(params.eventId); + if (!event) { + return { error: "Event not found" }; + } + + // Update the event to mark it as a qualifying event + const db = database(); + await db.update(schema.scoringEvents) + .set({ isQualifyingEvent: true }) + .where(eq(schema.scoringEvents.id, params.eventId)); + + return { success: "Event marked as qualifying event!" }; + } catch (error) { + console.error("Error marking event as qualifying:", error); + return { error: "Failed to mark event as qualifying" }; + } + } + + if (intent === "process-qp") { + try { + await processQualifyingEvent(params.eventId); + return { success: "Qualifying points processed and awarded!" }; + } catch (error) { + console.error("Error processing qualifying points:", error); + return { error: error instanceof Error ? error.message : "Failed to process qualifying points" }; + } + } + if (intent === "complete") { try { const event = await getScoringEventById(params.eventId); @@ -74,6 +122,12 @@ export async function action({ request, params }: Route.ActionArgs) { return { success: "Event completed and fantasy placements assigned to top 8!" }; } + // If this is a qualifying event, process qualifying points + if (event.isQualifyingEvent) { + await processQualifyingEvent(params.eventId); + return { success: "Event completed and qualifying points awarded!" }; + } + return { success: "Event marked as completed" }; } catch (error) { console.error("Error completing event:", error); diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.tsx b/app/routes/admin.sports-seasons.$id.events.$eventId.tsx index 9b49069..a9d22d9 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.tsx +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.tsx @@ -36,7 +36,7 @@ export default function EventResults({ loaderData, actionData, }: Route.ComponentProps) { - const { sportsSeason, event, participants, results, participantResults, seasonResults } = loaderData; + const { sportsSeason, event, participants, results, participantResults, seasonResults, qpConfig, qpStandings } = loaderData; const [hasChanges, setHasChanges] = useState(false); // Create a map of participants with results for easy lookup @@ -108,11 +108,60 @@ export default function EventResults({ ) : ( In Progress )} + {event.isQualifyingEvent && ( + + Qualifying Event + + )}
+ {/* Debug info and manual QP processing for completed major tournaments */} + {event.isComplete && event.eventType === "major_tournament" && ( + + + + Event Processing Status + + +
+
Event Type: {event.eventType}
+
Is Qualifying Event: {event.isQualifyingEvent ? 'Yes' : 'No'}
+
Sports Season Pattern: {sportsSeason.scoringPattern || 'not set'}
+
Results Count: {results.length}
+
+
+
+ +
+ {!event.isQualifyingEvent && ( +
+

+ This event needs to be marked as a qualifying event to award qualifying points. +

+
+ + +
+
+ )} + {event.isQualifyingEvent && ( +
+ + +
+ )} +
+
+
+ )} {actionData?.error && (
{actionData.error} @@ -288,6 +337,33 @@ export default function EventResults({ )} + {/* Manual QP Processing Button (for existing events) */} + {event.isComplete && + event.eventType === "major_tournament" && + sportsSeason.scoringPattern === "qualifying_points" && + results.length > 0 && ( + + + + Process Qualifying Points + + + This event is complete but qualifying points haven't been awarded yet. + Click below to award QP based on the results entered. + + + +
+ + +
+
+
+ )} + {/* Current Results */} @@ -321,6 +397,9 @@ export default function EventResults({ Placement Participant + {event.isQualifyingEvent && event.isComplete && ( + QP Awarded + )} {!event.isComplete && Actions} @@ -344,6 +423,17 @@ export default function EventResults({
{result.participant.name} + {event.isQualifyingEvent && event.isComplete && ( + + {result.qualifyingPointsAwarded ? ( + + {parseFloat(result.qualifyingPointsAwarded).toFixed(2)} QP + + ) : ( + 0 QP + )} + + )} {!event.isComplete && (
@@ -454,7 +544,7 @@ export default function EventResults({ {result.qualifyingPoints ? ( - {parseFloat(result.qualifyingPoints).toFixed(0)} pts + {parseFloat(result.qualifyingPoints).toFixed(2)} pts ) : ( - diff --git a/app/routes/admin.sports-seasons.$id.events.server.ts b/app/routes/admin.sports-seasons.$id.events.server.ts index 882c5b2..3b39285 100644 --- a/app/routes/admin.sports-seasons.$id.events.server.ts +++ b/app/routes/admin.sports-seasons.$id.events.server.ts @@ -7,6 +7,9 @@ import { deleteScoringEvent, type CreateScoringEventData, } from "~/models/scoring-event"; +import { getQPStandings } from "~/models/qualifying-points"; +import { finalizeQualifyingPoints } from "~/models/scoring-calculator"; +import { getScoringRules } from "~/models/scoring-rules"; export async function loader({ params }: Route.LoaderArgs) { const sportsSeason = await findSportsSeasonById(params.id); @@ -17,11 +20,25 @@ export async function loader({ params }: Route.LoaderArgs) { const events = await getScoringEventsForSportsSeason(params.id); + // For qualifying sports seasons, get QP standings + let qpStandings = null; + let scoringRules = null; + if (sportsSeason.scoringPattern === "qualifying_points") { + qpStandings = await getQPStandings(params.id); + + // Get scoring rules from a linked season (if any) + // For now, we'll use default scoring for projection + // Note: QP standings are global to the sports season, not league-specific + // When showing to league members, we would filter by their league's scoring rules + } + return { sportsSeason: sportsSeason as typeof sportsSeason & { sport: { id: string; name: string; type: string; slug: string }; }, events, + qpStandings, + scoringRules, }; } @@ -29,6 +46,16 @@ export async function action({ request, params }: Route.ActionArgs) { const formData = await request.formData(); const intent = formData.get("intent"); + if (intent === "finalize-qp") { + try { + await finalizeQualifyingPoints(params.id); + return { success: "Qualifying points finalized and fantasy placements assigned to top 8!" }; + } catch (error) { + console.error("Error finalizing qualifying points:", error); + return { error: error instanceof Error ? error.message : "Failed to finalize qualifying points" }; + } + } + if (intent === "delete-event") { const eventId = formData.get("eventId"); @@ -62,11 +89,23 @@ export async function action({ request, params }: Route.ActionArgs) { return { error: "Invalid event type" }; } + // Get the sports season to check if this should be a qualifying event + const sportsSeason = await findSportsSeasonById(params.id); + if (!sportsSeason) { + return { error: "Sports season not found" }; + } + + // Automatically mark major tournaments as qualifying events for qualifying_points sports seasons + const isQualifyingEvent = + sportsSeason.scoringPattern === "qualifying_points" && + eventType === "major_tournament"; + const eventData: CreateScoringEventData = { sportsSeasonId: params.id, name: name.trim(), eventType: eventType as "playoff_game" | "major_tournament" | "final_standings", eventDate: typeof eventDate === "string" && eventDate ? new Date(eventDate) : undefined, + isQualifyingEvent, }; try { diff --git a/app/routes/admin.sports-seasons.$id.events.tsx b/app/routes/admin.sports-seasons.$id.events.tsx index 47251a5..f7ecf34 100644 --- a/app/routes/admin.sports-seasons.$id.events.tsx +++ b/app/routes/admin.sports-seasons.$id.events.tsx @@ -21,6 +21,7 @@ import { import { Badge } from "~/components/ui/badge"; import { Calendar, Trophy, ArrowLeft, Trash2 } from "lucide-react"; import { format } from "date-fns"; +import { QualifyingPointsStandings } from "~/components/scoring/QualifyingPointsStandings"; export { loader, action }; @@ -28,7 +29,7 @@ export default function SportsSeasonEvents({ loaderData, actionData, }: Route.ComponentProps) { - const { sportsSeason, events } = loaderData; + const { sportsSeason, events, qpStandings, scoringRules } = loaderData; const getEventTypeLabel = (type: string) => { switch (type) { @@ -67,9 +68,45 @@ export default function SportsSeasonEvents({

{sportsSeason.sport.name} - {sportsSeason.name}

+ {sportsSeason.scoringPattern === "qualifying_points" && ( +
+ + Qualifying Points Mode + +
+ )}
+ {/* Success/Error Messages */} + {actionData?.error && ( +
+ {actionData.error} +
+ )} + + {actionData?.success && ( +
+ {actionData.success} +
+ )} + + {/* QP Standings for qualifying points sports */} + {sportsSeason.scoringPattern === "qualifying_points" && qpStandings && ( + = sportsSeason.totalMajors && + !sportsSeason.qualifyingPointsFinalized + } + /> + )} + {/* Create New Event Card */} @@ -93,7 +130,11 @@ export default function SportsSeasonEvents({
- @@ -103,6 +144,11 @@ export default function SportsSeasonEvents({ Final Standings + {sportsSeason.scoringPattern === "qualifying_points" && ( +

+ Major Tournament events will automatically award qualifying points when completed. +

+ )}
diff --git a/app/routes/admin.sports-seasons.$id.tsx b/app/routes/admin.sports-seasons.$id.tsx index 7832254..a24572a 100644 --- a/app/routes/admin.sports-seasons.$id.tsx +++ b/app/routes/admin.sports-seasons.$id.tsx @@ -31,6 +31,7 @@ import { AlertDialogTrigger, } from "~/components/ui/alert-dialog"; import { Trash2, Users, Trophy } from "lucide-react"; +import { useState } from "react"; export async function loader({ params }: Route.LoaderArgs) { const sportsSeason = await findSportsSeasonById(params.id); @@ -64,6 +65,8 @@ export async function action({ request, params }: Route.ActionArgs) { const endDate = formData.get("endDate"); const status = formData.get("status"); const scoringType = formData.get("scoringType"); + const scoringPattern = formData.get("scoringPattern"); + const totalMajors = formData.get("totalMajors"); // Validation if (typeof name !== "string" || !name.trim()) { @@ -87,15 +90,33 @@ export async function action({ request, params }: Route.ActionArgs) { return { error: "Invalid scoring type" }; } + const validScoringPatterns = ["single_elimination_playoff", "page_playoff", "season_standings", "qualifying_points"]; + if (scoringPattern && typeof scoringPattern === "string" && !validScoringPatterns.includes(scoringPattern)) { + return { error: "Invalid scoring pattern" }; + } + try { - await updateSportsSeason(params.id, { + const updateData: any = { name: name.trim(), year: yearNum, startDate: typeof startDate === "string" && startDate ? startDate : null, endDate: typeof endDate === "string" && endDate ? endDate : null, status, scoringType, - }); + }; + + if (scoringPattern && typeof scoringPattern === "string") { + updateData.scoringPattern = scoringPattern; + } + + if (totalMajors && typeof totalMajors === "string") { + const totalMajorsNum = parseInt(totalMajors, 10); + if (!isNaN(totalMajorsNum) && totalMajorsNum > 0) { + updateData.totalMajors = totalMajorsNum; + } + } + + await updateSportsSeason(params.id, updateData); return { success: true }; } catch (error) { @@ -107,6 +128,7 @@ export async function action({ request, params }: Route.ActionArgs) { export default function EditSportsSeason({ loaderData, actionData }: Route.ComponentProps) { const { sportsSeason, participants } = loaderData; const navigate = useNavigate(); + const [scoringPattern, setScoringPattern] = useState(sportsSeason.scoringPattern || ""); return (
@@ -200,8 +222,47 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo Majors +

+ Playoffs: Team sports playoffs. Regular Season: Full season standings. Majors: Individual sport majors. +

+
+ + +

+ Qualifying Points: For sports like Golf/Tennis where participants earn points across majors, then top 8 get fantasy points. +

+
+ + {scoringPattern === "qualifying_points" && ( +
+ + +

+ How many major tournaments will be tracked? (e.g., Golf has 4 majors, Tennis has 4 Grand Slams) +

+
+ )} + {actionData?.error && (
{actionData.error} diff --git a/app/routes/admin.sports-seasons.new.tsx b/app/routes/admin.sports-seasons.new.tsx index 1487b69..3fac0cc 100644 --- a/app/routes/admin.sports-seasons.new.tsx +++ b/app/routes/admin.sports-seasons.new.tsx @@ -19,6 +19,7 @@ import { SelectTrigger, SelectValue, } from "~/components/ui/select"; +import { useState } from "react"; export async function loader() { const sports = await findAllSports(); @@ -34,6 +35,8 @@ export async function action({ request }: Route.ActionArgs) { const endDate = formData.get("endDate"); const status = formData.get("status"); const scoringType = formData.get("scoringType"); + const scoringPattern = formData.get("scoringPattern"); + const totalMajors = formData.get("totalMajors"); // Validation if (typeof sportId !== "string" || !sportId) { @@ -61,8 +64,13 @@ export async function action({ request }: Route.ActionArgs) { return { error: "Invalid scoring type" }; } + const validScoringPatterns = ["single_elimination_playoff", "page_playoff", "season_standings", "qualifying_points"]; + if (scoringPattern && typeof scoringPattern === "string" && !validScoringPatterns.includes(scoringPattern)) { + return { error: "Invalid scoring pattern" }; + } + try { - await createSportsSeason({ + const seasonData: any = { sportId, name: name.trim(), year: yearNum, @@ -70,7 +78,20 @@ export async function action({ request }: Route.ActionArgs) { endDate: typeof endDate === "string" && endDate ? endDate : null, status, scoringType, - }); + }; + + if (scoringPattern && typeof scoringPattern === "string") { + seasonData.scoringPattern = scoringPattern; + } + + if (totalMajors && typeof totalMajors === "string") { + const totalMajorsNum = parseInt(totalMajors, 10); + if (!isNaN(totalMajorsNum) && totalMajorsNum > 0) { + seasonData.totalMajors = totalMajorsNum; + } + } + + await createSportsSeason(seasonData); return redirect("/admin/sports-seasons"); } catch (error) { @@ -82,6 +103,7 @@ export async function action({ request }: Route.ActionArgs) { export default function NewSportsSeason({ loaderData, actionData }: Route.ComponentProps) { const { sports } = loaderData; const currentYear = new Date().getFullYear(); + const [scoringPattern, setScoringPattern] = useState(""); return (
@@ -198,6 +220,42 @@ export default function NewSportsSeason({ loaderData, actionData }: Route.Compon

+
+ + +

+ Qualifying Points: For sports like Golf/Tennis where participants earn points across majors, then top 8 get fantasy points. +

+
+ + {scoringPattern === "qualifying_points" && ( +
+ + +

+ How many major tournaments will be tracked? (e.g., Golf has 4 majors, Tennis has 4 Grand Slams) +

+
+ )} + {actionData?.error && (
{actionData.error} diff --git a/plans/scoring-system.md b/plans/scoring-system.md index 2c09ede..9f35cf4 100644 --- a/plans/scoring-system.md +++ b/plans/scoring-system.md @@ -1180,14 +1180,54 @@ scoring_events { 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) - - [ ] UI for entering major tournament results - - [ ] Calculate and store QP totals - - [ ] Display QP standings - - [ ] Show projected fantasy points based on current QP (Q6) - - [ ] Manual finalization button (Q4) - - [ ] Handle QP ties with shared placements (Q5) +- [x] **3.2** Qualifying points (Golf/Tennis) ✅ *Completed* + - [x] QP value configuration per sports season (Q21) + - Created `qualifying_point_config` table with configurable QP values + - Default QP values: 1st=20, 2nd=14, 3rd=10, 4th=8, 5th-6th=5, 7th-8th=3, 9th-12th=2, 13th-16th=1 + - Model functions: `initializeQPConfig`, `getQPConfig`, `updateQPConfig`, `getQPForPlacement` + - [x] UI for entering major tournament results + - Uses existing event results system with Major Tournament event type + - Automatic detection: Major tournaments in qualifying_points seasons are marked as qualifying events + - Manual override: "Mark as Qualifying Event" button for retroactive fixing + - [x] Calculate and store QP totals + - `processQualifyingEvent` function in scoring-calculator + - Proper tie handling: Groups tied participants, sums QP for occupied positions, divides equally + - Example: 7-way tie for 14th (positions 14-20) = (1+1+1+0+0+0+0)/7 = 0.43 QP each + - `participant_qualifying_totals` table tracks running totals and events scored + - [x] Display QP standings + - `QualifyingPointsStandings` component shows current standings + - Displays total QP, events scored, and current rank + - Shows majors progress (X of Y completed) + - Visual indicators for finalized vs in-progress state + - QP values display with 2 decimal precision (.toFixed(2)) + - [x] Show projected fantasy points based on current QP (Q6) + - Calculates projected points for top 8 based on league scoring rules + - Shows before finalization to preview what participants would earn + - Updates dynamically as QP totals change throughout season + - [x] Manual finalization button (Q4) + - "Finalize Qualifying Points" button appears when all majors complete + - Confirmation dialog prevents accidental finalization + - Converts top 8 QP standings to fantasy placements (1-8) + - Awards fantasy points based on league scoring rules + - Locks QP totals (no further changes after finalization) + - [x] Handle QP ties with shared placements (Q5) + - Tie handling during QP awards: Same placement entered → system calculates shared positions + - Tie handling during finalization: Participants with same QP share fantasy placements + - Both work for any number of ties (2-way, 4-way, 7-way, etc.) + - [x] Sports season configuration + - Added "Scoring Pattern" dropdown to create/edit forms + - "Qualifying Points (Golf/Tennis)" option available + - Conditional "Total Majors" field (e.g., 4 for Golf, 4 for Tennis Grand Slams) + - Visual badge on events page showing "Qualifying Points Mode" + - [x] Smart event creation + - Event type defaults to "Major Tournament" for qualifying_points seasons + - Helper text explains Major Tournaments automatically award QP + - Process/Reprocess QP button for manual recalculation + - [x] Comprehensive test suite + - 17 unit tests in qualifying-points.test.ts + - 10 integration tests in scoring-calculator-qualifying.test.ts + - Tests cover: configuration, accumulation, tie handling, finalization, edge cases + - All 308 tests passing ✅ - [ ] **3.3** Page playoffs (AFL) - [ ] Research AFL page playoff structure @@ -1322,9 +1362,16 @@ scoring_events { - 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 3.2**: Qualifying Points (Golf/Tennis) - COMPLETE + - Two-phase scoring: Major tournaments award QP → Final QP standings convert to fantasy points + - Configurable QP values per sports season (default top 16) + - Proper tie handling with position sharing and point splitting + - Projected fantasy points display before finalization + - Manual finalization workflow after all majors complete + - Comprehensive test coverage (27 tests) +- ⏳ **Phase 3.3-3.4**: Page Playoffs & Pattern UI - 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) +**Current Focus**: Phase 3.3 - Page Playoffs (AFL) or Phase 4 - Standings & Display