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.
This commit is contained in:
Chris Parsons 2025-11-11 10:08:25 -08:00
parent d926486934
commit 1089022c09
12 changed files with 1753 additions and 36 deletions

View file

@ -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<number, number> = {
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<typeof standings[0] & { currentRank: number }> = [];
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 (
<Card className={isFinalized ? "border-green-200 dark:border-green-800" : "border-amber-200 dark:border-amber-800"}>
<CardHeader>
<CardTitle className={isFinalized ? "text-green-600 dark:text-green-400" : "text-amber-600 dark:text-amber-400"}>
<Trophy className="inline mr-2 h-5 w-5" />
Qualifying Points Standings
</CardTitle>
<CardDescription>
{isFinalized ? (
<span className="text-green-600 dark:text-green-400 font-semibold">
<CheckCircle2 className="inline h-4 w-4 mr-1" />
Finalized - Fantasy points have been assigned
</span>
) : (
<>
Current standings based on {majorsCompleted} of {totalMajors || '?'} major tournaments completed.
{scoringRules ? (
<span className="block mt-1">
Projected fantasy points show what participants would earn if finalized now.
</span>
) : (
<span className="block mt-1 text-muted-foreground">
Projected points will be shown when linked to a fantasy season.
</span>
)}
</>
)}
</CardDescription>
</CardHeader>
<CardContent>
{standings.length === 0 ? (
<p className="text-sm text-muted-foreground">
No qualifying points awarded yet. Complete a qualifying event to see standings.
</p>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20">Rank</TableHead>
<TableHead>Participant</TableHead>
<TableHead className="w-28 text-right">Total QP</TableHead>
<TableHead className="w-24 text-right">Events</TableHead>
{scoringRules && !isFinalized && (
<TableHead className="w-32 text-right">Projected Points</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{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 (
<TableRow key={standing.id} className={!isTop8 ? "opacity-60" : ""}>
<TableCell>
<div className="flex items-center gap-2">
{standing.currentRank === 1 && !isTied && <span className="text-xl">🥇</span>}
{standing.currentRank === 2 && !isTied && <span className="text-xl">🥈</span>}
{standing.currentRank === 3 && !isTied && <span className="text-xl">🥉</span>}
<span className="font-semibold">
{isTied && "T"}
{standing.currentRank}
{standing.currentRank === 1
? "st"
: standing.currentRank === 2
? "nd"
: standing.currentRank === 3
? "rd"
: "th"}
</span>
</div>
</TableCell>
<TableCell className="font-medium">
{standing.participant.name}
</TableCell>
<TableCell className="text-right">
<span className="font-semibold text-amber-600 dark:text-amber-400">
{parseFloat(standing.totalQualifyingPoints).toFixed(2)} QP
</span>
</TableCell>
<TableCell className="text-right text-muted-foreground">
{standing.eventsScored}
</TableCell>
{scoringRules && !isFinalized && (
<TableCell className="text-right">
{isTop8 ? (
<span className="font-semibold text-green-600 dark:text-green-400">
{projectedPoints} pts
</span>
) : (
<span className="text-muted-foreground">0 pts</span>
)}
</TableCell>
)}
</TableRow>
);
})}
</TableBody>
</Table>
{!isFinalized && canFinalize && (
<div className="mt-6 p-4 border-t">
<div className="space-y-3">
<div>
<h4 className="font-semibold text-sm mb-1">Ready to finalize?</h4>
<p className="text-sm text-muted-foreground">
All {totalMajors} major tournaments are complete. Finalizing will:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground mt-2 space-y-1">
<li>Convert the top 8 participants to fantasy placements 1-8</li>
<li>Award fantasy points based on league scoring rules</li>
<li>Update all league standings</li>
<li>Lock the qualifying points (no further changes)</li>
</ul>
</div>
<Form method="post">
<input type="hidden" name="intent" value="finalize-qp" />
<Button
type="submit"
className="bg-amber-600 hover:bg-amber-700"
onClick={(e) => {
if (!confirm("Are you sure you want to finalize qualifying points? This action cannot be undone.")) {
e.preventDefault();
}
}}
>
<CheckCircle2 className="mr-2 h-4 w-4" />
Finalize Qualifying Points & Assign Fantasy Points
</Button>
</Form>
</div>
</div>
)}
{!isFinalized && !canFinalize && (
<div className="mt-6 p-4 border-t">
<p className="text-sm text-muted-foreground">
<span className="font-semibold">Not ready to finalize yet.</span> Complete all {totalMajors} major tournaments before finalizing qualifying points.
({majorsCompleted} of {totalMajors} completed)
</p>
</div>
)}
</>
)}
</CardContent>
</Card>
);
}

