- 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>
276 lines
11 KiB
TypeScript
276 lines
11 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;
|
||
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 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>
|
||
);
|
||
}
|