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 {
|
||||
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}`;
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type { CalendarPanelEvent } from "./UpcomingCalendarPanel";
|
|||
|
||||
interface Props {
|
||||
events: CalendarPanelEvent[];
|
||||
title?: string;
|
||||
limit?: number;
|
||||
viewAllUrl?: string;
|
||||
emptyMessage?: string;
|
||||
|
|
@ -147,7 +148,7 @@ export function EventRow({
|
|||
</div>
|
||||
|
||||
{/* 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 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5" suppressHydrationWarning>
|
||||
|
|
@ -173,11 +174,11 @@ export function EventRow({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="w-px self-stretch bg-border shrink-0" />
|
||||
{/* Divider — hidden on mobile where layout is stacked */}
|
||||
<div className="hidden sm:block w-px self-stretch bg-border shrink-0" />
|
||||
|
||||
{/* Right: participants per league */}
|
||||
<div className="shrink-0">
|
||||
<div className="sm:shrink-0 min-w-0">
|
||||
<ParticipantsByLeague
|
||||
leagues={event.leagues}
|
||||
isAllCompete={event.isAllCompete}
|
||||
|
|
@ -199,6 +200,7 @@ export function EventRow({
|
|||
|
||||
export function UpcomingEventsCard({
|
||||
events,
|
||||
title = "My Upcoming Events",
|
||||
limit,
|
||||
viewAllUrl,
|
||||
emptyMessage,
|
||||
|
|
@ -220,7 +222,7 @@ export function UpcomingEventsCard({
|
|||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
{viewAllUrl && (
|
||||
<Link
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { database } from "~/database/context";
|
||||
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: {
|
||||
seasonId: string;
|
||||
|
|
@ -103,6 +104,129 @@ export async function getDraftedParticipantsBySportsSeason(
|
|||
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) {
|
||||
const db = database();
|
||||
await db
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { findCurrentSeasonWithSports } from "~/models/season";
|
|||
import { getSeasonStandings } from "~/models/standings";
|
||||
import { getUpcomingEventsForDraftedParticipants } from "~/models/scoring-event";
|
||||
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 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 dateToStr = addDays(today, 30).toISOString().split("T")[0];
|
||||
|
||||
const participantsBySportsSeason = myTeam && season
|
||||
? await getDraftedParticipantsBySportsSeason(myTeam.id, season.id)
|
||||
: new Map<string, Array<{ id: string; name: string }>>();
|
||||
const [participantsBySportsSeason, participantsWithPoints]: [
|
||||
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 dateTo = new Date(dateToStr + "T23:59:59.999Z");
|
||||
|
|
@ -122,6 +128,7 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
const sportsSeasons = await Promise.all(
|
||||
rawSportsSeasons.map(async (ss) => {
|
||||
const draftedParticipants = participantsBySportsSeason.get(ss.id) ?? [];
|
||||
const draftedParticipantsWithPoints = participantsWithPoints.get(ss.id) ?? [];
|
||||
const upcomingParticipantEvents =
|
||||
draftedParticipants.length > 0
|
||||
? await getUpcomingEventsForDraftedParticipants(
|
||||
|
|
@ -166,6 +173,7 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
scoringPattern: ss.scoringPattern,
|
||||
sport: ss.sport,
|
||||
upcomingParticipantEvents,
|
||||
draftedParticipantsWithPoints,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
|
@ -179,6 +187,8 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
sportName: ss.sport.name,
|
||||
sportSeasonName: ss.name,
|
||||
sportsSeasonPageUrl: `/leagues/${leagueId}/sports-seasons/${ss.id}`,
|
||||
leagueId,
|
||||
leagueName: league.name,
|
||||
}))
|
||||
)
|
||||
.toSorted((a, b) => toEventSortKey(a).localeCompare(toEventSortKey(b)));
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ import {
|
|||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { SportSeasonCard } from "~/components/sports/SportSeasonCard";
|
||||
import { UpcomingCalendarPanel } from "~/components/sport-season/UpcomingCalendarPanel";
|
||||
import { SportsSeasonsSummary } from "~/components/league/SportsSeasonsSummary";
|
||||
import { UpcomingEventsCard } from "~/components/sport-season/UpcomingEventsCard";
|
||||
import { buildTiedRankChecker, getDisplayRank } from "~/lib/standings-display";
|
||||
import { StandingsPreview, type StandingsPreviewEntry } from "~/components/league/StandingsPreview";
|
||||
import { formatAuditDetail } from "~/lib/audit-log-display";
|
||||
|
|
@ -195,30 +195,18 @@ fullStandingsHref={`/leagues/${league.id}/standings/${season.id}`}
|
|||
|
||||
{/* Sports Seasons Section */}
|
||||
{season && sortedSportsSeasons.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sports Seasons</CardTitle>
|
||||
<CardDescription>
|
||||
{sortedSportsSeasons.length} sport{sortedSportsSeasons.length !== 1 ? "s" : ""} in this season
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{sortedSportsSeasons.map((sportSeason) => (
|
||||
<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>
|
||||
<SportsSeasonsSummary
|
||||
leagueId={league.id}
|
||||
sportsSeasons={sortedSportsSeasons.map((ss) => ({
|
||||
id: ss.id,
|
||||
sportName: ss.sport.name,
|
||||
seasonName: ss.name,
|
||||
status: ss.status,
|
||||
scoringPattern: ss.scoringPattern,
|
||||
upcomingParticipantEvents: ss.upcomingParticipantEvents,
|
||||
draftedParticipants: ss.draftedParticipantsWithPoints,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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 */}
|
||||
<div className="space-y-6">
|
||||
{upcomingCalendarEvents.length > 0 && (
|
||||
<UpcomingCalendarPanel
|
||||
<UpcomingEventsCard
|
||||
events={upcomingCalendarEvents}
|
||||
showLeague={false}
|
||||
title="Upcoming Events"
|
||||
limit={6}
|
||||
viewAllUrl={`/leagues/${league.id}/upcoming-events`}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue