brackt/app/components/league/StandingsPreview.tsx
2026-04-17 14:17:49 -07:00

221 lines
7.5 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";
// ─── TeamAvatar ───────────────────────────────────────────────────────────────
const TEAM_AVATAR_COLORS = [
"#adf661",
"#2ce1c1",
"#8b5cf6",
"#f59e0b",
"#ef4444",
"#3b82f6",
];
function hashTeamId(id: string): number {
let hash = 0;
for (let i = 0; i < id.length; i++) {
hash = (hash * 31 + id.charCodeAt(i)) & 0xffff;
}
return hash;
}
function TeamAvatar({ teamId, teamName }: { teamId: string; teamName: string }) {
const color = TEAM_AVATAR_COLORS[hashTeamId(teamId) % TEAM_AVATAR_COLORS.length];
const initials =
teamName
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0].toUpperCase())
.join("") || "?";
return (
<div
role="img"
aria-label={teamName}
className="h-9 w-9 flex shrink-0 items-center justify-center font-bold text-sm"
style={{ backgroundColor: "#000", color }}
>
{initials}
</div>
);
}
// ─── Stat helpers (mirrors LeagueRow) ────────────────────────────────────────
function StatColumn({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="text-right shrink-0 flex-1 sm:flex-none">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{label}
</p>
<div className="flex items-baseline justify-end gap-1">{children}</div>
</div>
);
}
function StatDivider() {
return <div className="h-8 w-px bg-border shrink-0 self-center" />;
}
function RankChangeIndicator({ change }: { change: number }) {
if (change === 0) return null;
if (change > 0) {
return (
<span className="text-xs font-semibold text-primary" aria-label={`up ${change}`}>
{change}
</span>
);
}
return (
<span
className="text-xs font-semibold"
style={{ color: "var(--coral-accent, #ef4444)" }}
aria-label={`down ${Math.abs(change)}`}
>
{Math.abs(change)}
</span>
);
}
function PointChangeIndicator({ change }: { change: number }) {
const sign = change >= 0 ? "+" : "";
if (change >= 0) {
return <span className="text-xs font-semibold text-primary">{sign}{change.toFixed(1)}</span>;
}
return (
<span className="text-xs font-semibold" style={{ color: "var(--coral-accent, #ef4444)" }}>
{sign}{change.toFixed(1)}
</span>
);
}
// ─── 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 }: { entry: StandingsPreviewEntry }) {
return (
<>
{/* Left: avatar + name */}
<div className="flex items-center gap-3 flex-1 min-w-0">
<TeamAvatar teamId={entry.teamId} teamName={entry.teamName} />
<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">
<StatColumn label="Ranking">
<span className="text-2xl font-bold leading-none">{entry.displayRank}</span>
{entry.rankChange !== undefined && entry.rankChange !== 0 && (
<RankChangeIndicator change={entry.rankChange} />
)}
</StatColumn>
<StatDivider />
<StatColumn label="Points">
<span className="text-2xl font-bold leading-none text-electric">
{Math.round(entry.points).toLocaleString("en-US")}
</span>
{entry.pointChange !== undefined && entry.pointChange !== 0 && (
<PointChangeIndicator change={entry.pointChange} />
)}
</StatColumn>
</div>
</>
);
}
// ─── Public API ───────────────────────────────────────────────────────────────
export interface StandingsPreviewEntry {
teamId: string;
teamName: string;
ownerName?: string | null;
/** Pre-computed display rank, e.g. 1, "T2", "T5". Use getDisplayRank(). */
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;
}
export interface StandingsPreviewProps {
entries: StandingsPreviewEntry[];
description?: string;
fullStandingsHref?: string;
}
export function StandingsPreview({ entries, description, fullStandingsHref }: 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} />
</Link>
) : (
<div key={entry.teamId} className={rowClasses(entry.currentRank, false)}>
<RowContent entry={entry} />
</div>
)
)}
</div>
</CardContent>
</Card>
);
}