From 3c4ed6794611d13a4554979509db5474d14d0723 Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Fri, 27 Mar 2026 00:49:16 -0700 Subject: [PATCH] Add upcoming events pages and fix timezone filtering, fixes #213 (#235) - 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 --- .../sport-season/UpcomingCalendarPanel.tsx | 42 +++++- .../__tests__/UpcomingCalendarPanel.test.tsx | 6 +- app/lib/__tests__/date-utils.test.ts | 1 + .../__tests__/upcoming-calendar.test.ts | 4 +- app/models/scoring-event.ts | 6 +- app/routes.ts | 5 + app/routes/home.tsx | 17 ++- app/routes/leagues/$leagueId.server.ts | 10 +- app/routes/leagues/$leagueId.tsx | 7 +- .../leagues/$leagueId.upcoming-events.tsx | 125 ++++++++++++++++++ app/routes/upcoming-events.tsx | 120 +++++++++++++++++ 11 files changed, 317 insertions(+), 26 deletions(-) create mode 100644 app/routes/leagues/$leagueId.upcoming-events.tsx create mode 100644 app/routes/upcoming-events.tsx diff --git a/app/components/sport-season/UpcomingCalendarPanel.tsx b/app/components/sport-season/UpcomingCalendarPanel.tsx index 3a135ee..71acd58 100644 --- a/app/components/sport-season/UpcomingCalendarPanel.tsx +++ b/app/components/sport-season/UpcomingCalendarPanel.tsx @@ -1,5 +1,6 @@ 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 { Badge } from "~/components/ui/badge"; import { @@ -24,6 +25,12 @@ interface Props { events: CalendarPanelEvent[]; /** When true, renders a league name badge on each row */ 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 }> }) { @@ -117,7 +124,19 @@ function EventRow({ event, showLeague }: { event: CalendarPanelEvent; showLeague 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 ( @@ -127,15 +146,28 @@ export function UpcomingCalendarPanel({ events, showLeague = false }: Props) { - {events.length === 0 ? ( + {localFilteredEvents.length === 0 ? (

