2026-05-12 10:39:25 -07:00
|
|
|
|
import { Fragment } from "react";
|
2025-11-11 10:08:25 -08:00
|
|
|
|
import { Form } from "react-router";
|
|
|
|
|
|
import { Button } from "~/components/ui/button";
|
2026-04-12 17:06:20 -07:00
|
|
|
|
import { TeamOwnerBadge } from "~/components/ui/team-owner-badge";
|
2025-11-11 10:08:25 -08:00
|
|
|
|
import {
|
|
|
|
|
|
Card,
|
|
|
|
|
|
CardContent,
|
|
|
|
|
|
CardDescription,
|
|
|
|
|
|
CardHeader,
|
|
|
|
|
|
CardTitle,
|
|
|
|
|
|
} from "~/components/ui/card";
|
|
|
|
|
|
import { Trophy, CheckCircle2 } from "lucide-react";
|
|
|
|
|
|
|
2026-04-12 17:06:20 -07:00
|
|
|
|
interface TeamOwnership {
|
|
|
|
|
|
participantId: string;
|
|
|
|
|
|
teamName: string;
|
|
|
|
|
|
teamId: string;
|
|
|
|
|
|
ownerName?: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-11 10:08:25 -08:00
|
|
|
|
interface QPStanding {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
totalQualifyingPoints: string;
|
|
|
|
|
|
eventsScored: number;
|
|
|
|
|
|
finalRanking: number | null;
|
2026-06-06 09:38:47 -07:00
|
|
|
|
globalRank: number;
|
2025-11-11 10:08:25 -08:00
|
|
|
|
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;
|
2026-05-12 10:39:25 -07:00
|
|
|
|
canFinalize: boolean;
|
2026-04-12 17:06:20 -07:00
|
|
|
|
teamOwnerships?: TeamOwnership[];
|
2026-05-12 10:39:25 -07:00
|
|
|
|
userParticipantIds?: string[];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatQP(raw: string): string {
|
|
|
|
|
|
const n = parseFloat(raw);
|
|
|
|
|
|
if (isNaN(n)) return "—";
|
2026-05-17 21:58:53 -07:00
|
|
|
|
return n % 1 === 0 ? n.toString() : n.toFixed(2);
|
2025-11-11 10:08:25 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function QualifyingPointsStandings({
|
|
|
|
|
|
standings,
|
|
|
|
|
|
scoringRules,
|
|
|
|
|
|
isFinalized,
|
|
|
|
|
|
totalMajors,
|
|
|
|
|
|
majorsCompleted = 0,
|
|
|
|
|
|
canFinalize,
|
2026-04-12 17:06:20 -07:00
|
|
|
|
teamOwnerships = [],
|
2026-05-12 10:39:25 -07:00
|
|
|
|
userParticipantIds = [],
|
2025-11-11 10:08:25 -08:00
|
|
|
|
}: QualifyingPointsStandingsProps) {
|
2026-04-12 17:06:20 -07:00
|
|
|
|
const ownershipMap = new Map(teamOwnerships.map((o) => [o.participantId, o]));
|
2026-05-12 10:39:25 -07:00
|
|
|
|
const userParticipantSet = new Set(userParticipantIds);
|
2025-11-11 10:08:25 -08:00
|
|
|
|
|
2026-06-06 09:38:47 -07:00
|
|
|
|
// 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 })
|
|
|
|
|
|
);
|
2025-11-11 10:08:25 -08:00
|
|
|
|
|
2026-04-12 17:06:20 -07:00
|
|
|
|
const tiedCountByRank = new Map<number, number>();
|
|
|
|
|
|
for (const s of rankedStandings) {
|
|
|
|
|
|
tiedCountByRank.set(s.currentRank, (tiedCountByRank.get(s.currentRank) ?? 0) + 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 10:39:25 -07:00
|
|
|
|
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);
|
2026-04-12 17:06:20 -07:00
|
|
|
|
|
2025-11-11 10:08:25 -08:00
|
|
|
|
return (
|
2026-05-12 10:39:25 -07:00
|
|
|
|
<Card>
|
2025-11-11 10:08:25 -08:00
|
|
|
|
<CardHeader>
|
2026-05-12 10:39:25 -07:00
|
|
|
|
<CardTitle>
|
2025-11-11 10:08:25 -08:00
|
|
|
|
<Trophy className="inline mr-2 h-5 w-5" />
|
|
|
|
|
|
Qualifying Points Standings
|
|
|
|
|
|
</CardTitle>
|
|
|
|
|
|
<CardDescription>
|
|
|
|
|
|
{isFinalized ? (
|
2026-02-20 19:26:11 -08:00
|
|
|
|
<span className="text-emerald-400 font-semibold">
|
2025-11-11 10:08:25 -08:00
|
|
|
|
<CheckCircle2 className="inline h-4 w-4 mr-1" />
|
2026-05-12 10:39:25 -07:00
|
|
|
|
Finalized — fantasy points have been assigned
|
2025-11-11 10:08:25 -08:00
|
|
|
|
</span>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
2026-05-12 10:39:25 -07:00
|
|
|
|
Current standings based on {majorsCompleted} of {totalMajors || "?"} major
|
|
|
|
|
|
tournaments completed.
|
2025-11-11 10:08:25 -08:00
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</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>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
2026-05-12 10:39:25 -07:00
|
|
|
|
<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;
|
2025-11-11 10:08:25 -08:00
|
|
|
|
|
2026-05-12 10:39:25 -07:00
|
|
|
|
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
|
2025-11-11 10:08:25 -08:00
|
|
|
|
</span>
|
2026-05-12 10:39:25 -07:00
|
|
|
|
</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>
|
2025-11-11 10:08:25 -08:00
|
|
|
|
)}
|
2026-05-12 10:39:25 -07:00
|
|
|
|
</tr>
|
|
|
|
|
|
</Fragment>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
2025-11-11 10:08:25 -08:00
|
|
|
|
|
2026-05-12 10:39:25 -07:00
|
|
|
|
{/* canFinalize is controlled by the route loader; this section is only shown to league admins */}
|
2025-11-11 10:08:25 -08:00
|
|
|
|
{!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">
|
2026-05-12 10:39:25 -07:00
|
|
|
|
<li>Convert the top 8 participants to fantasy placements 1–8</li>
|
2025-11-11 10:08:25 -08:00
|
|
|
|
<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) => {
|
2026-05-12 10:39:25 -07:00
|
|
|
|
if (
|
|
|
|
|
|
!confirm(
|
|
|
|
|
|
"Are you sure you want to finalize qualifying points? This action cannot be undone."
|
|
|
|
|
|
)
|
|
|
|
|
|
) {
|
2025-11-11 10:08:25 -08:00
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<CheckCircle2 className="mr-2 h-4 w-4" />
|
|
|
|
|
|
Finalize Qualifying Points & Assign Fantasy Points
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-05-12 10:39:25 -07:00
|
|
|
|
{!isFinalized && !canFinalize && scoringRules && (
|
2025-11-11 10:08:25 -08:00
|
|
|
|
<div className="mt-6 p-4 border-t">
|
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
2026-05-12 10:39:25 -07:00
|
|
|
|
<span className="font-semibold">Not ready to finalize yet.</span> Complete all{" "}
|
|
|
|
|
|
{totalMajors} major tournaments before finalizing qualifying points. (
|
|
|
|
|
|
{majorsCompleted} of {totalMajors} completed)
|
2025-11-11 10:08:25 -08:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|