Update components on league homepage.

This commit is contained in:
Chris Parsons 2026-04-14 09:39:13 -07:00
parent f9c03c8bf1
commit bc7a816beb
7 changed files with 678 additions and 39 deletions

View 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 },
],
},
],
},
};

View 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>
);
}

View file

@ -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 ──────────────────────────────────────────────────────────────

View file

@ -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

View file

@ -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

View file

@ -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)));

View file

@ -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`}
/>