- Fix UTC midnight rollover bug: server now queries from yesterday UTC as a buffer; UpcomingCalendarPanel filters to local-today client-side via useEffect + Intl.DateTimeFormat, removing the need for any cookie or server-side timezone detection - Cap homepage and league page panels at 6 events with a "View all" link - Add /upcoming-events page (60-day view across all leagues) - Add /leagues/:leagueId/upcoming-events page (60-day per-league view) - Add emptyMessage prop to UpcomingCalendarPanel for context-specific copy - Change getUpcomingEventsForDraftedParticipants to accept pre-computed date strings instead of Date objects fixes #213 Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
29ea93ad6d
commit
3c4ed67946
11 changed files with 317 additions and 26 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { Calendar, Users } from "lucide-react";
|
import { Calendar, ChevronRight, Users } from "lucide-react";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
|
|
@ -24,6 +25,12 @@ interface Props {
|
||||||
events: CalendarPanelEvent[];
|
events: CalendarPanelEvent[];
|
||||||
/** When true, renders a league name badge on each row */
|
/** When true, renders a league name badge on each row */
|
||||||
showLeague?: boolean;
|
showLeague?: boolean;
|
||||||
|
/** If provided, only the first N events are rendered */
|
||||||
|
limit?: number;
|
||||||
|
/** If provided and events.length > limit, renders a "View all" footer link */
|
||||||
|
viewAllUrl?: string;
|
||||||
|
/** Override the default empty state message */
|
||||||
|
emptyMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ParticipantList({ participants }: { participants: Array<{ id: string; name: string }> }) {
|
function ParticipantList({ participants }: { participants: Array<{ id: string; name: string }> }) {
|
||||||
|
|
@ -117,7 +124,19 @@ function EventRow({ event, showLeague }: { event: CalendarPanelEvent; showLeague
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UpcomingCalendarPanel({ events, showLeague = false }: Props) {
|
export function UpcomingCalendarPanel({ events, showLeague = false, limit, viewAllUrl, emptyMessage }: Props) {
|
||||||
|
// Filter out past events client-side using local timezone. Server sends events
|
||||||
|
// starting from yesterday UTC as a buffer; useEffect trims to today-and-forward
|
||||||
|
// after hydration so the correct local date is used.
|
||||||
|
const [localFilteredEvents, setLocalFilteredEvents] = useState(events);
|
||||||
|
useEffect(() => {
|
||||||
|
const todayStr = new Intl.DateTimeFormat("en-CA").format(new Date());
|
||||||
|
setLocalFilteredEvents(events.filter((e) => (e.earliestGameTime ?? e.eventDate ?? "9999-12-31") >= todayStr));
|
||||||
|
}, [events]);
|
||||||
|
|
||||||
|
const displayedEvents = limit !== undefined ? localFilteredEvents.slice(0, limit) : localFilteredEvents;
|
||||||
|
const hiddenCount = limit !== undefined ? Math.max(0, localFilteredEvents.length - limit) : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
|
|
@ -127,15 +146,28 @@ export function UpcomingCalendarPanel({ events, showLeague = false }: Props) {
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{events.length === 0 ? (
|
{localFilteredEvents.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground py-2">
|
<p className="text-sm text-muted-foreground py-2">
|
||||||
No upcoming events in the next 30 days.
|
{emptyMessage ?? "No upcoming events in the next 30 days."}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
{events.map((event) => (
|
{displayedEvents.map((event) => (
|
||||||
<EventRow key={event.id} event={event} showLeague={showLeague} />
|
<EventRow key={event.id} event={event} showLeague={showLeague} />
|
||||||
))}
|
))}
|
||||||
|
{viewAllUrl && (
|
||||||
|
<div className="mt-3 pt-2 border-t">
|
||||||
|
<Link
|
||||||
|
to={viewAllUrl}
|
||||||
|
className="flex items-center gap-1 text-sm text-electric hover:underline"
|
||||||
|
>
|
||||||
|
{hiddenCount > 0
|
||||||
|
? `View all ${localFilteredEvents.length} upcoming events`
|
||||||
|
: "View all upcoming events"}
|
||||||
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ function makeEvent(overrides: Partial<CalendarPanelEvent> = {}): CalendarPanelEv
|
||||||
id: overrides.id ?? "event-1",
|
id: overrides.id ?? "event-1",
|
||||||
name: overrides.name ?? "UCL Quarterfinals",
|
name: overrides.name ?? "UCL Quarterfinals",
|
||||||
// Use explicit key check so callers can pass null to test the null-date path
|
// 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",
|
eventDate: "eventDate" in overrides ? (overrides.eventDate as string | null) : "2099-04-09",
|
||||||
earliestGameTime: "earliestGameTime" in overrides ? (overrides.earliestGameTime as string | null) : null,
|
earliestGameTime: "earliestGameTime" in overrides ? (overrides.earliestGameTime as string | null) : null,
|
||||||
matchLabel: overrides.matchLabel ?? null,
|
matchLabel: overrides.matchLabel ?? null,
|
||||||
eventType: overrides.eventType ?? "playoff_game",
|
eventType: overrides.eventType ?? "playoff_game",
|
||||||
|
|
@ -45,7 +45,7 @@ describe("UpcomingCalendarPanel", () => {
|
||||||
describe("event display", () => {
|
describe("event display", () => {
|
||||||
it("shows event name and formatted date", () => {
|
it("shows event name and formatted date", () => {
|
||||||
renderWithRouter(
|
renderWithRouter(
|
||||||
<UpcomingCalendarPanel events={[makeEvent({ name: "UCL QF", eventDate: "2025-04-09" })]} />
|
<UpcomingCalendarPanel events={[makeEvent({ name: "UCL QF", eventDate: "2099-04-09" })]} />
|
||||||
);
|
);
|
||||||
expect(screen.getByText("UCL QF")).toBeInTheDocument();
|
expect(screen.getByText("UCL QF")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Apr 9")).toBeInTheDocument();
|
expect(screen.getByText("Apr 9")).toBeInTheDocument();
|
||||||
|
|
@ -101,7 +101,7 @@ describe("UpcomingCalendarPanel", () => {
|
||||||
it("uses earliestGameTime date when eventDate is null", () => {
|
it("uses earliestGameTime date when eventDate is null", () => {
|
||||||
renderWithRouter(
|
renderWithRouter(
|
||||||
<UpcomingCalendarPanel
|
<UpcomingCalendarPanel
|
||||||
events={[makeEvent({ eventDate: null, earliestGameTime: "2025-04-09T14:00:00.000Z" })]}
|
events={[makeEvent({ eventDate: null, earliestGameTime: "2099-04-09T14:00:00.000Z" })]}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
// Should not show TBD
|
// Should not show TBD
|
||||||
|
|
|
||||||
|
|
@ -142,3 +142,4 @@ describe("toEventSortKey", () => {
|
||||||
expect(dateOnly < withTime).toBe(true);
|
expect(dateOnly < withTime).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,8 @@ vi.mock("drizzle-orm", () => ({
|
||||||
import { getUpcomingEventsForDraftedParticipants } from "../scoring-event";
|
import { getUpcomingEventsForDraftedParticipants } from "../scoring-event";
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
const DATE_FROM = new Date("2025-04-01");
|
const DATE_FROM = "2025-04-01";
|
||||||
const DATE_TO = new Date("2025-04-30");
|
const DATE_TO = "2025-04-30";
|
||||||
const SPORTS_SEASON_ID = "ss-1";
|
const SPORTS_SEASON_ID = "ss-1";
|
||||||
|
|
||||||
function makeParticipant(id: string, name: string) {
|
function makeParticipant(id: string, name: string) {
|
||||||
|
|
|
||||||
|
|
@ -373,15 +373,13 @@ export async function getUpcomingEventsForDraftedParticipants(
|
||||||
sportsSeasonId: string,
|
sportsSeasonId: string,
|
||||||
scoringPattern: string,
|
scoringPattern: string,
|
||||||
draftedParticipants: Array<{ id: string; name: string }>,
|
draftedParticipants: Array<{ id: string; name: string }>,
|
||||||
dateFrom: Date,
|
dateFromStr: string,
|
||||||
dateTo: Date,
|
dateToStr: string,
|
||||||
providedDb?: ReturnType<typeof database>
|
providedDb?: ReturnType<typeof database>
|
||||||
): Promise<UpcomingParticipantEvent[]> {
|
): Promise<UpcomingParticipantEvent[]> {
|
||||||
if (draftedParticipants.length === 0) return [];
|
if (draftedParticipants.length === 0) return [];
|
||||||
|
|
||||||
const db = providedDb || database();
|
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 draftedIds = draftedParticipants.map((p) => p.id);
|
||||||
const draftedMap = new Map(draftedParticipants.map((p) => [p.id, p]));
|
const draftedMap = new Map(draftedParticipants.map((p) => [p.id, p]));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,10 @@ export default [
|
||||||
route("leagues/new", "routes/leagues/new.tsx"),
|
route("leagues/new", "routes/leagues/new.tsx"),
|
||||||
route("leagues/:leagueId", "routes/leagues/$leagueId.tsx"),
|
route("leagues/:leagueId", "routes/leagues/$leagueId.tsx"),
|
||||||
route("leagues/:leagueId/settings", "routes/leagues/$leagueId.settings.tsx"),
|
route("leagues/:leagueId/settings", "routes/leagues/$leagueId.settings.tsx"),
|
||||||
|
route(
|
||||||
|
"leagues/:leagueId/upcoming-events",
|
||||||
|
"routes/leagues/$leagueId.upcoming-events.tsx"
|
||||||
|
),
|
||||||
route(
|
route(
|
||||||
"leagues/:leagueId/sports-seasons/:sportsSeasonId",
|
"leagues/:leagueId/sports-seasons/:sportsSeasonId",
|
||||||
"routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx"
|
"routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx"
|
||||||
|
|
@ -47,6 +51,7 @@ export default [
|
||||||
route("how-to-play", "routes/how-to-play.tsx"),
|
route("how-to-play", "routes/how-to-play.tsx"),
|
||||||
route("rules", "routes/rules.tsx"),
|
route("rules", "routes/rules.tsx"),
|
||||||
route("support", "routes/support.tsx"),
|
route("support", "routes/support.tsx"),
|
||||||
|
route("upcoming-events", "routes/upcoming-events.tsx"),
|
||||||
route("test-socket", "routes/test-socket.tsx"),
|
route("test-socket", "routes/test-socket.tsx"),
|
||||||
|
|
||||||
// Admin routes
|
// Admin routes
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useEffect } from "react";
|
||||||
import { Link, useSearchParams } from "react-router";
|
import { Link, useSearchParams } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { getAuth } from "@clerk/react-router/server";
|
||||||
import { addDays } from "date-fns";
|
import { addDays, subDays } from "date-fns";
|
||||||
|
|
||||||
import type { Route } from "./+types/home";
|
import type { Route } from "./+types/home";
|
||||||
import { findLeaguesWithActiveSeasonsByUserId } from "~/models/league";
|
import { findLeaguesWithActiveSeasonsByUserId } from "~/models/league";
|
||||||
|
|
@ -43,8 +43,8 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
const leagues = await findLeaguesWithActiveSeasonsByUserId(userId);
|
const leagues = await findLeaguesWithActiveSeasonsByUserId(userId);
|
||||||
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const calendarDateFrom = today;
|
const dateFromStr = subDays(today, 1).toISOString().split("T")[0];
|
||||||
const calendarDateTo = addDays(today, 30);
|
const dateToStr = addDays(today, 30).toISOString().split("T")[0];
|
||||||
|
|
||||||
// Fetch season details and calendar events in parallel per league
|
// Fetch season details and calendar events in parallel per league
|
||||||
const leaguesWithData = await Promise.all(
|
const leaguesWithData = await Promise.all(
|
||||||
|
|
@ -74,8 +74,8 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
ss.id,
|
ss.id,
|
||||||
ss.scoringPattern ?? "",
|
ss.scoringPattern ?? "",
|
||||||
draftedParticipants,
|
draftedParticipants,
|
||||||
calendarDateFrom,
|
dateFromStr,
|
||||||
calendarDateTo
|
dateToStr
|
||||||
);
|
);
|
||||||
|
|
||||||
return events.map((event) => ({
|
return events.map((event) => ({
|
||||||
|
|
@ -149,7 +149,12 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
||||||
|
|
||||||
{upcomingCalendarEvents.length > 0 && (
|
{upcomingCalendarEvents.length > 0 && (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<UpcomingCalendarPanel events={upcomingCalendarEvents} showLeague={true} />
|
<UpcomingCalendarPanel
|
||||||
|
events={upcomingCalendarEvents}
|
||||||
|
showLeague={true}
|
||||||
|
limit={6}
|
||||||
|
viewAllUrl="/upcoming-events"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { getAuth } from "@clerk/react-router/server";
|
||||||
import { addDays } from "date-fns";
|
import { addDays, subDays } from "date-fns";
|
||||||
import { toEventSortKey } from "~/lib/date-utils";
|
import { toEventSortKey } from "~/lib/date-utils";
|
||||||
import {
|
import {
|
||||||
findLeagueById,
|
findLeagueById,
|
||||||
|
|
@ -107,8 +107,8 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
|
|
||||||
const myTeam = teams.find((t) => t.ownerId === userId) ?? null;
|
const myTeam = teams.find((t) => t.ownerId === userId) ?? null;
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const calendarDateFrom = today;
|
const dateFromStr = subDays(today, 1).toISOString().split("T")[0];
|
||||||
const calendarDateTo = addDays(today, 30);
|
const dateToStr = addDays(today, 30).toISOString().split("T")[0];
|
||||||
|
|
||||||
const participantsBySportsSeason = myTeam && season
|
const participantsBySportsSeason = myTeam && season
|
||||||
? await getDraftedParticipantsBySportsSeason(myTeam.id, season.id)
|
? await getDraftedParticipantsBySportsSeason(myTeam.id, season.id)
|
||||||
|
|
@ -123,8 +123,8 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
ss.id,
|
ss.id,
|
||||||
ss.scoringPattern ?? "",
|
ss.scoringPattern ?? "",
|
||||||
draftedParticipants,
|
draftedParticipants,
|
||||||
calendarDateFrom,
|
dateFromStr,
|
||||||
calendarDateTo
|
dateToStr
|
||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -379,7 +379,12 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
|
||||||
{/* Right Column - 1/3 width on desktop */}
|
{/* Right Column - 1/3 width on desktop */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{upcomingCalendarEvents.length > 0 && (
|
{upcomingCalendarEvents.length > 0 && (
|
||||||
<UpcomingCalendarPanel events={upcomingCalendarEvents} showLeague={false} />
|
<UpcomingCalendarPanel
|
||||||
|
events={upcomingCalendarEvents}
|
||||||
|
showLeague={false}
|
||||||
|
limit={6}
|
||||||
|
viewAllUrl={`/leagues/${league.id}/upcoming-events`}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
|
||||||
125
app/routes/leagues/$leagueId.upcoming-events.tsx
Normal file
125
app/routes/leagues/$leagueId.upcoming-events.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
import { getAuth } from "@clerk/react-router/server";
|
||||||
|
import { addDays, subDays } from "date-fns";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
|
||||||
|
import type { Route } from "./+types/$leagueId.upcoming-events";
|
||||||
|
import {
|
||||||
|
findLeagueById,
|
||||||
|
isCommissioner,
|
||||||
|
isUserLeagueMember,
|
||||||
|
} from "~/models";
|
||||||
|
import { 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 { toEventSortKey } from "~/lib/date-utils";
|
||||||
|
|
||||||
|
export function meta({ data }: Route.MetaArgs) {
|
||||||
|
const leagueName = data?.leagueName ?? "League";
|
||||||
|
return [{ title: `${leagueName} — Upcoming Events — Brackt` }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loader(args: Route.LoaderArgs) {
|
||||||
|
const { userId } = await getAuth(args);
|
||||||
|
const { leagueId } = args.params;
|
||||||
|
|
||||||
|
const league = await findLeagueById(leagueId);
|
||||||
|
if (!league) {
|
||||||
|
throw new Response("League not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new Response("You must be logged in to view this league", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [isUserCommissioner, isUserMember] = await Promise.all([
|
||||||
|
isCommissioner(leagueId, userId),
|
||||||
|
isUserLeagueMember(leagueId, userId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!isUserCommissioner && !isUserMember) {
|
||||||
|
throw new Response("You do not have access to this league", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const seasonWithSports = await findCurrentSeasonWithSports(leagueId);
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const dateFromStr = subDays(today, 1).toISOString().split("T")[0];
|
||||||
|
const dateToStr = addDays(today, 60).toISOString().split("T")[0];
|
||||||
|
|
||||||
|
const calendarEvents: CalendarPanelEvent[] = [];
|
||||||
|
|
||||||
|
if (seasonWithSports?.seasonSports && seasonWithSports.id) {
|
||||||
|
const myTeam = await findTeamByOwnerAndSeason(userId, seasonWithSports.id);
|
||||||
|
|
||||||
|
if (myTeam) {
|
||||||
|
const participantsBySportsSeason = await getDraftedParticipantsBySportsSeason(
|
||||||
|
myTeam.id,
|
||||||
|
seasonWithSports.id
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
dateFromStr,
|
||||||
|
dateToStr
|
||||||
|
);
|
||||||
|
|
||||||
|
return events.map((event) => ({
|
||||||
|
...event,
|
||||||
|
sportName: ss.sport.name,
|
||||||
|
sportSeasonName: ss.name,
|
||||||
|
sportsSeasonPageUrl: `/leagues/${leagueId}/sports-seasons/${ss.id}`,
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
calendarEvents.push(...perSeasonEvents.flat());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const upcomingCalendarEvents = calendarEvents.toSorted((a, b) =>
|
||||||
|
toEventSortKey(a).localeCompare(toEventSortKey(b))
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
leagueName: league.name,
|
||||||
|
leagueId,
|
||||||
|
upcomingCalendarEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LeagueUpcomingEventsPage({ loaderData }: Route.ComponentProps) {
|
||||||
|
const { leagueName, leagueId, upcomingCalendarEvents } = loaderData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-8 px-4 max-w-2xl">
|
||||||
|
<div className="mb-6">
|
||||||
|
<Link
|
||||||
|
to={`/leagues/${leagueId}`}
|
||||||
|
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors mb-4"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-3.5 w-3.5" />
|
||||||
|
{leagueName}
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-3xl font-bold">Upcoming Events</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">Next 60 days · {leagueName}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UpcomingCalendarPanel
|
||||||
|
events={upcomingCalendarEvents}
|
||||||
|
showLeague={false}
|
||||||
|
emptyMessage="No upcoming events in the next 60 days for this league."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
app/routes/upcoming-events.tsx
Normal file
120
app/routes/upcoming-events.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import { getAuth } from "@clerk/react-router/server";
|
||||||
|
import { addDays, subDays } from "date-fns";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
|
||||||
|
import type { Route } from "./+types/upcoming-events";
|
||||||
|
import { findLeaguesWithActiveSeasonsByUserId } from "~/models/league";
|
||||||
|
import { 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 { toEventSortKey } from "~/lib/date-utils";
|
||||||
|
|
||||||
|
export function meta() {
|
||||||
|
return [{ title: "Upcoming Events — Brackt" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loader(args: Route.LoaderArgs) {
|
||||||
|
const { userId } = await getAuth(args);
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return { isLoggedIn: false as const, upcomingCalendarEvents: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const leagues = await findLeaguesWithActiveSeasonsByUserId(userId);
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const dateFromStr = subDays(today, 1).toISOString().split("T")[0];
|
||||||
|
const dateToStr = addDays(today, 60).toISOString().split("T")[0];
|
||||||
|
|
||||||
|
const leaguesWithData = await Promise.all(
|
||||||
|
leagues.map(async (league) => {
|
||||||
|
const calendarEvents: CalendarPanelEvent[] = [];
|
||||||
|
|
||||||
|
if (league.currentSeasonId) {
|
||||||
|
const [seasonWithSports, myTeam] = await Promise.all([
|
||||||
|
findCurrentSeasonWithSports(league.id),
|
||||||
|
findTeamByOwnerAndSeason(userId, league.currentSeasonId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (myTeam && seasonWithSports?.seasonSports) {
|
||||||
|
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,
|
||||||
|
dateFromStr,
|
||||||
|
dateToStr
|
||||||
|
);
|
||||||
|
|
||||||
|
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 calendarEvents;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const upcomingCalendarEvents = leaguesWithData
|
||||||
|
.flat()
|
||||||
|
.toSorted((a, b) => toEventSortKey(a).localeCompare(toEventSortKey(b)));
|
||||||
|
|
||||||
|
return { isLoggedIn: true as const, upcomingCalendarEvents };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UpcomingEventsPage({ loaderData }: Route.ComponentProps) {
|
||||||
|
const { isLoggedIn, upcomingCalendarEvents } = loaderData;
|
||||||
|
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-16 px-4 text-center">
|
||||||
|
<p className="text-muted-foreground">Sign in to see your upcoming events.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-8 px-4 max-w-2xl">
|
||||||
|
<div className="mb-6">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors mb-4"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-3.5 w-3.5" />
|
||||||
|
My Leagues
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-3xl font-bold">Upcoming Events</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">Next 60 days across all your leagues</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UpcomingCalendarPanel
|
||||||
|
events={upcomingCalendarEvents}
|
||||||
|
showLeague={true}
|
||||||
|
emptyMessage="No upcoming events in the next 60 days."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue