brackt/app/components/league/StandingsPreview.tsx
Chris Parsons b960d39be3
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
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 <noreply@anthropic.com>
2026-06-21 11:56:15 -07:00

196 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (13 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>
);
}