diff --git a/.storybook/preview.ts b/.storybook/preview.tsx similarity index 56% rename from .storybook/preview.ts rename to .storybook/preview.tsx index 60a654d..0f99854 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.tsx @@ -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) => ( + <> + + + +); 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; \ No newline at end of file +export default preview; diff --git a/app/components/league/StandingsPreview.stories.tsx b/app/components/league/StandingsPreview.stories.tsx new file mode 100644 index 0000000..6799c0e --- /dev/null +++ b/app/components/league/StandingsPreview.stories.tsx @@ -0,0 +1,265 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { StandingsPreview } from "./StandingsPreview"; + +const meta: Meta = { + title: "League/StandingsPreview", + component: StandingsPreview, + parameters: { + layout: "padded", + }, +}; + +export default meta; +type Story = StoryObj; + +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", + }, + ], + }, +}; diff --git a/app/components/league/StandingsPreview.tsx b/app/components/league/StandingsPreview.tsx new file mode 100644 index 0000000..01c57ce --- /dev/null +++ b/app/components/league/StandingsPreview.tsx @@ -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 ( +
+ {initials} +
+ ); +} + +// ─── Stat helpers (mirrors LeagueRow) ──────────────────────────────────────── + +function StatColumn({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+

+ {label} +

+
{children}
+
+ ); +} + +function StatDivider() { + return
; +} + +function RankChangeIndicator({ change }: { change: number }) { + if (change === 0) return null; + if (change > 0) { + return ( + + ▲{change} + + ); + } + return ( + + ▼{Math.abs(change)} + + ); +} + +function PointChangeIndicator({ change }: { change: number }) { + const sign = change >= 0 ? "+" : ""; + if (change >= 0) { + return {sign}{change.toFixed(1)}; + } + return ( + + {sign}{change.toFixed(1)} + + ); +} + +// ─── Row styles ─────────────────────────────────────────────────────────────── + +const ROW_RANK_CLASSES: Record = { + 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 */} +
+ +
+

{entry.teamName}

+ {entry.ownerName && ( +

{entry.ownerName}

+ )} +
+
+ + {/* Right: stats — second row on mobile */} +
+ + {entry.displayRank} + {entry.rankChange !== undefined && entry.rankChange !== 0 && ( + + )} + + + + + {Math.round(entry.points).toLocaleString("en-US")} + + {entry.pointChange !== undefined && entry.pointChange !== 0 && ( + + )} + +
+ + ); +} + +// ─── 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 (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; +} + +export interface StandingsPreviewProps { + entries: StandingsPreviewEntry[]; + description?: string; + fullStandingsHref?: string; +} + +export function StandingsPreview({ entries, description, fullStandingsHref }: StandingsPreviewProps) { + return ( + + +
+
+ +
+

Standings

+ {description && ( +

{description}

+ )} +
+
+ {fullStandingsHref && ( + + )} +
+
+ +
+ {entries.map((entry) => + entry.href ? ( + + + + ) : ( +
+ +
+ ) + )} +
+
+
+ ); +} diff --git a/app/components/ui/BracktGradients.tsx b/app/components/ui/BracktGradients.tsx index 8187870..d3e10e1 100644 --- a/app/components/ui/BracktGradients.tsx +++ b/app/components/ui/BracktGradients.tsx @@ -8,8 +8,10 @@ export function BracktGradients() { return ( - {/* Green (top) → Cyan (bottom) — the main Brackt brand gradient */} - + {/* Green (top) → Cyan (bottom) — the main Brackt brand gradient. + userSpaceOnUse + 0→24 matches the Lucide icon viewBox so horizontal + strokes (zero bounding-box height) don't produce a degenerate gradient. */} + diff --git a/app/routes/leagues/$leagueId.tsx b/app/routes/leagues/$leagueId.tsx index da96fd8..b461590 100644 --- a/app/routes/leagues/$leagueId.tsx +++ b/app/routes/leagues/$leagueId.tsx @@ -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) {
{/* Standings Panel - active/completed seasons */} {season && isActiveOrCompleted && ( - - -
-
- Standings - - {season.status === "completed" ? "Final standings" : "Current season standings"} - -
- -
-
- -
- {sortedTeams.map((team) => { - const standing = standingsMap.get(team.id); - const ownerName = team.ownerId ? ownerMap[team.ownerId] : null; - return ( -
-
- {getDisplayRank(standing, standings.length, standing ? isTiedRank(standing.currentRank) : false)} -
-
- -
-
- {standing ? (standing.actualPoints ?? standing.totalPoints) : 0} pts -
-
- ); - })} -
-
-
+ )} {/* Sports Seasons Section */}