- No upcoming events in the next 30 days. + {emptyMessage ?? "No upcoming events in the next 30 days."}

) : (
- {events.map((event) => ( + {displayedEvents.map((event) => ( ))} + {viewAllUrl && ( +
+ + {hiddenCount > 0 + ? `View all ${localFilteredEvents.length} upcoming events` + : "View all upcoming events"} + + +
+ )}
)}
diff --git a/app/components/sport-season/__tests__/UpcomingCalendarPanel.test.tsx b/app/components/sport-season/__tests__/UpcomingCalendarPanel.test.tsx index 756cf18..8a4d43b 100644 --- a/app/components/sport-season/__tests__/UpcomingCalendarPanel.test.tsx +++ b/app/components/sport-season/__tests__/UpcomingCalendarPanel.test.tsx @@ -13,7 +13,7 @@ function makeEvent(overrides: Partial = {}): CalendarPanelEv 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", + eventDate: "eventDate" in overrides ? (overrides.eventDate as string | null) : "2099-04-09", earliestGameTime: "earliestGameTime" in overrides ? (overrides.earliestGameTime as string | null) : null, matchLabel: overrides.matchLabel ?? null, eventType: overrides.eventType ?? "playoff_game", @@ -45,7 +45,7 @@ describe("UpcomingCalendarPanel", () => { describe("event display", () => { it("shows event name and formatted date", () => { renderWithRouter( - + ); expect(screen.getByText("UCL QF")).toBeInTheDocument(); expect(screen.getByText("Apr 9")).toBeInTheDocument(); @@ -101,7 +101,7 @@ describe("UpcomingCalendarPanel", () => { it("uses earliestGameTime date when eventDate is null", () => { renderWithRouter( ); // Should not show TBD diff --git a/app/lib/__tests__/date-utils.test.ts b/app/lib/__tests__/date-utils.test.ts index 0a4f81b..036934a 100644 --- a/app/lib/__tests__/date-utils.test.ts +++ b/app/lib/__tests__/date-utils.test.ts @@ -142,3 +142,4 @@ describe("toEventSortKey", () => { expect(dateOnly < withTime).toBe(true); }); }); + diff --git a/app/models/__tests__/upcoming-calendar.test.ts b/app/models/__tests__/upcoming-calendar.test.ts index 76a86f8..83f7aa5 100644 --- a/app/models/__tests__/upcoming-calendar.test.ts +++ b/app/models/__tests__/upcoming-calendar.test.ts @@ -42,8 +42,8 @@ vi.mock("drizzle-orm", () => ({ import { getUpcomingEventsForDraftedParticipants } from "../scoring-event"; // ── Helpers ──────────────────────────────────────────────────────────────── -const DATE_FROM = new Date("2025-04-01"); -const DATE_TO = new Date("2025-04-30"); +const DATE_FROM = "2025-04-01"; +const DATE_TO = "2025-04-30"; const SPORTS_SEASON_ID = "ss-1"; function makeParticipant(id: string, name: string) { diff --git a/app/models/scoring-event.ts b/app/models/scoring-event.ts index 2e5e9e5..bc65ce6 100644 --- a/app/models/scoring-event.ts +++ b/app/models/scoring-event.ts @@ -373,15 +373,13 @@ export async function getUpcomingEventsForDraftedParticipants( sportsSeasonId: string, scoringPattern: string, draftedParticipants: Array<{ id: string; name: string }>, - dateFrom: Date, - dateTo: Date, + dateFromStr: string, + dateToStr: string, providedDb?: ReturnType ): Promise { 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])); diff --git a/app/routes.ts b/app/routes.ts index 56c282d..50e4959 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -6,6 +6,10 @@ export default [ route("leagues/new", "routes/leagues/new.tsx"), route("leagues/:leagueId", "routes/leagues/$leagueId.tsx"), route("leagues/:leagueId/settings", "routes/leagues/$leagueId.settings.tsx"), + route( + "leagues/:leagueId/upcoming-events", + "routes/leagues/$leagueId.upcoming-events.tsx" + ), route( "leagues/:leagueId/sports-seasons/:sportsSeasonId", "routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx" @@ -47,6 +51,7 @@ export default [ route("how-to-play", "routes/how-to-play.tsx"), route("rules", "routes/rules.tsx"), route("support", "routes/support.tsx"), + route("upcoming-events", "routes/upcoming-events.tsx"), route("test-socket", "routes/test-socket.tsx"), // Admin routes diff --git a/app/routes/home.tsx b/app/routes/home.tsx index 2a09d86..9cf1a30 100644 --- a/app/routes/home.tsx +++ b/app/routes/home.tsx @@ -2,7 +2,7 @@ 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 { addDays, subDays } from "date-fns"; import type { Route } from "./+types/home"; import { findLeaguesWithActiveSeasonsByUserId } from "~/models/league"; @@ -43,8 +43,8 @@ export async function loader(args: Route.LoaderArgs) { const leagues = await findLeaguesWithActiveSeasonsByUserId(userId); const today = new Date(); - const calendarDateFrom = today; - const calendarDateTo = addDays(today, 30); + const dateFromStr = subDays(today, 1).toISOString().split("T")[0]; + const dateToStr = addDays(today, 30).toISOString().split("T")[0]; // Fetch season details and calendar events in parallel per league const leaguesWithData = await Promise.all( @@ -74,8 +74,8 @@ export async function loader(args: Route.LoaderArgs) { ss.id, ss.scoringPattern ?? "", draftedParticipants, - calendarDateFrom, - calendarDateTo + dateFromStr, + dateToStr ); return events.map((event) => ({ @@ -149,7 +149,12 @@ export default function Home({ loaderData }: Route.ComponentProps) { {upcomingCalendarEvents.length > 0 && (
- +
)} diff --git a/app/routes/leagues/$leagueId.server.ts b/app/routes/leagues/$leagueId.server.ts index c96fa2e..e241096 100644 --- a/app/routes/leagues/$leagueId.server.ts +++ b/app/routes/leagues/$leagueId.server.ts @@ -1,5 +1,5 @@ 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 { findLeagueById, @@ -107,8 +107,8 @@ export async function loader(args: Route.LoaderArgs) { const myTeam = teams.find((t) => t.ownerId === userId) ?? null; const today = new Date(); - const calendarDateFrom = today; - const calendarDateTo = addDays(today, 30); + const dateFromStr = subDays(today, 1).toISOString().split("T")[0]; + const dateToStr = addDays(today, 30).toISOString().split("T")[0]; const participantsBySportsSeason = myTeam && season ? await getDraftedParticipantsBySportsSeason(myTeam.id, season.id) @@ -123,8 +123,8 @@ export async function loader(args: Route.LoaderArgs) { ss.id, ss.scoringPattern ?? "", draftedParticipants, - calendarDateFrom, - calendarDateTo + dateFromStr, + dateToStr ) : []; return { diff --git a/app/routes/leagues/$leagueId.tsx b/app/routes/leagues/$leagueId.tsx index 8c5a183..9ae1dca 100644 --- a/app/routes/leagues/$leagueId.tsx +++ b/app/routes/leagues/$leagueId.tsx @@ -379,7 +379,12 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) { {/* Right Column - 1/3 width on desktop */}
{upcomingCalendarEvents.length > 0 && ( - + )} diff --git a/app/routes/leagues/$leagueId.upcoming-events.tsx b/app/routes/leagues/$leagueId.upcoming-events.tsx new file mode 100644 index 0000000..64b9f4d --- /dev/null +++ b/app/routes/leagues/$leagueId.upcoming-events.tsx @@ -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 ( +
+
+ + + {leagueName} + +

Upcoming Events

+

Next 60 days · {leagueName}

+
+ + +
+ ); +} diff --git a/app/routes/upcoming-events.tsx b/app/routes/upcoming-events.tsx new file mode 100644 index 0000000..3274840 --- /dev/null +++ b/app/routes/upcoming-events.tsx @@ -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 ( +
+

Sign in to see your upcoming events.

+
+ ); + } + + return ( +
+
+ + + My Leagues + +

Upcoming Events

+

Next 60 days across all your leagues

+
+ + +
+ ); +}