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
This commit is contained in:
Claude 2026-03-22 17:14:33 +00:00
parent c1be92b2af
commit 0b74a6a2d2
No known key found for this signature in database
6 changed files with 325 additions and 121 deletions

View file

@ -0,0 +1,37 @@
/**
* Reusable component for displaying actual / projected points in a stacked format.
* Used in standings tables and anywhere else points need to be shown with projections.
*/
interface PointsDisplayProps {
actualPoints: number | null | undefined;
projectedPoints: number | null | undefined;
totalPoints: number;
participantsRemaining?: number;
}
export function PointsDisplay({
actualPoints,
projectedPoints,
totalPoints,
participantsRemaining,
}: PointsDisplayProps) {
const displayed = actualPoints !== null && actualPoints !== undefined ? actualPoints : totalPoints;
const hasProjection =
projectedPoints !== null &&
projectedPoints !== undefined &&
participantsRemaining !== undefined &&
participantsRemaining > 0;
if (!hasProjection) {
return <span className="font-semibold">{displayed.toFixed(2)}</span>;
}
return (
<div className="flex flex-col items-end">
<span className="font-semibold">{displayed.toFixed(2)}</span>
<span className="text-xs text-muted-foreground">
{projectedPoints!.toFixed(2)} proj
</span>
</div>
);
}

View file

