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>
196 lines
7.3 KiB
TypeScript
196 lines
7.3 KiB
TypeScript
import { ListOrdered } from "lucide-react";
|
||
import { Link } from "react-router";
|
||
import { Button } from "~/components/ui/button";
|
||
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, 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 ───────────────────────────────────────────────────────────────
|
||
|
||
const ROW_RANK_CLASSES: Record<number, string> = {
|
||
1: "bg-yellow-500/10 hover:bg-yellow-500/15",
|
||
2: "bg-white/[0.14] hover:bg-white/[0.18]",
|
||
3: "bg-orange-600/[0.08] hover:bg-orange-600/[0.12]",
|
||
};
|
||
|
||
function rowClasses(currentRank: number | undefined, hasHref: boolean): string {
|
||
const podium = currentRank !== undefined ? ROW_RANK_CLASSES[currentRank] : undefined;
|
||
const base = podium ?? `bg-white/[0.04] ${hasHref ? "hover:bg-white/[0.07]" : ""}`;
|
||
return `flex flex-col sm:flex-row sm:items-center gap-0 rounded-lg px-3 py-3 sm:px-5 sm:py-4 transition-colors ${base}`;
|
||
}
|
||
|
||
// ─── Row content ──────────────────────────────────────────────────────────────
|
||
|
||
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 */}
|
||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||
<TeamAvatar
|
||
teamId={entry.teamId}
|
||
teamName={entry.teamName}
|
||
logoUrl={entry.logoUrl}
|
||
flagConfig={entry.flagConfig}
|
||
avatarType={entry.avatarType}
|
||
ownerAvatarData={entry.ownerAvatarData}
|
||
/>
|
||
<div className="min-w-0">
|
||
<p className="font-medium text-sm leading-tight truncate">{entry.teamName}</p>
|
||
{entry.ownerName && (
|
||
<p className="text-xs text-muted-foreground truncate">{entry.ownerName}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 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">
|
||
<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 />
|
||
<StatColumn
|
||
label="Points"
|
||
reserveDelta={rowHasChange}
|
||
value={
|
||
<span className="text-2xl font-bold leading-none text-electric">
|
||
{Math.round(entry.points).toLocaleString("en-US")}
|
||
</span>
|
||
}
|
||
delta={
|
||
entry.pointChange !== undefined && entry.pointChange !== 0 ? (
|
||
<DeltaBadge delta={entry.pointChange} kind="points" />
|
||
) : undefined
|
||
}
|
||
/>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||
|
||
export interface StandingsPreviewEntry {
|
||
teamId: string;
|
||
teamName: string;
|
||
ownerName?: string | null;
|
||
logoUrl?: string | null;
|
||
flagConfig?: RawFlagConfig | null;
|
||
avatarType?: string | null;
|
||
ownerAvatarData?: AvatarData | null;
|
||
/** Pre-computed display rank. T prefix (if any) is stripped at render time. */
|
||
displayRank: string | number;
|
||
/** Numeric rank used to select the gold/silver/bronze row tint (1–3 only). */
|
||
currentRank?: number;
|
||
points: number;
|
||
href?: string;
|
||
/** Positive = moved up, negative = moved down. From TeamStanding.rankChange. */
|
||
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,
|
||
showProjected = false,
|
||
}: StandingsPreviewProps) {
|
||
return (
|
||
<Card className="gap-2">
|
||
<CardHeader className="px-3 sm:px-6 pb-2">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<GradientIcon icon={ListOrdered} className="h-5 w-5 shrink-0" />
|
||
<div>
|
||
<h2 className="text-xl font-bold leading-none tracking-tight">Standings</h2>
|
||
{description && (
|
||
<p className="text-sm text-muted-foreground mt-0.5">{description}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{fullStandingsHref && (
|
||
<Button variant="outline" size="sm" asChild>
|
||
<Link to={fullStandingsHref}>Full Standings</Link>
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="px-3 sm:px-6">
|
||
<div className="space-y-3">
|
||
{entries.map((entry) =>
|
||
entry.href ? (
|
||
<Link
|
||
key={entry.teamId}
|
||
to={entry.href}
|
||
className={rowClasses(entry.currentRank, true)}
|
||
>
|
||
<RowContent entry={entry} showProjected={showProjected} />
|
||
</Link>
|
||
) : (
|
||
<div key={entry.teamId} className={rowClasses(entry.currentRank, false)}>
|
||
<RowContent entry={entry} showProjected={showProjected} />
|
||
</div>
|
||
)
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|