Update components on league homepage.
This commit is contained in:
parent
d744e0d858
commit
e93a7fcae2
7 changed files with 678 additions and 39 deletions
230
app/components/league/SportsSeasonsSummary.stories.tsx
Normal file
230
app/components/league/SportsSeasonsSummary.stories.tsx
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { SportsSeasonsSummary } from "./SportsSeasonsSummary";
|
||||||
|
import type { SportSeasonSummaryItem } from "./SportsSeasonsSummary";
|
||||||
|
|
||||||
|
const meta: Meta<typeof SportsSeasonsSummary> = {
|
||||||
|
title: "League/SportsSeasonsSummary",
|
||||||
|
component: SportsSeasonsSummary,
|
||||||
|
parameters: {
|
||||||
|
layout: "padded",
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
leagueId: "league-abc-123",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof SportsSeasonsSummary>;
|
||||||
|
|
||||||
|
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const nba: SportSeasonSummaryItem = {
|
||||||
|
id: "ss-nba",
|
||||||
|
sportName: "NBA",
|
||||||
|
seasonName: "2025 NBA Season",
|
||||||
|
status: "active",
|
||||||
|
scoringPattern: "playoff_bracket",
|
||||||
|
draftedParticipants: [
|
||||||
|
{ id: "p1", name: "LeBron James", earnedPoints: null, currentQP: null },
|
||||||
|
{ id: "p2", name: "Jayson Tatum", earnedPoints: 62.5, currentQP: null },
|
||||||
|
],
|
||||||
|
upcomingParticipantEvents: [
|
||||||
|
{
|
||||||
|
id: "ev-nba-1",
|
||||||
|
name: "Conference Semifinals",
|
||||||
|
matchLabel: "Game 2",
|
||||||
|
eventDate: null,
|
||||||
|
earliestGameTime: "2026-04-20T19:30:00.000Z",
|
||||||
|
eventType: "playoff_game",
|
||||||
|
sportsSeasonId: "ss-nba",
|
||||||
|
relevantParticipants: [{ id: "p1", name: "LeBron James" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const f1: SportSeasonSummaryItem = {
|
||||||
|
id: "ss-f1",
|
||||||
|
sportName: "F1",
|
||||||
|
seasonName: "2025 Formula 1 World Championship",
|
||||||
|
status: "active",
|
||||||
|
scoringPattern: "season_standings",
|
||||||
|
draftedParticipants: [
|
||||||
|
{ id: "p3", name: "Max Verstappen", earnedPoints: 250, currentQP: null },
|
||||||
|
{ id: "p4", name: "Charles Leclerc", earnedPoints: 125, currentQP: null },
|
||||||
|
{ id: "p5", name: "Fernando Alonso", earnedPoints: 62.5, currentQP: null },
|
||||||
|
{ id: "p6", name: "Lewis Hamilton", earnedPoints: 62.5, currentQP: null },
|
||||||
|
],
|
||||||
|
upcomingParticipantEvents: [
|
||||||
|
{
|
||||||
|
id: "ev-f1-1",
|
||||||
|
name: "Monaco Grand Prix",
|
||||||
|
matchLabel: null,
|
||||||
|
eventDate: "2026-05-25",
|
||||||
|
earliestGameTime: null,
|
||||||
|
eventType: "race",
|
||||||
|
sportsSeasonId: "ss-f1",
|
||||||
|
relevantParticipants: [
|
||||||
|
{ id: "p3", name: "Max Verstappen" },
|
||||||
|
{ id: "p4", name: "Charles Leclerc" },
|
||||||
|
{ id: "p5", name: "Fernando Alonso" },
|
||||||
|
{ id: "p6", name: "Lewis Hamilton" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const golf: SportSeasonSummaryItem = {
|
||||||
|
id: "ss-golf",
|
||||||
|
sportName: "Golf",
|
||||||
|
seasonName: "2025 Major Season",
|
||||||
|
status: "active",
|
||||||
|
scoringPattern: "qualifying_points",
|
||||||
|
draftedParticipants: [
|
||||||
|
{ id: "p7", name: "Scottie Scheffler", earnedPoints: null, currentQP: 550 },
|
||||||
|
{ id: "p8", name: "Rory McIlroy", earnedPoints: null, currentQP: 325 },
|
||||||
|
],
|
||||||
|
upcomingParticipantEvents: [
|
||||||
|
{
|
||||||
|
id: "ev-golf-1",
|
||||||
|
name: "US Open",
|
||||||
|
matchLabel: null,
|
||||||
|
eventDate: "2026-06-15",
|
||||||
|
earliestGameTime: null,
|
||||||
|
eventType: "tournament",
|
||||||
|
sportsSeasonId: "ss-golf",
|
||||||
|
relevantParticipants: [
|
||||||
|
{ id: "p7", name: "Scottie Scheffler" },
|
||||||
|
{ id: "p8", name: "Rory McIlroy" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const darts: SportSeasonSummaryItem = {
|
||||||
|
id: "ss-darts",
|
||||||
|
sportName: "Darts",
|
||||||
|
seasonName: "PDC World Darts Championship 2025",
|
||||||
|
status: "completed",
|
||||||
|
scoringPattern: "playoff_bracket",
|
||||||
|
draftedParticipants: [
|
||||||
|
{ id: "p9", name: "Michael van Gerwen", earnedPoints: 250, currentQP: null },
|
||||||
|
],
|
||||||
|
upcomingParticipantEvents: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const snooker: SportSeasonSummaryItem = {
|
||||||
|
id: "ss-snooker",
|
||||||
|
sportName: "Snooker",
|
||||||
|
seasonName: "2025 World Snooker Championship",
|
||||||
|
status: "upcoming",
|
||||||
|
scoringPattern: "playoff_bracket",
|
||||||
|
draftedParticipants: [
|
||||||
|
{ id: "p10", name: "Ronnie O'Sullivan", earnedPoints: null, currentQP: null },
|
||||||
|
{ id: "p11", name: "Judd Trump", earnedPoints: null, currentQP: null },
|
||||||
|
],
|
||||||
|
upcomingParticipantEvents: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const nhl: SportSeasonSummaryItem = {
|
||||||
|
id: "ss-nhl",
|
||||||
|
sportName: "NHL",
|
||||||
|
seasonName: "2024-25 NHL Season",
|
||||||
|
status: "active",
|
||||||
|
scoringPattern: "season_standings",
|
||||||
|
draftedParticipants: [
|
||||||
|
{ id: "p12", name: "Connor McDavid", earnedPoints: null, currentQP: null },
|
||||||
|
{ id: "p13", name: "Nathan MacKinnon", earnedPoints: 125, currentQP: null },
|
||||||
|
{ id: "p14", name: "David Pastrnak", earnedPoints: 62.5, currentQP: null },
|
||||||
|
],
|
||||||
|
upcomingParticipantEvents: [
|
||||||
|
{
|
||||||
|
id: "ev-nhl-1",
|
||||||
|
name: "Playoffs Round 1",
|
||||||
|
matchLabel: "Game 4",
|
||||||
|
eventDate: null,
|
||||||
|
earliestGameTime: "2026-04-21T23:00:00.000Z",
|
||||||
|
eventType: "playoff_game",
|
||||||
|
sportsSeasonId: "ss-nhl",
|
||||||
|
relevantParticipants: [{ id: "p12", name: "Connor McDavid" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Stories ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const AllStatuses: Story = {
|
||||||
|
args: {
|
||||||
|
sportsSeasons: [nba, golf, snooker, darts],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ActiveWithPoints: Story = {
|
||||||
|
name: "Active Sports — Mix of earned + pending points",
|
||||||
|
args: {
|
||||||
|
sportsSeasons: [nba, f1, nhl],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const QualifyingPoints: Story = {
|
||||||
|
name: "Qualifying Points Sport (Golf)",
|
||||||
|
args: {
|
||||||
|
sportsSeasons: [golf],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithViewAllLink: Story = {
|
||||||
|
args: {
|
||||||
|
sportsSeasons: [nba, f1, golf, snooker, darts],
|
||||||
|
allSeasonsHref: "/leagues/league-abc-123/sports-seasons",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ManyParticipants: Story = {
|
||||||
|
name: "Many Participants (overflow chip)",
|
||||||
|
args: {
|
||||||
|
sportsSeasons: [f1],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NoParticipants: Story = {
|
||||||
|
name: "No Drafted Participants (viewer not in league)",
|
||||||
|
args: {
|
||||||
|
sportsSeasons: [nba, snooker, darts].map((ss) => ({ ...ss, draftedParticipants: [] })),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CompletedWithPoints: Story = {
|
||||||
|
name: "Completed — Points Earned",
|
||||||
|
args: {
|
||||||
|
sportsSeasons: [
|
||||||
|
darts,
|
||||||
|
{ ...snooker, status: "completed" as const, draftedParticipants: [
|
||||||
|
{ id: "p10", name: "Ronnie O'Sullivan", earnedPoints: 125, currentQP: null },
|
||||||
|
]},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LargeLeague: Story = {
|
||||||
|
name: "Large League (6 sports)",
|
||||||
|
args: {
|
||||||
|
sportsSeasons: [nba, f1, nhl, golf, snooker, darts],
|
||||||
|
allSeasonsHref: "/leagues/league-abc-123/sports-seasons",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LongNames: Story = {
|
||||||
|
args: {
|
||||||
|
sportsSeasons: [
|
||||||
|
{
|
||||||
|
...f1,
|
||||||
|
sportName: "UEFA Champions League European Football",
|
||||||
|
seasonName: "2024/25 Group Stage through Final — Extended Edition",
|
||||||
|
draftedParticipants: [
|
||||||
|
{ id: "p3", name: "Erling Braut Haaland", earnedPoints: 250, currentQP: null },
|
||||||
|
{ id: "p4", name: "Vinicius José de Oliveira Junior", earnedPoints: 125, currentQP: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
285
app/components/league/SportsSeasonsSummary.tsx
Normal file
285
app/components/league/SportsSeasonsSummary.tsx
Normal file
|
|
@ -0,0 +1,285 @@
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { Calendar, CheckCircle2, ChevronRight, Clock, Layers, ListOrdered, SquareStack, Trophy } from "lucide-react";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader } from "~/components/ui/card";
|
||||||
|
import { GradientIcon } from "~/components/ui/GradientIcon";
|
||||||
|
import type { UpcomingParticipantEvent } from "~/models/scoring-event";
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface DraftedParticipantSummary {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/** Fantasy league points earned (position → scoring rules). null = no result yet. */
|
||||||
|
earnedPoints: number | null;
|
||||||
|
/** Accumulated qualifying points for qualifying_points sports. null for other sports. */
|
||||||
|
currentQP: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SportSeasonSummaryItem {
|
||||||
|
id: string;
|
||||||
|
sportName: string;
|
||||||
|
seasonName: string;
|
||||||
|
status: "upcoming" | "active" | "completed";
|
||||||
|
scoringPattern?: string | null;
|
||||||
|
upcomingParticipantEvents?: UpcomingParticipantEvent[];
|
||||||
|
/** All participants the current user drafted for this sport, with points data. */
|
||||||
|
draftedParticipants?: DraftedParticipantSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SportsSeasonsSummaryProps {
|
||||||
|
leagueId: string;
|
||||||
|
sportsSeasons: SportSeasonSummaryItem[];
|
||||||
|
/** Link to the full sports seasons list, if one exists. */
|
||||||
|
allSeasonsHref?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const STATUS_ORDER: Record<SportSeasonSummaryItem["status"], number> = {
|
||||||
|
active: 0,
|
||||||
|
upcoming: 1,
|
||||||
|
completed: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
function sortedByStatus(items: SportSeasonSummaryItem[]): SportSeasonSummaryItem[] {
|
||||||
|
return [...items].toSorted((a, b) => {
|
||||||
|
const diff = STATUS_ORDER[a.status] - STATUS_ORDER[b.status];
|
||||||
|
if (diff !== 0) return diff;
|
||||||
|
return a.sportName.localeCompare(b.sportName);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const PATTERN_ICON: Record<string, { icon: typeof ChevronRight; title: string }> = {
|
||||||
|
playoff_bracket: { icon: ChevronRight, title: "Bracket" },
|
||||||
|
season_standings: { icon: ListOrdered, title: "Season Standings" },
|
||||||
|
qualifying_points: { icon: SquareStack, title: "Qualifying Points" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function SportIcon({ pattern }: { pattern?: string | null }) {
|
||||||
|
const entry = pattern ? PATTERN_ICON[pattern] : null;
|
||||||
|
const Icon = entry?.icon ?? Trophy;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="h-9 w-9 shrink-0 flex items-center justify-center bg-black"
|
||||||
|
title={entry?.title}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format the next upcoming event into a short string. */
|
||||||
|
function formatNextEvent(event: UpcomingParticipantEvent): string {
|
||||||
|
const base = event.matchLabel
|
||||||
|
? `${event.name} — ${event.matchLabel}`
|
||||||
|
: event.name;
|
||||||
|
|
||||||
|
const date = event.earliestGameTime
|
||||||
|
? format(new Date(event.earliestGameTime), "MMM d")
|
||||||
|
: event.eventDate
|
||||||
|
? format(new Date(event.eventDate + "T00:00:00"), "MMM d")
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return date ? `${base} · ${date}` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPoints(p: DraftedParticipantSummary, scoringPattern?: string | null): string | null {
|
||||||
|
if (scoringPattern === "qualifying_points" && p.earnedPoints === null) {
|
||||||
|
if (!p.currentQP) return null;
|
||||||
|
return `${p.currentQP % 1 === 0 ? p.currentQP : p.currentQP.toFixed(1)} QP`;
|
||||||
|
}
|
||||||
|
if (p.earnedPoints === null) return null;
|
||||||
|
return `${Math.round(p.earnedPoints)} pts`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Section header ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type SectionStatus = "active" | "upcoming" | "completed";
|
||||||
|
|
||||||
|
function SectionHeader({ status }: { status: SectionStatus }) {
|
||||||
|
if (status === "active") {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 px-1 py-0.5">
|
||||||
|
<span className="relative flex h-2 w-2 shrink-0">
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-500 opacity-60" />
|
||||||
|
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-widest text-emerald-400">
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === "upcoming") {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 px-1 py-0.5">
|
||||||
|
<Clock className="h-3 w-3 text-electric shrink-0" />
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-widest text-electric">
|
||||||
|
Upcoming
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 px-1 py-0.5">
|
||||||
|
<CheckCircle2 className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||||
|
Completed
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Participant chip ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ParticipantChip({
|
||||||
|
participant,
|
||||||
|
scoringPattern,
|
||||||
|
}: {
|
||||||
|
participant: DraftedParticipantSummary;
|
||||||
|
scoringPattern?: string | null;
|
||||||
|
}) {
|
||||||
|
const pointsLabel = formatPoints(participant, scoringPattern);
|
||||||
|
return (
|
||||||
|
<Badge variant="secondary" className="text-xs h-5 px-1.5 shrink-0 gap-1">
|
||||||
|
<span>{participant.name}</span>
|
||||||
|
{pointsLabel && (
|
||||||
|
<span className="text-muted-foreground font-normal">· {pointsLabel}</span>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Single sport row ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SportRow({
|
||||||
|
item,
|
||||||
|
leagueId,
|
||||||
|
}: {
|
||||||
|
item: SportSeasonSummaryItem;
|
||||||
|
leagueId: string;
|
||||||
|
}) {
|
||||||
|
const participants = item.draftedParticipants ?? [];
|
||||||
|
const events = item.upcomingParticipantEvents ?? [];
|
||||||
|
const nextEvent = events[0] ?? null;
|
||||||
|
const isCompleted = item.status === "completed";
|
||||||
|
|
||||||
|
const inner = (
|
||||||
|
<div
|
||||||
|
className={`group flex gap-3 rounded-lg px-3 py-2.5 transition-colors ${
|
||||||
|
isCompleted
|
||||||
|
? "hover:bg-white/[0.04] opacity-70 hover:opacity-100"
|
||||||
|
: "hover:bg-white/[0.06]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Left: scoring pattern icon in black box */}
|
||||||
|
<SportIcon pattern={item.scoringPattern} />
|
||||||
|
|
||||||
|
{/* Right: name + participants + next event */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-semibold text-sm leading-tight truncate">{item.sportName}</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate mb-1.5">{item.seasonName}</p>
|
||||||
|
|
||||||
|
{participants.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{participants.map((p) => (
|
||||||
|
<ParticipantChip key={p.id} participant={p} scoringPattern={item.scoringPattern} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{nextEvent && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-1.5">
|
||||||
|
<Calendar className="h-3 w-3 text-electric shrink-0" />
|
||||||
|
<span className="truncate" suppressHydrationWarning>
|
||||||
|
{formatNextEvent(nextEvent)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/leagues/${leagueId}/sports-seasons/${item.id}`}
|
||||||
|
className="block"
|
||||||
|
aria-label={`${item.sportName} — ${item.seasonName}`}
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Section ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
status,
|
||||||
|
items,
|
||||||
|
leagueId,
|
||||||
|
}: {
|
||||||
|
status: SectionStatus;
|
||||||
|
items: SportSeasonSummaryItem[];
|
||||||
|
leagueId: string;
|
||||||
|
}) {
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<SectionHeader status={status} />
|
||||||
|
<div className="grid gap-0.5 sm:grid-cols-2">
|
||||||
|
{items.map((item) => (
|
||||||
|
<SportRow key={item.id} item={item} leagueId={leagueId} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function SportsSeasonsSummary({
|
||||||
|
leagueId,
|
||||||
|
sportsSeasons,
|
||||||
|
allSeasonsHref,
|
||||||
|
}: SportsSeasonsSummaryProps) {
|
||||||
|
const sorted = sortedByStatus(sportsSeasons);
|
||||||
|
const active = sorted.filter((s) => s.status === "active");
|
||||||
|
const upcoming = sorted.filter((s) => s.status === "upcoming");
|
||||||
|
const completed = sorted.filter((s) => s.status === "completed");
|
||||||
|
|
||||||
|
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={Layers} className="h-5 w-5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold leading-none tracking-tight">Sports</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{allSeasonsHref && (
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<Link to={allSeasonsHref}>View All</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="px-3 sm:px-6 space-y-3">
|
||||||
|
<Section status="active" items={active} leagueId={leagueId} />
|
||||||
|
{active.length > 0 && upcoming.length > 0 && (
|
||||||
|
<div className="border-t border-border/40" />
|
||||||
|
)}
|
||||||
|
<Section status="upcoming" items={upcoming} leagueId={leagueId} />
|
||||||
|
{(active.length > 0 || upcoming.length > 0) && completed.length > 0 && (
|
||||||
|
<div className="border-t border-border/40" />
|
||||||
|
)}
|
||||||
|
<Section status="completed" items={completed} leagueId={leagueId} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -111,7 +111,7 @@ const ROW_RANK_CLASSES: Record<number, string> = {
|
||||||
function rowClasses(currentRank: number | undefined, hasHref: boolean): string {
|
function rowClasses(currentRank: number | undefined, hasHref: boolean): string {
|
||||||
const podium = currentRank !== undefined ? ROW_RANK_CLASSES[currentRank] : undefined;
|
const podium = currentRank !== undefined ? ROW_RANK_CLASSES[currentRank] : undefined;
|
||||||
const base = podium ?? `bg-white/[0.04] ${hasHref ? "hover:bg-white/[0.07]" : ""}`;
|
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}`;
|
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 ──────────────────────────────────────────────────────────────
|
// ─── Row content ──────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import type { CalendarPanelEvent } from "./UpcomingCalendarPanel";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
events: CalendarPanelEvent[];
|
events: CalendarPanelEvent[];
|
||||||
|
title?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
viewAllUrl?: string;
|
viewAllUrl?: string;
|
||||||
emptyMessage?: string;
|
emptyMessage?: string;
|
||||||
|
|
@ -147,7 +148,7 @@ export function EventRow({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className={`flex flex-1 min-w-0 items-center gap-3 ${isLast ? "" : "pb-10"}`}>
|
<div className={`flex flex-1 min-w-0 flex-col sm:flex-row sm:items-center gap-2 sm:gap-3 ${isLast ? "" : "pb-10"}`}>
|
||||||
{/* Left: date + name */}
|
{/* Left: date + name */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-0.5" suppressHydrationWarning>
|
<div className="flex items-center gap-2 mb-0.5" suppressHydrationWarning>
|
||||||
|
|
@ -173,11 +174,11 @@ export function EventRow({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Divider */}
|
{/* Divider — hidden on mobile where layout is stacked */}
|
||||||
<div className="w-px self-stretch bg-border shrink-0" />
|
<div className="hidden sm:block w-px self-stretch bg-border shrink-0" />
|
||||||
|
|
||||||
{/* Right: participants per league */}
|
{/* Right: participants per league */}
|
||||||
<div className="shrink-0">
|
<div className="sm:shrink-0 min-w-0">
|
||||||
<ParticipantsByLeague
|
<ParticipantsByLeague
|
||||||
leagues={event.leagues}
|
leagues={event.leagues}
|
||||||
isAllCompete={event.isAllCompete}
|
isAllCompete={event.isAllCompete}
|
||||||
|
|
@ -199,6 +200,7 @@ export function EventRow({
|
||||||
|
|
||||||
export function UpcomingEventsCard({
|
export function UpcomingEventsCard({
|
||||||
events,
|
events,
|
||||||
|
title = "My Upcoming Events",
|
||||||
limit,
|
limit,
|
||||||
viewAllUrl,
|
viewAllUrl,
|
||||||
emptyMessage,
|
emptyMessage,
|
||||||
|
|
@ -220,7 +222,7 @@ export function UpcomingEventsCard({
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<GradientIcon icon={Clock} className="h-5 w-5 shrink-0" />
|
<GradientIcon icon={Clock} className="h-5 w-5 shrink-0" />
|
||||||
<span className="text-xl font-bold leading-none tracking-tight">My Upcoming Events</span>
|
<span className="text-xl font-bold leading-none tracking-tight">{title}</span>
|
||||||
</div>
|
</div>
|
||||||
{viewAllUrl && (
|
{viewAllUrl && (
|
||||||
<Link
|
<Link
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and, inArray } from "drizzle-orm";
|
||||||
|
import { getScoringRules, calculateFantasyPoints, calculateBracketPoints } from "./scoring-rules";
|
||||||
|
|
||||||
export async function createDraftPick(data: {
|
export async function createDraftPick(data: {
|
||||||
seasonId: string;
|
seasonId: string;
|
||||||
|
|
@ -103,6 +104,129 @@ export async function getDraftedParticipantsBySportsSeason(
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DraftedParticipantWithPoints {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/** Fantasy league points earned (position → scoring rules). null = no result yet. */
|
||||||
|
earnedPoints: number | null;
|
||||||
|
/** Accumulated qualifying points for qualifying_points sports. null for other sports. */
|
||||||
|
currentQP: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a team's drafted participants with their current earned points, grouped by
|
||||||
|
* sports season. Used for the league home summary card.
|
||||||
|
*
|
||||||
|
* - playoff_bracket / season_standings: earnedPoints = finalPosition → scoring rules
|
||||||
|
* - qualifying_points (active): currentQP = accumulated QP from participantQualifyingTotals
|
||||||
|
* - qualifying_points (finalized): earnedPoints = finalPosition → scoring rules (participantResults written)
|
||||||
|
* - No EV / projected points — actual earned only.
|
||||||
|
*/
|
||||||
|
export async function getDraftedParticipantsWithPoints(
|
||||||
|
teamId: string,
|
||||||
|
seasonId: string,
|
||||||
|
providedDb?: ReturnType<typeof database>
|
||||||
|
): Promise<Map<string, DraftedParticipantWithPoints[]>> {
|
||||||
|
const db = providedDb || database();
|
||||||
|
|
||||||
|
const scoringRules = await getScoringRules(seasonId, db);
|
||||||
|
if (!scoringRules) return new Map();
|
||||||
|
|
||||||
|
// One query: picks → participant → sportsSeason (scoringPattern) + results (position)
|
||||||
|
const picks = await db.query.draftPicks.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(schema.draftPicks.teamId, teamId),
|
||||||
|
eq(schema.draftPicks.seasonId, seasonId)
|
||||||
|
),
|
||||||
|
columns: {},
|
||||||
|
with: {
|
||||||
|
participant: {
|
||||||
|
columns: { id: true, name: true, sportsSeasonId: true },
|
||||||
|
with: {
|
||||||
|
sportsSeason: { columns: { id: true, scoringPattern: true } },
|
||||||
|
results: { columns: { finalPosition: true }, limit: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Collect unique sportsSeasonIds per scoring pattern
|
||||||
|
const bracketSeasonIds = new Set<string>();
|
||||||
|
const qpSeasonIds = new Set<string>();
|
||||||
|
const qpParticipantIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const pick of picks) {
|
||||||
|
const pattern = pick.participant.sportsSeason.scoringPattern;
|
||||||
|
const ssId = pick.participant.sportsSeasonId;
|
||||||
|
if (pattern === "playoff_bracket") bracketSeasonIds.add(ssId);
|
||||||
|
if (pattern === "qualifying_points") {
|
||||||
|
qpSeasonIds.add(ssId);
|
||||||
|
qpParticipantIds.add(pick.participant.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch-fetch bracket template IDs (one per sports season)
|
||||||
|
const bracketTemplateMap = new Map<string, string | null>();
|
||||||
|
if (bracketSeasonIds.size > 0) {
|
||||||
|
const events = await db.query.scoringEvents.findMany({
|
||||||
|
where: inArray(schema.scoringEvents.sportsSeasonId, [...bracketSeasonIds]),
|
||||||
|
columns: { sportsSeasonId: true, bracketTemplateId: true },
|
||||||
|
});
|
||||||
|
for (const ev of events) {
|
||||||
|
if (!bracketTemplateMap.has(ev.sportsSeasonId)) {
|
||||||
|
bracketTemplateMap.set(ev.sportsSeasonId, ev.bracketTemplateId ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch-fetch QP totals for qualifying_points participants
|
||||||
|
const qpMap = new Map<string, number>(); // participantId → totalQP
|
||||||
|
if (qpParticipantIds.size > 0 && qpSeasonIds.size > 0) {
|
||||||
|
const totals = await db.query.participantQualifyingTotals.findMany({
|
||||||
|
where: and(
|
||||||
|
inArray(schema.participantQualifyingTotals.participantId, [...qpParticipantIds]),
|
||||||
|
inArray(schema.participantQualifyingTotals.sportsSeasonId, [...qpSeasonIds])
|
||||||
|
),
|
||||||
|
columns: { participantId: true, totalQualifyingPoints: true },
|
||||||
|
});
|
||||||
|
for (const row of totals) {
|
||||||
|
qpMap.set(row.participantId, parseFloat(row.totalQualifyingPoints));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assemble result grouped by sportsSeasonId
|
||||||
|
const result = new Map<string, DraftedParticipantWithPoints[]>();
|
||||||
|
|
||||||
|
for (const pick of picks) {
|
||||||
|
const { id, name, sportsSeasonId } = pick.participant;
|
||||||
|
const pattern = pick.participant.sportsSeason.scoringPattern;
|
||||||
|
const resultRow = pick.participant.results[0] ?? null;
|
||||||
|
|
||||||
|
let earnedPoints: number | null = null;
|
||||||
|
let currentQP: number | null = null;
|
||||||
|
|
||||||
|
if (pattern === "qualifying_points" && (resultRow?.finalPosition === null || resultRow?.finalPosition === undefined)) {
|
||||||
|
// Active QP season: show accumulated qualifying points
|
||||||
|
currentQP = qpMap.get(id) ?? null;
|
||||||
|
} else if (resultRow?.finalPosition !== null && resultRow?.finalPosition !== undefined) {
|
||||||
|
// Finalized result for any pattern (including finalized QP seasons)
|
||||||
|
earnedPoints = pattern === "playoff_bracket"
|
||||||
|
? calculateBracketPoints(
|
||||||
|
resultRow.finalPosition,
|
||||||
|
scoringRules,
|
||||||
|
bracketTemplateMap.get(sportsSeasonId) ?? null
|
||||||
|
)
|
||||||
|
: calculateFantasyPoints(resultRow.finalPosition, scoringRules);
|
||||||
|
}
|
||||||
|
|
||||||
|
const arr = result.get(sportsSeasonId) ?? [];
|
||||||
|
result.set(sportsSeasonId, arr);
|
||||||
|
arr.push({ id, name, earnedPoints, currentQP });
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteAllDraftPicks(seasonId: string) {
|
export async function deleteAllDraftPicks(seasonId: string) {
|
||||||
const db = database();
|
const db = database();
|
||||||
await db
|
await db
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import { findCurrentSeasonWithSports } from "~/models/season";
|
||||||
import { getSeasonStandings } from "~/models/standings";
|
import { getSeasonStandings } from "~/models/standings";
|
||||||
import { getUpcomingEventsForDraftedParticipants } from "~/models/scoring-event";
|
import { getUpcomingEventsForDraftedParticipants } from "~/models/scoring-event";
|
||||||
import { getUpcomingGroupStageMatchesForParticipants } from "~/models/group-stage-match";
|
import { getUpcomingGroupStageMatchesForParticipants } from "~/models/group-stage-match";
|
||||||
import { getDraftedParticipantsBySportsSeason } from "~/models/draft-pick";
|
import { getDraftedParticipantsBySportsSeason, getDraftedParticipantsWithPoints, type DraftedParticipantWithPoints } from "~/models/draft-pick";
|
||||||
import { getAuditLogForSeason } from "~/models/audit-log";
|
import { getAuditLogForSeason } from "~/models/audit-log";
|
||||||
import type { Route } from "./+types/$leagueId";
|
import type { Route } from "./+types/$leagueId";
|
||||||
|
|
||||||
|
|
@ -112,9 +112,15 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
const dateFromStr = subDays(today, 1).toISOString().split("T")[0];
|
const dateFromStr = subDays(today, 1).toISOString().split("T")[0];
|
||||||
const dateToStr = addDays(today, 30).toISOString().split("T")[0];
|
const dateToStr = addDays(today, 30).toISOString().split("T")[0];
|
||||||
|
|
||||||
const participantsBySportsSeason = myTeam && season
|
const [participantsBySportsSeason, participantsWithPoints]: [
|
||||||
? await getDraftedParticipantsBySportsSeason(myTeam.id, season.id)
|
Map<string, Array<{ id: string; name: string }>>,
|
||||||
: new Map<string, Array<{ id: string; name: string }>>();
|
Map<string, DraftedParticipantWithPoints[]>
|
||||||
|
] = myTeam && season
|
||||||
|
? await Promise.all([
|
||||||
|
getDraftedParticipantsBySportsSeason(myTeam.id, season.id),
|
||||||
|
getDraftedParticipantsWithPoints(myTeam.id, season.id),
|
||||||
|
])
|
||||||
|
: [new Map(), new Map()];
|
||||||
|
|
||||||
const dateFrom = new Date(dateFromStr + "T00:00:00.000Z");
|
const dateFrom = new Date(dateFromStr + "T00:00:00.000Z");
|
||||||
const dateTo = new Date(dateToStr + "T23:59:59.999Z");
|
const dateTo = new Date(dateToStr + "T23:59:59.999Z");
|
||||||
|
|
@ -122,6 +128,7 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
const sportsSeasons = await Promise.all(
|
const sportsSeasons = await Promise.all(
|
||||||
rawSportsSeasons.map(async (ss) => {
|
rawSportsSeasons.map(async (ss) => {
|
||||||
const draftedParticipants = participantsBySportsSeason.get(ss.id) ?? [];
|
const draftedParticipants = participantsBySportsSeason.get(ss.id) ?? [];
|
||||||
|
const draftedParticipantsWithPoints = participantsWithPoints.get(ss.id) ?? [];
|
||||||
const upcomingParticipantEvents =
|
const upcomingParticipantEvents =
|
||||||
draftedParticipants.length > 0
|
draftedParticipants.length > 0
|
||||||
? await getUpcomingEventsForDraftedParticipants(
|
? await getUpcomingEventsForDraftedParticipants(
|
||||||
|
|
@ -166,6 +173,7 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
scoringPattern: ss.scoringPattern,
|
scoringPattern: ss.scoringPattern,
|
||||||
sport: ss.sport,
|
sport: ss.sport,
|
||||||
upcomingParticipantEvents,
|
upcomingParticipantEvents,
|
||||||
|
draftedParticipantsWithPoints,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
@ -179,6 +187,8 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
sportName: ss.sport.name,
|
sportName: ss.sport.name,
|
||||||
sportSeasonName: ss.name,
|
sportSeasonName: ss.name,
|
||||||
sportsSeasonPageUrl: `/leagues/${leagueId}/sports-seasons/${ss.id}`,
|
sportsSeasonPageUrl: `/leagues/${leagueId}/sports-seasons/${ss.id}`,
|
||||||
|
leagueId,
|
||||||
|
leagueName: league.name,
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
.toSorted((a, b) => toEventSortKey(a).localeCompare(toEventSortKey(b)));
|
.toSorted((a, b) => toEventSortKey(a).localeCompare(toEventSortKey(b)));
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ import {
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "~/components/ui/card";
|
} from "~/components/ui/card";
|
||||||
import { SportSeasonCard } from "~/components/sports/SportSeasonCard";
|
import { SportsSeasonsSummary } from "~/components/league/SportsSeasonsSummary";
|
||||||
import { UpcomingCalendarPanel } from "~/components/sport-season/UpcomingCalendarPanel";
|
import { UpcomingEventsCard } from "~/components/sport-season/UpcomingEventsCard";
|
||||||
import { buildTiedRankChecker, getDisplayRank } from "~/lib/standings-display";
|
import { buildTiedRankChecker, getDisplayRank } from "~/lib/standings-display";
|
||||||
import { StandingsPreview, type StandingsPreviewEntry } from "~/components/league/StandingsPreview";
|
import { StandingsPreview, type StandingsPreviewEntry } from "~/components/league/StandingsPreview";
|
||||||
import { formatAuditDetail } from "~/lib/audit-log-display";
|
import { formatAuditDetail } from "~/lib/audit-log-display";
|
||||||
|
|
@ -195,30 +195,18 @@ fullStandingsHref={`/leagues/${league.id}/standings/${season.id}`}
|
||||||
|
|
||||||
{/* Sports Seasons Section */}
|
{/* Sports Seasons Section */}
|
||||||
{season && sortedSportsSeasons.length > 0 && (
|
{season && sortedSportsSeasons.length > 0 && (
|
||||||
<Card>
|
<SportsSeasonsSummary
|
||||||
<CardHeader>
|
leagueId={league.id}
|
||||||
<CardTitle>Sports Seasons</CardTitle>
|
sportsSeasons={sortedSportsSeasons.map((ss) => ({
|
||||||
<CardDescription>
|
id: ss.id,
|
||||||
{sortedSportsSeasons.length} sport{sortedSportsSeasons.length !== 1 ? "s" : ""} in this season
|
sportName: ss.sport.name,
|
||||||
</CardDescription>
|
seasonName: ss.name,
|
||||||
</CardHeader>
|
status: ss.status,
|
||||||
<CardContent>
|
scoringPattern: ss.scoringPattern,
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
upcomingParticipantEvents: ss.upcomingParticipantEvents,
|
||||||
{sortedSportsSeasons.map((sportSeason) => (
|
draftedParticipants: ss.draftedParticipantsWithPoints,
|
||||||
<SportSeasonCard
|
}))}
|
||||||
key={sportSeason.id}
|
/>
|
||||||
leagueId={league.id}
|
|
||||||
sportSeasonId={sportSeason.id}
|
|
||||||
sportName={sportSeason.sport.name}
|
|
||||||
seasonName={sportSeason.name}
|
|
||||||
status={sportSeason.status}
|
|
||||||
scoringPattern={sportSeason.scoringPattern}
|
|
||||||
upcomingParticipantEvents={sportSeason.upcomingParticipantEvents}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isUserCommissioner && season && season.status === "pre_draft" && availableTeamCount > 0 && (
|
{isUserCommissioner && season && season.status === "pre_draft" && availableTeamCount > 0 && (
|
||||||
|
|
@ -358,9 +346,9 @@ fullStandingsHref={`/leagues/${league.id}/standings/${season.id}`}
|
||||||
{/* Right Column - 1/3 width on desktop */}
|
{/* Right Column - 1/3 width on desktop */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{upcomingCalendarEvents.length > 0 && (
|
{upcomingCalendarEvents.length > 0 && (
|
||||||
<UpcomingCalendarPanel
|
<UpcomingEventsCard
|
||||||
events={upcomingCalendarEvents}
|
events={upcomingCalendarEvents}
|
||||||
showLeague={false}
|
title="Upcoming Events"
|
||||||
limit={6}
|
limit={6}
|
||||||
viewAllUrl={`/leagues/${league.id}/upcoming-events`}
|
viewAllUrl={`/leagues/${league.id}/upcoming-events`}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue