Display upcoming matches (#141)
This commit is contained in:
parent
f97ee4e4d8
commit
4fe76b6845
12 changed files with 1452 additions and 40 deletions
144
app/components/sport-season/UpcomingCalendarPanel.tsx
Normal file
144
app/components/sport-season/UpcomingCalendarPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
215
app/components/sports/__tests__/SportSeasonCard.test.tsx
Normal file
215
app/components/sports/__tests__/SportSeasonCard.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
415
app/models/__tests__/upcoming-calendar.test.ts
Normal file
415
app/models/__tests__/upcoming-calendar.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue