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 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-03-27 00:49:16 -07:00 committed by GitHub
parent 29ea93ad6d
commit 3c4ed67946
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 317 additions and 26 deletions

View file

@ -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 (
<Card>
<CardHeader className="pb-3">
@ -127,15 +146,28 @@ export function UpcomingCalendarPanel({ events, showLeague = false }: Props) {
</CardTitle>
</CardHeader>
<CardContent>
{events.length === 0 ? (
{localFilteredEvents.length === 0 ? (
<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>
) : (
<div>
{events.map((event) => (
{displayedEvents.map((event) => (
<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>
)}
</CardContent>

View file

@ -13,7 +13,7 @@ function makeEvent(overrides: Partial<CalendarPanelEvent> = {}): 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(
<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("Apr 9")).toBeInTheDocument();
@ -101,7 +101,7 @@ describe("UpcomingCalendarPanel", () => {
it("uses earliestGameTime date when eventDate is null", () => {
renderWithRouter(
<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

View file

@ -142,3 +142,4 @@ describe("toEventSortKey", () => {
expect(dateOnly < withTime).toBe(true);
});
});

View file

@ -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) {

View file

@ -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<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]));

View file

@ -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

View file

@ -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 && (
<div className="mb-8">
<UpcomingCalendarPanel events={upcomingCalendarEvents} showLeague={true} />
<UpcomingCalendarPanel
events={upcomingCalendarEvents}
showLeague={true}
limit={6}
viewAllUrl="/upcoming-events"
/>
</div>
)}

View file

@ -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 {

View file

@ -379,7 +379,12 @@ 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} />
<UpcomingCalendarPanel
events={upcomingCalendarEvents}
showLeague={false}
limit={6}
viewAllUrl={`/leagues/${league.id}/upcoming-events`}
/>
)}
<Card>
<CardHeader>

View 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>
);
}

View 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>
);
}