2026-03-22 11:05:13 -07:00
|
|
|
import { useMemo } from "react";
|
2025-11-13 13:24:03 -08:00
|
|
|
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "~/components/ui/table";
|
|
|
|
|
import { Badge } from "~/components/ui/badge";
|
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
|
|
|
import { TeamNameDisplay } from "~/components/ui/team-name-display";
|
2026-03-22 11:05:13 -07:00
|
|
|
import { PointsDisplay } from "~/components/standings/PointsDisplay";
|
|
|
|
|
import { useSortableData, type SortConfig, type SortDirection } from "~/hooks/useSortableData";
|
2025-11-13 13:24:03 -08:00
|
|
|
import { type TeamStanding } from "~/types/standings";
|
|
|
|
|
|
|
|
|
|
interface StandingsTableProps {
|
|
|
|
|
standings: TeamStanding[];
|
2025-11-14 20:01:21 -08:00
|
|
|
leagueId: string;
|
|
|
|
|
seasonId: string;
|
2025-11-13 13:24:03 -08:00
|
|
|
}
|
|
|
|
|
|
2026-03-22 11:05:13 -07:00
|
|
|
// Flat row shape used for sorting
|
|
|
|
|
interface StandingRow extends TeamStanding {
|
|
|
|
|
_sortPoints: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Comparators are pure functions with no component state — define at module level
|
|
|
|
|
const comparators = {
|
|
|
|
|
// Rank: ties broken alphabetically by team name regardless of sort direction
|
|
|
|
|
currentRank: (a: StandingRow, b: StandingRow, dir: SortDirection) => {
|
|
|
|
|
const cmp = a.currentRank - b.currentRank;
|
|
|
|
|
if (cmp !== 0) return dir === "asc" ? cmp : -cmp;
|
|
|
|
|
return a.teamName.localeCompare(b.teamName);
|
|
|
|
|
},
|
|
|
|
|
_sortPoints: (a: StandingRow, b: StandingRow, dir: SortDirection) => {
|
|
|
|
|
const cmp = b._sortPoints - a._sortPoints;
|
|
|
|
|
return dir === "asc" ? -cmp : cmp;
|
|
|
|
|
},
|
|
|
|
|
sevenDayPointChange: (a: StandingRow, b: StandingRow, dir: SortDirection) => {
|
|
|
|
|
const aVal = a.sevenDayPointChange ?? 0;
|
|
|
|
|
const bVal = b.sevenDayPointChange ?? 0;
|
|
|
|
|
const cmp = bVal - aVal;
|
|
|
|
|
return dir === "asc" ? -cmp : cmp;
|
|
|
|
|
},
|
|
|
|
|
teamName: (a: StandingRow, b: StandingRow, dir: SortDirection) => {
|
|
|
|
|
const cmp = a.teamName.localeCompare(b.teamName);
|
|
|
|
|
return dir === "asc" ? cmp : -cmp;
|
|
|
|
|
},
|
|
|
|
|
participantsRemaining: (a: StandingRow, b: StandingRow, dir: SortDirection) => {
|
|
|
|
|
const cmp = a.participantsRemaining - b.participantsRemaining;
|
|
|
|
|
return dir === "asc" ? cmp : -cmp;
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-13 13:24:03 -08:00
|
|
|
/**
|
2026-03-22 11:05:13 -07:00
|
|
|
* Display team standings with ranking, points, 7-day change, and sortable columns.
|
2025-11-13 13:24:03 -08:00
|
|
|
*/
|
2026-03-22 11:05:13 -07:00
|
|
|
export function StandingsTable({ standings, leagueId, seasonId }: StandingsTableProps) {
|
|
|
|
|
const rows: StandingRow[] = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
standings.map((s) => ({
|
|
|
|
|
...s,
|
|
|
|
|
_sortPoints: s.actualPoints ?? s.totalPoints,
|
|
|
|
|
})),
|
|
|
|
|
[standings]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const { sortedData, sortConfig, requestSort } = useSortableData<StandingRow>(
|
|
|
|
|
rows,
|
|
|
|
|
"currentRank",
|
|
|
|
|
"asc",
|
|
|
|
|
comparators
|
|
|
|
|
);
|
|
|
|
|
|
2025-11-13 13:24:03 -08:00
|
|
|
return (
|
|
|
|
|
<div className="rounded-md border">
|
|
|
|
|
<Table>
|
|
|
|
|
<TableHeader>
|
|
|
|
|
<TableRow>
|
2026-03-22 11:05:13 -07:00
|
|
|
<SortableHead
|
|
|
|
|
label="Rank"
|
|
|
|
|
sortKey="currentRank"
|
|
|
|
|
sortConfig={sortConfig}
|
|
|
|
|
onSort={() => requestSort("currentRank")}
|
|
|
|
|
className="w-[110px]"
|
|
|
|
|
/>
|
|
|
|
|
<SortableHead
|
|
|
|
|
label="Team"
|
|
|
|
|
sortKey="teamName"
|
|
|
|
|
sortConfig={sortConfig}
|
|
|
|
|
onSort={() => requestSort("teamName")}
|
|
|
|
|
/>
|
|
|
|
|
<SortableHead
|
|
|
|
|
label={
|
|
|
|
|
<div>
|
|
|
|
|
<div>Points</div>
|
|
|
|
|
<div className="text-xs font-normal text-muted-foreground">actual / projected</div>
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
sortKey="_sortPoints"
|
|
|
|
|
sortConfig={sortConfig}
|
|
|
|
|
onSort={() => requestSort("_sortPoints")}
|
|
|
|
|
className="text-right"
|
|
|
|
|
/>
|
|
|
|
|
<SortableHead
|
|
|
|
|
label={
|
|
|
|
|
<div>
|
|
|
|
|
<div>7-Day Change</div>
|
|
|
|
|
<div className="text-xs font-normal text-muted-foreground">pts / rank</div>
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
sortKey="sevenDayPointChange"
|
|
|
|
|
sortConfig={sortConfig}
|
|
|
|
|
onSort={() => requestSort("sevenDayPointChange")}
|
|
|
|
|
className="text-right"
|
|
|
|
|
/>
|
|
|
|
|
<SortableHead
|
|
|
|
|
label="Remaining"
|
|
|
|
|
sortKey="participantsRemaining"
|
|
|
|
|
sortConfig={sortConfig}
|
|
|
|
|
onSort={() => requestSort("participantsRemaining")}
|
|
|
|
|
className="text-right"
|
|
|
|
|
/>
|
2025-11-13 13:24:03 -08:00
|
|
|
</TableRow>
|
|
|
|
|
</TableHeader>
|
|
|
|
|
<TableBody>
|
2026-03-22 11:05:13 -07:00
|
|
|
{sortedData.length === 0 ? (
|
2025-11-13 13:24:03 -08:00
|
|
|
<TableRow>
|
2026-03-22 11:05:13 -07:00
|
|
|
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
2025-11-13 13:24:03 -08:00
|
|
|
No standings data available
|
|
|
|
|
</TableCell>
|
|
|
|
|
</TableRow>
|
|
|
|
|
) : (
|
2026-03-22 11:05:13 -07:00
|
|
|
sortedData.map((standing) => (
|
2025-11-13 13:24:03 -08:00
|
|
|
<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>
|
2025-11-14 20:01:21 -08:00
|
|
|
<TableCell className="font-medium">
|
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
|
|
|
<TeamNameDisplay
|
|
|
|
|
teamName={standing.teamName}
|
|
|
|
|
ownerName={standing.ownerName}
|
|
|
|
|
href={`/leagues/${leagueId}/standings/${seasonId}/teams/${standing.teamId}`}
|
|
|
|
|
/>
|
2025-11-14 20:01:21 -08:00
|
|
|
</TableCell>
|
2026-03-22 11:05:13 -07:00
|
|
|
<TableCell className="text-right">
|
|
|
|
|
<PointsDisplay
|
|
|
|
|
actualPoints={standing.actualPoints}
|
|
|
|
|
projectedPoints={standing.projectedPoints}
|
|
|
|
|
totalPoints={standing.totalPoints}
|
|
|
|
|
participantsRemaining={standing.participantsRemaining}
|
|
|
|
|
/>
|
2026-02-14 22:30:12 -08:00
|
|
|
</TableCell>
|
|
|
|
|
<TableCell className="text-right">
|
2026-03-22 11:05:13 -07:00
|
|
|
<SevenDayChange
|
|
|
|
|
pointChange={standing.sevenDayPointChange}
|
|
|
|
|
rankChange={standing.rankChange}
|
|
|
|
|
/>
|
2025-11-13 13:24:03 -08:00
|
|
|
</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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 11:05:13 -07:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Sub-components
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
interface SortableHeadProps {
|
|
|
|
|
label: React.ReactNode;
|
|
|
|
|
sortKey: keyof StandingRow;
|
|
|
|
|
sortConfig: SortConfig<StandingRow>;
|
|
|
|
|
onSort: () => void;
|
|
|
|
|
className?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function SortableHead({ label, sortKey, sortConfig, onSort, className }: SortableHeadProps) {
|
|
|
|
|
const isActive = sortConfig.key === sortKey;
|
|
|
|
|
const arrow = isActive ? (sortConfig.direction === "asc" ? " ↑" : " ↓") : " ↕";
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<TableHead
|
|
|
|
|
className={`cursor-pointer select-none hover:text-foreground ${isActive ? "text-foreground" : "text-muted-foreground"} ${className ?? ""}`}
|
|
|
|
|
onClick={onSort}
|
|
|
|
|
>
|
|
|
|
|
<span className="inline-flex items-baseline gap-0.5">
|
|
|
|
|
{label}
|
|
|
|
|
<span className="text-xs opacity-60">{arrow}</span>
|
|
|
|
|
</span>
|
|
|
|
|
</TableHead>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-13 13:24:03 -08:00
|
|
|
function RankBadge({ rank }: { rank: number }) {
|
|
|
|
|
if (rank === 1) {
|
|
|
|
|
return (
|
2026-02-20 19:26:11 -08:00
|
|
|
<Badge className="bg-amber-accent hover:bg-amber-accent/80 text-background font-bold">
|
2025-11-13 13:24:03 -08:00
|
|
|
🏆 1st
|
|
|
|
|
</Badge>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (rank === 2) {
|
|
|
|
|
return (
|
2026-02-20 19:26:11 -08:00
|
|
|
<Badge className="bg-muted hover:bg-muted/80 text-muted-foreground font-bold">
|
2025-11-13 13:24:03 -08:00
|
|
|
🥈 2nd
|
|
|
|
|
</Badge>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (rank === 3) {
|
|
|
|
|
return (
|
2026-02-20 19:26:11 -08:00
|
|
|
<Badge className="bg-coral-accent hover:bg-coral-accent/80 text-background font-bold">
|
2025-11-13 13:24:03 -08:00
|
|
|
🥉 3rd
|
|
|
|
|
</Badge>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return (
|
|
|
|
|
<Badge variant="outline" className="font-semibold">
|
|
|
|
|
{rank}
|
|
|
|
|
</Badge>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function MovementIndicator({ change }: { change: number }) {
|
|
|
|
|
if (change > 0) {
|
|
|
|
|
return (
|
2026-03-22 11:05:13 -07:00
|
|
|
<span
|
|
|
|
|
className="text-emerald-400 text-sm font-medium"
|
|
|
|
|
title={`Up ${change} ${change === 1 ? "place" : "places"} vs 7 days ago`}
|
|
|
|
|
>
|
2025-11-13 13:24:03 -08:00
|
|
|
↑{change}
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (change < 0) {
|
|
|
|
|
return (
|
2026-03-22 11:05:13 -07:00
|
|
|
<span
|
|
|
|
|
className="text-coral-accent text-sm font-medium"
|
|
|
|
|
title={`Down ${Math.abs(change)} ${Math.abs(change) === 1 ? "place" : "places"} vs 7 days ago`}
|
|
|
|
|
>
|
2025-11-13 13:24:03 -08:00
|
|
|
↓{Math.abs(change)}
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 11:05:13 -07:00
|
|
|
function SevenDayChange({
|
|
|
|
|
pointChange,
|
|
|
|
|
rankChange,
|
2025-11-13 13:24:03 -08:00
|
|
|
}: {
|
2026-03-22 11:05:13 -07:00
|
|
|
pointChange?: number;
|
|
|
|
|
rankChange: number;
|
2025-11-13 13:24:03 -08:00
|
|
|
}) {
|
2026-03-22 11:05:13 -07:00
|
|
|
const hasData = pointChange !== undefined;
|
2025-11-13 13:24:03 -08:00
|
|
|
|
2026-03-22 11:05:13 -07:00
|
|
|
if (!hasData) {
|
|
|
|
|
return <span className="text-muted-foreground text-sm">—</span>;
|
2025-11-13 13:24:03 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
2026-03-22 11:05:13 -07:00
|
|
|
<div className="flex flex-col items-end gap-0.5">
|
|
|
|
|
<span className={`text-sm font-medium ${pointChange >= 0 ? "text-emerald-400" : "text-coral-accent"}`}>
|
|
|
|
|
{pointChange >= 0 ? "+" : ""}{pointChange.toFixed(2)} pts
|
|
|
|
|
</span>
|
|
|
|
|
{rankChange > 0 ? (
|
|
|
|
|
<span className="text-xs text-emerald-400">↑{rankChange} rank</span>
|
|
|
|
|
) : rankChange < 0 ? (
|
|
|
|
|
<span className="text-xs text-coral-accent">↓{Math.abs(rankChange)} rank</span>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="text-xs text-muted-foreground">— rank</span>
|
|
|
|
|
)}
|
2025-11-13 13:24:03 -08:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|