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>
This commit is contained in:
parent
c1be92b2af
commit
8c5389909d
7 changed files with 365 additions and 226 deletions
37
app/components/standings/PointsDisplay.tsx
Normal file
37
app/components/standings/PointsDisplay.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,50 +1,130 @@
|
||||||
|
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 SortConfig, 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, 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]
|
||||||
|
);
|
||||||
|
|
||||||
|
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 +141,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 +173,35 @@ export function StandingsTable({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ---------------------------------------------------------------------------
|
||||||
* Display rank badge with special styling for top 3
|
// 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 }) {
|
function RankBadge({ rank }: { rank: number }) {
|
||||||
if (rank === 1) {
|
if (rank === 1) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -116,7 +210,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 +217,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 +224,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 +231,55 @@ 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) => (
|
<span className={`text-sm font-medium ${pointChange >= 0 ? "text-emerald-400" : "text-coral-accent"}`}>
|
||||||
<span
|
{pointChange >= 0 ? "+" : ""}{pointChange.toFixed(2)} pts
|
||||||
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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,6 @@ function renderWithRouter(ui: React.ReactElement) {
|
||||||
return render(<BrowserRouter>{ui}</BrowserRouter>);
|
return render(<BrowserRouter>{ui}</BrowserRouter>);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* StandingsTable Component Tests
|
|
||||||
* Phase 4.1: Test standings display with tiebreakers and placement breakdown
|
|
||||||
* Phase 4.3: Added tests for clickable team links
|
|
||||||
*/
|
|
||||||
describe("StandingsTable", () => {
|
describe("StandingsTable", () => {
|
||||||
const mockLeagueId = "league-123";
|
const mockLeagueId = "league-123";
|
||||||
const mockSeasonId = "season-456";
|
const mockSeasonId = "season-456";
|
||||||
|
|
@ -25,7 +20,7 @@ describe("StandingsTable", () => {
|
||||||
totalPoints: 250.5,
|
totalPoints: 250.5,
|
||||||
currentRank: 1,
|
currentRank: 1,
|
||||||
previousRank: 2,
|
previousRank: 2,
|
||||||
rankChange: 1, // Moved up
|
rankChange: 1,
|
||||||
placementCounts: {
|
placementCounts: {
|
||||||
first: 2,
|
first: 2,
|
||||||
second: 1,
|
second: 1,
|
||||||
|
|
@ -45,7 +40,7 @@ describe("StandingsTable", () => {
|
||||||
totalPoints: 245.0,
|
totalPoints: 245.0,
|
||||||
currentRank: 2,
|
currentRank: 2,
|
||||||
previousRank: 1,
|
previousRank: 1,
|
||||||
rankChange: -1, // Moved down
|
rankChange: -1,
|
||||||
placementCounts: {
|
placementCounts: {
|
||||||
first: 1,
|
first: 1,
|
||||||
second: 2,
|
second: 2,
|
||||||
|
|
@ -65,7 +60,7 @@ describe("StandingsTable", () => {
|
||||||
totalPoints: 200.0,
|
totalPoints: 200.0,
|
||||||
currentRank: 3,
|
currentRank: 3,
|
||||||
previousRank: 3,
|
previousRank: 3,
|
||||||
rankChange: 0, // No change
|
rankChange: 0,
|
||||||
placementCounts: {
|
placementCounts: {
|
||||||
first: 0,
|
first: 0,
|
||||||
second: 1,
|
second: 1,
|
||||||
|
|
@ -135,7 +130,6 @@ describe("StandingsTable", () => {
|
||||||
|
|
||||||
renderWithRouter(<StandingsTable standings={standings} leagueId={mockLeagueId} seasonId={mockSeasonId} />);
|
renderWithRouter(<StandingsTable standings={standings} leagueId={mockLeagueId} seasonId={mockSeasonId} />);
|
||||||
|
|
||||||
// Should not have emoji, just the number
|
|
||||||
const badge = screen.getByText("4");
|
const badge = screen.getByText("4");
|
||||||
expect(badge).toBeInTheDocument();
|
expect(badge).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
@ -157,80 +151,59 @@ describe("StandingsTable", () => {
|
||||||
it("should not show indicator for no rank change", () => {
|
it("should not show indicator for no rank change", () => {
|
||||||
renderWithRouter(<StandingsTable standings={mockStandings} leagueId={mockLeagueId} seasonId={mockSeasonId} />);
|
renderWithRouter(<StandingsTable standings={mockStandings} leagueId={mockLeagueId} seasonId={mockSeasonId} />);
|
||||||
|
|
||||||
// Third team has no change, so no arrow
|
// Only first two teams have rank changes; use \d to avoid matching sort-header arrows
|
||||||
const arrows = screen.queryAllByText(/↑|↓/);
|
const arrows = screen.queryAllByText(/[↑↓]\d/);
|
||||||
expect(arrows).toHaveLength(2); // Only first two teams
|
expect(arrows).toHaveLength(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Placement Breakdown", () => {
|
describe("7-Day Change Column", () => {
|
||||||
it("should display placement counts", () => {
|
it("should show dash when sevenDayPointChange is not present", () => {
|
||||||
const singleTeam: TeamStanding[] = [
|
renderWithRouter(<StandingsTable standings={mockStandings} leagueId={mockLeagueId} seasonId={mockSeasonId} />);
|
||||||
{
|
|
||||||
teamId: "team1",
|
|
||||||
teamName: "Test Team",
|
|
||||||
totalPoints: 250,
|
|
||||||
currentRank: 1,
|
|
||||||
previousRank: null,
|
|
||||||
rankChange: 0,
|
|
||||||
placementCounts: {
|
|
||||||
first: 2,
|
|
||||||
second: 1,
|
|
||||||
third: 0,
|
|
||||||
fourth: 1,
|
|
||||||
fifth: 0,
|
|
||||||
sixth: 0,
|
|
||||||
seventh: 0,
|
|
||||||
eighth: 1,
|
|
||||||
},
|
|
||||||
participantsRemaining: 0,
|
|
||||||
calculatedAt: new Date(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
renderWithRouter(<StandingsTable standings={singleTeam} leagueId={mockLeagueId} seasonId={mockSeasonId} showPlacementBreakdown={true} />);
|
// All mock standings lack sevenDayPointChange, so all rows show —
|
||||||
|
const dashes = screen.getAllByText("—");
|
||||||
// Test Team: 2 firsts, 1 second, 1 fourth, 1 eighth
|
expect(dashes.length).toBeGreaterThanOrEqual(3);
|
||||||
expect(screen.getByText("1st×2")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("2nd×1")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("4th×1")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("8th×1")).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should only show non-zero placement counts", () => {
|
it("should display positive point change in green with + prefix", () => {
|
||||||
renderWithRouter(<StandingsTable standings={mockStandings} leagueId={mockLeagueId} seasonId={mockSeasonId} showPlacementBreakdown={true} />);
|
|
||||||
|
|
||||||
// Should not show "3rd×0" for Champions United
|
|
||||||
const placements = screen.queryByText("3rd×0");
|
|
||||||
expect(placements).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should hide placement breakdown when disabled", () => {
|
|
||||||
renderWithRouter(<StandingsTable standings={mockStandings} leagueId={mockLeagueId} seasonId={mockSeasonId} showPlacementBreakdown={false} />);
|
|
||||||
|
|
||||||
expect(screen.queryByText("1st×2")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show 'None yet' when no placements", () => {
|
|
||||||
const standings: TeamStanding[] = [
|
const standings: TeamStanding[] = [
|
||||||
{
|
{ ...mockStandings[0], sevenDayPointChange: 12.5 },
|
||||||
...mockStandings[0],
|
|
||||||
placementCounts: {
|
|
||||||
first: 0,
|
|
||||||
second: 0,
|
|
||||||
third: 0,
|
|
||||||
fourth: 0,
|
|
||||||
fifth: 0,
|
|
||||||
sixth: 0,
|
|
||||||
seventh: 0,
|
|
||||||
eighth: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
renderWithRouter(<StandingsTable standings={standings} leagueId={mockLeagueId} seasonId={mockSeasonId} showPlacementBreakdown={true} />);
|
renderWithRouter(<StandingsTable standings={standings} leagueId={mockLeagueId} seasonId={mockSeasonId} />);
|
||||||
|
|
||||||
expect(screen.getByText("None yet")).toBeInTheDocument();
|
expect(screen.getByText("+12.50 pts")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should display negative point change with no + prefix", () => {
|
||||||
|
const standings: TeamStanding[] = [
|
||||||
|
{ ...mockStandings[0], sevenDayPointChange: -3.25 },
|
||||||
|
];
|
||||||
|
|
||||||
|
renderWithRouter(<StandingsTable standings={standings} leagueId={mockLeagueId} seasonId={mockSeasonId} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("-3.25 pts")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show rank change within the 7-day column", () => {
|
||||||
|
const standings: TeamStanding[] = [
|
||||||
|
{ ...mockStandings[0], sevenDayPointChange: 10, rankChange: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
renderWithRouter(<StandingsTable standings={standings} leagueId={mockLeagueId} seasonId={mockSeasonId} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("↑2 rank")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show no rank change indicator when rank is unchanged", () => {
|
||||||
|
const standings: TeamStanding[] = [
|
||||||
|
{ ...mockStandings[0], sevenDayPointChange: 5, rankChange: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
renderWithRouter(<StandingsTable standings={standings} leagueId={mockLeagueId} seasonId={mockSeasonId} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("— rank")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -256,23 +229,18 @@ describe("StandingsTable", () => {
|
||||||
expect(screen.getByText("Rank")).toBeInTheDocument();
|
expect(screen.getByText("Rank")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Team")).toBeInTheDocument();
|
expect(screen.getByText("Team")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Points")).toBeInTheDocument();
|
expect(screen.getByText("Points")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Placements")).toBeInTheDocument();
|
expect(screen.getByText("7-Day Change")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Remaining")).toBeInTheDocument();
|
expect(screen.getByText("Remaining")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render teams in order", () => {
|
it("should render teams in order by rank", () => {
|
||||||
const { container } = renderWithRouter(<StandingsTable standings={mockStandings} leagueId={mockLeagueId} seasonId={mockSeasonId} />);
|
const { container } = renderWithRouter(<StandingsTable standings={mockStandings} leagueId={mockLeagueId} seasonId={mockSeasonId} />);
|
||||||
|
|
||||||
const rows = container.querySelectorAll("tbody tr");
|
const rows = container.querySelectorAll("tbody tr");
|
||||||
expect(rows).toHaveLength(3);
|
expect(rows).toHaveLength(3);
|
||||||
|
|
||||||
// First row should be Champions United (rank 1)
|
|
||||||
expect(rows[0].textContent).toContain("Champions United");
|
expect(rows[0].textContent).toContain("Champions United");
|
||||||
|
|
||||||
// Second row should be Second Place Squad (rank 2)
|
|
||||||
expect(rows[1].textContent).toContain("Second Place Squad");
|
expect(rows[1].textContent).toContain("Second Place Squad");
|
||||||
|
|
||||||
// Third row should be Bronze Warriors (rank 3)
|
|
||||||
expect(rows[2].textContent).toContain("Bronze Warriors");
|
expect(rows[2].textContent).toContain("Bronze Warriors");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -280,30 +248,16 @@ describe("StandingsTable", () => {
|
||||||
describe("Tied Ranks", () => {
|
describe("Tied Ranks", () => {
|
||||||
it("should handle teams with same rank", () => {
|
it("should handle teams with same rank", () => {
|
||||||
const tiedStandings: TeamStanding[] = [
|
const tiedStandings: TeamStanding[] = [
|
||||||
{
|
{ ...mockStandings[0], currentRank: 1, teamName: "Team A" },
|
||||||
...mockStandings[0],
|
{ ...mockStandings[1], currentRank: 1, teamName: "Team B" },
|
||||||
currentRank: 1,
|
{ ...mockStandings[2], currentRank: 3, teamName: "Team C" },
|
||||||
teamName: "Team A",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...mockStandings[1],
|
|
||||||
currentRank: 1,
|
|
||||||
teamName: "Team B",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...mockStandings[2],
|
|
||||||
currentRank: 3, // Skips 2
|
|
||||||
teamName: "Team C",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
renderWithRouter(<StandingsTable standings={tiedStandings} leagueId={mockLeagueId} seasonId={mockSeasonId} />);
|
renderWithRouter(<StandingsTable standings={tiedStandings} leagueId={mockLeagueId} seasonId={mockSeasonId} />);
|
||||||
|
|
||||||
// Both teams should show rank 1
|
|
||||||
const firstPlaceBadges = screen.getAllByText(/🏆.*1st/);
|
const firstPlaceBadges = screen.getAllByText(/🏆.*1st/);
|
||||||
expect(firstPlaceBadges).toHaveLength(2);
|
expect(firstPlaceBadges).toHaveLength(2);
|
||||||
|
|
||||||
// Third team should show rank 3 (not 2)
|
|
||||||
expect(screen.getByText(/🥉.*3rd/)).toBeInTheDocument();
|
expect(screen.getByText(/🥉.*3rd/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -320,13 +274,5 @@ describe("StandingsTable", () => {
|
||||||
expect(downArrow).toBeInTheDocument();
|
expect(downArrow).toBeInTheDocument();
|
||||||
expect(downArrow?.getAttribute("title")).toContain("Down 1 place");
|
expect(downArrow?.getAttribute("title")).toContain("Down 1 place");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should have title attributes for placement counts", () => {
|
|
||||||
const { container } = renderWithRouter(<StandingsTable standings={mockStandings} leagueId={mockLeagueId} seasonId={mockSeasonId} showPlacementBreakdown={true} />);
|
|
||||||
|
|
||||||
const firstPlace = container.querySelector('[title*="1st place"]');
|
|
||||||
expect(firstPlace).toBeInTheDocument();
|
|
||||||
expect(firstPlace?.getAttribute("title")).toContain("2 1st place finishes");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
74
app/hooks/useSortableData.ts
Normal file
74
app/hooks/useSortableData.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -157,16 +157,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 +177,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 +209,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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue