brackt/app/components/standings/StandingsTable.tsx
Chris Parsons 8c5389909d
Redesign standings page with sortable table, 7-day change, and chart repositioned (#205)
* Redesign standings page with sortable table, 7-day change, and chart repositioned

- Move point progression chart below the standings table
- Replace separate Points/Projected/Placement columns with:
  - Single stacked "actual / projected" Points column (shared PointsDisplay component)
  - "7-Day Change" column showing points gained + rank change over past 7 days
- Remove Placement breakdown column
- Sort ties alphabetically by team name (rank sort only)
- All columns are sortable via new reusable useSortableData hook
- Add sevenDayPointChange to TeamStandingWithChange type and model

https://claude.ai/code/session_01CuCKFVYbpsKSQoFDcTYfY7

* Code review fixes: module-level comparators, correct type on SortableHead, handle negative point change, remove redundant spread

- Move comparators object to module level (closes over nothing, no useMemo needed)
- Use SortConfig<StandingRow> on SortableHead instead of inline duplicate type
- SevenDayChange: handle negative pointChange with correct sign and color
- Remove redundant sevenDayPointChange explicit assignment (already in ...standing spread)

https://claude.ai/code/session_01CuCKFVYbpsKSQoFDcTYfY7

* Update StandingsTable tests to match redesigned component

- Remove showPlacementBreakdown prop (no longer exists on component)
- Replace Placement Breakdown test suite with 7-Day Change column tests
- Update header assertion: "Placements" → "7-Day Change"
- Add tests for positive/negative point change display and rank change indicators
- Remove accessibility test for placement title attributes

https://claude.ai/code/session_01CuCKFVYbpsKSQoFDcTYfY7

* Fix movement indicator arrow count assertion to exclude sort header arrows

queryAllByText(/↑|↓/) was matching the active sort column's ↑ indicator.
Narrow to /[↑↓]\d/ so only movement indicators (↑1, ↓1) are counted.

https://claude.ai/code/session_01CuCKFVYbpsKSQoFDcTYfY7

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-22 11:05:13 -07:00

285 lines
9.1 KiB
TypeScript

import { useMemo } from "react";
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 { PointsDisplay } from "~/components/standings/PointsDisplay";
import { useSortableData, type SortConfig, type SortDirection } from "~/hooks/useSortableData";
import { type TeamStanding } from "~/types/standings";
interface StandingsTableProps {
standings: TeamStanding[];
leagueId: string;
seasonId: string;
}
// 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;
},
};
/**
* Display team standings with ranking, points, 7-day change, and sortable columns.
*/
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
);
return (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<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"
/>
</TableRow>
</TableHeader>
<TableBody>
{sortedData.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
No standings data available
</TableCell>
</TableRow>
) : (
sortedData.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">
<PointsDisplay
actualPoints={standing.actualPoints}
projectedPoints={standing.projectedPoints}
totalPoints={standing.totalPoints}
participantsRemaining={standing.participantsRemaining}
/>
</TableCell>
<TableCell className="text-right">
<SevenDayChange
pointChange={standing.sevenDayPointChange}
rankChange={standing.rankChange}
/>
</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>
);
}
// ---------------------------------------------------------------------------
// 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>
);
}
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>
);
}
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"} vs 7 days ago`}
>
{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"} vs 7 days ago`}
>
{Math.abs(change)}
</span>
);
}
return null;
}
function SevenDayChange({
pointChange,
rankChange,
}: {
pointChange?: number;
rankChange: number;
}) {
const hasData = pointChange !== undefined;
if (!hasData) {
return <span className="text-muted-foreground text-sm"></span>;
}
return (
<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>
)}
</div>
);
}