From b395cfae29226a1b43f1f3651135e3f8c783b9ff Mon Sep 17 00:00:00 2001 From: Neil Goldader Date: Sun, 13 Sep 2026 23:48:18 -0400 Subject: [PATCH] fix: reduce live ESPN refresh latency --- e2e/tests/live-refresh.spec.ts | 42 ++++++++++++- src/app/api/game-stats/[eventId]/route.ts | 4 +- src/app/api/schedule/route.ts | 2 +- src/hooks/useGameStats.ts | 73 +++++++++++++---------- src/hooks/useSeasonSchedule.ts | 62 ++++++++++++------- 5 files changed, 125 insertions(+), 58 deletions(-) diff --git a/e2e/tests/live-refresh.spec.ts b/e2e/tests/live-refresh.spec.ts index d1a33b5..524f172 100644 --- a/e2e/tests/live-refresh.spec.ts +++ b/e2e/tests/live-refresh.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from "../fixtures/test-fixtures"; import { mockGameBoxscore, mockPreseasonSchedule } from "../fixtures/mock-data"; -test("refreshes live schedule and selected stats every ten seconds", async ({ +test("refreshes selected live stats every three seconds and the schedule every five", async ({ page, seedUser: _seedUser, mockEspnApi: _mockEspnApi, @@ -38,10 +38,46 @@ test("refreshes live schedule and selected stats every ten seconds", async ({ const initialScheduleRequests = scheduleRequests; const initialStatsRequests = statsRequests; homeScore = 17; - await page.clock.fastForward(10_000); - await expect.poll(() => scheduleRequests).toBeGreaterThan(initialScheduleRequests); + await page.clock.fastForward(3_000); await expect.poll(() => statsRequests).toBeGreaterThan(initialStatsRequests); await expect(panel.locator("span").filter({ hasText: /^17$/ })).toBeVisible(); + await page.clock.fastForward(2_000); + await expect.poll(() => scheduleRequests).toBeGreaterThan(initialScheduleRequests); +}); + +test("refreshes live data immediately after reconnecting", async ({ + page, + seedUser: _seedUser, + mockEspnApi: _mockEspnApi, +}) => { + await page.setViewportSize({ width: 1440, height: 900 }); + let scheduleRequests = 0; + let statsRequests = 0; + const game = mockPreseasonSchedule.games[0]; + await page.route("**/api/schedule**", (route) => { + scheduleRequests++; + return route.fulfill({ json: mockPreseasonSchedule }); + }); + await page.route("**/api/game-stats/**", (route) => { + statsRequests++; + return route.fulfill({ + json: { + ...mockGameBoxscore, + eventId: game.id, + isInProgress: true, + isComplete: false, + }, + }); + }); + await page.goto("/"); + await page.getByTestId(`live-dashboard-game-${game.id}`).click(); + const panel = page.getByTestId("game-stats-panel"); + await expect(panel.getByText("Total Yards", { exact: true })).toBeVisible(); + const initialScheduleRequests = scheduleRequests; + const initialStatsRequests = statsRequests; + await page.evaluate(() => window.dispatchEvent(new Event("online"))); + await expect.poll(() => scheduleRequests).toBeGreaterThan(initialScheduleRequests); + await expect.poll(() => statsRequests).toBeGreaterThan(initialStatsRequests); }); test("detects kickoff without reloading a schedule with no live games", async ({ diff --git a/src/app/api/game-stats/[eventId]/route.ts b/src/app/api/game-stats/[eventId]/route.ts index fcdd729..1aabcbb 100644 --- a/src/app/api/game-stats/[eventId]/route.ts +++ b/src/app/api/game-stats/[eventId]/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { fetchGameBoxscore } from "@/lib/espn-boxscore"; export const dynamic = "force-dynamic"; -export const revalidate = 5; +export const revalidate = 2; export async function GET(_request: Request, { params }: { params: Promise<{ eventId: string }> }) { try { @@ -16,7 +16,7 @@ export async function GET(_request: Request, { params }: { params: Promise<{ eve return NextResponse.json(boxscore, { headers: { - "Cache-Control": "public, max-age=0, s-maxage=5, must-revalidate", + "Cache-Control": "public, max-age=0, s-maxage=2, must-revalidate", }, }); } catch (error) { diff --git a/src/app/api/schedule/route.ts b/src/app/api/schedule/route.ts index 34a2caf..9c0ffb5 100644 --- a/src/app/api/schedule/route.ts +++ b/src/app/api/schedule/route.ts @@ -25,7 +25,7 @@ export async function GET(request: NextRequest) { Number.isFinite(season) ? season : undefined, ); return NextResponse.json(schedule, { - headers: { "Cache-Control": "public, max-age=0, s-maxage=5, must-revalidate" }, + headers: { "Cache-Control": "public, max-age=0, s-maxage=2, must-revalidate" }, }); } catch (error) { console.error("Failed to fetch NFL schedule:", error); diff --git a/src/hooks/useGameStats.ts b/src/hooks/useGameStats.ts index 8e5dd2d..9ed3c7f 100644 --- a/src/hooks/useGameStats.ts +++ b/src/hooks/useGameStats.ts @@ -30,39 +30,46 @@ export function useGameStats( const current = isOpen && state.eventId === eventId ? state : null; const stats = current?.stats ?? null; - const fetchStats = useCallback(async () => { - if (!eventId || !isOpen) return; + const fetchStats = useCallback( + async (background = false) => { + if (!eventId || !isOpen) return; + if (request.current && !request.current.signal.aborted) return; - request.current?.abort(); - const controller = new AbortController(); - request.current = controller; - setState((previous) => ({ - eventId, - stats: previous.eventId === eventId ? previous.stats : null, - lastUpdated: previous.eventId === eventId ? previous.lastUpdated : null, - isLoading: true, - error: null, - })); - - try { - const res = await fetch(`/api/game-stats/${eventId}`, { signal: controller.signal }); - if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`); - const data: GameBoxscore = await res.json(); - if (data.eventId !== eventId) throw new Error("Game stats do not match the requested game"); - if (controller.signal.aborted) return; - setState({ eventId, stats: data, lastUpdated: new Date(), isLoading: false, error: null }); - } catch (e) { - if (controller.signal.aborted) return; + const controller = new AbortController(); + request.current = controller; setState((previous) => ({ - ...previous, - isLoading: false, - error: e instanceof Error ? e : new Error("Unknown error"), + eventId, + stats: previous.eventId === eventId ? previous.stats : null, + lastUpdated: previous.eventId === eventId ? previous.lastUpdated : null, + isLoading: background ? previous.isLoading : true, + error: null, })); - } - }, [eventId, isOpen]); + + try { + const res = await fetch(`/api/game-stats/${eventId}`, { signal: controller.signal }); + if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`); + const data: GameBoxscore = await res.json(); + if (data.eventId !== eventId) throw new Error("Game stats do not match the requested game"); + if (controller.signal.aborted) return; + setState({ eventId, stats: data, lastUpdated: new Date(), isLoading: false, error: null }); + } catch (e) { + if (controller.signal.aborted) return; + if (!background) { + setState((previous) => ({ + ...previous, + isLoading: false, + error: e instanceof Error ? e : new Error("Unknown error"), + })); + } + } finally { + if (request.current === controller) request.current = null; + } + }, + [eventId, isOpen], + ); useEffect(() => { - fetchStats(); + void fetchStats(false); return () => { request.current?.abort(); }; @@ -73,13 +80,17 @@ export function useGameStats( useEffect(() => { if (!eventId || !isOpen || !autoRefresh || isComplete) return; const refreshIfVisible = () => { - if (document.visibilityState === "visible") void fetchStats(); + if (document.visibilityState === "visible") void fetchStats(true); }; - const interval = setInterval(refreshIfVisible, isLive ? 10_000 : 60_000); + const interval = setInterval(refreshIfVisible, isLive ? 3_000 : 60_000); document.addEventListener("visibilitychange", refreshIfVisible); + window.addEventListener("focus", refreshIfVisible); + window.addEventListener("online", refreshIfVisible); return () => { clearInterval(interval); document.removeEventListener("visibilitychange", refreshIfVisible); + window.removeEventListener("focus", refreshIfVisible); + window.removeEventListener("online", refreshIfVisible); }; }, [eventId, isOpen, autoRefresh, isComplete, isLive, fetchStats]); @@ -93,7 +104,7 @@ export function useGameStats( stats, isLoading: current?.isLoading ?? Boolean(isOpen && eventId), error: current?.error ?? null, - refetch: fetchStats, + refetch: () => fetchStats(false), lastUpdated: current?.lastUpdated ?? null, }; } diff --git a/src/hooks/useSeasonSchedule.ts b/src/hooks/useSeasonSchedule.ts index cc50a00..9a847f9 100644 --- a/src/hooks/useSeasonSchedule.ts +++ b/src/hooks/useSeasonSchedule.ts @@ -21,6 +21,7 @@ export function useSeasonSchedule() { const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const requestIdRef = useRef(0); + const requestRef = useRef(null); const requestUrl = useMemo(() => { const params = new URLSearchParams(); @@ -33,42 +34,61 @@ export function useSeasonSchedule() { return query ? `/api/schedule?${query}` : "/api/schedule"; }, [requestedPhase, requestedSeason, requestedWeek]); - const loadSchedule = useCallback(async () => { - const requestId = ++requestIdRef.current; - setIsLoading(true); - setError(null); + const loadSchedule = useCallback( + async (background = false) => { + if (requestRef.current && !requestRef.current.signal.aborted) return; - try { - const response = await fetch(requestUrl); - if (!response.ok) throw new Error(`Schedule request failed: ${response.status}`); - const nextSchedule: SeasonSchedule = await response.json(); - if (requestId === requestIdRef.current) setSchedule(nextSchedule); - } catch (requestError) { - if (requestId !== requestIdRef.current) return; - console.error("Failed to load schedule:", requestError); - setError("We couldn't load the schedule. Check your connection and try again."); - } finally { - if (requestId === requestIdRef.current) setIsLoading(false); - } - }, [requestUrl]); + const requestId = ++requestIdRef.current; + const controller = new AbortController(); + requestRef.current = controller; + if (!background) setIsLoading(true); + if (!background) setError(null); + + try { + const response = await fetch(requestUrl, { signal: controller.signal }); + if (!response.ok) throw new Error(`Schedule request failed: ${response.status}`); + const nextSchedule: SeasonSchedule = await response.json(); + if (requestId === requestIdRef.current) { + setSchedule(nextSchedule); + setError(null); + } + } catch (requestError) { + if (controller.signal.aborted || requestId !== requestIdRef.current) return; + console.error("Failed to load schedule:", requestError); + if (!background) { + setError("We couldn't load the schedule. Check your connection and try again."); + } + } finally { + if (requestRef.current === controller) requestRef.current = null; + if (!background && requestId === requestIdRef.current) setIsLoading(false); + } + }, + [requestUrl], + ); useEffect(() => { - void loadSchedule(); + void loadSchedule(false); return () => { requestIdRef.current += 1; + requestRef.current?.abort(); + requestRef.current = null; }; }, [loadSchedule]); const hasLiveGames = schedule?.games.some((game) => game.isInProgress) ?? false; useEffect(() => { const refreshIfVisible = () => { - if (document.visibilityState === "visible") void loadSchedule(); + if (document.visibilityState === "visible") void loadSchedule(true); }; - const interval = window.setInterval(refreshIfVisible, hasLiveGames ? 10_000 : 60_000); + const interval = window.setInterval(refreshIfVisible, hasLiveGames ? 5_000 : 60_000); document.addEventListener("visibilitychange", refreshIfVisible); + window.addEventListener("focus", refreshIfVisible); + window.addEventListener("online", refreshIfVisible); return () => { window.clearInterval(interval); document.removeEventListener("visibilitychange", refreshIfVisible); + window.removeEventListener("focus", refreshIfVisible); + window.removeEventListener("online", refreshIfVisible); }; }, [loadSchedule, hasLiveGames]); @@ -92,7 +112,7 @@ export function useSeasonSchedule() { selectedPhase: requestedPhase ?? schedule?.phase ?? null, isLoading, error, - retry: loadSchedule, + retry: () => loadSchedule(false), selectPhase: (phase: SeasonPhase) => { if (schedule) updateSelection(phase, schedule.seasonYear); },