diff --git a/app/components/league/SportsSeasonsSummary.stories.tsx b/app/components/league/SportsSeasonsSummary.stories.tsx new file mode 100644 index 0000000..3b4e227 --- /dev/null +++ b/app/components/league/SportsSeasonsSummary.stories.tsx @@ -0,0 +1,230 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SportsSeasonsSummary } from "./SportsSeasonsSummary"; +import type { SportSeasonSummaryItem } from "./SportsSeasonsSummary"; + +const meta: Meta = { + title: "League/SportsSeasonsSummary", + component: SportsSeasonsSummary, + parameters: { + layout: "padded", + }, + args: { + leagueId: "league-abc-123", + }, +}; + +export default meta; +type Story = StoryObj; + +// ─── 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 }, + ], + }, + ], + }, +}; diff --git a/app/components/league/SportsSeasonsSummary.tsx b/app/components/league/SportsSeasonsSummary.tsx new file mode 100644 index 0000000..543f122 --- /dev/null +++ b/app/components/league/SportsSeasonsSummary.tsx @@ -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 = { + 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 = { + 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 ( +
+ +
+ ); +} + +/** 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 ( +
+ + + + + + Active + +
+ ); + } + if (status === "upcoming") { + return ( +
+ + + Upcoming + +
+ ); + } + return ( +
+ + + Completed + +
+ ); +} + +// ─── Participant chip ───────────────────────────────────────────────────────── + +function ParticipantChip({ + participant, + scoringPattern, +}: { + participant: DraftedParticipantSummary; + scoringPattern?: string | null; +}) { + const pointsLabel = formatPoints(participant, scoringPattern); + return ( + + {participant.name} + {pointsLabel && ( + · {pointsLabel} + )} + + ); +} + +// ─── 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 = ( +
+ {/* Left: scoring pattern icon in black box */} + + + {/* Right: name + participants + next event */} +
+

{item.sportName}

+

{item.seasonName}

+ + {participants.length > 0 && ( +
+ {participants.map((p) => ( + + ))} +
+ )} + + {nextEvent && ( +
+ + + {formatNextEvent(nextEvent)} + +
+ )} +
+
+ ); + + return ( + + {inner} + + ); +} + +// ─── Section ────────────────────────────────────────────────────────────────── + +function Section({ + status, + items, + leagueId, +}: { + status: SectionStatus; + items: SportSeasonSummaryItem[]; + leagueId: string; +}) { + if (items.length === 0) return null; + + return ( +
+ +
+ {items.map((item) => ( + + ))} +
+
+ ); +} + +// ─── 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 ( + + +
+
+ +
+

Sports

+
+
+ {allSeasonsHref && ( + + )} +
+
+ + +
+ {active.length > 0 && upcoming.length > 0 && ( +
+ )} +
+ {(active.length > 0 || upcoming.length > 0) && completed.length > 0 && ( +
+ )} +
+ + + ); +} diff --git a/app/components/league/StandingsPreview.tsx b/app/components/league/StandingsPreview.tsx index 01c57ce..f951a53 100644 --- a/app/components/league/StandingsPreview.tsx +++ b/app/components/league/StandingsPreview.tsx @@ -111,7 +111,7 @@ const ROW_RANK_CLASSES: Record = { 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 ────────────────────────────────────────────────────────────── diff --git a/app/components/sport-season/UpcomingEventsCard.tsx b/app/components/sport-season/UpcomingEventsCard.tsx index 115b72e..d6d3e2e 100644 --- a/app/components/sport-season/UpcomingEventsCard.tsx +++ b/app/components/sport-season/UpcomingEventsCard.tsx @@ -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({
{/* Content */} -
+
{/* Left: date + name */}
@@ -173,11 +174,11 @@ export function EventRow({
- {/* Divider */} -
+ {/* Divider — hidden on mobile where layout is stacked */} +
{/* Right: participants per league */} -
+
- My Upcoming Events + {title}
{viewAllUrl && ( +): Promise> { + 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(); + const qpSeasonIds = new Set(); + const qpParticipantIds = new Set(); + + 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(); + 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(); // 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(); + + 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 diff --git a/app/routes/leagues/$leagueId.server.ts b/app/routes/leagues/$leagueId.server.ts index 338fabf..e5bad02 100644 --- a/app/routes/leagues/$leagueId.server.ts +++ b/app/routes/leagues/$leagueId.server.ts @@ -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>(); + const [participantsBySportsSeason, participantsWithPoints]: [ + Map>, + Map + ] = 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))); diff --git a/app/routes/leagues/$leagueId.tsx b/app/routes/leagues/$leagueId.tsx index b461590..0d62ca0 100644 --- a/app/routes/leagues/$leagueId.tsx +++ b/app/routes/leagues/$leagueId.tsx @@ -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 && ( - - - Sports Seasons - - {sortedSportsSeasons.length} sport{sortedSportsSeasons.length !== 1 ? "s" : ""} in this season - - - -
- {sortedSportsSeasons.map((sportSeason) => ( - - ))} -
-
-
+ ({ + 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 */}
{upcomingCalendarEvents.length > 0 && ( -