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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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