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,
};
});