From b960d39be352103729e60ea92e8fa9b9ce0c83a7 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Sun, 21 Jun 2026 11:56:15 -0700 Subject: [PATCH] Add projections to full standings; clean up change indicators Surface projected final points on the full standings page and restructure the rank/point change indicators so columns align cleanly on desktop and mobile. - StatHelpers: stacked label/value/delta columns with a per-row reservable delta line; merge rank/point indicators into a single DeltaBadge; parameterize StatDivider height. - StandingsPreview: opt-in `showProjected` column (projected points only), gated on participants remaining; reserve the delta line only when a row actually moved (no stray em-dashes). - Full standings page: pass projected data + showProjected (hidden when the season is complete). - League home: fetch via getSevenDayStandingsChange so the preview shows the same 7-day rank/point changes as the full standings page. - LeagueRow: top-align stats and restore h-8 dividers so the shared-component changes don't alter the league list rows. Co-Authored-By: Claude Opus 4.8 --- app/components/league/LeagueRow.tsx | 39 ++++--- .../league/StandingsPreview.stories.tsx | 46 ++++++++ app/components/league/StandingsPreview.tsx | 90 +++++++++++++--- app/components/league/StatHelpers.tsx | 102 +++++++++++------- .../__tests__/StandingsPreview.test.tsx | 50 +++++++++ app/routes/leagues/$leagueId.server.ts | 4 +- .../leagues/$leagueId.standings.$seasonId.tsx | 3 + app/routes/leagues/$leagueId.tsx | 2 +- 8 files changed, 266 insertions(+), 70 deletions(-) diff --git a/app/components/league/LeagueRow.tsx b/app/components/league/LeagueRow.tsx index 94b2877..0fa2cfe 100644 --- a/app/components/league/LeagueRow.tsx +++ b/app/components/league/LeagueRow.tsx @@ -118,20 +118,23 @@ function ActiveRow({ {/* Stats — second row on mobile, right side on desktop */} {showStats && ( -
+
{displayRank !== undefined && ( )} - {currentRank !== undefined && totalPoints !== undefined && } + {currentRank !== undefined && totalPoints !== undefined && } {totalPoints !== undefined && ( - - - {Math.round(totalPoints).toLocaleString("en-US")} - - + + {Math.round(totalPoints).toLocaleString("en-US")} + + } + /> )}
)} @@ -169,17 +172,21 @@ function PreDraftRow({
{/* Stats — second row on mobile, right side on desktop */}
- - {draftTimeValue} - + {draftTimeValue}} + /> {draftPosition !== undefined && ( <> - - - - {ordinal(draftPosition)} - - + + + {ordinal(draftPosition)} + + } + /> )}
diff --git a/app/components/league/StandingsPreview.stories.tsx b/app/components/league/StandingsPreview.stories.tsx index 6799c0e..bec6d07 100644 --- a/app/components/league/StandingsPreview.stories.tsx +++ b/app/components/league/StandingsPreview.stories.tsx @@ -137,6 +137,52 @@ export const FullLeague: Story = { }, }; +export const WithProjections: Story = { + args: { + showProjected: true, + entries: [ + { + teamId: "t1", + teamName: "Lightning Wolves", + ownerName: "alice", + displayRank: 1, + currentRank: 1, + points: 2810, + rankChange: 2, + pointChange: 87.5, + projectedPoints: 3120, + participantsRemaining: 3, + href: "/leagues/1/standings/1/teams/t1", + }, + { + teamId: "t2", + teamName: "Shadow Hawks", + ownerName: "bob", + displayRank: 2, + currentRank: 2, + points: 2654.5, + rankChange: -1, + pointChange: 42.0, + projectedPoints: 2980, + participantsRemaining: 2, + href: "/leagues/1/standings/1/teams/t2", + }, + { + teamId: "t3", + teamName: "Iron Eagles", + ownerName: "carol", + displayRank: 3, + currentRank: 3, + points: 2493, + // No 7-day change and no live participants — placeholders keep alignment. + projectedPoints: 2493, + participantsRemaining: 0, + href: "/leagues/1/standings/1/teams/t3", + }, + ], + }, +}; + export const WithTies: Story = { args: { entries: [ diff --git a/app/components/league/StandingsPreview.tsx b/app/components/league/StandingsPreview.tsx index 9de5a1c..a2afd43 100644 --- a/app/components/league/StandingsPreview.tsx +++ b/app/components/league/StandingsPreview.tsx @@ -5,7 +5,17 @@ import { Card, CardContent, CardHeader } from "~/components/ui/card"; import { GradientIcon } from "~/components/ui/GradientIcon"; import { TeamAvatar } from "~/components/TeamAvatar"; import type { AvatarData, RawFlagConfig } from "~/lib/flag-types"; -import { StatColumn, StatDivider, PointChangeIndicator, RankingDisplay } from "./StatHelpers"; +import { StatColumn, StatDivider, DeltaBadge, RankingDisplay } from "./StatHelpers"; + +/** Mirrors PointsDisplay gating: only project while the team still has participants live. */ +function hasProjection(entry: StandingsPreviewEntry): boolean { + return ( + entry.projectedPoints !== null && + entry.projectedPoints !== undefined && + entry.participantsRemaining !== undefined && + entry.participantsRemaining > 0 + ); +} // ─── Row styles ─────────────────────────────────────────────────────────────── @@ -23,7 +33,20 @@ function rowClasses(currentRank: number | undefined, hasHref: boolean): string { // ─── Row content ────────────────────────────────────────────────────────────── -function RowContent({ entry }: { entry: StandingsPreviewEntry }) { +function RowContent({ + entry, + showProjected, +}: { + entry: StandingsPreviewEntry; + showProjected: boolean; +}) { + // Only reserve the delta line when this row actually changed. A row with no rank + // or point movement drops the line entirely (no "—" placeholders); rows that moved + // keep aligned columns by showing "—" for whichever stat didn't change. + const rowHasChange = + (entry.rankChange !== undefined && entry.rankChange !== 0) || + (entry.pointChange !== undefined && entry.pointChange !== 0); + return ( <> {/* Left: avatar + name */} @@ -46,16 +69,44 @@ function RowContent({ entry }: { entry: StandingsPreviewEntry }) { {/* Right: stats — second row on mobile */}
- + + {showProjected && } + {showProjected && ( + + {Math.round(entry.projectedPoints as number).toLocaleString("en-US")} + + ) : ( + + — + + ) + } + /> + )} - - - {Math.round(entry.points).toLocaleString("en-US")} - - {entry.pointChange !== undefined && entry.pointChange !== 0 && ( - - )} - + + {Math.round(entry.points).toLocaleString("en-US")} + + } + delta={ + entry.pointChange !== undefined && entry.pointChange !== 0 ? ( + + ) : undefined + } + />
); @@ -81,15 +132,26 @@ export interface StandingsPreviewEntry { rankChange?: number; /** 7-day point delta. From TeamStanding.sevenDayPointChange. */ pointChange?: number; + /** Projected final points. From TeamStanding.projectedPoints. */ + projectedPoints?: number | null; + /** Live participants left; projection only shown while > 0. */ + participantsRemaining?: number; } export interface StandingsPreviewProps { entries: StandingsPreviewEntry[]; description?: string; fullStandingsHref?: string; + /** When true, adds a "Projected" column (projected final points). Default false. */ + showProjected?: boolean; } -export function StandingsPreview({ entries, description, fullStandingsHref }: StandingsPreviewProps) { +export function StandingsPreview({ + entries, + description, + fullStandingsHref, + showProjected = false, +}: StandingsPreviewProps) { return ( @@ -119,11 +181,11 @@ export function StandingsPreview({ entries, description, fullStandingsHref }: St to={entry.href} className={rowClasses(entry.currentRank, true)} > - + ) : (
- +
) )} diff --git a/app/components/league/StatHelpers.tsx b/app/components/league/StatHelpers.tsx index a21525c..3ea3909 100644 --- a/app/components/league/StatHelpers.tsx +++ b/app/components/league/StatHelpers.tsx @@ -1,30 +1,71 @@ +/** + * Stacked stat column: label / value / delta. The delta slot is always rendered + * (a muted "—" placeholder when there is no change) so that every row reserves the + * same vertical space and the columns line up across the standings table. + */ export function StatColumn({ label, - children, + value, + delta, + reserveDelta = false, }: { label: string; - children: React.ReactNode; + value: React.ReactNode; + /** Optional change indicator rendered on its own line below the value. */ + delta?: React.ReactNode; + /** + * When true, the delta line is always rendered (a muted placeholder when there is + * no delta) so columns stay vertically aligned across rows. When false and no delta + * is provided, the line is omitted entirely. + */ + reserveDelta?: boolean; }) { + const showDeltaLine = delta !== undefined || reserveDelta; return (

{label}

-
{children}
+
{value}
+ {showDeltaLine && ( +
+ {delta ?? } +
+ )}
); } -export function StatDivider() { - return
; +export function StatDivider({ className = "h-12" }: { className?: string } = {}) { + return
; } -export function RankChangeIndicator({ delta }: { delta: number }) { - if (delta === 0) return null; - if (delta > 0) { +/** + * 7-day change indicator. `rank` deltas are unitless places; `points` deltas are + * rounded point totals. Positive = up (green ▲), negative = down (coral ▼). + */ +export function DeltaBadge({ + delta, + kind, +}: { + delta: number; + kind: "rank" | "points"; +}) { + const magnitude = kind === "rank" ? Math.abs(delta) : Math.abs(Math.round(delta)); + const up = delta > 0; + const label = + kind === "rank" + ? up + ? `up ${magnitude}` + : `down ${magnitude}` + : up + ? `+${magnitude} points` + : `-${magnitude} points`; + + if (up) { return ( - - ▲{delta} + + ▲{magnitude} ); } @@ -32,9 +73,9 @@ export function RankChangeIndicator({ delta }: { delta: number }) { - ▼{Math.abs(delta)} + ▼{magnitude} ); } @@ -42,35 +83,22 @@ export function RankChangeIndicator({ delta }: { delta: number }) { export function RankingDisplay({ displayRank, rankChange, + reserveDelta = false, }: { displayRank: string | number; rankChange?: number; + reserveDelta?: boolean; }) { return ( - - {displayRank} - {rankChange !== undefined && rankChange !== 0 && ( - - )} - - ); -} - -export function PointChangeIndicator({ delta }: { delta: number }) { - if (delta >= 0) { - return ( - - ▲{Math.round(delta)} - - ); - } - return ( - - ▼{Math.abs(Math.round(delta))} - + {displayRank}} + delta={ + rankChange !== undefined && rankChange !== 0 ? ( + + ) : undefined + } + /> ); } diff --git a/app/components/league/__tests__/StandingsPreview.test.tsx b/app/components/league/__tests__/StandingsPreview.test.tsx index 14b7636..ba390c4 100644 --- a/app/components/league/__tests__/StandingsPreview.test.tsx +++ b/app/components/league/__tests__/StandingsPreview.test.tsx @@ -103,4 +103,54 @@ describe("StandingsPreview", () => { expect(link).toHaveAttribute("href", "/leagues/1/standings/1"); }); }); + + describe("Projections", () => { + it("does not render a Projected column by default", () => { + const entry: StandingsPreviewEntry = { + ...baseEntry, + projectedPoints: 3000, + participantsRemaining: 3, + }; + renderWithRouter(); + + expect(screen.queryByText("Projected")).not.toBeInTheDocument(); + expect(screen.queryByText("3,000")).not.toBeInTheDocument(); + }); + + it("renders the projected value when showProjected and participants remain", () => { + const entry: StandingsPreviewEntry = { + ...baseEntry, + projectedPoints: 3000, + participantsRemaining: 3, + }; + renderWithRouter(); + + expect(screen.getByText("Projected")).toBeInTheDocument(); + expect(screen.getByText("3,000")).toBeInTheDocument(); + }); + + it("shows a placeholder instead of a value when no participants remain", () => { + const entry: StandingsPreviewEntry = { + ...baseEntry, + projectedPoints: 9999, + participantsRemaining: 0, + }; + renderWithRouter(); + + // Column header still present, but the projected total is not shown. + expect(screen.getByText("Projected")).toBeInTheDocument(); + expect(screen.queryByText("9,999")).not.toBeInTheDocument(); + }); + + it("shows a placeholder when projectedPoints is missing", () => { + const entry: StandingsPreviewEntry = { + ...baseEntry, + projectedPoints: null, + participantsRemaining: 3, + }; + renderWithRouter(); + + expect(screen.getByText("Projected")).toBeInTheDocument(); + }); + }); }); diff --git a/app/routes/leagues/$leagueId.server.ts b/app/routes/leagues/$leagueId.server.ts index e10b048..dc1c39f 100644 --- a/app/routes/leagues/$leagueId.server.ts +++ b/app/routes/leagues/$leagueId.server.ts @@ -12,7 +12,7 @@ import { findDraftSlotsBySeasonId, } from "~/models"; import { findCurrentSeasonWithSports } from "~/models/season"; -import { getSeasonStandings } from "~/models/standings"; +import { getSevenDayStandingsChange } from "~/models/standings"; import { getUpcomingEventsForDraftedParticipants } from "~/models/scoring-event"; import { getUpcomingGroupStageMatchesForParticipants } from "~/models/group-stage-match"; import { getDraftedParticipantsBySportsSeason, getDraftedParticipantsWithPoints, type DraftedParticipantWithPoints } from "~/models/draft-pick"; @@ -77,7 +77,7 @@ export async function loader(args: Route.LoaderArgs) { // Fetch standings for active/completed seasons const standings = season && (season.status === "active" || season.status === "completed") - ? await getSeasonStandings(season.id) + ? await getSevenDayStandingsChange(season.id) : []; // Batch-fetch all users needed for owner and commissioner maps in one query diff --git a/app/routes/leagues/$leagueId.standings.$seasonId.tsx b/app/routes/leagues/$leagueId.standings.$seasonId.tsx index 00e345a..4c3abd2 100644 --- a/app/routes/leagues/$leagueId.standings.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.standings.$seasonId.tsx @@ -140,6 +140,8 @@ export default function LeagueStandings() { points: s.totalPoints, rankChange: s.rankChange, pointChange: s.sevenDayPointChange, + projectedPoints: s.projectedPoints, + participantsRemaining: s.participantsRemaining, href: `/leagues/${league.id}/standings/${season.id}/teams/${s.teamId}`, })); @@ -181,6 +183,7 @@ export default function LeagueStandings() { {progressionData.chartData.length > 0 && ( diff --git a/app/routes/leagues/$leagueId.tsx b/app/routes/leagues/$leagueId.tsx index a7dcccd..e21d7cf 100644 --- a/app/routes/leagues/$leagueId.tsx +++ b/app/routes/leagues/$leagueId.tsx @@ -116,7 +116,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) { currentRank: standing?.currentRank, points: standing ? (standing.actualPoints ?? standing.totalPoints) : 0, href: season ? `/leagues/${league.id}/standings/${season.id}/teams/${team.id}` : undefined, - rankChange: standing?.rankChange, + rankChange: standing?.sevenDayRankChange, pointChange: standing?.sevenDayPointChange, }; });