brackt/app/components/standings/StandingsTable.tsx
Chris Parsons 063834d8e6
Display team owner names in standings views (#184)
* Show team name + username in standings, extract TeamNameDisplay component

- Add TeamNameDisplay component that renders team name (as link) with owner username below, matching the league homepage style
- Update StandingsTable to use TeamNameDisplay with owner username shown below team name
- Update league homepage standings section to use TeamNameDisplay
- Add ownerName/teamOwnerId fields to TeamStanding type
- Extend getSeasonStandings to include teamOwnerId from team relation
- Fetch and attach owner display names in the full standings page loader

https://claude.ai/code/session_01EYgGnuTBaRVdBDapJRTxDZ

* Address code review: type hygiene, explicit types, parallel fetching, style fix

- Remove teamOwnerId from TeamStanding type (was an internal server concern, not display data)
- Remove teamOwnerId from getSeasonStandings return value
- Add TeamStandingWithChange interface extending TeamStanding with sevenDayRankChange/sevenDayOldRank fields
- Annotate getSevenDayStandingsChange with explicit Promise<TeamStandingWithChange[]> return type
- Re-export TeamStandingWithChange from models/standings.ts
- Standings loader: query teams table directly for ownerIds instead of relying on teamOwnerId in standings data
- Standings loader: parallelize all independent queries (standings, teams, progressionData, seasonComplete, completionPercentage) with Promise.all
- Restore font-medium on StandingsTable team TableCell

https://claude.ai/code/session_01EYgGnuTBaRVdBDapJRTxDZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-19 20:19:28 -07:00

214 lines
6.9 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 { TeamNameDisplay } from "~/components/ui/team-name-display";
import { type TeamStanding } from "~/types/standings";
interface StandingsTableProps {
standings: TeamStanding[];
leagueId: string;
seasonId: string;
showPlacementBreakdown?: boolean;
}
/**
* Display team standings with ranking, points, and placement breakdown
* Phase 4.1: Enhanced standings table with tiebreakers
* Phase 4.3: Added clickable links to team breakdown pages
*/
export function StandingsTable({
standings,
leagueId,
seasonId,
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>
<TableHead className="text-right">Projected</TableHead>
{showPlacementBreakdown && (
<TableHead className="text-center">Placements</TableHead>
)}
<TableHead className="text-right">Remaining</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{standings.length === 0 ? (
<TableRow>
<TableCell colSpan={showPlacementBreakdown ? 6 : 5} 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">
<TeamNameDisplay
teamName={standing.teamName}
ownerName={standing.ownerName}
href={`/leagues/${leagueId}/standings/${seasonId}/teams/${standing.teamId}`}
/>
</TableCell>
<TableCell className="text-right font-semibold">
{standing.actualPoints !== null && standing.actualPoints !== undefined
? standing.actualPoints.toFixed(2)
: standing.totalPoints.toFixed(2)}
</TableCell>
<TableCell className="text-right">
{standing.projectedPoints !== null && standing.projectedPoints !== undefined ? (
<div className="flex flex-col">
<span className="font-semibold text-primary">
{standing.projectedPoints.toFixed(2)}
</span>
{standing.participantsRemaining > 0 && (
<span className="text-xs text-muted-foreground">
+{(standing.projectedPoints - (standing.actualPoints ?? standing.totalPoints)).toFixed(2)} EV
</span>
)}
</div>
) : (
<span className="text-muted-foreground">-</span>
)}
</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-amber-accent hover:bg-amber-accent/80 text-background font-bold">
🏆 1st
</Badge>
);
}
if (rank === 2) {
return (
<Badge className="bg-muted hover:bg-muted/80 text-muted-foreground font-bold">
🥈 2nd
</Badge>
);
}
if (rank === 3) {
return (
<Badge className="bg-coral-accent hover:bg-coral-accent/80 text-background 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-emerald-400 text-sm font-medium" title={`Up ${change} ${change === 1 ? 'place' : 'places'}`}>
{change}
</span>
);
}
if (change < 0) {
return (
<span className="text-coral-accent 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-amber-accent" },
{ label: "2nd", count: placements.second, color: "text-muted-foreground" },
{ label: "3rd", count: placements.third, color: "text-coral-accent" },
{ label: "4th", count: placements.fourth, color: "text-electric" },
{ label: "5th", count: placements.fifth, color: "text-purple-400" },
{ label: "6th", count: placements.sixth, color: "text-emerald-400" },
{ label: "7th", count: placements.seventh, color: "text-pink-400" },
{ label: "8th", count: placements.eighth, color: "text-indigo-400" },
];
// 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>
);
}