- Filter the QP table to show only participants with QP > 0 or drafted by any team in the league (undrafted 0-QP participants hidden) - Compute global ranks with tie handling across the full field before filtering so displayed rank numbers and the top-8 Points Line remain correct after rows are removed - Fix flaky CI timeouts: world-cup simulator test (100 → 50 iterations), SportsSection userEvent test (explicit 15s timeout) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
262 lines
10 KiB
TypeScript
262 lines
10 KiB
TypeScript
import { Fragment } from "react";
|
||
import { Form } from "react-router";
|
||
import { Button } from "~/components/ui/button";
|
||
import { TeamOwnerBadge } from "~/components/ui/team-owner-badge";
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardDescription,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from "~/components/ui/card";
|
||
import { Trophy, CheckCircle2 } from "lucide-react";
|
||
|
||
interface TeamOwnership {
|
||
participantId: string;
|
||
teamName: string;
|
||
teamId: string;
|
||
ownerName?: string;
|
||
}
|
||
|
||
interface QPStanding {
|
||
id: string;
|
||
totalQualifyingPoints: string;
|
||
eventsScored: number;
|
||
finalRanking: number | null;
|
||
globalRank: number;
|
||
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;
|
||
teamOwnerships?: TeamOwnership[];
|
||
userParticipantIds?: string[];
|
||
}
|
||
|
||
function formatQP(raw: string): string {
|
||
const n = parseFloat(raw);
|
||
if (isNaN(n)) return "—";
|
||
return n % 1 === 0 ? n.toString() : n.toFixed(2);
|
||
}
|
||
|
||
export function QualifyingPointsStandings({
|
||
standings,
|
||
scoringRules,
|
||
isFinalized,
|
||
totalMajors,
|
||
majorsCompleted = 0,
|
||
canFinalize,
|
||
teamOwnerships = [],
|
||
userParticipantIds = [],
|
||
}: QualifyingPointsStandingsProps) {
|
||
const ownershipMap = new Map(teamOwnerships.map((o) => [o.participantId, o]));
|
||
const userParticipantSet = new Set(userParticipantIds);
|
||
|
||
// Use the pre-computed global rank from the loader so displayed ranks reflect the full
|
||
// participant field even when the array has been filtered down to drafted/points-earning rows.
|
||
const rankedStandings: Array<QPStanding & { currentRank: number }> = standings.map(
|
||
(standing) => ({ ...standing, currentRank: standing.globalRank })
|
||
);
|
||
|
||
const tiedCountByRank = new Map<number, number>();
|
||
for (const s of rankedStandings) {
|
||
tiedCountByRank.set(s.currentRank, (tiedCountByRank.get(s.currentRank) ?? 0) + 1);
|
||
}
|
||
|
||
const showOwnership = teamOwnerships.length > 0;
|
||
|
||
// Index of first row with rank > 8; -1 when there is no cutoff.
|
||
const firstOver8Idx = rankedStandings.findIndex((s) => s.currentRank > 8);
|
||
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>
|
||
<Trophy className="inline mr-2 h-5 w-5" />
|
||
Qualifying Points Standings
|
||
</CardTitle>
|
||
<CardDescription>
|
||
{isFinalized ? (
|
||
<span className="text-emerald-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.
|
||
</>
|
||
)}
|
||
</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>
|
||
) : (
|
||
<>
|
||
<div className="overflow-x-auto -mx-6 px-6">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="text-xs text-muted-foreground uppercase tracking-wide border-b">
|
||
<th className="text-left py-1.5 pl-2 pr-2 w-10">#</th>
|
||
<th className="text-left py-1.5">Participant</th>
|
||
<th className="text-right py-1.5 px-2 w-24">Total QP</th>
|
||
{showOwnership && (
|
||
<th className="hidden sm:table-cell text-right py-1.5 pl-4 w-40">Drafted By</th>
|
||
)}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rankedStandings.map((standing, idx) => {
|
||
const ownership = showOwnership
|
||
? ownershipMap.get(standing.participant.id)
|
||
: undefined;
|
||
const isTop8 = standing.currentRank <= 8;
|
||
const isTied = (tiedCountByRank.get(standing.currentRank) ?? 1) > 1;
|
||
const isOwned = userParticipantSet.has(standing.participant.id);
|
||
const showPointsLineBefore = firstOver8Idx !== -1 && idx === firstOver8Idx;
|
||
const isLastBeforePointsLine = firstOver8Idx !== -1 && idx === firstOver8Idx - 1;
|
||
|
||
return (
|
||
<Fragment key={standing.id}>
|
||
{showPointsLineBefore && (
|
||
<tr>
|
||
<td colSpan={100} className="py-0.5">
|
||
<div className="flex w-full items-center gap-2">
|
||
<div className="flex-1 border-t border-dashed border-amber-500/40" />
|
||
<span className="text-[10px] text-amber-600/70 dark:text-amber-500/70 uppercase tracking-wide whitespace-nowrap font-medium px-1">
|
||
Points Line
|
||
</span>
|
||
<div className="flex-1 border-t border-dashed border-amber-500/40" />
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
)}
|
||
<tr
|
||
className={`${isLastBeforePointsLine ? "" : "border-b border-border/50 last:border-0"} ${
|
||
isOwned
|
||
? "bg-primary/5"
|
||
: isTop8
|
||
? "hover:bg-muted/30"
|
||
: "opacity-60 hover:bg-muted/30"
|
||
}`}
|
||
>
|
||
<td className="py-2 pl-2 pr-2 text-muted-foreground tabular-nums">
|
||
{isTied ? `T${standing.currentRank}` : standing.currentRank}
|
||
</td>
|
||
<td className="py-2 font-medium">
|
||
<span className={isOwned ? "text-primary" : ""}>
|
||
{standing.participant.name}
|
||
</span>
|
||
{ownership && (
|
||
<div className="sm:hidden mt-0.5">
|
||
<TeamOwnerBadge
|
||
teamName={ownership.teamName}
|
||
ownerName={ownership.ownerName}
|
||
/>
|
||
</div>
|
||
)}
|
||
</td>
|
||
<td className="py-2 px-2 text-right tabular-nums">
|
||
<span className={`font-semibold ${isTop8 ? "text-amber-accent" : "text-muted-foreground"}`}>
|
||
{formatQP(standing.totalQualifyingPoints)} QP
|
||
</span>
|
||
</td>
|
||
{showOwnership && (
|
||
<td className="hidden sm:table-cell py-2 pl-4 text-right">
|
||
{ownership ? (
|
||
<div className="flex justify-end">
|
||
<TeamOwnerBadge
|
||
teamName={ownership.teamName}
|
||
ownerName={ownership.ownerName}
|
||
align="right"
|
||
/>
|
||
</div>
|
||
) : (
|
||
<span className="text-xs text-muted-foreground">—</span>
|
||
)}
|
||
</td>
|
||
)}
|
||
</tr>
|
||
</Fragment>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* canFinalize is controlled by the route loader; this section is only shown to league admins */}
|
||
{!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 && scoringRules && (
|
||
<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>
|
||
);
|
||
}
|