@ -1,50 +1,138 @@
import { useMemo } from "react";
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "~/components/ui/table"; import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "~/components/ui/table";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { TeamNameDisplay } from "~/components/ui/team-name-display"; import { TeamNameDisplay } from "~/components/ui/team-name-display";
import { PointsDisplay } from "~/components/standings/PointsDisplay";
import { useSortableData, type SortDirection } from "~/hooks/useSortableData";
import { type TeamStanding } from "~/types/standings"; import { type TeamStanding } from "~/types/standings";
interface StandingsTableProps { interface StandingsTableProps {
standings: TeamStanding[]; standings: TeamStanding[];
leagueId: string; leagueId: string;
seasonId: string; seasonId: string;
showPlacementBreakdown?: boolean; }
// Flat row shape used for sorting
interface StandingRow extends TeamStanding {
_sortPoints: number;
} }
/** /**
* Display team standings with ranking, points, and placement breakdown * Display team standings with ranking, points, 7-day change, and sortable columns.
* Phase 4.1: Enhanced standings table with tiebreakers
* Phase 4.3: Added clickable links to team breakdown pages
*/ */
export function StandingsTable({ export function StandingsTable({ standings, leagueId, seasonId }: StandingsTableProps) {
standings, const rows: StandingRow[] = useMemo(
leagueId, () =>
seasonId, standings.map((s) => ({
showPlacementBreakdown = true, ...s,
}: StandingsTableProps) { _sortPoints: s.actualPoints ?? s.totalPoints,
})),
[standings]
);
// Custom comparators
const comparators = useMemo(
() => ({
// Rank: ascending by rank, ties broken alphabetically by team name
currentRank: (a: StandingRow, b: StandingRow, dir: SortDirection) => {
const cmp = a.currentRank - b.currentRank;
if (cmp !== 0) return dir === "asc" ? cmp : -cmp;
// Within ties, always sort by team name A→Z regardless of direction
return a.teamName.localeCompare(b.teamName);
},
// Points: descending by default means best score first
_sortPoints: (a: StandingRow, b: StandingRow, dir: SortDirection) => {
const cmp = b._sortPoints - a._sortPoints;
return dir === "asc" ? -cmp : cmp;
},
// 7-day point change: higher change = better, descending first
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;
},
// Team name: A→Z ascending
teamName: (a: StandingRow, b: StandingRow, dir: SortDirection) => {
const cmp = a.teamName.localeCompare(b.teamName);
return dir === "asc" ? cmp : -cmp;
},
// Remaining: fewer remaining = closer to done, ascending = most remaining first
participantsRemaining: (a: StandingRow, b: StandingRow, dir: SortDirection) => {
const cmp = a.participantsRemaining - b.participantsRemaining;
return dir === "asc" ? cmp : -cmp;
},
}),
[]
);
const { sortedData, sortConfig, requestSort } = useSortableData<StandingRow>(
rows,
"currentRank",
"asc",
comparators
);
return ( return (
<div className="rounded-md border"> <div className="rounded-md border">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-[80px]">Rank</TableHead> <SortableHead
<TableHead>Team</TableHead> label="Rank"
<TableHead className="text-right">Points</TableHead> sortKey="currentRank"
<TableHead className="text-right">Projected</TableHead> sortConfig={sortConfig}
{showPlacementBreakdown && ( onSort={() => requestSort("currentRank")}
<TableHead className="text-center">Placements</TableHead> className="w-[110px]"
)} />
<TableHead className="text-right">Remaining</TableHead> <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> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{standings.length === 0 ? ( {sortedData.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={showPlacementBreakdown ? 6 : 5} className="text-center text-muted-foreground"> <TableCell colSpan={5} className="text-center text-muted-foreground">
No standings data available No standings data available
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
standings.map((standing) => ( sortedData.map((standing) => (
<TableRow key={standing.teamId}> <TableRow key={standing.teamId}>
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -61,32 +149,20 @@ export function StandingsTable({
href={`/leagues/${leagueId}/standings/${seasonId}/teams/${standing.teamId}`} href={`/leagues/${leagueId}/standings/${seasonId}/teams/${standing.teamId}`}
/> />
</TableCell> </TableCell>
<TableCell className="text-right font-semibold"> <TableCell className="text-right">
{standing.actualPoints !== null && standing.actualPoints !== undefined <PointsDisplay
? standing.actualPoints.toFixed(2) actualPoints={standing.actualPoints}
: standing.totalPoints.toFixed(2)} projectedPoints={standing.projectedPoints}
totalPoints={standing.totalPoints}
participantsRemaining={standing.participantsRemaining}
/>
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
{standing.projectedPoints !== null && standing.projectedPoints !== undefined ? ( <SevenDayChange
<div className="flex flex-col"> pointChange={standing.sevenDayPointChange}
<span className="font-semibold text-primary"> rankChange={standing.rankChange}
{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> </TableCell>
{showPlacementBreakdown && (
<TableCell>
<PlacementBreakdown placements={standing.placementCounts} />
</TableCell>
)}
<TableCell className="text-right text-muted-foreground"> <TableCell className="text-right text-muted-foreground">
{standing.participantsRemaining > 0 ? ( {standing.participantsRemaining > 0 ? (
<Badge variant="secondary"> <Badge variant="secondary">
@ -105,9 +181,35 @@ export function StandingsTable({
); );
} }
/** // ---------------------------------------------------------------------------
* Display rank badge with special styling for top 3 // Sub-components
*/ // ---------------------------------------------------------------------------
interface SortableHeadProps {
label: React.ReactNode;
sortKey: string;
sortConfig: { key: string | null; direction: "asc" | "desc" };
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 }) { function RankBadge({ rank }: { rank: number }) {
if (rank === 1) { if (rank === 1) {
return ( return (
@ -116,7 +218,6 @@ function RankBadge({ rank }: { rank: number }) {
</Badge> </Badge>
); );
} }
if (rank === 2) { if (rank === 2) {
return ( return (
<Badge className="bg-muted hover:bg-muted/80 text-muted-foreground font-bold"> <Badge className="bg-muted hover:bg-muted/80 text-muted-foreground font-bold">
@ -124,7 +225,6 @@ function RankBadge({ rank }: { rank: number }) {
</Badge> </Badge>
); );
} }
if (rank === 3) { if (rank === 3) {
return ( return (
<Badge className="bg-coral-accent hover:bg-coral-accent/80 text-background font-bold"> <Badge className="bg-coral-accent hover:bg-coral-accent/80 text-background font-bold">
@ -132,7 +232,6 @@ function RankBadge({ rank }: { rank: number }) {
</Badge> </Badge>
); );
} }
return ( return (
<Badge variant="outline" className="font-semibold"> <Badge variant="outline" className="font-semibold">
{rank} {rank}
@ -140,75 +239,57 @@ function RankBadge({ rank }: { rank: number }) {
); );
} }
/**
* Show movement indicator (up/down arrow)
*/
function MovementIndicator({ change }: { change: number }) { function MovementIndicator({ change }: { change: number }) {
if (change > 0) { if (change > 0) {
return ( return (
<span className="text-emerald-400 text-sm font-medium" title={`Up ${change} ${change === 1 ? 'place' : 'places'}`}> <span
className="text-emerald-400 text-sm font-medium"
title={`Up ${change} ${change === 1 ? "place" : "places"} vs 7 days ago`}
>
{change} {change}
</span> </span>
); );
} }
if (change < 0) { if (change < 0) {
return ( return (
<span className="text-coral-accent text-sm font-medium" title={`Down ${Math.abs(change)} ${Math.abs(change) === 1 ? 'place' : 'places'}`}> <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)} {Math.abs(change)}
</span> </span>
); );
} }
return null; return null;
} }
/** function SevenDayChange({
* Display placement breakdown showing counts for each placement (1st-8th) pointChange,
*/ rankChange,
function PlacementBreakdown({
placements,
}: { }: {
placements: { pointChange?: number;
first: number; rankChange: number;
second: number;
third: number;
fourth: number;
fifth: number;
sixth: number;
seventh: number;
eighth: number;
};
}) { }) {
const items = [ const hasData = pointChange !== undefined;
{ 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 if (!hasData) {
const nonZero = items.filter((item) => item.count > 0); return <span className="text-muted-foreground text-sm"></span>;
if (nonZero.length === 0) {
return <span className="text-muted-foreground text-sm">None yet</span>;
} }
return ( return (
<div className="flex flex-wrap gap-2 justify-center"> <div className="flex flex-col items-end gap-0.5">
{nonZero.map((item) => ( {/* Point change — always non-negative */}
<span <span className="text-sm font-medium text-emerald-400">
key={item.label} +{pointChange.toFixed(2)} pts
className={`text-sm font-medium ${item.color}`}
title={`${item.count} ${item.label} place ${item.count === 1 ? 'finish' : 'finishes'}`}
>
{item.label}×{item.count}
</span> </span>
))} {/* Rank change */}
{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> </div>
); );
} }

View file

@ -0,0 +1,74 @@
import { useState, useMemo } from "react";
export type SortDirection = "asc" | "desc";
export interface SortConfig<T> {
key: keyof T | null;
direction: SortDirection;
}
type Comparators<T> = Partial<Record<keyof T, (a: T, b: T, direction: SortDirection) => number>>;
/**
* Generic hook for client-side sortable table data.
*
* @param data - The array of items to sort
* @param defaultKey - The column key to sort by initially (null = no sort)
* @param defaultDirection - Initial sort direction
* @param comparators - Optional per-key custom comparators. Receives both items and the
* current direction so the comparator can handle ascending/descending internally
* (return positive to sort a after b).
*/
export function useSortableData<T>(
data: T[],
defaultKey: keyof T | null = null,
defaultDirection: SortDirection = "asc",
comparators?: Comparators<T>
): {
sortedData: T[];
sortConfig: SortConfig<T>;
requestSort: (key: keyof T) => void;
} {
const [sortConfig, setSortConfig] = useState<SortConfig<T>>({
key: defaultKey,
direction: defaultDirection,
});
const sortedData = useMemo(() => {
if (!sortConfig.key) return data;
const key = sortConfig.key;
const dir = sortConfig.direction;
return [...data].sort((a, b) => {
// Use custom comparator if provided
if (comparators?.[key]) {
return comparators[key]!(a, b, dir);
}
const aVal = a[key];
const bVal = b[key];
if (aVal === null || aVal === undefined) return 1;
if (bVal === null || bVal === undefined) return -1;
let cmp = 0;
if (typeof aVal === "string" && typeof bVal === "string") {
cmp = aVal.localeCompare(bVal);
} else {
cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
}
return dir === "asc" ? cmp : -cmp;
});
}, [data, sortConfig, comparators]);
const requestSort = (key: keyof T) => {
setSortConfig((prev) => ({
key,
direction: prev.key === key && prev.direction === "asc" ? "desc" : "asc",
}));
};
return { sortedData, sortConfig, requestSort };
}

View file

@ -309,20 +309,30 @@ export async function getSevenDayStandingsChange(
), ),
}); });
// Create a map of team -> old rank // Create a map of team -> old rank and old points
const oldRanks = new Map<string, number>(); const oldRanks = new Map<string, number>();
const oldPoints = new Map<string, number>();
for (const snapshot of snapshots) { for (const snapshot of snapshots) {
oldRanks.set(snapshot.teamId, snapshot.rank); oldRanks.set(snapshot.teamId, snapshot.rank);
const snapshotPoints = snapshot.actualPoints
? parseFloat(snapshot.actualPoints)
: parseFloat(snapshot.totalPoints);
oldPoints.set(snapshot.teamId, snapshotPoints);
} }
// Add 7-day changes to current standings // Add 7-day changes to current standings
return current.map((standing) => ({ return current.map((standing) => {
const currentPoints = standing.actualPoints ?? standing.totalPoints;
const oldPoint = oldPoints.get(standing.teamId);
return {
...standing, ...standing,
sevenDayRankChange: oldRanks.has(standing.teamId) sevenDayRankChange: oldRanks.has(standing.teamId)
? (oldRanks.get(standing.teamId) ?? 0) - standing.currentRank ? (oldRanks.get(standing.teamId) ?? 0) - standing.currentRank
: 0, : 0,
sevenDayOldRank: oldRanks.get(standing.teamId) || null, sevenDayOldRank: oldRanks.get(standing.teamId) || null,
})); sevenDayPointChange: oldPoint !== undefined ? currentPoints - oldPoint : 0,
};
});
} }
/** /**

View file

@ -102,6 +102,7 @@ export async function loader(args: Route.LoaderArgs) {
const formattedStandings = standingsWithComparison.map((standing) => ({ const formattedStandings = standingsWithComparison.map((standing) => ({
...standing, ...standing,
rankChange: standing.sevenDayRankChange, rankChange: standing.sevenDayRankChange,
sevenDayPointChange: standing.sevenDayPointChange,
ownerName: ownerNameByTeamId.get(standing.teamId) ?? null, ownerName: ownerNameByTeamId.get(standing.teamId) ?? null,
})); }));
@ -157,16 +158,6 @@ export default function LeagueStandings() {
</div> </div>
</div> </div>
{/* Point Progression Chart */}
{progressionData.chartData.length > 0 && (
<div className="mb-6">
<PointProgressionChart
chartData={progressionData.chartData}
teams={progressionData.teams}
/>
</div>
)}
{/* Standings Card */} {/* Standings Card */}
<Card> <Card>
<CardHeader> <CardHeader>
@ -187,12 +178,21 @@ export default function LeagueStandings() {
standings={standings} standings={standings}
leagueId={league.id} leagueId={league.id}
seasonId={season.id} seasonId={season.id}
showPlacementBreakdown={true}
/> />
)} )}
</CardContent> </CardContent>
</Card> </Card>
{/* Point Progression Chart */}
{progressionData.chartData.length > 0 && (
<div className="mt-6">
<PointProgressionChart
chartData={progressionData.chartData}
teams={progressionData.teams}
/>
</div>
)}
{/* Info Card */} {/* Info Card */}
<Card className="mt-6"> <Card className="mt-6">
<CardContent className="pt-6"> <CardContent className="pt-6">
@ -210,13 +210,12 @@ export default function LeagueStandings() {
and so on through 8th place. and so on through 8th place.
</p> </p>
<p> <p>
<strong>Movement Indicators:</strong> Arrows show rank changes <strong>Movement Indicators:</strong> Arrows (/) next to rank show rank changes
compared to 7 days ago. Green arrows () indicate improvement in rank, compared to 7 days ago.
red arrows () indicate decline.
</p> </p>
<p> <p>
<strong>Placement Breakdown:</strong> Shows how many times each <strong>7-Day Change:</strong> Shows points earned and rank movement
team's participants finished in each position (1st×2 means 2 first-place finishes). over the past 7 days.
</p> </p>
{progressionData.chartData.length > 0 && ( {progressionData.chartData.length > 0 && (
<p> <p>

View file

@ -26,6 +26,8 @@ export interface TeamStanding {
actualPoints?: number | null; actualPoints?: number | null;
projectedPoints?: number | null; projectedPoints?: number | null;
participantsFinished?: number | null; participantsFinished?: number | null;
// 7-day point change (optional, present when loaded with change data)
sevenDayPointChange?: number;
} }
export interface TeamStandingSnapshot { export interface TeamStandingSnapshot {
@ -37,4 +39,5 @@ export interface TeamStandingSnapshot {
export interface TeamStandingWithChange extends TeamStanding { export interface TeamStandingWithChange extends TeamStanding {
sevenDayRankChange: number; sevenDayRankChange: number;
sevenDayOldRank: number | null; sevenDayOldRank: number | null;
sevenDayPointChange: number;
} }