diff --git a/app/components/standings/PointsDisplay.tsx b/app/components/standings/PointsDisplay.tsx new file mode 100644 index 0000000..ee26457 --- /dev/null +++ b/app/components/standings/PointsDisplay.tsx @@ -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 {displayed.toFixed(2)}; + } + + return ( +
+ {displayed.toFixed(2)} + + {projectedPoints!.toFixed(2)} proj + +
+ ); +} diff --git a/app/components/standings/StandingsTable.tsx b/app/components/standings/StandingsTable.tsx index 923671b..951fc06 100644 --- a/app/components/standings/StandingsTable.tsx +++ b/app/components/standings/StandingsTable.tsx @@ -1,50 +1,138 @@ +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 SortDirection } from "~/hooks/useSortableData"; import { type TeamStanding } from "~/types/standings"; interface StandingsTableProps { standings: TeamStanding[]; leagueId: 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 - * Phase 4.1: Enhanced standings table with tiebreakers - * Phase 4.3: Added clickable links to team breakdown pages + * Display team standings with ranking, points, 7-day change, and sortable columns. */ -export function StandingsTable({ - standings, - leagueId, - seasonId, - showPlacementBreakdown = true, -}: StandingsTableProps) { +export function StandingsTable({ standings, leagueId, seasonId }: StandingsTableProps) { + const rows: StandingRow[] = useMemo( + () => + standings.map((s) => ({ + ...s, + _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( + rows, + "currentRank", + "asc", + comparators + ); + return (
- Rank - Team - Points - Projected - {showPlacementBreakdown && ( - Placements - )} - Remaining + requestSort("currentRank")} + className="w-[110px]" + /> + requestSort("teamName")} + /> + +
Points
+
actual / projected
+ + } + sortKey="_sortPoints" + sortConfig={sortConfig} + onSort={() => requestSort("_sortPoints")} + className="text-right" + /> + +
7-Day Change
+
pts / rank
+ + } + sortKey="sevenDayPointChange" + sortConfig={sortConfig} + onSort={() => requestSort("sevenDayPointChange")} + className="text-right" + /> + requestSort("participantsRemaining")} + className="text-right" + />
- {standings.length === 0 ? ( + {sortedData.length === 0 ? ( - + No standings data available ) : ( - standings.map((standing) => ( + sortedData.map((standing) => (
@@ -61,32 +149,20 @@ export function StandingsTable({ href={`/leagues/${leagueId}/standings/${seasonId}/teams/${standing.teamId}`} /> - - {standing.actualPoints !== null && standing.actualPoints !== undefined - ? standing.actualPoints.toFixed(2) - : standing.totalPoints.toFixed(2)} + + - {standing.projectedPoints !== null && standing.projectedPoints !== undefined ? ( -
- - {standing.projectedPoints.toFixed(2)} - - {standing.participantsRemaining > 0 && ( - - +{(standing.projectedPoints - (standing.actualPoints ?? standing.totalPoints)).toFixed(2)} EV - - )} -
- ) : ( - - - )} +
- {showPlacementBreakdown && ( - - - - )} {standing.participantsRemaining > 0 ? ( @@ -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 ( + + + {label} + {arrow} + + + ); +} + function RankBadge({ rank }: { rank: number }) { if (rank === 1) { return ( @@ -116,7 +218,6 @@ function RankBadge({ rank }: { rank: number }) { ); } - if (rank === 2) { return ( @@ -124,7 +225,6 @@ function RankBadge({ rank }: { rank: number }) { ); } - if (rank === 3) { return ( @@ -132,7 +232,6 @@ function RankBadge({ rank }: { rank: number }) { ); } - return ( {rank} @@ -140,75 +239,57 @@ function RankBadge({ rank }: { rank: number }) { ); } -/** - * Show movement indicator (up/down arrow) - */ function MovementIndicator({ change }: { change: number }) { if (change > 0) { return ( - + ↑{change} ); } - if (change < 0) { return ( - + ↓{Math.abs(change)} ); } - return null; } -/** - * Display placement breakdown showing counts for each placement (1st-8th) - */ -function PlacementBreakdown({ - placements, +function SevenDayChange({ + pointChange, + rankChange, }: { - placements: { - first: number; - second: number; - third: number; - fourth: number; - fifth: number; - sixth: number; - seventh: number; - eighth: number; - }; + pointChange?: number; + rankChange: 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" }, - ]; + const hasData = pointChange !== undefined; - // Only show placements that have counts > 0 - const nonZero = items.filter((item) => item.count > 0); - - if (nonZero.length === 0) { - return None yet; + if (!hasData) { + return ; } return ( -
- {nonZero.map((item) => ( - - {item.label}×{item.count} - - ))} +
+ {/* Point change — always non-negative */} + + +{pointChange.toFixed(2)} pts + + {/* Rank change */} + {rankChange > 0 ? ( + ↑{rankChange} rank + ) : rankChange < 0 ? ( + ↓{Math.abs(rankChange)} rank + ) : ( + — rank + )}
); } diff --git a/app/hooks/useSortableData.ts b/app/hooks/useSortableData.ts new file mode 100644 index 0000000..9f75c3a --- /dev/null +++ b/app/hooks/useSortableData.ts @@ -0,0 +1,74 @@ +import { useState, useMemo } from "react"; + +export type SortDirection = "asc" | "desc"; + +export interface SortConfig { + key: keyof T | null; + direction: SortDirection; +} + +type Comparators = Partial 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( + data: T[], + defaultKey: keyof T | null = null, + defaultDirection: SortDirection = "asc", + comparators?: Comparators +): { + sortedData: T[]; + sortConfig: SortConfig; + requestSort: (key: keyof T) => void; +} { + const [sortConfig, setSortConfig] = useState>({ + 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 }; +} diff --git a/app/models/standings.ts b/app/models/standings.ts index abf5104..ca1e633 100644 --- a/app/models/standings.ts +++ b/app/models/standings.ts @@ -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(); + const oldPoints = new Map(); for (const snapshot of snapshots) { 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 - return current.map((standing) => ({ - ...standing, - sevenDayRankChange: oldRanks.has(standing.teamId) - ? (oldRanks.get(standing.teamId) ?? 0) - standing.currentRank - : 0, - sevenDayOldRank: oldRanks.get(standing.teamId) || null, - })); + return current.map((standing) => { + const currentPoints = standing.actualPoints ?? standing.totalPoints; + const oldPoint = oldPoints.get(standing.teamId); + return { + ...standing, + sevenDayRankChange: oldRanks.has(standing.teamId) + ? (oldRanks.get(standing.teamId) ?? 0) - standing.currentRank + : 0, + sevenDayOldRank: oldRanks.get(standing.teamId) || null, + sevenDayPointChange: oldPoint !== undefined ? currentPoints - oldPoint : 0, + }; + }); } /** diff --git a/app/routes/leagues/$leagueId.standings.$seasonId.tsx b/app/routes/leagues/$leagueId.standings.$seasonId.tsx index 91c1a12..3b78887 100644 --- a/app/routes/leagues/$leagueId.standings.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.standings.$seasonId.tsx @@ -102,6 +102,7 @@ export async function loader(args: Route.LoaderArgs) { const formattedStandings = standingsWithComparison.map((standing) => ({ ...standing, rankChange: standing.sevenDayRankChange, + sevenDayPointChange: standing.sevenDayPointChange, ownerName: ownerNameByTeamId.get(standing.teamId) ?? null, })); @@ -157,16 +158,6 @@ export default function LeagueStandings() {
- {/* Point Progression Chart */} - {progressionData.chartData.length > 0 && ( -
- -
- )} - {/* Standings Card */} @@ -187,12 +178,21 @@ export default function LeagueStandings() { standings={standings} leagueId={league.id} seasonId={season.id} - showPlacementBreakdown={true} /> )} + {/* Point Progression Chart */} + {progressionData.chartData.length > 0 && ( +
+ +
+ )} + {/* Info Card */} @@ -210,13 +210,12 @@ export default function LeagueStandings() { and so on through 8th place.

- Movement Indicators: Arrows show rank changes - compared to 7 days ago. Green arrows (↑) indicate improvement in rank, - red arrows (↓) indicate decline. + Movement Indicators: Arrows (↑/↓) next to rank show rank changes + compared to 7 days ago.

- Placement Breakdown: Shows how many times each - team's participants finished in each position (1st×2 means 2 first-place finishes). + 7-Day Change: Shows points earned and rank movement + over the past 7 days.

{progressionData.chartData.length > 0 && (

diff --git a/app/types/standings.ts b/app/types/standings.ts index 643df61..bd41297 100644 --- a/app/types/standings.ts +++ b/app/types/standings.ts @@ -26,6 +26,8 @@ export interface TeamStanding { actualPoints?: number | null; projectedPoints?: number | null; participantsFinished?: number | null; + // 7-day point change (optional, present when loaded with change data) + sevenDayPointChange?: number; } export interface TeamStandingSnapshot { @@ -37,4 +39,5 @@ export interface TeamStandingSnapshot { export interface TeamStandingWithChange extends TeamStanding { sevenDayRankChange: number; sevenDayOldRank: number | null; + sevenDayPointChange: number; }