Add StandingsPreview card component with podium row styling

- New StandingsPreview component with gold/silver/bronze row tints for
  top 3, team avatar, and LeagueRow-style stat columns (Ranking + Points)
  with rank and 7-day point change indicators
- Fix GradientIcon in Storybook by adding BracktGradients decorator to
  preview.tsx (renamed from .ts to support JSX)
- Fix degenerate SVG gradient on horizontal strokes by switching
  BracktGradients to gradientUnits="userSpaceOnUse" with Lucide-space
  coordinates (0→24)
- Revert erroneous fill: url(#gradient) from GradientIcon; stroke-only
  fix was sufficient once gradientUnits was corrected

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-04-13 22:28:20 -07:00
parent aacf0b29c8
commit f9c03c8bf1
5 changed files with 523 additions and 51 deletions

View file

@ -1,9 +1,18 @@
import React from 'react';
import { withRouter } from 'storybook-addon-remix-react-router';
import type { Preview } from '@storybook/react-vite'
import type { Preview, Decorator } from '@storybook/react-vite'
import '../app/app.css'
import { BracktGradients } from '../app/components/ui/BracktGradients';
const withBracktGradients: Decorator = (Story) => (
<>
<BracktGradients />
<Story />
</>
);
const preview: Preview = {
decorators: [withRouter], // This wraps all stories in a Router context
decorators: [withRouter, withBracktGradients],
parameters: {
controls: {
matchers: {
@ -21,4 +30,4 @@ const preview: Preview = {
},
};
export default preview;
export default preview;

View file

@ -0,0 +1,265 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { StandingsPreview } from "./StandingsPreview";
const meta: Meta<typeof StandingsPreview> = {
title: "League/StandingsPreview",
component: StandingsPreview,
parameters: {
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof StandingsPreview>;
export const TopThreePodium: Story = {
args: {
entries: [
{
teamId: "t1",
teamName: "Lightning Wolves",
ownerName: "alice",
displayRank: 1,
currentRank: 1,
points: 2810,
href: "/leagues/1/standings/1/teams/t1",
},
{
teamId: "t2",
teamName: "Shadow Hawks",
ownerName: "bob",
displayRank: 2,
currentRank: 2,
points: 2654.5,
href: "/leagues/1/standings/1/teams/t2",
},
{
teamId: "t3",
teamName: "Iron Eagles",
ownerName: "carol",
displayRank: 3,
currentRank: 3,
points: 2493,
href: "/leagues/1/standings/1/teams/t3",
},
],
},
};
export const FullLeague: Story = {
args: {
entries: [
{
teamId: "t1",
teamName: "Lightning Wolves",
ownerName: "alice",
displayRank: 1,
currentRank: 1,
points: 2810,
rankChange: 2,
pointChange: 87.5,
href: "/leagues/1/standings/1/teams/t1",
},
{
teamId: "t2",
teamName: "Shadow Hawks",
ownerName: "bob",
displayRank: 2,
currentRank: 2,
points: 2654.5,
rankChange: 0,
pointChange: 42.0,
href: "/leagues/1/standings/1/teams/t2",
},
{
teamId: "t3",
teamName: "Iron Eagles",
ownerName: "carol",
displayRank: 3,
currentRank: 3,
points: 2493,
rankChange: -1,
pointChange: -12.3,
href: "/leagues/1/standings/1/teams/t3",
},
{
teamId: "t4",
teamName: "Cyber Foxes",
ownerName: "dave",
displayRank: 4,
currentRank: 4,
points: 2311.8,
rankChange: 1,
pointChange: 23.1,
href: "/leagues/1/standings/1/teams/t4",
},
{
teamId: "t5",
teamName: "Neon Tigers",
ownerName: "eve",
displayRank: 5,
currentRank: 5,
points: 2108,
rankChange: -2,
pointChange: -55.0,
href: "/leagues/1/standings/1/teams/t5",
},
{
teamId: "t6",
teamName: "Phantom Sharks",
ownerName: "frank",
displayRank: 6,
currentRank: 6,
points: 1987.3,
rankChange: 0,
pointChange: 0,
href: "/leagues/1/standings/1/teams/t6",
},
{
teamId: "t7",
teamName: "Crimson Tide FC",
ownerName: "grace",
displayRank: 7,
currentRank: 7,
points: 1834,
href: "/leagues/1/standings/1/teams/t7",
},
{
teamId: "t8",
teamName: "Arctic Wolves United",
ownerName: "hank",
displayRank: 8,
currentRank: 8,
points: 1692.6,
href: "/leagues/1/standings/1/teams/t8",
},
],
},
};
export const WithTies: Story = {
args: {
entries: [
{
teamId: "t1",
teamName: "Lightning Wolves",
ownerName: "alice",
displayRank: "T1",
currentRank: 1,
points: 2810,
href: "/leagues/1/standings/1/teams/t1",
},
{
teamId: "t2",
teamName: "Shadow Hawks",
ownerName: "bob",
displayRank: "T1",
currentRank: 1,
points: 2810,
href: "/leagues/1/standings/1/teams/t2",
},
{
teamId: "t3",
teamName: "Iron Eagles",
ownerName: "carol",
displayRank: "T3",
currentRank: 3,
points: 2493,
href: "/leagues/1/standings/1/teams/t3",
},
{
teamId: "t4",
teamName: "Cyber Foxes",
ownerName: "dave",
displayRank: "T3",
currentRank: 3,
points: 2493,
href: "/leagues/1/standings/1/teams/t4",
},
],
},
};
export const AllUnranked: Story = {
args: {
entries: [
{
teamId: "t1",
teamName: "Lightning Wolves",
ownerName: "alice",
displayRank: "T1",
points: 0,
href: "/leagues/1/standings/1/teams/t1",
},
{
teamId: "t2",
teamName: "Shadow Hawks",
ownerName: "bob",
displayRank: "T1",
points: 0,
href: "/leagues/1/standings/1/teams/t2",
},
{
teamId: "t3",
teamName: "Iron Eagles",
ownerName: "carol",
displayRank: "T1",
points: 0,
href: "/leagues/1/standings/1/teams/t3",
},
],
},
};
export const NoOwnerNames: Story = {
args: {
entries: [
{
teamId: "t1",
teamName: "Lightning Wolves",
displayRank: 1,
currentRank: 1,
points: 2810,
},
{
teamId: "t2",
teamName: "Shadow Hawks",
displayRank: 2,
currentRank: 2,
points: 2654.5,
},
{
teamId: "t3",
teamName: "Iron Eagles",
displayRank: 3,
currentRank: 3,
points: 2493,
},
],
},
};
export const LongTeamNames: Story = {
args: {
entries: [
{
teamId: "t1",
teamName: "The Unstoppable Championship Winning Lightning Wolves",
ownerName: "alice_with_a_really_long_username",
displayRank: 1,
currentRank: 1,
points: 2810,
href: "/leagues/1/standings/1/teams/t1",
},
{
teamId: "t2",
teamName: "Shadow Hawks of the Northern Division",
ownerName: "bob",
displayRank: 2,
currentRank: 2,
points: 2654.5,
href: "/leagues/1/standings/1/teams/t2",
},
],
},
};

View file

@ -0,0 +1,221 @@
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-2.5 sm:px-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>
);
}

View file

@ -8,8 +8,10 @@ export function BracktGradients() {
return (
<svg width="0" height="0" aria-hidden className="absolute">
<defs>
{/* Green (top) → Cyan (bottom) — the main Brackt brand gradient */}
<linearGradient id="brackt-primary-gradient" x1="0" y1="0" x2="0" y2="1">
{/* Green (top) Cyan (bottom) the main Brackt brand gradient.
userSpaceOnUse + 024 matches the Lucide icon viewBox so horizontal
strokes (zero bounding-box height) don't produce a degenerate gradient. */}
<linearGradient id="brackt-primary-gradient" x1="0" y1="0" x2="0" y2="24" gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor="#adf661" />
<stop offset="100%" stopColor="#2ce1c1" />
</linearGradient>

View file

@ -14,8 +14,8 @@ import {
} from "~/components/ui/card";
import { SportSeasonCard } from "~/components/sports/SportSeasonCard";
import { UpcomingCalendarPanel } from "~/components/sport-season/UpcomingCalendarPanel";
import { TeamNameDisplay } from "~/components/ui/team-name-display";
import { buildTiedRankChecker, getDisplayRank } from "~/lib/standings-display";
import { StandingsPreview, type StandingsPreviewEntry } from "~/components/league/StandingsPreview";
import { formatAuditDetail } from "~/lib/audit-log-display";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
@ -97,6 +97,21 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
return rankA - rankB;
});
const standingsEntries: StandingsPreviewEntry[] = sortedTeams.map((team) => {
const standing = standingsMap.get(team.id);
return {
teamId: team.id,
teamName: team.name,
ownerName: team.ownerId ? ownerMap[team.ownerId] : null,
displayRank: getDisplayRank(standing, standings.length, standing ? isTiedRank(standing.currentRank) : false),
currentRank: standing?.currentRank,
points: standing ? (standing.actualPoints ?? standing.totalPoints) : 0,
href: season ? `/leagues/${league.id}/standings/${season.id}/teams/${team.id}` : undefined,
rankChange: standing?.rankChange,
pointChange: standing?.sevenDayPointChange,
};
});
// Pre-compute sorted sports seasons: active → upcoming → completed,
// season_standings last within active, then alphabetical by sport name
const sortedSportsSeasons = [...sportsSeasons].toSorted((a, b) => {
@ -171,51 +186,11 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
<div className="md:col-span-2 space-y-6">
{/* Standings Panel - active/completed seasons */}
{season && isActiveOrCompleted && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Standings</CardTitle>
<CardDescription>
{season.status === "completed" ? "Final standings" : "Current season standings"}
</CardDescription>
</div>
<Button variant="outline" size="sm" asChild>
<Link to={`/leagues/${league.id}/standings/${season.id}`}>
Full Standings
</Link>
</Button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-1">
{sortedTeams.map((team) => {
const standing = standingsMap.get(team.id);
const ownerName = team.ownerId ? ownerMap[team.ownerId] : null;
return (
<div
key={team.id}
className="flex items-center gap-3 py-2 border-b last:border-0"
>
<div className="w-8 text-center text-sm font-bold text-muted-foreground">
{getDisplayRank(standing, standings.length, standing ? isTiedRank(standing.currentRank) : false)}
</div>
<div className="flex-1 min-w-0">
<TeamNameDisplay
teamName={team.name}
ownerName={ownerName}
href={`/leagues/${league.id}/standings/${season.id}/teams/${team.id}`}
/>
</div>
<div className="text-sm font-medium tabular-nums">
{standing ? (standing.actualPoints ?? standing.totalPoints) : 0} pts
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
<StandingsPreview
entries={standingsEntries}
description={season.status === "completed" ? "Final standings" : undefined}
fullStandingsHref={`/leagues/${league.id}/standings/${season.id}`}
/>
)}
{/* Sports Seasons Section */}