brackt/app/components/scoring/QualifyingPointsStandings.tsx
Chris Parsons 1089022c09 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.
2025-11-11 10:08:25 -08:00

259 lines
9.6 KiB
TypeScript

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