brackt/app/components/standings/StandingsTable.tsx
Chris Parsons 0385ca220c feat: add standings page and enhance standings functionality
- Added a new route for league standings: `/leagues/:leagueId/standings/:seasonId`
- Implemented the `StandingsTable` component to display team standings with ranking, points, and placement breakdown.
- Integrated tiebreaker logic for ranking teams based on total points and placements.
- Enhanced the league home page with a link to view standings.
- Updated scoring system documentation to reflect the new standings features.
- Added comprehensive tests for tiebreaker logic and `StandingsTable` component.
- Created shared types for standings to be used in both server and client code.
2025-11-13 13:24:03 -08:00

183 lines
5.4 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 { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "~/components/ui/table";
import { Badge } from "~/components/ui/badge";
import { type TeamStanding } from "~/types/standings";
interface StandingsTableProps {
standings: TeamStanding[];
showPlacementBreakdown?: boolean;
}
/**
* Display team standings with ranking, points, and placement breakdown
* Phase 4.1: Enhanced standings table with tiebreakers
*/
export function StandingsTable({
standings,
showPlacementBreakdown = true,
}: StandingsTableProps) {
return (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[80px]">Rank</TableHead>
<TableHead>Team</TableHead>
<TableHead className="text-right">Points</TableHead>
{showPlacementBreakdown && (
<TableHead className="text-center">Placements</TableHead>
)}
<TableHead className="text-right">Remaining</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{standings.length === 0 ? (
<TableRow>
<TableCell colSpan={showPlacementBreakdown ? 5 : 4} className="text-center text-muted-foreground">
No standings data available
</TableCell>
</TableRow>
) : (
standings.map((standing) => (
<TableRow key={standing.teamId}>
<TableCell>
<div className="flex items-center gap-2">
<RankBadge rank={standing.currentRank} />
{standing.rankChange !== 0 && (
<MovementIndicator change={standing.rankChange} />
)}
</div>
</TableCell>
<TableCell className="font-medium">{standing.teamName}</TableCell>
<TableCell className="text-right font-semibold">
{standing.totalPoints.toFixed(1)}
</TableCell>
{showPlacementBreakdown && (
<TableCell>
<PlacementBreakdown placements={standing.placementCounts} />
</TableCell>
)}
<TableCell className="text-right text-muted-foreground">
{standing.participantsRemaining > 0 ? (
<Badge variant="secondary">
{standing.participantsRemaining} remaining
</Badge>
) : (
<Badge variant="outline">Complete</Badge>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
);
}
/**
* Display rank badge with special styling for top 3
*/
function RankBadge({ rank }: { rank: number }) {
if (rank === 1) {
return (
<Badge className="bg-yellow-500 hover:bg-yellow-600 text-white font-bold">
🏆 1st
</Badge>
);
}
if (rank === 2) {
return (
<Badge className="bg-gray-400 hover:bg-gray-500 text-white font-bold">
🥈 2nd
</Badge>
);
}
if (rank === 3) {
return (
<Badge className="bg-orange-600 hover:bg-orange-700 text-white font-bold">
🥉 3rd
</Badge>
);
}
return (
<Badge variant="outline" className="font-semibold">
{rank}
</Badge>
);
}
/**
* Show movement indicator (up/down arrow)
*/
function MovementIndicator({ change }: { change: number }) {
if (change > 0) {
return (
<span className="text-green-600 text-sm font-medium" title={`Up ${change} ${change === 1 ? 'place' : 'places'}`}>
{change}
</span>
);
}
if (change < 0) {
return (
<span className="text-red-600 text-sm font-medium" title={`Down ${Math.abs(change)} ${Math.abs(change) === 1 ? 'place' : 'places'}`}>
{Math.abs(change)}
</span>
);
}
return null;
}
/**
* Display placement breakdown showing counts for each placement (1st-8th)
*/
function PlacementBreakdown({
placements,
}: {
placements: {
first: number;
second: number;
third: number;
fourth: number;
fifth: number;
sixth: number;
seventh: number;
eighth: number;
};
}) {
const items = [
{ label: "1st", count: placements.first, color: "text-yellow-600" },
{ label: "2nd", count: placements.second, color: "text-gray-500" },
{ label: "3rd", count: placements.third, color: "text-orange-600" },
{ label: "4th", count: placements.fourth, color: "text-blue-600" },
{ label: "5th", count: placements.fifth, color: "text-purple-600" },
{ label: "6th", count: placements.sixth, color: "text-green-600" },
{ label: "7th", count: placements.seventh, color: "text-pink-600" },
{ label: "8th", count: placements.eighth, color: "text-indigo-600" },
];
// Only show placements that have counts > 0
const nonZero = items.filter((item) => item.count > 0);
if (nonZero.length === 0) {
return <span className="text-muted-foreground text-sm">None yet</span>;
}
return (
<div className="flex flex-wrap gap-2 justify-center">
{nonZero.map((item) => (
<span
key={item.label}
className={`text-sm font-medium ${item.color}`}
title={`${item.count} ${item.label} place ${item.count === 1 ? 'finish' : 'finishes'}`}
>
{item.label}×{item.count}
</span>
))}
</div>
);
}