From 8c5389909d091604603142439f317fd754cd5277 Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Sun, 22 Mar 2026 11:05:13 -0700 Subject: [PATCH] Redesign standings page with sortable table, 7-day change, and chart repositioned (#205) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 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 --- app/components/standings/PointsDisplay.tsx | 37 +++ app/components/standings/StandingsTable.tsx | 265 +++++++++++------- .../__tests__/StandingsTable.test.tsx | 156 ++++------- app/hooks/useSortableData.ts | 74 +++++ app/models/standings.ts | 26 +- .../leagues/$leagueId.standings.$seasonId.tsx | 30 +- app/types/standings.ts | 3 + 7 files changed, 365 insertions(+), 226 deletions(-) create mode 100644 app/components/standings/PointsDisplay.tsx create mode 100644 app/hooks/useSortableData.ts 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..e90287a 100644 --- a/app/components/standings/StandingsTable.tsx +++ b/app/components/standings/StandingsTable.tsx @@ -1,50 +1,130 @@ +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; - 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 - * 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] + ); + + 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 +141,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 +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; + 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 +210,6 @@ function RankBadge({ rank }: { rank: number }) { ); } - if (rank === 2) { return ( @@ -124,7 +217,6 @@ function RankBadge({ rank }: { rank: number }) { ); } - if (rank === 3) { return ( @@ -132,7 +224,6 @@ function RankBadge({ rank }: { rank: number }) { ); } - return ( {rank} @@ -140,75 +231,55 @@ 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} - - ))} +
+ = 0 ? "text-emerald-400" : "text-coral-accent"}`}> + {pointChange >= 0 ? "+" : ""}{pointChange.toFixed(2)} pts + + {rankChange > 0 ? ( + ↑{rankChange} rank + ) : rankChange < 0 ? ( + ↓{Math.abs(rankChange)} rank + ) : ( + — rank + )}
); } diff --git a/app/components/standings/__tests__/StandingsTable.test.tsx b/app/components/standings/__tests__/StandingsTable.test.tsx index 46bb86f..7518f55 100644 --- a/app/components/standings/__tests__/StandingsTable.test.tsx +++ b/app/components/standings/__tests__/StandingsTable.test.tsx @@ -9,11 +9,6 @@ function renderWithRouter(ui: React.ReactElement) { return render({ui}); } -/** - * 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", () => { const mockLeagueId = "league-123"; const mockSeasonId = "season-456"; @@ -25,7 +20,7 @@ describe("StandingsTable", () => { totalPoints: 250.5, currentRank: 1, previousRank: 2, - rankChange: 1, // Moved up + rankChange: 1, placementCounts: { first: 2, second: 1, @@ -45,7 +40,7 @@ describe("StandingsTable", () => { totalPoints: 245.0, currentRank: 2, previousRank: 1, - rankChange: -1, // Moved down + rankChange: -1, placementCounts: { first: 1, second: 2, @@ -65,7 +60,7 @@ describe("StandingsTable", () => { totalPoints: 200.0, currentRank: 3, previousRank: 3, - rankChange: 0, // No change + rankChange: 0, placementCounts: { first: 0, second: 1, @@ -135,7 +130,6 @@ describe("StandingsTable", () => { renderWithRouter(); - // Should not have emoji, just the number const badge = screen.getByText("4"); expect(badge).toBeInTheDocument(); }); @@ -157,80 +151,59 @@ describe("StandingsTable", () => { it("should not show indicator for no rank change", () => { renderWithRouter(); - // Third team has no change, so no arrow - const arrows = screen.queryAllByText(/↑|↓/); - expect(arrows).toHaveLength(2); // Only first two teams + // Only first two teams have rank changes; use \d to avoid matching sort-header arrows + const arrows = screen.queryAllByText(/[↑↓]\d/); + expect(arrows).toHaveLength(2); }); }); - describe("Placement Breakdown", () => { - it("should display placement counts", () => { - const singleTeam: TeamStanding[] = [ - { - 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(), - }, - ]; + describe("7-Day Change Column", () => { + it("should show dash when sevenDayPointChange is not present", () => { + renderWithRouter(); - renderWithRouter(); - - // Test Team: 2 firsts, 1 second, 1 fourth, 1 eighth - expect(screen.getByText("1st×2")).toBeInTheDocument(); - expect(screen.getByText("2nd×1")).toBeInTheDocument(); - expect(screen.getByText("4th×1")).toBeInTheDocument(); - expect(screen.getByText("8th×1")).toBeInTheDocument(); + // All mock standings lack sevenDayPointChange, so all rows show — + const dashes = screen.getAllByText("—"); + expect(dashes.length).toBeGreaterThanOrEqual(3); }); - it("should only show non-zero placement counts", () => { - renderWithRouter(); - - // 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(); - - expect(screen.queryByText("1st×2")).not.toBeInTheDocument(); - }); - - it("should show 'None yet' when no placements", () => { + it("should display positive point change in green with + prefix", () => { const standings: TeamStanding[] = [ - { - ...mockStandings[0], - placementCounts: { - first: 0, - second: 0, - third: 0, - fourth: 0, - fifth: 0, - sixth: 0, - seventh: 0, - eighth: 0, - }, - }, + { ...mockStandings[0], sevenDayPointChange: 12.5 }, ]; - renderWithRouter(); + renderWithRouter(); - 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(); + + 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(); + + 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(); + + expect(screen.getByText("— rank")).toBeInTheDocument(); }); }); @@ -256,23 +229,18 @@ describe("StandingsTable", () => { expect(screen.getByText("Rank")).toBeInTheDocument(); expect(screen.getByText("Team")).toBeInTheDocument(); expect(screen.getByText("Points")).toBeInTheDocument(); - expect(screen.getByText("Placements")).toBeInTheDocument(); + expect(screen.getByText("7-Day Change")).toBeInTheDocument(); expect(screen.getByText("Remaining")).toBeInTheDocument(); }); - it("should render teams in order", () => { + it("should render teams in order by rank", () => { const { container } = renderWithRouter(); const rows = container.querySelectorAll("tbody tr"); expect(rows).toHaveLength(3); - // First row should be Champions United (rank 1) expect(rows[0].textContent).toContain("Champions United"); - - // Second row should be Second Place Squad (rank 2) expect(rows[1].textContent).toContain("Second Place Squad"); - - // Third row should be Bronze Warriors (rank 3) expect(rows[2].textContent).toContain("Bronze Warriors"); }); }); @@ -280,30 +248,16 @@ describe("StandingsTable", () => { describe("Tied Ranks", () => { it("should handle teams with same rank", () => { const tiedStandings: TeamStanding[] = [ - { - ...mockStandings[0], - currentRank: 1, - teamName: "Team A", - }, - { - ...mockStandings[1], - currentRank: 1, - teamName: "Team B", - }, - { - ...mockStandings[2], - currentRank: 3, // Skips 2 - teamName: "Team C", - }, + { ...mockStandings[0], currentRank: 1, teamName: "Team A" }, + { ...mockStandings[1], currentRank: 1, teamName: "Team B" }, + { ...mockStandings[2], currentRank: 3, teamName: "Team C" }, ]; renderWithRouter(); - // Both teams should show rank 1 const firstPlaceBadges = screen.getAllByText(/🏆.*1st/); expect(firstPlaceBadges).toHaveLength(2); - // Third team should show rank 3 (not 2) expect(screen.getByText(/🥉.*3rd/)).toBeInTheDocument(); }); }); @@ -320,13 +274,5 @@ describe("StandingsTable", () => { expect(downArrow).toBeInTheDocument(); expect(downArrow?.getAttribute("title")).toContain("Down 1 place"); }); - - it("should have title attributes for placement counts", () => { - const { container } = renderWithRouter(); - - const firstPlace = container.querySelector('[title*="1st place"]'); - expect(firstPlace).toBeInTheDocument(); - expect(firstPlace?.getAttribute("title")).toContain("2 1st place finishes"); - }); }); }); 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..09abf2c 100644 --- a/app/routes/leagues/$leagueId.standings.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.standings.$seasonId.tsx @@ -157,16 +157,6 @@ export default function LeagueStandings() {
- {/* Point Progression Chart */} - {progressionData.chartData.length > 0 && ( -
- -
- )} - {/* Standings Card */} @@ -187,12 +177,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 +209,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; }