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>
241 lines
8.9 KiB
TypeScript
241 lines
8.9 KiB
TypeScript
import { formatDistanceToNow } from "date-fns";
|
||
import { Link } from "react-router";
|
||
import { Button } from "~/components/ui/button";
|
||
import { LeagueAvatar } from "./LeagueAvatar";
|
||
import { StatColumn, StatDivider, RankingDisplay } from "./StatHelpers";
|
||
|
||
export interface LeagueRowProps {
|
||
leagueId: string;
|
||
leagueName: string;
|
||
numSports: number;
|
||
status: "draft" | "active" | "pre_draft" | "completed";
|
||
seasonId?: string;
|
||
displayRank?: string | number;
|
||
currentRank?: number;
|
||
totalPoints?: number;
|
||
previousRank?: number;
|
||
completionPercentage?: number;
|
||
draftDateTime?: string | null;
|
||
picksUntilMyTurn?: number;
|
||
draftPosition?: number;
|
||
isCommishOnly?: boolean;
|
||
}
|
||
|
||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
function ordinal(n: number): string {
|
||
const v = n % 100;
|
||
// 11–13 are always "th" (e.g. 11th, 12th, 13th)
|
||
if (v >= 11 && v <= 13) return `${n}th`;
|
||
const s = ["th", "st", "nd", "rd"];
|
||
return `${n}${s[n % 10] ?? "th"}`;
|
||
}
|
||
|
||
|
||
// ─── Season progress bar ──────────────────────────────────────────────────────
|
||
|
||
function SeasonProgress({ pct, status }: { pct: number; status: "active" | "completed" }) {
|
||
const label = status === "completed" ? "100% Complete" : `${pct}% Complete`;
|
||
const fill = status === "completed" ? 100 : pct;
|
||
return (
|
||
<div className="flex items-center gap-1.5 mt-1.5 w-40">
|
||
<div className="flex-1 h-1 rounded-full bg-white/10 overflow-hidden">
|
||
<div
|
||
className="h-full rounded-full bg-electric transition-all"
|
||
style={{ width: `${fill}%` }}
|
||
/>
|
||
</div>
|
||
<span className="text-xs text-muted-foreground shrink-0">{label}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Row variants ─────────────────────────────────────────────────────────────
|
||
|
||
function DraftRow({ leagueId, leagueName, seasonId, picksUntilMyTurn }: LeagueRowProps) {
|
||
const draftUrl = `/leagues/${leagueId}/draft/${seasonId}`;
|
||
const picksLabel =
|
||
picksUntilMyTurn === 0
|
||
? "You're on the clock!"
|
||
: picksUntilMyTurn !== undefined
|
||
? `Up in ${picksUntilMyTurn} pick${picksUntilMyTurn === 1 ? "" : "s"}`
|
||
: null;
|
||
|
||
return (
|
||
<div className="flex items-start gap-3 rounded-lg border border-primary/40 bg-primary/10 px-3 py-3 sm:px-5 sm:py-4">
|
||
<LeagueAvatar leagueId={leagueId} leagueName={leagueName} />
|
||
<div className="flex-1 min-w-0">
|
||
<Link to={`/leagues/${leagueId}`} className="font-semibold leading-tight truncate hover:underline block">{leagueName}</Link>
|
||
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-electric" />
|
||
<span className="text-xs font-semibold tracking-wide text-electric uppercase">
|
||
Draft in Progress
|
||
</span>
|
||
</div>
|
||
{picksLabel && (
|
||
<span className="text-xs text-muted-foreground">{picksLabel}</span>
|
||
)}
|
||
</div>
|
||
{seasonId && (
|
||
<Button asChild size="sm" className="mt-3 sm:hidden">
|
||
<Link to={draftUrl}>Enter Draft</Link>
|
||
</Button>
|
||
)}
|
||
</div>
|
||
{seasonId && (
|
||
<Button asChild size="sm" className="shrink-0 hidden sm:inline-flex">
|
||
<Link to={draftUrl}>Enter Draft</Link>
|
||
</Button>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ActiveRow({
|
||
leagueId,
|
||
leagueName,
|
||
displayRank,
|
||
currentRank,
|
||
totalPoints,
|
||
previousRank,
|
||
completionPercentage = 0,
|
||
}: LeagueRowProps) {
|
||
const showStats = currentRank !== undefined || totalPoints !== undefined;
|
||
|
||
return (
|
||
<Link
|
||
to={`/leagues/${leagueId}`}
|
||
className="flex flex-col sm:flex-row sm:items-center gap-3 rounded-lg bg-card px-3 py-3 sm:px-5 sm:py-4 hover:bg-white/[0.06] transition-colors"
|
||
>
|
||
{/* Avatar + name */}
|
||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||
<LeagueAvatar leagueId={leagueId} leagueName={leagueName} />
|
||
<div className="min-w-0">
|
||
<p className="font-semibold leading-tight truncate">{leagueName}</p>
|
||
<SeasonProgress pct={completionPercentage} status="active" />
|
||
</div>
|
||
</div>
|
||
{/* Stats — second row on mobile, right side on desktop */}
|
||
{showStats && (
|
||
<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 && (
|
||
<RankingDisplay
|
||
displayRank={displayRank}
|
||
rankChange={previousRank !== undefined && currentRank !== undefined && previousRank !== currentRank ? previousRank - currentRank : undefined}
|
||
/>
|
||
)}
|
||
{currentRank !== undefined && totalPoints !== undefined && <StatDivider className="h-8" />}
|
||
{totalPoints !== undefined && (
|
||
<StatColumn
|
||
label="Points"
|
||
value={
|
||
<span className="text-2xl font-bold leading-none text-electric">
|
||
{Math.round(totalPoints).toLocaleString("en-US")}
|
||
</span>
|
||
}
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
function PreDraftRow({
|
||
leagueId,
|
||
leagueName,
|
||
draftDateTime,
|
||
draftPosition,
|
||
}: LeagueRowProps) {
|
||
let draftTimeValue = "Not scheduled";
|
||
if (draftDateTime) {
|
||
const draftDate = new Date(draftDateTime);
|
||
draftTimeValue =
|
||
draftDate > new Date()
|
||
? formatDistanceToNow(draftDate, { addSuffix: true })
|
||
: "Starting soon";
|
||
}
|
||
|
||
return (
|
||
<Link
|
||
to={`/leagues/${leagueId}`}
|
||
className="flex flex-col sm:flex-row sm:items-center gap-3 rounded-lg bg-card px-3 py-3 sm:px-5 sm:py-4 hover:bg-white/[0.06] transition-colors"
|
||
>
|
||
{/* Avatar + name */}
|
||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||
<LeagueAvatar leagueId={leagueId} leagueName={leagueName} />
|
||
<div className="min-w-0">
|
||
<p className="font-semibold leading-tight truncate">{leagueName}</p>
|
||
<p className="text-xs text-muted-foreground mt-0.5">Pre-Draft</p>
|
||
</div>
|
||
</div>
|
||
{/* 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">
|
||
<StatColumn
|
||
label="Draft"
|
||
value={<span className="text-2xl font-bold leading-none">{draftTimeValue}</span>}
|
||
/>
|
||
{draftPosition !== undefined && (
|
||
<>
|
||
<StatDivider className="h-8" />
|
||
<StatColumn
|
||
label="Position"
|
||
value={
|
||
<span className="text-2xl font-bold leading-none">
|
||
{ordinal(draftPosition)}
|
||
</span>
|
||
}
|
||
/>
|
||
</>
|
||
)}
|
||
</div>
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
function CompletedRow({
|
||
leagueId,
|
||
leagueName,
|
||
completionPercentage = 0,
|
||
}: LeagueRowProps) {
|
||
return (
|
||
<Link
|
||
to={`/leagues/${leagueId}`}
|
||
className="flex items-center gap-3 rounded-lg bg-card px-3 py-3 sm:px-5 sm:py-4 hover:bg-white/[0.06] transition-colors"
|
||
>
|
||
<LeagueAvatar leagueId={leagueId} leagueName={leagueName} />
|
||
<div className="flex-1 min-w-0">
|
||
<p className="font-semibold leading-tight truncate">{leagueName}</p>
|
||
<SeasonProgress pct={completionPercentage} status="completed" />
|
||
</div>
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
// ─── Commish-only row ─────────────────────────────────────────────────────────
|
||
|
||
function CommishOnlyRow({ leagueId, leagueName }: Pick<LeagueRowProps, "leagueId" | "leagueName">) {
|
||
return (
|
||
<Link
|
||
to={`/leagues/${leagueId}`}
|
||
className="flex items-center gap-3 rounded-lg bg-card px-3 py-3 sm:px-5 sm:py-4 hover:bg-white/[0.06] transition-colors"
|
||
>
|
||
<LeagueAvatar leagueId={leagueId} leagueName={leagueName} />
|
||
<div className="min-w-0">
|
||
<p className="font-semibold leading-tight truncate">{leagueName}</p>
|
||
<p className="text-xs text-muted-foreground mt-0.5">Commissioner</p>
|
||
</div>
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
// ─── Public export ────────────────────────────────────────────────────────────
|
||
|
||
export function LeagueRow(props: LeagueRowProps) {
|
||
if (props.status === "draft") return <DraftRow {...props} />;
|
||
if (props.isCommishOnly) return <CommishOnlyRow {...props} />;
|
||
if (props.status === "active") return <ActiveRow {...props} />;
|
||
if (props.status === "pre_draft") return <PreDraftRow {...props} />;
|
||
return <CompletedRow {...props} />;
|
||
}
|