brackt/app/components/scoring/QualifyingPointsStandings.tsx
Chris Parsons 73f0256ff5
Some checks failed
🚀 Deploy / 🧪 Test (pull_request) Failing after 2m33s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m20s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Fix QP standings: filter to drafted/points-earning participants with correct global ranks
- 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>
2026-06-06 09:38:47 -07:00

262 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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