brackt/app/components/StandingsTable.tsx
Chris Parsons 611e1ccf0a
Show tied ranks with T prefix across standings and Discord (#239)
* Show tied ranks with T prefix across standings and Discord notifications, fixes #197

- Add buildTiedRankChecker() utility to standings-display.ts; replaces
  four copies of inline rank-count logic spread across components,
  the league home route, and the Discord service
- Prefix shared ranks with "T" (e.g. "T3") in StandingsTable (league
  panel), StandingsTable (full standings), the league home preview, and
  Discord webhook notifications
- Add useMemo wrapping in StandingsTable components so tie detection
  does not recompute on every render
- Fix compareTeamsForRanking to round total points to hundredths before
  comparing, preventing floating-point noise from producing spurious
  non-ties in the standings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix discord test to expect escaped period in rank display

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 20:45:15 -07:00

173 lines
6.1 KiB
TypeScript

import { useMemo } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import { Badge } from "~/components/ui/badge";
import { TrendingUp, TrendingDown, Minus, Trophy, Medal, Award } from "lucide-react";
import { buildTiedRankChecker } from "~/lib/standings-display";
export interface StandingsRow {
teamId: string;
teamName: string;
totalPoints: number;
currentRank: number;
previousRank?: number | null;
firstPlaceCount: number;
secondPlaceCount: number;
thirdPlaceCount: number;
fourthPlaceCount: number;
fifthPlaceCount: number;
sixthPlaceCount: number;
seventhPlaceCount: number;
eighthPlaceCount: number;
participantsRemaining: number;
}
interface StandingsTableProps {
standings: StandingsRow[];
showMovement?: boolean;
showPlacementBreakdown?: boolean;
}
function getRankBadge(rank: number, isTied?: boolean) {
const t = isTied ? "T" : "";
if (rank === 1) {
return (
<div className="flex items-center gap-2">
<Trophy className="h-5 w-5 text-yellow-500" />
<span className="font-bold text-lg">{t}{rank}</span>
</div>
);
} else if (rank === 2) {
return (
<div className="flex items-center gap-2">
<Medal className="h-5 w-5 text-gray-400" />
<span className="font-semibold">{t}{rank}</span>
</div>
);
} else if (rank === 3) {
return (
<div className="flex items-center gap-2">
<Award className="h-5 w-5 text-amber-700" />
<span className="font-semibold">{t}{rank}</span>
</div>
);
} else {
return <span className="font-medium">{t}{rank}</span>;
}
}
export function StandingsTable({
standings,
showMovement = true,
showPlacementBreakdown = false,
}: StandingsTableProps) {
const isTied = useMemo(
() => buildTiedRankChecker(standings.map((s) => s.currentRank)),
[standings]
);
const getMovementIndicator = (current: number, previous?: number | null) => {
if (!showMovement || !previous) return null;
const change = previous - current; // Positive means moved up
if (change > 0) {
return (
<div className="flex items-center gap-1 text-emerald-400">
<TrendingUp className="h-3 w-3" />
<span className="text-xs">+{change}</span>
</div>
);
} else if (change < 0) {
return (
<div className="flex items-center gap-1 text-coral-accent">
<TrendingDown className="h-3 w-3" />
<span className="text-xs">{change}</span>
</div>
);
} else {
return (
<div className="flex items-center gap-1 text-muted-foreground">
<Minus className="h-3 w-3" />
<span className="text-xs">-</span>
</div>
);
}
};
return (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">Rank</TableHead>
{showMovement && <TableHead className="w-20">Change</TableHead>}
<TableHead>Team</TableHead>
<TableHead className="text-right">Total Points</TableHead>
{showPlacementBreakdown && (
<>
<TableHead className="text-center w-12" title="1st place finishes">🥇</TableHead>
<TableHead className="text-center w-12" title="2nd place finishes">🥈</TableHead>
<TableHead className="text-center w-12" title="3rd place finishes">🥉</TableHead>
<TableHead className="text-center w-12" title="4th place finishes">4th</TableHead>
<TableHead className="text-center w-12" title="5th place finishes">5th</TableHead>
<TableHead className="text-center w-12" title="6th place finishes">6th</TableHead>
<TableHead className="text-center w-12" title="7th place finishes">7th</TableHead>
<TableHead className="text-center w-12" title="8th place finishes">8th</TableHead>
</>
)}
<TableHead className="text-right">Remaining</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{standings.length === 0 ? (
<TableRow>
<TableCell colSpan={showPlacementBreakdown ? 13 : 5} className="text-center text-muted-foreground">
No standings data available yet
</TableCell>
</TableRow>
) : (
standings.map((row) => (
<TableRow key={row.teamId} className={row.currentRank <= 3 ? "bg-muted/30" : undefined}>
<TableCell>
{getRankBadge(row.currentRank, isTied(row.currentRank))}
</TableCell>
{showMovement && (
<TableCell>
{getMovementIndicator(row.currentRank, row.previousRank)}
</TableCell>
)}
<TableCell className="font-medium">{row.teamName}</TableCell>
<TableCell className="text-right font-semibold">
{row.totalPoints.toFixed(2)}
</TableCell>
{showPlacementBreakdown && (
<>
<TableCell className="text-center">{row.firstPlaceCount || "-"}</TableCell>
<TableCell className="text-center">{row.secondPlaceCount || "-"}</TableCell>
<TableCell className="text-center">{row.thirdPlaceCount || "-"}</TableCell>
<TableCell className="text-center">{row.fourthPlaceCount || "-"}</TableCell>
<TableCell className="text-center">{row.fifthPlaceCount || "-"}</TableCell>
<TableCell className="text-center">{row.sixthPlaceCount || "-"}</TableCell>
<TableCell className="text-center">{row.seventhPlaceCount || "-"}</TableCell>
<TableCell className="text-center">{row.eighthPlaceCount || "-"}</TableCell>
</>
)}
<TableCell className="text-right">
{row.participantsRemaining > 0 ? (
<Badge variant="secondary">{row.participantsRemaining}</Badge>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
);
}