brackt/app/components/scoring/QualifyingPointsStandings.tsx
Chris Parsons ed57c67b93 Fix standings table bugs and polish across all three scoring patterns
- Fix isLastBeforePointsLine/PlayoffLine: was incorrectly suppressing the
  bottom border on the last row of a section when no divider followed
  (nextStanding === undefined case). Now correctly requires nextStanding
  to exist and exceed the cutoff rank.
- Fix totalCols colSpan overcounting: hidden sm:table-cell columns
  (Drafted By / Mgr) don't occupy column slots on mobile, so counting
  them caused the divider rows to span one too many. Replaced with
  colSpan={100} (browser caps to actual column count).
- Move pointsLinePushed mutation out of render in SeasonStandings and
  QualifyingPointsStandings: replaced let+mutation+array-push pattern
  with pre-computed firstOver8Idx and React.Fragment per row.
- Replace array-returning .map() with keyed React.Fragment in both files.
- Remove unused description prop from SeasonStandingsProps.
- Use useId() for Switch id props in all three components to prevent
  id collisions when mounted multiple times.
- Fix formatQP NaN fallback from "0" to "—".
- Add comment noting canFinalize guards the admin-only finalize UI.
- Drop dead description prop pass-through in SportSeasonDisplay.

Fixes #408

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 10:14:20 -07:00

276 lines
11 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;
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(1);
}
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);
// Assign current ranks with tie handling
const rankedStandings: Array<QPStanding & { currentRank: number }> = [];
let previousQP = -1;
let previousRankStart = -1;
for (let index = 0; index < standings.length; index++) {
const standing = standings[index];
const currentQP = parseFloat(standing.totalQualifyingPoints);
let currentRank: number;
if (index > 0 && Math.abs(currentQP - previousQP) < 0.001) {
currentRank = previousRankStart;
} else {
currentRank = index + 1;
}
rankedStandings.push({ ...standing, currentRank });
previousRankStart = currentRank;
previousQP = currentQP;
}
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>
);
}