Display upcoming matches (#141)

This commit is contained in:
Chris Parsons 2026-03-12 10:12:38 -07:00 committed by GitHub
parent f97ee4e4d8
commit 4fe76b6845
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1452 additions and 40 deletions

View file

@ -0,0 +1,144 @@
import { format } from "date-fns";
import { Calendar, Users } from "lucide-react";
import { Link } from "react-router";
import { Badge } from "~/components/ui/badge";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { formatEventDate } from "~/lib/date-utils";
import type { UpcomingParticipantEvent } from "~/models/scoring-event";
export interface CalendarPanelEvent extends UpcomingParticipantEvent {
sportName: string;
sportSeasonName: string;
/** Present when showing events across multiple leagues (home page) */
leagueName?: string;
leagueId?: string;
sportsSeasonPageUrl?: string;
}
interface Props {
events: CalendarPanelEvent[];
/** When true, renders a league name badge on each row */
showLeague?: boolean;
}
function ParticipantList({ participants }: { participants: Array<{ id: string; name: string }> }) {
const MAX_SHOWN = 3;
const shown = participants.slice(0, MAX_SHOWN);
const overflow = participants.length - MAX_SHOWN;
return (
<span className="flex flex-wrap items-center gap-1">
{shown.map((p) => (
<Badge key={p.id} variant="secondary" className="text-xs font-normal">
{p.name}
</Badge>
))}
{overflow > 0 && (
<Badge variant="outline" className="text-xs text-muted-foreground">
+{overflow} more
</Badge>
)}
</span>
);
}
function EventRow({ event, showLeague }: { event: CalendarPanelEvent; showLeague: boolean }) {
// Prefer game-level scheduledAt over event-level eventDate for display
const gameDate = event.earliestGameTime ? new Date(event.earliestGameTime) : null;
const dateStr = gameDate
? format(gameDate, "MMM d")
: formatEventDate(event.eventDate);
const participantCount = event.relevantParticipants.length;
const isAllCompete = event.eventType !== "playoff_game";
const displayName = event.matchLabel
? `${event.name}${event.matchLabel}`
: event.name;
const content = (
<div className="flex items-start gap-3 py-2.5 border-b last:border-0 group">
{/* Date pill */}
<div className="w-14 shrink-0 text-center">
<span className="text-xs font-semibold text-electric" suppressHydrationWarning>
{dateStr ?? "TBD"}
</span>
{gameDate && (
<p
className="text-xs text-muted-foreground leading-tight"
suppressHydrationWarning
>
{gameDate.toLocaleTimeString(undefined, {
hour: "numeric",
minute: "2-digit",
})}
</p>
)}
</div>
{/* Event details */}
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center flex-wrap gap-2">
<span className="text-sm font-medium truncate">{displayName}</span>
{showLeague && event.leagueName && (
<Badge variant="outline" className="text-xs shrink-0">
{event.leagueName}
</Badge>
)}
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="truncate">{event.sportName} · {event.sportSeasonName}</span>
</div>
<div className="flex items-center gap-1.5">
<Users className="h-3 w-3 text-muted-foreground shrink-0" />
{isAllCompete && participantCount > 1 ? (
<span className="text-xs text-muted-foreground">
{participantCount} of your picks
</span>
) : (
<ParticipantList participants={event.relevantParticipants} />
)}
</div>
</div>
</div>
);
if (event.sportsSeasonPageUrl) {
return (
<Link to={event.sportsSeasonPageUrl} className="block hover:bg-muted/40 -mx-2 px-2 rounded transition-colors">
{content}
</Link>
);
}
return content;
}
export function UpcomingCalendarPanel({ events, showLeague = false }: Props) {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Calendar className="h-4 w-4 text-electric" />
Upcoming Events
</CardTitle>
</CardHeader>
<CardContent>
{events.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
No upcoming events in the next 30 days.
</p>
) : (
<div>
{events.map((event) => (
<EventRow key={event.id} event={event} showLeague={showLeague} />
))}
</div>
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,217 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { BrowserRouter } from "react-router";
import { UpcomingCalendarPanel } from "../UpcomingCalendarPanel";
import type { CalendarPanelEvent } from "../UpcomingCalendarPanel";
function renderWithRouter(ui: React.ReactElement) {
return render(<BrowserRouter>{ui}</BrowserRouter>);
}
function makeEvent(overrides: Partial<CalendarPanelEvent> = {}): CalendarPanelEvent {
return {
id: overrides.id ?? "event-1",
name: overrides.name ?? "UCL Quarterfinals",
// Use explicit key check so callers can pass null to test the null-date path
eventDate: "eventDate" in overrides ? (overrides.eventDate as string | null) : "2025-04-09",
earliestGameTime: "earliestGameTime" in overrides ? (overrides.earliestGameTime as string | null) : null,
matchLabel: overrides.matchLabel ?? null,
eventType: overrides.eventType ?? "playoff_game",
sportsSeasonId: overrides.sportsSeasonId ?? "ss-1",
sportName: overrides.sportName ?? "Football",
sportSeasonName: overrides.sportSeasonName ?? "UCL 2025",
leagueName: overrides.leagueName,
leagueId: overrides.leagueId,
sportsSeasonPageUrl: overrides.sportsSeasonPageUrl,
relevantParticipants: overrides.relevantParticipants ?? [
{ id: "p1", name: "Man City" },
],
};
}
describe("UpcomingCalendarPanel", () => {
describe("empty state", () => {
it("shows empty state message when no events", () => {
renderWithRouter(<UpcomingCalendarPanel events={[]} />);
expect(screen.getByText(/No upcoming events in the next 30 days/i)).toBeInTheDocument();
});
it("renders the panel title", () => {
renderWithRouter(<UpcomingCalendarPanel events={[]} />);
expect(screen.getByText("Upcoming Events")).toBeInTheDocument();
});
});
describe("event display", () => {
it("shows event name and formatted date", () => {
renderWithRouter(
<UpcomingCalendarPanel events={[makeEvent({ name: "UCL QF", eventDate: "2025-04-09" })]} />
);
expect(screen.getByText("UCL QF")).toBeInTheDocument();
expect(screen.getByText("Apr 9")).toBeInTheDocument();
});
it("shows sport name and season name", () => {
renderWithRouter(
<UpcomingCalendarPanel
events={[makeEvent({ sportName: "Football", sportSeasonName: "UCL 2025" })]}
/>
);
expect(screen.getByText(/Football · UCL 2025/)).toBeInTheDocument();
});
it("shows participant badge for bracket events", () => {
renderWithRouter(
<UpcomingCalendarPanel
events={[makeEvent({
eventType: "playoff_game",
relevantParticipants: [{ id: "p1", name: "Man City" }],
})]}
/>
);
expect(screen.getByText("Man City")).toBeInTheDocument();
});
it("shows 'N of your picks' for all-compete events with multiple participants", () => {
renderWithRouter(
<UpcomingCalendarPanel
events={[makeEvent({
eventType: "schedule_event",
name: "Australian GP",
relevantParticipants: [
{ id: "p1", name: "Verstappen" },
{ id: "p2", name: "Hamilton" },
{ id: "p3", name: "Leclerc" },
{ id: "p4", name: "Norris" },
{ id: "p5", name: "Russell" },
],
})]}
/>
);
expect(screen.getByText("5 of your picks")).toBeInTheDocument();
});
it("renders TBD when both eventDate and earliestGameTime are null", () => {
renderWithRouter(
<UpcomingCalendarPanel events={[makeEvent({ eventDate: null })]} />
);
expect(screen.getByText("TBD")).toBeInTheDocument();
});
it("uses earliestGameTime date when eventDate is null", () => {
renderWithRouter(
<UpcomingCalendarPanel
events={[makeEvent({ eventDate: null, earliestGameTime: "2025-04-09T14:00:00.000Z" })]}
/>
);
// Should not show TBD
expect(screen.queryByText("TBD")).not.toBeInTheDocument();
});
it("shows matchLabel appended to event name when present", () => {
renderWithRouter(
<UpcomingCalendarPanel
events={[makeEvent({ name: "PDC World Championship", matchLabel: "Semifinals Game #1" })]}
/>
);
expect(screen.getByText(/PDC World Championship — Semifinals Game #1/)).toBeInTheDocument();
});
it("shows plain event name when matchLabel is null", () => {
renderWithRouter(
<UpcomingCalendarPanel events={[makeEvent({ name: "UCL QF", matchLabel: null })]} />
);
expect(screen.getByText("UCL QF")).toBeInTheDocument();
});
});
describe("participant overflow", () => {
it("shows first 3 participants and an overflow badge for 4+", () => {
renderWithRouter(
<UpcomingCalendarPanel
events={[makeEvent({
eventType: "playoff_game",
relevantParticipants: [
{ id: "p1", name: "Alpha" },
{ id: "p2", name: "Beta" },
{ id: "p3", name: "Gamma" },
{ id: "p4", name: "Delta" },
{ id: "p5", name: "Epsilon" },
],
})]}
/>
);
expect(screen.getByText("Alpha")).toBeInTheDocument();
expect(screen.getByText("Beta")).toBeInTheDocument();
expect(screen.getByText("Gamma")).toBeInTheDocument();
expect(screen.queryByText("Delta")).not.toBeInTheDocument();
expect(screen.getByText("+2 more")).toBeInTheDocument();
});
it("shows all participants when exactly 3", () => {
renderWithRouter(
<UpcomingCalendarPanel
events={[makeEvent({
eventType: "playoff_game",
relevantParticipants: [
{ id: "p1", name: "Alpha" },
{ id: "p2", name: "Beta" },
{ id: "p3", name: "Gamma" },
],
})]}
/>
);
expect(screen.getByText("Alpha")).toBeInTheDocument();
expect(screen.getByText("Gamma")).toBeInTheDocument();
expect(screen.queryByText(/\+\d+ more/)).not.toBeInTheDocument();
});
});
describe("league display", () => {
it("does not show league name when showLeague is false (default)", () => {
renderWithRouter(
<UpcomingCalendarPanel
events={[makeEvent({ leagueName: "My League" })]}
showLeague={false}
/>
);
expect(screen.queryByText("My League")).not.toBeInTheDocument();
});
it("shows league name badge when showLeague is true", () => {
renderWithRouter(
<UpcomingCalendarPanel
events={[makeEvent({ leagueName: "My League" })]}
showLeague={true}
/>
);
expect(screen.getByText("My League")).toBeInTheDocument();
});
it("renders multiple events from different leagues", () => {
renderWithRouter(
<UpcomingCalendarPanel
events={[
makeEvent({ id: "e1", name: "UCL QF", leagueName: "League A" }),
makeEvent({ id: "e2", name: "Australian GP", leagueName: "League B", eventType: "schedule_event" }),
]}
showLeague={true}
/>
);
expect(screen.getByText("League A")).toBeInTheDocument();
expect(screen.getByText("League B")).toBeInTheDocument();
});
});
describe("link behaviour", () => {
it("wraps event row in a link when sportsSeasonPageUrl is provided", () => {
renderWithRouter(
<UpcomingCalendarPanel
events={[makeEvent({ sportsSeasonPageUrl: "/leagues/l1/sports-seasons/ss1" })]}
/>
);
const link = screen.getByRole("link");
expect(link).toHaveAttribute("href", "/leagues/l1/sports-seasons/ss1");
});
});
});

View file

@ -1,5 +1,5 @@
import { format } from "date-fns";
import { Link } from "react-router";
import { format, parseISO } from "date-fns";
import { Badge } from "~/components/ui/badge";
import {
Card,
@ -8,7 +8,9 @@ import {
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Trophy, Target, Flag, Calendar } from "lucide-react";
import { Trophy, Target, Flag, Calendar, Users } from "lucide-react";
import { formatEventDate } from "~/lib/date-utils";
import type { UpcomingParticipantEvent } from "~/models/scoring-event";
interface SportSeasonCardProps {
leagueId: string;
@ -17,16 +19,47 @@ interface SportSeasonCardProps {
seasonName: string;
status: "upcoming" | "active" | "completed";
scoringPattern?: string | null;
nextEvent?: { name: string; eventDate: string | null } | null;
upcomingParticipantEvents?: UpcomingParticipantEvent[];
}
function formatNextEventDate(dateStr: string | null | undefined): string {
if (!dateStr) return "";
try {
return format(parseISO(dateStr), "MMM d");
} catch {
return "";
}
const MAX_EVENTS_SHOWN = 3;
function EventLine({ event }: { event: UpcomingParticipantEvent }) {
const gameDate = event.earliestGameTime ? new Date(event.earliestGameTime) : null;
const dateStr = gameDate
? format(gameDate, "MMM d")
: formatEventDate(event.eventDate);
const isAllCompete = event.eventType !== "playoff_game";
const count = event.relevantParticipants.length;
const displayName = event.matchLabel
? `${event.name}${event.matchLabel}`
: event.name;
return (
<div className="flex items-center gap-2 text-sm">
<Calendar className="h-3.5 w-3.5 text-electric shrink-0" />
<span className="truncate">
<span className="font-medium" suppressHydrationWarning>{displayName}</span>
{dateStr && (
<span className="text-muted-foreground" suppressHydrationWarning>
{" — "}{dateStr}
</span>
)}
</span>
{isAllCompete && count > 1 ? (
<Badge variant="secondary" className="text-xs shrink-0 ml-auto">
<Users className="h-3 w-3 mr-1" />
{count}
</Badge>
) : (
event.relevantParticipants.slice(0, 2).map((p) => (
<Badge key={p.id} variant="secondary" className="text-xs shrink-0 ml-auto first:ml-auto [&+*]:ml-0">
{p.name}
</Badge>
))
)}
</div>
);
}
export function SportSeasonCard({
@ -36,7 +69,7 @@ export function SportSeasonCard({
seasonName,
status,
scoringPattern,
nextEvent,
upcomingParticipantEvents = [],
}: SportSeasonCardProps) {
const getStatusBadge = () => {
switch (status) {
@ -81,7 +114,9 @@ export function SportSeasonCard({
const scoringLabel = getScoringPatternLabel();
const isPlayoffType = scoringPattern === "playoff_bracket";
const nextEventDate = formatNextEventDate(nextEvent?.eventDate);
const shownEvents = upcomingParticipantEvents.slice(0, MAX_EVENTS_SHOWN);
const overflowCount = upcomingParticipantEvents.length - MAX_EVENTS_SHOWN;
return (
<Link to={`/leagues/${leagueId}/sports-seasons/${sportSeasonId}`}>
@ -107,16 +142,16 @@ export function SportSeasonCard({
<span>{scoringLabel}</span>
</div>
)}
{nextEvent && (
<div className="flex items-center gap-2 text-sm">
<Calendar className="h-4 w-4 text-electric shrink-0" />
<span className="text-muted-foreground truncate">
<span className="text-electric font-medium">Next: </span>
{nextEvent.name}
{nextEventDate && (
<span className="text-muted-foreground"> {nextEventDate}</span>
)}
</span>
{shownEvents.length > 0 && (
<div className="space-y-1.5 pt-0.5">
{shownEvents.map((event) => (
<EventLine key={event.id} event={event} />
))}
{overflowCount > 0 && (
<p className="text-xs text-muted-foreground pl-5">
+{overflowCount} more event{overflowCount > 1 ? "s" : ""}
</p>
)}
</div>
)}
</div>

View file

@ -0,0 +1,215 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { BrowserRouter } from "react-router";
import { SportSeasonCard } from "../SportSeasonCard";
import type { UpcomingParticipantEvent } from "~/models/scoring-event";
function renderWithRouter(ui: React.ReactElement) {
return render(<BrowserRouter>{ui}</BrowserRouter>);
}
function makeEvent(overrides: Partial<UpcomingParticipantEvent> = {}): UpcomingParticipantEvent {
return {
id: overrides.id ?? "event-1",
name: overrides.name ?? "UCL Quarterfinals",
eventDate: overrides.eventDate ?? "2025-04-09",
earliestGameTime: "earliestGameTime" in overrides ? (overrides.earliestGameTime as string | null) : null,
matchLabel: overrides.matchLabel ?? null,
eventType: overrides.eventType ?? "playoff_game",
sportsSeasonId: overrides.sportsSeasonId ?? "ss-1",
relevantParticipants: overrides.relevantParticipants ?? [
{ id: "p1", name: "Man City" },
],
};
}
const BASE_PROPS = {
leagueId: "league-1",
sportSeasonId: "ss-1",
sportName: "Football",
seasonName: "UCL 2025",
status: "active" as const,
};
describe("SportSeasonCard", () => {
describe("base rendering", () => {
it("shows sport name and season name", () => {
renderWithRouter(<SportSeasonCard {...BASE_PROPS} />);
expect(screen.getByText("Football")).toBeInTheDocument();
expect(screen.getByText("UCL 2025")).toBeInTheDocument();
});
it("links to the sports season page", () => {
renderWithRouter(<SportSeasonCard {...BASE_PROPS} />);
expect(screen.getByRole("link")).toHaveAttribute(
"href",
"/leagues/league-1/sports-seasons/ss-1"
);
});
it("shows Active badge for active status", () => {
renderWithRouter(<SportSeasonCard {...BASE_PROPS} status="active" />);
expect(screen.getByText("Active")).toBeInTheDocument();
});
it("shows Upcoming badge for upcoming status", () => {
renderWithRouter(<SportSeasonCard {...BASE_PROPS} status="upcoming" />);
expect(screen.getByText("Upcoming")).toBeInTheDocument();
});
it("shows Completed badge for completed status", () => {
renderWithRouter(<SportSeasonCard {...BASE_PROPS} status="completed" />);
expect(screen.getByText("Completed")).toBeInTheDocument();
});
});
describe("no upcoming events", () => {
it("renders without error when upcomingParticipantEvents is empty", () => {
renderWithRouter(<SportSeasonCard {...BASE_PROPS} upcomingParticipantEvents={[]} />);
expect(screen.getByText("Football")).toBeInTheDocument();
});
it("renders without error when upcomingParticipantEvents is omitted (pre-draft)", () => {
renderWithRouter(<SportSeasonCard {...BASE_PROPS} />);
expect(screen.getByText("Football")).toBeInTheDocument();
});
});
describe("bracket event display", () => {
it("shows event name and date for a bracket event", () => {
renderWithRouter(
<SportSeasonCard
{...BASE_PROPS}
upcomingParticipantEvents={[
makeEvent({ name: "UCL QF", eventDate: "2025-04-09", eventType: "playoff_game" }),
]}
/>
);
expect(screen.getByText("UCL QF")).toBeInTheDocument();
expect(screen.getByText("— Apr 9")).toBeInTheDocument();
});
it("shows participant badge for bracket events with one pick", () => {
renderWithRouter(
<SportSeasonCard
{...BASE_PROPS}
upcomingParticipantEvents={[
makeEvent({
eventType: "playoff_game",
relevantParticipants: [{ id: "p1", name: "Man City" }],
}),
]}
/>
);
expect(screen.getByText("Man City")).toBeInTheDocument();
});
it("shows participant badges for two picks in same bracket event", () => {
renderWithRouter(
<SportSeasonCard
{...BASE_PROPS}
upcomingParticipantEvents={[
makeEvent({
eventType: "playoff_game",
relevantParticipants: [
{ id: "p1", name: "Man City" },
{ id: "p2", name: "Arsenal" },
],
}),
]}
/>
);
expect(screen.getByText("Man City")).toBeInTheDocument();
expect(screen.getByText("Arsenal")).toBeInTheDocument();
});
});
describe("all-compete event display", () => {
it("shows participant count badge for schedule_event with multiple picks", () => {
renderWithRouter(
<SportSeasonCard
{...BASE_PROPS}
upcomingParticipantEvents={[
makeEvent({
eventType: "schedule_event",
name: "Australian GP",
relevantParticipants: [
{ id: "p1", name: "Verstappen" },
{ id: "p2", name: "Hamilton" },
{ id: "p3", name: "Leclerc" },
{ id: "p4", name: "Norris" },
{ id: "p5", name: "Russell" },
],
}),
]}
/>
);
// Shows count badge, not individual names
expect(screen.getByText("5")).toBeInTheDocument();
expect(screen.queryByText("Verstappen")).not.toBeInTheDocument();
});
});
describe("event overflow", () => {
it("shows up to 3 events without overflow message", () => {
renderWithRouter(
<SportSeasonCard
{...BASE_PROPS}
upcomingParticipantEvents={[
makeEvent({ id: "e1", name: "Event One" }),
makeEvent({ id: "e2", name: "Event Two" }),
makeEvent({ id: "e3", name: "Event Three" }),
]}
/>
);
expect(screen.getByText("Event One")).toBeInTheDocument();
expect(screen.getByText("Event Three")).toBeInTheDocument();
expect(screen.queryByText(/more event/i)).not.toBeInTheDocument();
});
it("shows overflow count when more than 3 events", () => {
renderWithRouter(
<SportSeasonCard
{...BASE_PROPS}
upcomingParticipantEvents={[
makeEvent({ id: "e1", name: "Event One" }),
makeEvent({ id: "e2", name: "Event Two" }),
makeEvent({ id: "e3", name: "Event Three" }),
makeEvent({ id: "e4", name: "Event Four" }),
makeEvent({ id: "e5", name: "Event Five" }),
]}
/>
);
expect(screen.getByText("Event One")).toBeInTheDocument();
expect(screen.queryByText("Event Four")).not.toBeInTheDocument();
expect(screen.getByText("+2 more events")).toBeInTheDocument();
});
it("uses singular 'event' when overflow is 1", () => {
renderWithRouter(
<SportSeasonCard
{...BASE_PROPS}
upcomingParticipantEvents={[
makeEvent({ id: "e1", name: "Event One" }),
makeEvent({ id: "e2", name: "Event Two" }),
makeEvent({ id: "e3", name: "Event Three" }),
makeEvent({ id: "e4", name: "Event Four" }),
]}
/>
);
expect(screen.getByText("+1 more event")).toBeInTheDocument();
});
});
describe("scoring pattern label", () => {
it("shows Playoff Bracket label", () => {
renderWithRouter(<SportSeasonCard {...BASE_PROPS} scoringPattern="playoff_bracket" />);
expect(screen.getByText("Playoff Bracket")).toBeInTheDocument();
});
it("shows Season Standings label", () => {
renderWithRouter(<SportSeasonCard {...BASE_PROPS} scoringPattern="season_standings" />);
expect(screen.getByText("Season Standings")).toBeInTheDocument();
});
});
});

View file

@ -16,6 +16,21 @@
* clear the field. Always write the result to a separate hidden input, not back to
* the datetime-local input itself.
*/
import { format, parseISO } from "date-fns";
/**
* Format a YYYY-MM-DD date string for display (e.g. "Apr 9").
* Returns null when the value is missing or unparseable callers decide how to handle that case.
*/
export function formatEventDate(dateStr: string | null | undefined): string | null {
if (!dateStr) return null;
try {
return format(parseISO(dateStr), "MMM d");
} catch {
return null;
}
}
export function localDateTimeToUtcIso(value: string | null | undefined): string | null {
if (!value) return null;
const date = new Date(value);

View file

@ -0,0 +1,415 @@
/**
* Tests for getUpcomingEventsForDraftedParticipants logic.
*
* We test the exported function indirectly by exercising the pure
* business-logic paths (all-compete vs bracket, date filtering,
* deduplication, empty inputs) using a vi.mock of the database context
* so no real DB is required.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
// ── DB mock ────────────────────────────────────────────────────────────────
// Must be declared before the import that triggers the module graph.
const mockDb = {
query: {
scoringEvents: { findMany: vi.fn() },
},
selectDistinct: vi.fn(),
};
vi.mock("~/database/context", () => ({
database: () => mockDb,
}));
vi.mock("~/database/schema", () => ({
scoringEvents: { sportsSeasonId: "se.sports_season_id", isComplete: "se.is_complete", eventDate: "se.event_date", eventType: "se.event_type" },
playoffMatches: { scoringEventId: "pm.scoring_event_id", participant1Id: "pm.participant1_id", participant2Id: "pm.participant2_id", round: "pm.round", matchNumber: "pm.match_number" },
playoffMatchGames: { playoffMatchId: "pmg.playoff_match_id", scheduledAt: "pmg.scheduled_at", gameNumber: "pmg.game_number" },
}));
vi.mock("drizzle-orm", () => ({
eq: (col: unknown, val: unknown) => ({ type: "eq", col, val }),
and: (...args: unknown[]) => ({ type: "and", args }),
asc: (col: unknown) => ({ type: "asc", col }),
desc: (col: unknown) => ({ type: "desc", col }),
gte: (col: unknown, val: unknown) => ({ type: "gte", col, val }),
lte: (col: unknown, val: unknown) => ({ type: "lte", col, val }),
or: (...args: unknown[]) => ({ type: "or", args }),
inArray: (col: unknown, arr: unknown) => ({ type: "inArray", col, arr }),
isNotNull: (col: unknown) => ({ type: "isNotNull", col }),
}));
import { getUpcomingEventsForDraftedParticipants } from "../scoring-event";
// ── Helpers ────────────────────────────────────────────────────────────────
const DATE_FROM = new Date("2025-04-01");
const DATE_TO = new Date("2025-04-30");
const SPORTS_SEASON_ID = "ss-1";
function makeParticipant(id: string, name: string) {
return { id, name };
}
function makeScoringEvent(overrides: Partial<{
id: string; name: string; eventDate: string | null;
eventType: string; sportsSeasonId: string; isComplete: boolean;
}> = {}) {
return {
id: overrides.id ?? "event-1",
name: overrides.name ?? "Test Event",
eventDate: overrides.eventDate ?? "2025-04-10",
eventType: overrides.eventType ?? "schedule_event",
sportsSeasonId: overrides.sportsSeasonId ?? SPORTS_SEASON_ID,
isComplete: overrides.isComplete ?? false,
};
}
// ── All-compete patterns ───────────────────────────────────────────────────
describe("getUpcomingEventsForDraftedParticipants — all-compete sports", () => {
beforeEach(() => {
vi.clearAllMocks();
// selectDistinct won't be called for all-compete
mockDb.selectDistinct.mockReturnValue({ from: vi.fn().mockReturnThis(), innerJoin: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(), orderBy: vi.fn().mockResolvedValue([]) });
});
it("returns [] when draftedParticipants is empty", async () => {
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "season_standings", [], DATE_FROM, DATE_TO
);
expect(result).toEqual([]);
expect(mockDb.query.scoringEvents.findMany).not.toHaveBeenCalled();
});
it("returns all events with all drafted participants attached", async () => {
const participants = [makeParticipant("p1", "Verstappen"), makeParticipant("p2", "Hamilton")];
const events = [makeScoringEvent({ id: "e1", name: "Australian GP" })];
mockDb.query.scoringEvents.findMany.mockResolvedValue(events);
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "season_standings", participants, DATE_FROM, DATE_TO
);
expect(result).toHaveLength(1);
expect(result[0].name).toBe("Australian GP");
expect(result[0].relevantParticipants).toEqual(participants);
});
it("works for qualifying_points pattern", async () => {
const participants = [makeParticipant("g1", "Scheffler")];
mockDb.query.scoringEvents.findMany.mockResolvedValue([makeScoringEvent({ id: "e1", name: "Masters" })]);
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "qualifying_points", participants, DATE_FROM, DATE_TO
);
expect(result).toHaveLength(1);
});
it("unknown pattern (e.g. golf_qualifying_points) falls through to bracket path, not all-compete", async () => {
// golf_qualifying_points is a simulatorType, not a scoringPattern.
// Unknown patterns are treated as bracket sports (selectDistinct path).
const participants = [makeParticipant("g1", "McIlroy")];
const selectChain = { from: vi.fn().mockReturnThis(), innerJoin: vi.fn().mockReturnThis(), leftJoin: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(), orderBy: vi.fn().mockResolvedValue([]) };
mockDb.selectDistinct.mockReturnValue(selectChain);
await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "golf_qualifying_points", participants, DATE_FROM, DATE_TO
);
// Should NOT call the all-compete findMany path
expect(mockDb.query.scoringEvents.findMany).not.toHaveBeenCalled();
// Should call the bracket selectDistinct path
expect(mockDb.selectDistinct).toHaveBeenCalled();
});
it("returns multiple events sorted by eventDate", async () => {
const participants = [makeParticipant("p1", "Verstappen")];
const events = [
makeScoringEvent({ id: "e2", name: "Miami GP", eventDate: "2025-04-20" }),
makeScoringEvent({ id: "e1", name: "Australian GP", eventDate: "2025-04-06" }),
];
// findMany is ordered by asc(eventDate) in the real query; simulate same order
mockDb.query.scoringEvents.findMany.mockResolvedValue([events[1], events[0]]);
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "season_standings", participants, DATE_FROM, DATE_TO
);
expect(result[0].name).toBe("Australian GP");
expect(result[1].name).toBe("Miami GP");
});
});
// ── Bracket patterns ───────────────────────────────────────────────────────
describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => {
function makeSelectChain(rows: unknown[]) {
const chain = {
from: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
leftJoin: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockResolvedValue(rows),
};
return chain;
}
beforeEach(() => {
vi.clearAllMocks();
});
it("returns [] when draftedParticipants is empty", async () => {
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "playoff_bracket", [], DATE_FROM, DATE_TO
);
expect(result).toEqual([]);
expect(mockDb.selectDistinct).not.toHaveBeenCalled();
});
it("returns one event with one relevant participant (participant1)", async () => {
const rows = [{
id: "e1", name: "UCL QF", eventDate: "2025-04-09",
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
participant1Id: "man-city", participant2Id: "bayern",
round: "Quarterfinals", gameNumber: 1, scheduledAt: null,
}];
mockDb.selectDistinct.mockReturnValue(makeSelectChain(rows));
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "playoff_bracket",
[makeParticipant("man-city", "Man City")],
DATE_FROM, DATE_TO
);
expect(result).toHaveLength(1);
expect(result[0].name).toBe("UCL QF");
expect(result[0].relevantParticipants).toEqual([{ id: "man-city", name: "Man City" }]);
});
it("returns one event when both participants are drafted (no duplication)", async () => {
// Two rows for the same event — the DB query uses selectDistinct on event columns
// but we may still get two rows if the WHERE matches on both participants.
// The grouping logic must deduplicate the event itself.
const rows = [
{
id: "e1", name: "UCL QF", eventDate: "2025-04-09",
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
participant1Id: "man-city", participant2Id: "arsenal",
round: "Quarterfinals", gameNumber: 1, scheduledAt: null,
},
];
mockDb.selectDistinct.mockReturnValue(makeSelectChain(rows));
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "playoff_bracket",
[makeParticipant("man-city", "Man City"), makeParticipant("arsenal", "Arsenal")],
DATE_FROM, DATE_TO
);
expect(result).toHaveLength(1);
expect(result[0].relevantParticipants).toHaveLength(2);
const names = result[0].relevantParticipants.map((p) => p.name);
expect(names).toContain("Man City");
expect(names).toContain("Arsenal");
});
it("returns multiple events when picks are in different matches", async () => {
const rows = [
{
id: "e1", name: "UCL QF", eventDate: "2025-04-09",
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
participant1Id: "man-city", participant2Id: "real-madrid",
round: "Quarterfinals", gameNumber: 1, scheduledAt: null,
},
{
id: "e1", name: "UCL QF", eventDate: "2025-04-09",
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
participant1Id: "arsenal", participant2Id: "psg",
round: "Quarterfinals", gameNumber: 1, scheduledAt: null,
},
];
mockDb.selectDistinct.mockReturnValue(makeSelectChain(rows));
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "playoff_bracket",
[makeParticipant("man-city", "Man City"), makeParticipant("arsenal", "Arsenal")],
DATE_FROM, DATE_TO
);
// Both matches are in the same event → one result with both picks
expect(result).toHaveLength(1);
expect(result[0].relevantParticipants).toHaveLength(2);
});
it("only includes drafted participants in relevantParticipants, not opponents", async () => {
const rows = [{
id: "e1", name: "UCL QF", eventDate: "2025-04-09",
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
participant1Id: "man-city", participant2Id: "real-madrid",
round: "Quarterfinals", gameNumber: 1, scheduledAt: null,
}];
mockDb.selectDistinct.mockReturnValue(makeSelectChain(rows));
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "playoff_bracket",
[makeParticipant("man-city", "Man City")],
DATE_FROM, DATE_TO
);
expect(result[0].relevantParticipants).toEqual([{ id: "man-city", name: "Man City" }]);
// Real Madrid (the opponent) should NOT appear
expect(result[0].relevantParticipants.find((p) => p.id === "real-madrid")).toBeUndefined();
});
it("works for ucl_bracket pattern", async () => {
mockDb.selectDistinct.mockReturnValue(makeSelectChain([{
id: "e1", name: "UCL SF", eventDate: "2025-04-29",
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
participant1Id: "man-city", participant2Id: "arsenal",
round: "Semifinals", gameNumber: 1, scheduledAt: null,
}]));
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "ucl_bracket",
[makeParticipant("man-city", "Man City")],
DATE_FROM, DATE_TO
);
expect(result).toHaveLength(1);
});
it("includes events with null eventDate when they have games scheduled within the window", async () => {
// Real-world case: admin sets game times on individual games (scheduledAt)
// but leaves scoringEvent.eventDate null — the event must still appear.
const rows = [{
id: "e1", name: "UCL QF",
eventDate: null, // ← no date on the event itself
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
participant1Id: "barcelona", participant2Id: "sporting",
round: "Knockout Stage", gameNumber: 1, scheduledAt: null,
}];
mockDb.selectDistinct.mockReturnValue(makeSelectChain(rows));
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "playoff_bracket",
[makeParticipant("barcelona", "Barcelona")],
DATE_FROM, DATE_TO
);
expect(result).toHaveLength(1);
expect(result[0].name).toBe("UCL QF");
expect(result[0].eventDate).toBeNull();
expect(result[0].relevantParticipants).toEqual([{ id: "barcelona", name: "Barcelona" }]);
});
it("surfaces earliestGameTime from scheduledAt when eventDate is null", async () => {
const gameTime = new Date("2025-04-09T14:00:00.000Z");
const rows = [{
id: "e1", name: "UCL QF",
eventDate: null,
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
participant1Id: "barcelona", participant2Id: "sporting",
round: "Knockout Stage", gameNumber: 1, scheduledAt: gameTime,
}];
mockDb.selectDistinct.mockReturnValue(makeSelectChain(rows));
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "playoff_bracket",
[makeParticipant("barcelona", "Barcelona")],
DATE_FROM, DATE_TO
);
expect(result[0].earliestGameTime).toBe(gameTime.toISOString());
expect(result[0].eventDate).toBeNull();
});
it("picks the earliest scheduledAt when multiple game rows exist for an event", async () => {
const gameTime1 = new Date("2025-04-09T13:00:00.000Z");
const gameTime2 = new Date("2025-04-09T15:00:00.000Z");
const rows = [
{
id: "e1", name: "UCL QF", eventDate: null,
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
participant1Id: "barcelona", participant2Id: "sporting",
round: "Knockout Stage", gameNumber: 1, scheduledAt: gameTime2,
},
{
id: "e1", name: "UCL QF", eventDate: null,
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
participant1Id: "barcelona", participant2Id: "sporting",
round: "Knockout Stage", gameNumber: 2, scheduledAt: gameTime1,
},
];
mockDb.selectDistinct.mockReturnValue(makeSelectChain(rows));
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "playoff_bracket",
[makeParticipant("barcelona", "Barcelona")],
DATE_FROM, DATE_TO
);
// Should use the earlier time
expect(result[0].earliestGameTime).toBe(gameTime1.toISOString());
});
it("sets matchLabel to round+game when single game key (non-redundant)", async () => {
const rows = [{
id: "e1", name: "PDC World Championship", eventDate: "2025-04-09",
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
participant1Id: "man-city", participant2Id: "arsenal",
round: "Semifinals", gameNumber: 1, scheduledAt: null,
}];
mockDb.selectDistinct.mockReturnValue(makeSelectChain(rows));
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "playoff_bracket",
[makeParticipant("man-city", "Man City")],
DATE_FROM, DATE_TO
);
expect(result[0].matchLabel).toBe("Semifinals Game #1");
});
it("omits redundant round prefix when round name matches event name", async () => {
// e.g. event named "Knockout Stage", round also "Knockout Stage", single game
// → show "Game #1" rather than the redundant "Knockout Stage Game #1"
const rows = [{
id: "e1", name: "Knockout Stage", eventDate: null,
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
participant1Id: "barcelona", participant2Id: "sporting",
round: "Knockout Stage", gameNumber: 1, scheduledAt: null,
}];
mockDb.selectDistinct.mockReturnValue(makeSelectChain(rows));
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "playoff_bracket",
[makeParticipant("barcelona", "Barcelona")],
DATE_FROM, DATE_TO
);
expect(result[0].matchLabel).toBe("Game #1");
});
it("all-compete events have null earliestGameTime and matchLabel", async () => {
const participants = [makeParticipant("p1", "Verstappen")];
mockDb.query.scoringEvents.findMany.mockResolvedValue([
makeScoringEvent({ id: "e1", name: "Australian GP" }),
]);
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "season_standings", participants, DATE_FROM, DATE_TO
);
expect(result[0].earliestGameTime).toBeNull();
expect(result[0].matchLabel).toBeNull();
});
it("uses leftJoin so events without any games are still considered", async () => {
const chain = makeSelectChain([]);
mockDb.selectDistinct.mockReturnValue(chain);
await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "playoff_bracket",
[makeParticipant("man-city", "Man City")],
DATE_FROM, DATE_TO
);
expect(chain.leftJoin).toHaveBeenCalled();
});
});

View file

@ -64,6 +64,45 @@ export async function isParticipantDrafted(seasonId: string, participantId: stri
return !!pick;
}
/**
* Get a team's drafted participants grouped by sports season.
* Returns a map of sportsSeasonId [{id, name}].
*/
export async function getDraftedParticipantsBySportsSeason(
teamId: string,
seasonId: string,
providedDb?: ReturnType<typeof database>
): Promise<Map<string, Array<{ id: string; name: string }>>> {
const db = providedDb || database();
const results = await db
.select({
sportsSeasonId: schema.participants.sportsSeasonId,
participantId: schema.participants.id,
participantName: schema.participants.name,
})
.from(schema.draftPicks)
.innerJoin(
schema.participants,
eq(schema.draftPicks.participantId, schema.participants.id)
)
.where(
and(
eq(schema.draftPicks.teamId, teamId),
eq(schema.draftPicks.seasonId, seasonId)
)
);
const map = new Map<string, Array<{ id: string; name: string }>>();
for (const row of results) {
if (!map.has(row.sportsSeasonId)) {
map.set(row.sportsSeasonId, []);
}
map.get(row.sportsSeasonId)!.push({ id: row.participantId, name: row.participantName });
}
return map;
}
export async function deleteAllDraftPicks(seasonId: string) {
const db = database();
await db

View file

@ -1,6 +1,6 @@
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and, desc, asc, gte, lte } from "drizzle-orm";
import { eq, and, desc, asc, gte, lte, or, inArray, isNotNull } from "drizzle-orm";
import { recalculateAffectedLeagues } from "./scoring-calculator";
import { recalculateParticipantQP } from "./qualifying-points";
@ -326,6 +326,216 @@ export async function getRecentCompletedEvents(
});
}
// Scoring patterns where each participant competes in every event.
// Valid scoringPatternEnum values: playoff_bracket | season_standings | qualifying_points
const ALL_COMPETE_PATTERNS = new Set(["season_standings", "qualifying_points"]);
export interface UpcomingParticipantEvent {
id: string;
name: string;
eventDate: string | null;
/**
* Earliest scheduledAt timestamp (ISO string) across individual playoff match
* games for this event bracket sports only. Used when eventDate is null.
*/
earliestGameTime: string | null;
/**
* e.g. "Semifinals Game #1" derived from playoffMatches.round and
* playoffMatchGames.gameNumber. null when unavailable or redundant with event name.
*/
matchLabel: string | null;
eventType: string;
sportsSeasonId: string;
/** Only the current user's drafted participants involved in this event */
relevantParticipants: Array<{ id: string; name: string }>;
}
/**
* Get upcoming scoring events (within the given date window) that involve
* at least one of the given drafted participants.
*
* - Bracket sports (playoff_bracket, ucl_bracket): only events where a
* participant appears in a playoff match.
* - All-compete sports (season_standings, qualifying_points, etc.): every
* upcoming event; all draftedParticipants are attached.
*
* Events without a date or that are already complete are excluded.
*/
export async function getUpcomingEventsForDraftedParticipants(
sportsSeasonId: string,
scoringPattern: string,
draftedParticipants: Array<{ id: string; name: string }>,
dateFrom: Date,
dateTo: Date,
providedDb?: ReturnType<typeof database>
): Promise<UpcomingParticipantEvent[]> {
if (draftedParticipants.length === 0) return [];
const db = providedDb || database();
const dateFromStr = dateFrom.toISOString().split("T")[0];
const dateToStr = dateTo.toISOString().split("T")[0];
const draftedIds = draftedParticipants.map((p) => p.id);
const draftedMap = new Map(draftedParticipants.map((p) => [p.id, p]));
if (ALL_COMPETE_PATTERNS.has(scoringPattern)) {
const events = await db.query.scoringEvents.findMany({
where: and(
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
eq(schema.scoringEvents.isComplete, false),
isNotNull(schema.scoringEvents.eventDate),
gte(schema.scoringEvents.eventDate, dateFromStr),
lte(schema.scoringEvents.eventDate, dateToStr)
),
orderBy: [asc(schema.scoringEvents.eventDate)],
});
return events.map((e) => ({
id: e.id,
name: e.name,
eventDate: e.eventDate,
earliestGameTime: null,
matchLabel: null,
eventType: e.eventType,
sportsSeasonId: e.sportsSeasonId,
relevantParticipants: draftedParticipants,
}));
}
// Bracket sports: join through playoff_matches → playoff_match_games to find relevant events.
// scoringEvent.eventDate may be null — admins often set dates on individual games
// (playoffMatchGames.scheduledAt) rather than on the event record itself.
// We include an event if EITHER its eventDate OR any of its game scheduledAt timestamps
// falls within the window.
//
// Use start-of-day for the timestamp lower bound so games that already happened
// earlier today are still included.
const dateFromTimestamp = new Date(dateFromStr + "T00:00:00.000Z");
const dateToTimestamp = new Date(dateToStr + "T23:59:59.999Z");
const rows = await db
.selectDistinct({
id: schema.scoringEvents.id,
name: schema.scoringEvents.name,
eventDate: schema.scoringEvents.eventDate,
eventType: schema.scoringEvents.eventType,
sportsSeasonId: schema.scoringEvents.sportsSeasonId,
participant1Id: schema.playoffMatches.participant1Id,
participant2Id: schema.playoffMatches.participant2Id,
round: schema.playoffMatches.round,
gameNumber: schema.playoffMatchGames.gameNumber,
scheduledAt: schema.playoffMatchGames.scheduledAt,
})
.from(schema.scoringEvents)
.innerJoin(
schema.playoffMatches,
eq(schema.playoffMatches.scoringEventId, schema.scoringEvents.id)
)
.leftJoin(
schema.playoffMatchGames,
eq(schema.playoffMatchGames.playoffMatchId, schema.playoffMatches.id)
)
.where(
and(
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
eq(schema.scoringEvents.isComplete, false),
or(
inArray(schema.playoffMatches.participant1Id, draftedIds),
// participant2Id is nullable UUID — same underlying type as participant1Id;
// the notNull difference only exists at the TypeScript layer
// eslint-disable-next-line @typescript-eslint/no-explicit-any
inArray(schema.playoffMatches.participant2Id as any, draftedIds)
),
or(
// scoringEvent has an explicit date within the window
and(
isNotNull(schema.scoringEvents.eventDate),
gte(schema.scoringEvents.eventDate, dateFromStr),
lte(schema.scoringEvents.eventDate, dateToStr)
),
// Or one of the individual games is scheduled within the window
and(
isNotNull(schema.playoffMatchGames.scheduledAt),
gte(schema.playoffMatchGames.scheduledAt, dateFromTimestamp),
lte(schema.playoffMatchGames.scheduledAt, dateToTimestamp)
)
)
)
)
.orderBy(asc(schema.scoringEvents.eventDate));
// Group rows by event, collecting relevant participants, earliest game time, and match label.
const eventMap = new Map<string, UpcomingParticipantEvent>();
const participantsByEvent = new Map<string, Map<string, { id: string; name: string }>>();
// Unique "round|gameNumber" combos per event — used to build matchLabel
const gameKeysByEvent = new Map<string, Set<string>>();
// Min scheduledAt per event
const earliestTimeByEvent = new Map<string, Date>();
for (const row of rows) {
if (!eventMap.has(row.id)) {
eventMap.set(row.id, {
id: row.id,
name: row.name,
eventDate: row.eventDate,
earliestGameTime: null,
matchLabel: null,
eventType: row.eventType,
sportsSeasonId: row.sportsSeasonId,
relevantParticipants: [],
});
participantsByEvent.set(row.id, new Map());
gameKeysByEvent.set(row.id, new Set());
}
const pMap = participantsByEvent.get(row.id)!;
if (row.participant1Id && draftedMap.has(row.participant1Id)) {
pMap.set(row.participant1Id, draftedMap.get(row.participant1Id)!);
}
if (row.participant2Id && draftedMap.has(row.participant2Id)) {
pMap.set(row.participant2Id, draftedMap.get(row.participant2Id)!);
}
if (row.round && row.gameNumber != null) {
gameKeysByEvent.get(row.id)!.add(`${row.round}|${row.gameNumber}`);
}
if (row.scheduledAt) {
const prev = earliestTimeByEvent.get(row.id);
if (!prev || row.scheduledAt < prev) {
earliestTimeByEvent.set(row.id, row.scheduledAt);
}
}
}
for (const [eventId, event] of eventMap) {
event.relevantParticipants = Array.from(participantsByEvent.get(eventId)!.values());
const earliest = earliestTimeByEvent.get(eventId);
event.earliestGameTime = earliest ? earliest.toISOString() : null;
// Build matchLabel from game keys
const gameKeys = gameKeysByEvent.get(eventId)!;
if (gameKeys.size === 1) {
const [round, gameNumberStr] = [...gameKeys][0].split("|");
// When round name duplicates the event name, omit the round to avoid
// "Knockout Stage — Knockout Stage Game #1"; just show "Game #1" instead.
event.matchLabel =
round === event.name
? `Game #${gameNumberStr}`
: `${round} Game #${gameNumberStr}`;
} else if (gameKeys.size > 1) {
// Multiple games — use just the round if it differs from the event name
const rounds = new Set([...gameKeys].map((k) => k.split("|")[0]));
if (rounds.size === 1) {
const round = [...rounds][0];
event.matchLabel = round === event.name ? null : round;
}
}
}
return Array.from(eventMap.values());
}
/**
* Bulk create scoring events for a sports season.
* Returns created events in order.

View file

@ -39,6 +39,19 @@ export async function findTeamsByOwnerId(ownerId: string): Promise<Team[]> {
});
}
export async function findTeamByOwnerAndSeason(
ownerId: string,
seasonId: string
): Promise<Team | undefined> {
const db = database();
return await db.query.teams.findFirst({
where: and(
eq(schema.teams.ownerId, ownerId),
eq(schema.teams.seasonId, seasonId)
),
});
}
export async function findAvailableTeams(seasonId: string): Promise<Team[]> {
const db = database();
return await db.query.teams.findMany({

View file

@ -2,10 +2,16 @@ import { useEffect } from "react";
import { Link, useSearchParams } from "react-router";
import { toast } from "sonner";
import { getAuth } from "@clerk/react-router/server";
import { addDays } from "date-fns";
import type { Route } from "./+types/home";
import { findLeaguesWithActiveSeasonsByUserId } from "~/models/league";
import { findSeasonById } from "~/models/season";
import { findSeasonById, findCurrentSeasonWithSports } from "~/models/season";
import { findTeamByOwnerAndSeason } from "~/models/team";
import { getDraftedParticipantsBySportsSeason } from "~/models/draft-pick";
import { getUpcomingEventsForDraftedParticipants } from "~/models/scoring-event";
import type { CalendarPanelEvent } from "~/components/sport-season/UpcomingCalendarPanel";
import { UpcomingCalendarPanel } from "~/components/sport-season/UpcomingCalendarPanel";
import { Button } from "~/components/ui/button";
import {
Card,
@ -29,33 +35,88 @@ export async function loader(args: Route.LoaderArgs) {
const { userId } = await getAuth(args);
if (!userId) {
return { leagues: [], isLoggedIn: false };
return { leagues: [], isLoggedIn: false, upcomingCalendarEvents: [] };
}
// Fetch leagues where user has a team in the current season
const leagues = await findLeaguesWithActiveSeasonsByUserId(userId);
// Fetch season details for each league's current season
const leaguesWithSeasons = await Promise.all(
const today = new Date();
const calendarDateFrom = today;
const calendarDateTo = addDays(today, 30);
// Fetch season details and calendar events in parallel per league
const leaguesWithData = await Promise.all(
leagues.map(async (league) => {
const season = league.currentSeasonId
? await findSeasonById(league.currentSeasonId)
: null;
const [season, seasonWithSports] = await Promise.all([
league.currentSeasonId ? findSeasonById(league.currentSeasonId) : null,
findCurrentSeasonWithSports(league.id),
]);
// Build calendar events for this league
const calendarEvents: CalendarPanelEvent[] = [];
if (league.currentSeasonId && seasonWithSports?.seasonSports) {
const myTeam = await findTeamByOwnerAndSeason(userId, league.currentSeasonId);
if (myTeam) {
const participantsBySportsSeason = await getDraftedParticipantsBySportsSeason(
myTeam.id,
league.currentSeasonId
);
const perSeasonEvents = await Promise.all(
seasonWithSports.seasonSports.map(async ({ sportsSeason: ss }) => {
const draftedParticipants = participantsBySportsSeason.get(ss.id) ?? [];
if (draftedParticipants.length === 0) return [];
const events = await getUpcomingEventsForDraftedParticipants(
ss.id,
ss.scoringPattern ?? "",
draftedParticipants,
calendarDateFrom,
calendarDateTo
);
return events.map((event) => ({
...event,
sportName: ss.sport.name,
sportSeasonName: ss.name,
leagueName: league.name,
leagueId: league.id,
sportsSeasonPageUrl: `/leagues/${league.id}/sports-seasons/${ss.id}`,
}));
})
);
calendarEvents.push(...perSeasonEvents.flat());
}
}
return {
...league,
currentSeason: season,
calendarEvents,
};
})
);
const upcomingCalendarEvents = leaguesWithData
.flatMap((l) => l.calendarEvents)
.sort((a, b) => {
const dateA = a.eventDate ?? (a.earliestGameTime ? a.earliestGameTime.split("T")[0] : "");
const dateB = b.eventDate ?? (b.earliestGameTime ? b.earliestGameTime.split("T")[0] : "");
return dateA.localeCompare(dateB);
});
return {
leagues: leaguesWithSeasons,
leagues: leaguesWithData,
isLoggedIn: true,
upcomingCalendarEvents,
};
}
export default function Home({ loaderData }: Route.ComponentProps) {
const { leagues, isLoggedIn } = loaderData;
const { leagues, isLoggedIn, upcomingCalendarEvents } = loaderData;
const [searchParams, setSearchParams] = useSearchParams();
useEffect(() => {
@ -89,6 +150,12 @@ export default function Home({ loaderData }: Route.ComponentProps) {
</Button>
</div>
{upcomingCalendarEvents.length > 0 && (
<div className="mb-8">
<UpcomingCalendarPanel events={upcomingCalendarEvents} showLeague={true} />
</div>
)}
{leagues.length === 0 ? (
<Card>
<CardHeader>

View file

@ -1,4 +1,5 @@
import { getAuth } from "@clerk/react-router/server";
import { addDays } from "date-fns";
import {
findLeagueById,
findTeamsBySeasonId,
@ -10,7 +11,8 @@ import {
} from "~/models";
import { findCurrentSeasonWithSports } from "~/models/season";
import { getSeasonStandings } from "~/models/standings";
import { getNextScoringEvent } from "~/models/scoring-event";
import { getUpcomingEventsForDraftedParticipants } from "~/models/scoring-event";
import { getDraftedParticipantsBySportsSeason } from "~/models/draft-pick";
import type { Route } from "./+types/$leagueId";
export async function loader(args: Route.LoaderArgs) {
@ -112,27 +114,61 @@ export async function loader(args: Route.LoaderArgs) {
// Count teams with owners
const teamsWithOwners = teams.filter((t) => t.ownerId !== null).length;
// Get sports seasons data including next upcoming event for each
// Get sports seasons data with upcoming participant events for the current user
const rawSportsSeasons = seasonWithSports?.seasonSports?.map((ss) => ss.sportsSeason) || [];
const myTeam = teams.find((t) => t.ownerId === userId) ?? null;
const today = new Date();
const calendarDateFrom = today;
const calendarDateTo = addDays(today, 30);
const participantsBySportsSeason = myTeam
? await getDraftedParticipantsBySportsSeason(myTeam.id, season!.id)
: new Map<string, Array<{ id: string; name: string }>>();
const sportsSeasons = await Promise.all(
rawSportsSeasons.map(async (ss) => {
const nextEvent =
ss.status === "active" ? await getNextScoringEvent(ss.id) : null;
const draftedParticipants = participantsBySportsSeason.get(ss.id) ?? [];
const upcomingParticipantEvents =
draftedParticipants.length > 0
? await getUpcomingEventsForDraftedParticipants(
ss.id,
ss.scoringPattern ?? "",
draftedParticipants,
calendarDateFrom,
calendarDateTo
)
: [];
return {
id: ss.id,
name: ss.name,
status: ss.status as "upcoming" | "active" | "completed",
scoringPattern: ss.scoringPattern,
sport: ss.sport,
nextEvent: nextEvent
? { name: nextEvent.name, eventDate: nextEvent.eventDate }
: null,
upcomingParticipantEvents,
};
})
);
const sportsCount = sportsSeasons.length;
// Flatten all events into a panel-ready list, sorted by date
const upcomingCalendarEvents = sportsSeasons
.flatMap((ss) =>
ss.upcomingParticipantEvents.map((e) => ({
...e,
sportName: ss.sport.name,
sportSeasonName: ss.name,
sportsSeasonPageUrl: `/leagues/${leagueId}/sports-seasons/${ss.id}`,
}))
)
.sort((a, b) => {
// Prefer eventDate; fall back to the date portion of earliestGameTime for bracket
// events where the admin set times on individual games rather than the event record.
const dateA = a.eventDate ?? (a.earliestGameTime ? a.earliestGameTime.split("T")[0] : "");
const dateB = b.eventDate ?? (b.earliestGameTime ? b.earliestGameTime.split("T")[0] : "");
return dateA.localeCompare(dateB);
});
// Check if draft order is set
const isDraftOrderSet = draftSlots.length > 0;
@ -156,5 +192,6 @@ export async function loader(args: Route.LoaderArgs) {
sportsSeasons,
standings,
origin,
upcomingCalendarEvents,
};
}

View file

@ -13,6 +13,7 @@ import {
CardTitle,
} from "~/components/ui/card";
import { SportSeasonCard } from "~/components/sports/SportSeasonCard";
import { UpcomingCalendarPanel } from "~/components/sport-season/UpcomingCalendarPanel";
import { getDisplayRank } from "~/lib/standings-display";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
@ -39,6 +40,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
sportsSeasons,
standings,
origin,
upcomingCalendarEvents,
} = loaderData;
const myTeam = teams.find((t) => t.ownerId === currentUserId);
@ -237,7 +239,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
seasonName={sportSeason.name}
status={sportSeason.status}
scoringPattern={sportSeason.scoringPattern}
nextEvent={sportSeason.nextEvent}
upcomingParticipantEvents={sportSeason.upcomingParticipantEvents}
/>
))}
</div>
@ -381,6 +383,9 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
{/* Right Column - 1/3 width on desktop */}
<div className="space-y-6">
{upcomingCalendarEvents.length > 0 && (
<UpcomingCalendarPanel events={upcomingCalendarEvents} showLeague={false} />
)}
<Card>
<CardHeader>
<CardTitle>League Info</CardTitle>