View file

@ -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<number, number> = {
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<number, number> = { 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<number, number> = { 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);
});
});
});

View file

@ -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<number, number> = {
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);
});
});
});

View file

@ -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<typeof database>
) {
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<typeof database>
) {
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<typeof database>
) {
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<typeof database>
): Promise<number> {
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<typeof database>
) {
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<typeof database>
) {
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<typeof database>
) {
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<string>();
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<typeof database>
) {
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<typeof database>
) {
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<typeof database>
) {
const db = providedDb || database();
const standings = await getQPStandings(sportsSeasonId, db);
// Group participants by QP total to handle ties
const groupedByPoints = new Map<string, typeof standings>();
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<typeof database>
) {
const db = providedDb || database();
await db
.delete(schema.participantQualifyingTotals)
.where(eq(schema.participantQualifyingTotals.sportsSeasonId, sportsSeasonId));
}

View file

@ -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<void> {
const db = providedDb || database();
// TODO: Implement in Phase 3
// 1. Get event results
// 2. Award qualifying points based on placement
// 3. Update participant_qualifying_totals
// 4. Check if all majors complete
// 5. Update QP standings displays
// 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<number, typeof results>();
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<void> {
const db = providedDb || database();
// TODO: Implement in Phase 3
// 1. Get all participants ranked by total QP
// 2. Handle ties in QP (share placements)
// 3. Assign final placements (1-8) based on QP ranking
// 4. Update participantResults with final placements
// 5. Mark sports season as finalized
// 6. Trigger recalculation for all affected leagues
// 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<string, typeof standings>();
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`
);
}

View file

@ -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);

View file

@ -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({
) : (
<Badge variant="secondary">In Progress</Badge>
)}
{event.isQualifyingEvent && (
<Badge variant="outline" className="border-amber-500 text-amber-600">
Qualifying Event
</Badge>
)}
</div>
</div>
</div>
<div className="space-y-6">
{/* Debug info and manual QP processing for completed major tournaments */}
{event.isComplete && event.eventType === "major_tournament" && (
<Card className="border-blue-200 dark:border-blue-800">
<CardHeader>
<CardTitle className="text-blue-600 dark:text-blue-400">
Event Processing Status
</CardTitle>
<CardDescription>
<div className="space-y-1 font-mono text-xs">
<div>Event Type: {event.eventType}</div>
<div>Is Qualifying Event: {event.isQualifyingEvent ? 'Yes' : 'No'}</div>
<div>Sports Season Pattern: {sportsSeason.scoringPattern || 'not set'}</div>
<div>Results Count: {results.length}</div>
</div>
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{!event.isQualifyingEvent && (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
This event needs to be marked as a qualifying event to award qualifying points.
</p>
<Form method="post">
<input type="hidden" name="intent" value="mark-qualifying" />
<Button type="submit" variant="outline">
Mark as Qualifying Event
</Button>
</Form>
</div>
)}
{event.isQualifyingEvent && (
<Form method="post">
<input type="hidden" name="intent" value="process-qp" />
<Button type="submit" className="bg-blue-600 hover:bg-blue-700">
<Trophy className="mr-2 h-4 w-4" />
Process/Reprocess Qualifying Points
</Button>
</Form>
)}
</div>
</CardContent>
</Card>
)}
{actionData?.error && (
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
{actionData.error}
@ -288,6 +337,33 @@ export default function EventResults({
</Card>
)}
{/* Manual QP Processing Button (for existing events) */}
{event.isComplete &&
event.eventType === "major_tournament" &&
sportsSeason.scoringPattern === "qualifying_points" &&
results.length > 0 && (
<Card className="border-amber-200 dark:border-amber-800">
<CardHeader>
<CardTitle className="text-amber-600 dark:text-amber-400">
Process Qualifying Points
</CardTitle>
<CardDescription>
This event is complete but qualifying points haven't been awarded yet.
Click below to award QP based on the results entered.
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post">
<input type="hidden" name="intent" value="process-qp" />
<Button type="submit" className="bg-amber-600 hover:bg-amber-700">
<Trophy className="mr-2 h-4 w-4" />
Award Qualifying Points Now
</Button>
</Form>
</CardContent>
</Card>
)}
{/* Current Results */}
<Card>
<CardHeader>
@ -321,6 +397,9 @@ export default function EventResults({
<TableRow>
<TableHead className="w-24">Placement</TableHead>
<TableHead>Participant</TableHead>
{event.isQualifyingEvent && event.isComplete && (
<TableHead className="w-32 text-right">QP Awarded</TableHead>
)}
{!event.isComplete && <TableHead className="w-32">Actions</TableHead>}
</TableRow>
</TableHeader>
@ -344,6 +423,17 @@ export default function EventResults({
</div>
</TableCell>
<TableCell>{result.participant.name}</TableCell>
{event.isQualifyingEvent && event.isComplete && (
<TableCell className="text-right">
{result.qualifyingPointsAwarded ? (
<span className="font-semibold text-amber-600 dark:text-amber-400">
{parseFloat(result.qualifyingPointsAwarded).toFixed(2)} QP
</span>
) : (
<span className="text-muted-foreground">0 QP</span>
)}
</TableCell>
)}
{!event.isComplete && (
<TableCell>
<div className="flex items-center gap-2">
@ -454,7 +544,7 @@ export default function EventResults({
<TableCell className="text-right font-semibold">
{result.qualifyingPoints ? (
<span className={result.qualifyingPoints === "0.00" ? "text-muted-foreground" : "text-green-600 dark:text-green-400"}>
{parseFloat(result.qualifyingPoints).toFixed(0)} pts
{parseFloat(result.qualifyingPoints).toFixed(2)} pts
</span>
) : (
<span className="text-muted-foreground">-</span>

View file

@ -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 {

View file

@ -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({
<p className="text-muted-foreground mt-1">
{sportsSeason.sport.name} - {sportsSeason.name}
</p>
{sportsSeason.scoringPattern === "qualifying_points" && (
<div className="mt-2">
<Badge variant="outline" className="bg-amber-50 text-amber-700 border-amber-300 dark:bg-amber-950 dark:text-amber-300">
Qualifying Points Mode
</Badge>
</div>
)}
</div>
<div className="space-y-6">
{/* Success/Error Messages */}
{actionData?.error && (
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
{actionData.error}
</div>
)}
{actionData?.success && (
<div className="bg-green-500/15 text-green-700 dark:text-green-400 px-4 py-3 rounded-md text-sm">
{actionData.success}
</div>
)}
{/* QP Standings for qualifying points sports */}
{sportsSeason.scoringPattern === "qualifying_points" && qpStandings && (
<QualifyingPointsStandings
standings={qpStandings}
scoringRules={scoringRules}
isFinalized={sportsSeason.qualifyingPointsFinalized || false}
totalMajors={sportsSeason.totalMajors}
majorsCompleted={sportsSeason.majorsCompleted}
canFinalize={
sportsSeason.totalMajors !== null &&
sportsSeason.majorsCompleted >= sportsSeason.totalMajors &&
!sportsSeason.qualifyingPointsFinalized
}
/>
)}
{/* Create New Event Card */}
<Card>
<CardHeader>
@ -93,7 +130,11 @@ export default function SportsSeasonEvents({
<div className="space-y-2">
<Label htmlFor="eventType">Event Type</Label>
<Select name="eventType" defaultValue="playoff_game" required>
<Select
name="eventType"
defaultValue={sportsSeason.scoringPattern === "qualifying_points" ? "major_tournament" : "playoff_game"}
required
>
<SelectTrigger id="eventType">
<SelectValue />
</SelectTrigger>
@ -103,6 +144,11 @@ export default function SportsSeasonEvents({
<SelectItem value="final_standings">Final Standings</SelectItem>
</SelectContent>
</Select>
{sportsSeason.scoringPattern === "qualifying_points" && (
<p className="text-sm text-muted-foreground">
Major Tournament events will automatically award qualifying points when completed.
</p>
)}
</div>
<div className="space-y-2">

View file

@ -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<string>(sportsSeason.scoringPattern || "");
return (
<div className="p-8">
@ -200,8 +222,47 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
<SelectItem value="majors">Majors</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
Playoffs: Team sports playoffs. Regular Season: Full season standings. Majors: Individual sport majors.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="scoringPattern">Scoring Pattern (Optional)</Label>
<Select name="scoringPattern" value={scoringPattern} onValueChange={setScoringPattern}>
<SelectTrigger id="scoringPattern">
<SelectValue placeholder="Select scoring pattern (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="single_elimination_playoff">Single Elimination Playoff</SelectItem>
<SelectItem value="page_playoff">Page Playoff</SelectItem>
<SelectItem value="season_standings">Season Standings</SelectItem>
<SelectItem value="qualifying_points">Qualifying Points (Golf/Tennis)</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
Qualifying Points: For sports like Golf/Tennis where participants earn points across majors, then top 8 get fantasy points.
</p>
</div>
{scoringPattern === "qualifying_points" && (
<div className="space-y-2">
<Label htmlFor="totalMajors">Total Majors</Label>
<Input
id="totalMajors"
name="totalMajors"
type="number"
min="1"
max="10"
defaultValue={sportsSeason.totalMajors || 4}
placeholder="e.g., 4 (for Golf)"
/>
<p className="text-sm text-muted-foreground">
How many major tournaments will be tracked? (e.g., Golf has 4 majors, Tennis has 4 Grand Slams)
</p>
</div>
)}
{actionData?.error && (
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
{actionData.error}

View file

@ -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<string>("");
return (
<div className="p-8">
@ -198,6 +220,42 @@ export default function NewSportsSeason({ loaderData, actionData }: Route.Compon
</p>
</div>
<div className="space-y-2">
<Label htmlFor="scoringPattern">Scoring Pattern (Optional)</Label>
<Select name="scoringPattern" onValueChange={setScoringPattern}>
<SelectTrigger id="scoringPattern">
<SelectValue placeholder="Select scoring pattern (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="single_elimination_playoff">Single Elimination Playoff</SelectItem>
<SelectItem value="page_playoff">Page Playoff</SelectItem>
<SelectItem value="season_standings">Season Standings</SelectItem>
<SelectItem value="qualifying_points">Qualifying Points (Golf/Tennis)</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
Qualifying Points: For sports like Golf/Tennis where participants earn points across majors, then top 8 get fantasy points.
</p>
</div>
{scoringPattern === "qualifying_points" && (
<div className="space-y-2">
<Label htmlFor="totalMajors">Total Majors</Label>
<Input
id="totalMajors"
name="totalMajors"
type="number"
min="1"
max="10"
defaultValue="4"
placeholder="e.g., 4 (for Golf)"
/>
<p className="text-sm text-muted-foreground">
How many major tournaments will be tracked? (e.g., Golf has 4 majors, Tennis has 4 Grand Slams)
</p>
</div>
)}
{actionData?.error && (
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
{actionData.error}

View file

@ -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