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:
parent
d926486934
commit
1089022c09
12 changed files with 1753 additions and 36 deletions
259
app/components/scoring/QualifyingPointsStandings.tsx
Normal file
259
app/components/scoring/QualifyingPointsStandings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
339
app/models/__tests__/qualifying-points.test.ts
Normal file
339
app/models/__tests__/qualifying-points.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
199
app/models/__tests__/scoring-calculator-qualifying.test.ts
Normal file
199
app/models/__tests__/scoring-calculator-qualifying.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
348
app/models/qualifying-points.ts
Normal file
348
app/models/qualifying-points.ts
Normal 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));
|
||||
}
|
||||
|
|
@ -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`
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue