Add projections to full standings; clean up change indicators
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m23s
🚀 Deploy / 🧪 Test (push) Successful in 3m9s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m20s
🚀 Deploy / 🐳 Build (push) Successful in 1m9s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m23s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (push) Successful in 12s

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 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-06-21 11:56:15 -07:00
parent 4a5ea6fdc5
commit b960d39be3
8 changed files with 266 additions and 70 deletions

View file

@ -118,20 +118,23 @@ function ActiveRow({
</div> </div>
{/* Stats — second row on mobile, right side on desktop */} {/* Stats — second row on mobile, right side on desktop */}
{showStats && ( {showStats && (
<div className="flex items-center gap-4 border-t border-border/50 pt-2 sm:border-0 sm:pt-0 sm:shrink-0"> <div className="flex items-start gap-4 border-t border-border/50 pt-2 sm:border-0 sm:pt-0 sm:shrink-0">
{displayRank !== undefined && ( {displayRank !== undefined && (
<RankingDisplay <RankingDisplay
displayRank={displayRank} displayRank={displayRank}
rankChange={previousRank !== undefined && currentRank !== undefined && previousRank !== currentRank ? previousRank - currentRank : undefined} rankChange={previousRank !== undefined && currentRank !== undefined && previousRank !== currentRank ? previousRank - currentRank : undefined}
/> />
)} )}
{currentRank !== undefined && totalPoints !== undefined && <StatDivider />} {currentRank !== undefined && totalPoints !== undefined && <StatDivider className="h-8" />}
{totalPoints !== undefined && ( {totalPoints !== undefined && (
<StatColumn label="Points"> <StatColumn
<span className="text-2xl font-bold leading-none text-electric"> label="Points"
{Math.round(totalPoints).toLocaleString("en-US")} value={
</span> <span className="text-2xl font-bold leading-none text-electric">
</StatColumn> {Math.round(totalPoints).toLocaleString("en-US")}
</span>
}
/>
)} )}
</div> </div>
)} )}
@ -169,17 +172,21 @@ function PreDraftRow({
</div> </div>
{/* Stats — second row on mobile, right side on desktop */} {/* Stats — second row on mobile, right side on desktop */}
<div className="flex items-center gap-4 border-t border-border/50 pt-2 sm:border-0 sm:pt-0 sm:shrink-0"> <div className="flex items-center gap-4 border-t border-border/50 pt-2 sm:border-0 sm:pt-0 sm:shrink-0">
<StatColumn label="Draft"> <StatColumn
<span className="text-2xl font-bold leading-none">{draftTimeValue}</span> label="Draft"
</StatColumn> value={<span className="text-2xl font-bold leading-none">{draftTimeValue}</span>}
/>
{draftPosition !== undefined && ( {draftPosition !== undefined && (
<> <>
<StatDivider /> <StatDivider className="h-8" />
<StatColumn label="Position"> <StatColumn
<span className="text-2xl font-bold leading-none"> label="Position"
{ordinal(draftPosition)} value={
</span> <span className="text-2xl font-bold leading-none">
</StatColumn> {ordinal(draftPosition)}
</span>
}
/>
</> </>
)} )}
</div> </div>

View file

@ -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 = { export const WithTies: Story = {
args: { args: {
entries: [ entries: [

View file

@ -5,7 +5,17 @@ import { Card, CardContent, CardHeader } from "~/components/ui/card";
import { GradientIcon } from "~/components/ui/GradientIcon"; import { GradientIcon } from "~/components/ui/GradientIcon";
import { TeamAvatar } from "~/components/TeamAvatar"; import { TeamAvatar } from "~/components/TeamAvatar";
import type { AvatarData, RawFlagConfig } from "~/lib/flag-types"; 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 ─────────────────────────────────────────────────────────────── // ─── Row styles ───────────────────────────────────────────────────────────────
@ -23,7 +33,20 @@ function rowClasses(currentRank: number | undefined, hasHref: boolean): string {
// ─── Row content ────────────────────────────────────────────────────────────── // ─── 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 ( return (
<> <>
{/* Left: avatar + name */} {/* Left: avatar + name */}
@ -46,16 +69,44 @@ function RowContent({ entry }: { entry: StandingsPreviewEntry }) {
{/* Right: stats — second row on mobile */} {/* Right: stats — second row on mobile */}
<div className="flex items-center gap-4 w-full border-t border-border/30 pt-2 mt-1 sm:w-auto sm:border-0 sm:pt-0 sm:mt-0 sm:shrink-0"> <div className="flex items-center gap-4 w-full border-t border-border/30 pt-2 mt-1 sm:w-auto sm:border-0 sm:pt-0 sm:mt-0 sm:shrink-0">
<RankingDisplay displayRank={entry.displayRank} rankChange={entry.rankChange} /> <RankingDisplay
displayRank={entry.displayRank}
rankChange={entry.rankChange}
reserveDelta={rowHasChange}
/>
{showProjected && <StatDivider />}
{showProjected && (
<StatColumn
label="Projected"
reserveDelta={rowHasChange}
value={
hasProjection(entry) ? (
<span className="text-2xl font-normal leading-none text-muted-foreground">
{Math.round(entry.projectedPoints as number).toLocaleString("en-US")}
</span>
) : (
<span className="text-2xl font-normal leading-none text-muted-foreground/40">
</span>
)
}
/>
)}
<StatDivider /> <StatDivider />
<StatColumn label="Points"> <StatColumn
<span className="text-2xl font-bold leading-none text-electric"> label="Points"
{Math.round(entry.points).toLocaleString("en-US")} reserveDelta={rowHasChange}
</span> value={
{entry.pointChange !== undefined && entry.pointChange !== 0 && ( <span className="text-2xl font-bold leading-none text-electric">
<PointChangeIndicator delta={entry.pointChange} /> {Math.round(entry.points).toLocaleString("en-US")}
)} </span>
</StatColumn> }
delta={
entry.pointChange !== undefined && entry.pointChange !== 0 ? (
<DeltaBadge delta={entry.pointChange} kind="points" />
) : undefined
}
/>
</div> </div>
</> </>
); );
@ -81,15 +132,26 @@ export interface StandingsPreviewEntry {
rankChange?: number; rankChange?: number;
/** 7-day point delta. From TeamStanding.sevenDayPointChange. */ /** 7-day point delta. From TeamStanding.sevenDayPointChange. */
pointChange?: number; pointChange?: number;
/** Projected final points. From TeamStanding.projectedPoints. */
projectedPoints?: number | null;
/** Live participants left; projection only shown while > 0. */
participantsRemaining?: number;
} }
export interface StandingsPreviewProps { export interface StandingsPreviewProps {
entries: StandingsPreviewEntry[]; entries: StandingsPreviewEntry[];
description?: string; description?: string;
fullStandingsHref?: 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 ( return (
<Card className="gap-2"> <Card className="gap-2">
<CardHeader className="px-3 sm:px-6 pb-2"> <CardHeader className="px-3 sm:px-6 pb-2">
@ -119,11 +181,11 @@ export function StandingsPreview({ entries, description, fullStandingsHref }: St
to={entry.href} to={entry.href}
className={rowClasses(entry.currentRank, true)} className={rowClasses(entry.currentRank, true)}
> >
<RowContent entry={entry} /> <RowContent entry={entry} showProjected={showProjected} />
</Link> </Link>
) : ( ) : (
<div key={entry.teamId} className={rowClasses(entry.currentRank, false)}> <div key={entry.teamId} className={rowClasses(entry.currentRank, false)}>
<RowContent entry={entry} /> <RowContent entry={entry} showProjected={showProjected} />
</div> </div>
) )
)} )}

View file

@ -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({ export function StatColumn({
label, label,
children, value,
delta,
reserveDelta = false,
}: { }: {
label: string; 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 ( return (
<div className="text-right shrink-0 flex-1 sm:flex-none"> <div className="text-right shrink-0 flex-1 sm:flex-none">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{label} {label}
</p> </p>
<div className="flex items-baseline justify-end gap-1">{children}</div> <div className="flex items-baseline justify-end">{value}</div>
{showDeltaLine && (
<div className="flex items-baseline justify-end leading-none mt-0.5">
{delta ?? <span className="text-xs text-muted-foreground/60"></span>}
</div>
)}
</div> </div>
); );
} }
export function StatDivider() { export function StatDivider({ className = "h-12" }: { className?: string } = {}) {
return <div className="h-8 w-px bg-border shrink-0 self-center" />; return <div className={`${className} w-px bg-border shrink-0 self-center`} />;
} }
export function RankChangeIndicator({ delta }: { delta: number }) { /**
if (delta === 0) return null; * 7-day change indicator. `rank` deltas are unitless places; `points` deltas are
if (delta > 0) { * 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 ( return (
<span className="text-xs font-semibold text-primary" aria-label={`up ${delta}`}> <span className="text-xs font-semibold text-primary" aria-label={label}>
{delta} {magnitude}
</span> </span>
); );
} }
@ -32,9 +73,9 @@ export function RankChangeIndicator({ delta }: { delta: number }) {
<span <span
className="text-xs font-semibold" className="text-xs font-semibold"
style={{ color: "var(--coral-accent, #ef4444)" }} style={{ color: "var(--coral-accent, #ef4444)" }}
aria-label={`down ${Math.abs(delta)}`} aria-label={label}
> >
{Math.abs(delta)} {magnitude}
</span> </span>
); );
} }
@ -42,35 +83,22 @@ export function RankChangeIndicator({ delta }: { delta: number }) {
export function RankingDisplay({ export function RankingDisplay({
displayRank, displayRank,
rankChange, rankChange,
reserveDelta = false,
}: { }: {
displayRank: string | number; displayRank: string | number;
rankChange?: number; rankChange?: number;
reserveDelta?: boolean;
}) { }) {
return ( return (
<StatColumn label="Ranking"> <StatColumn
<span className="text-2xl font-bold leading-none">{displayRank}</span> label="Ranking"
{rankChange !== undefined && rankChange !== 0 && ( reserveDelta={reserveDelta}
<RankChangeIndicator delta={rankChange} /> value={<span className="text-2xl font-bold leading-none">{displayRank}</span>}
)} delta={
</StatColumn> rankChange !== undefined && rankChange !== 0 ? (
); <DeltaBadge delta={rankChange} kind="rank" />
} ) : undefined
}
export function PointChangeIndicator({ delta }: { delta: number }) { />
if (delta >= 0) {
return (
<span className="text-xs font-semibold text-primary" aria-label={`+${Math.round(delta)} points`}>
{Math.round(delta)}
</span>
);
}
return (
<span
className="text-xs font-semibold"
style={{ color: "var(--coral-accent, #ef4444)" }}
aria-label={`${Math.round(delta)} points`}
>
{Math.abs(Math.round(delta))}
</span>
); );
} }

View file

@ -103,4 +103,54 @@ describe("StandingsPreview", () => {
expect(link).toHaveAttribute("href", "/leagues/1/standings/1"); 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(<StandingsPreview entries={[entry]} />);
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(<StandingsPreview entries={[entry]} showProjected />);
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(<StandingsPreview entries={[entry]} showProjected />);
// 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(<StandingsPreview entries={[entry]} showProjected />);
expect(screen.getByText("Projected")).toBeInTheDocument();
});
});
}); });

View file

@ -12,7 +12,7 @@ import {
findDraftSlotsBySeasonId, findDraftSlotsBySeasonId,
} from "~/models"; } from "~/models";
import { findCurrentSeasonWithSports } from "~/models/season"; import { findCurrentSeasonWithSports } from "~/models/season";
import { getSeasonStandings } from "~/models/standings"; import { getSevenDayStandingsChange } from "~/models/standings";
import { getUpcomingEventsForDraftedParticipants } from "~/models/scoring-event"; import { getUpcomingEventsForDraftedParticipants } from "~/models/scoring-event";
import { getUpcomingGroupStageMatchesForParticipants } from "~/models/group-stage-match"; import { getUpcomingGroupStageMatchesForParticipants } from "~/models/group-stage-match";
import { getDraftedParticipantsBySportsSeason, getDraftedParticipantsWithPoints, type DraftedParticipantWithPoints } from "~/models/draft-pick"; 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 // Fetch standings for active/completed seasons
const standings = const standings =
season && (season.status === "active" || season.status === "completed") 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 // Batch-fetch all users needed for owner and commissioner maps in one query

View file

@ -140,6 +140,8 @@ export default function LeagueStandings() {
points: s.totalPoints, points: s.totalPoints,
rankChange: s.rankChange, rankChange: s.rankChange,
pointChange: s.sevenDayPointChange, pointChange: s.sevenDayPointChange,
projectedPoints: s.projectedPoints,
participantsRemaining: s.participantsRemaining,
href: `/leagues/${league.id}/standings/${season.id}/teams/${s.teamId}`, href: `/leagues/${league.id}/standings/${season.id}/teams/${s.teamId}`,
})); }));
@ -181,6 +183,7 @@ export default function LeagueStandings() {
<StandingsPreview <StandingsPreview
entries={previewEntries} entries={previewEntries}
description={seasonComplete ? "Final Standings" : "Current Standings"} description={seasonComplete ? "Final Standings" : "Current Standings"}
showProjected={!seasonComplete}
/> />
{progressionData.chartData.length > 0 && ( {progressionData.chartData.length > 0 && (

View file

@ -116,7 +116,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
currentRank: standing?.currentRank, currentRank: standing?.currentRank,
points: standing ? (standing.actualPoints ?? standing.totalPoints) : 0, points: standing ? (standing.actualPoints ?? standing.totalPoints) : 0,
href: season ? `/leagues/${league.id}/standings/${season.id}/teams/${team.id}` : undefined, href: season ? `/leagues/${league.id}/standings/${season.id}/teams/${team.id}` : undefined,
rankChange: standing?.rankChange, rankChange: standing?.sevenDayRankChange,
pointChange: standing?.sevenDayPointChange, pointChange: standing?.sevenDayPointChange,
}; };
}); });