Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions e2e/tests/live-refresh.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 ({
Expand Down
4 changes: 2 additions & 2 deletions src/app/api/game-stats/[eventId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/schedule/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
73 changes: 42 additions & 31 deletions src/hooks/useGameStats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
};
Expand All @@ -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]);

Expand All @@ -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,
};
}
62 changes: 41 additions & 21 deletions src/hooks/useSeasonSchedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export function useSeasonSchedule() {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const requestIdRef = useRef(0);
const requestRef = useRef<AbortController | null>(null);

const requestUrl = useMemo(() => {
const params = new URLSearchParams();
Expand All @@ -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]);

Expand All @@ -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);
},
Expand Down
Loading