brackt/app/components/scoring/QualifyingPointsStandings.tsx
Claude cab161424e
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m6s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m21s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Trim trailing zeros on qualifying-points values
Display fractional QP with up to 2 decimals and trailing zeros trimmed
(1.50→1.5, 0.50→0.5) in both the Discord embed (formatQPValue) and the web
UI (formatQP) so the two surfaces stay consistent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011U7mTxDVK2cH8E2BWjCiUv
2026-07-09 06:34:25 +00:00

264 lines
10 KiB
TypeScript
Raw Permalink 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 "—";
// At most 2 decimals, trailing zeros trimmed (1.50→1.5). Mirrors the Discord
// embed's formatQPValue (app/services/discord.ts) so the site and Discord agree.
return parseFloat(n.toFixed(2)).toString();
}
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>
);
}