diff --git a/PLAN.md b/PLAN.md index baf3354..17d0ff0 100644 --- a/PLAN.md +++ b/PLAN.md @@ -18,6 +18,7 @@ - Client-side clip and assignment reads now persist locally and revalidate against server ETags. - Normalized playback media is cacheable for offline clip playback. - Auth and session responses remain `no-store` so cached data is limited to app-owned clip state. +- TeamSnap read queries now use cached-first stale-while-revalidate behavior on the client. ## Storage Status - Backend media persists in the `backend-media` named Docker volume. diff --git a/README.md b/README.md index 9162950..ca29696 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ Walkup is a collaborative baseball walk-up song app built as a React PWA with a ## Frontend Responsibilities - TeamSnap SDK bootstrap with server-issued access tokens -- Team/game browsing from TeamSnap +- Team/game browsing from TeamSnap with cached-first revalidation - Song upload and clip creation - Game assignments and gameday console - PWA install/offline shell diff --git a/docs/architecture.md b/docs/architecture.md index c74ba2b..33ee2c5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,6 +15,7 @@ Walkup is a baseball walk-up song app with a React PWA frontend and a FastAPI ba - `frontend/` contains the React application. - The app uses React Router for navigation and TanStack Query for server state. - TeamSnap data is loaded through the official JavaScript SDK from the browser after the backend provides an access token. +- Read-only TeamSnap queries use a stale-while-revalidate cache in the browser so the UI can render immediately from stored data and update when a fresh response arrives. - The UI includes player, gameday, and library views for clip management and gameday playback. - The app is shipped as a PWA with install and offline-prep behavior. - Normalized playback media is cached by the service worker, and the backend marks those files cacheable while keeping auth/session responses `no-store`. diff --git a/frontend/src/hooks/useWalkupContext.tsx b/frontend/src/hooks/useWalkupContext.tsx index f7ab3fe..f697aae 100644 --- a/frontend/src/hooks/useWalkupContext.tsx +++ b/frontend/src/hooks/useWalkupContext.tsx @@ -5,7 +5,7 @@ import { api } from "../api/client"; import type { TeamSnapEvent, TeamSnapMember } from "../api/types"; import { queryClient } from "../lib/queryClient"; import { findCurrentPlayer, findNextGame, sortGames } from "../lib/teamsnapHelpers"; -import { teamsnapClient } from "../lib/teamsnap"; +import { teamSnapQueryKeys, teamsnapClient } from "../lib/teamsnap"; import { useSession } from "./useSession"; const TEAM_STORAGE_KEY = "walkup.selectedTeamId"; @@ -24,10 +24,13 @@ const WalkupContext = createContext(null); function useBuildWalkupContext() { const sessionQuery = useSession(); const isTeamSnap = sessionQuery.data?.authenticated === true && sessionQuery.data?.provider === "teamsnap"; + const teamSnapCacheScope = isTeamSnap + ? String(sessionQuery.data?.external_user_id ?? sessionQuery.data?.external_team_id ?? "teamsnap") + : "anonymous"; const [selectedTeamId, setSelectedTeamId] = useState(readStoredTeamId); const teamsQuery = useQuery({ - queryKey: ["teamsnap", "teams"], - queryFn: () => teamsnapClient.loadTeams(), + queryKey: teamSnapQueryKeys.teams(teamSnapCacheScope), + queryFn: () => teamsnapClient.loadTeams(teamSnapCacheScope), enabled: isTeamSnap, networkMode: "always", retry: 0, @@ -53,15 +56,15 @@ function useBuildWalkupContext() { }, [resolvedTeamId, selectedTeam, selectedTeamId, teams.length]); const membersQuery = useQuery({ - queryKey: ["teamsnap", "members", resolvedTeamId], - queryFn: () => teamsnapClient.loadMembers(resolvedTeamId), + queryKey: teamSnapQueryKeys.members(teamSnapCacheScope, resolvedTeamId), + queryFn: () => teamsnapClient.loadMembers(resolvedTeamId, teamSnapCacheScope), enabled: isTeamSnap && Boolean(resolvedTeamId), networkMode: "always", retry: 0, }); const eventsQuery = useQuery({ - queryKey: ["teamsnap", "events", resolvedTeamId], - queryFn: () => teamsnapClient.loadEvents(resolvedTeamId), + queryKey: teamSnapQueryKeys.events(teamSnapCacheScope, resolvedTeamId), + queryFn: () => teamsnapClient.loadEvents(resolvedTeamId, teamSnapCacheScope), enabled: isTeamSnap && Boolean(resolvedTeamId), networkMode: "always", retry: 0, @@ -112,6 +115,7 @@ function useBuildWalkupContext() { isTeamSnap, sessionQuery, teamsQuery, + teamSnapCacheScope, selectedTeam, selectedTeamId, hasSelectedTeam: Boolean(resolvedTeamId), diff --git a/frontend/src/lib/teamSnapCache.ts b/frontend/src/lib/teamSnapCache.ts new file mode 100644 index 0000000..e27eed1 --- /dev/null +++ b/frontend/src/lib/teamSnapCache.ts @@ -0,0 +1,86 @@ +import type { QueryKey } from "@tanstack/react-query"; + +import { queryClient } from "./queryClient"; +import { readCachedValue, writeCachedValue } from "./offlineCache"; + +const inFlightRequests = new Map>(); + +export function normalizeTeamSnapCacheScope(scope?: string | number | null): string { + const value = scope == null ? "" : String(scope).trim(); + return value || "anonymous"; +} + +function buildCacheParts(scope: string | number | null | undefined, resource: string, parts: readonly unknown[]): readonly unknown[] { + return ["teamsnap", normalizeTeamSnapCacheScope(scope), resource, ...parts]; +} + +function cacheKey(parts: readonly unknown[]): string { + return JSON.stringify(parts); +} + +function startFreshFetch( + cacheParts: readonly unknown[], + queryKey: QueryKey, + fetchFresh: () => Promise, +): Promise { + const key = cacheKey(cacheParts); + const existing = inFlightRequests.get(key); + if (existing) { + return existing as Promise; + } + + const request = fetchFresh() + .then((data) => { + writeCachedValue(cacheParts, data); + queryClient.setQueryData(queryKey, data); + return data; + }) + .finally(() => { + inFlightRequests.delete(key); + }); + + inFlightRequests.set(key, request); + return request; +} + +export async function staleWhileRevalidateTeamSnap({ + cacheParts, + queryKey, + fetchFresh, +}: { + cacheParts: readonly unknown[]; + queryKey: QueryKey; + fetchFresh: () => Promise; +}): Promise { + const cached = readCachedValue(cacheParts); + if (cached) { + void startFreshFetch(cacheParts, queryKey, fetchFresh).catch(() => undefined); + return cached.data; + } + + return startFreshFetch(cacheParts, queryKey, fetchFresh); +} + +export const teamSnapQueryKeys = { + me(scope?: string | number | null): QueryKey { + return buildCacheParts(scope, "me", []); + }, + teams(scope?: string | number | null): QueryKey { + return buildCacheParts(scope, "teams", []); + }, + members(scope: string | number | null | undefined, teamId: string): QueryKey { + return buildCacheParts(scope, "members", [teamId]); + }, + events(scope: string | number | null | undefined, teamId: string): QueryKey { + return buildCacheParts(scope, "events", [teamId]); + }, + availabilities(scope: string | number | null | undefined, teamId: string, eventId?: string): QueryKey { + return buildCacheParts(scope, "availabilities", [teamId, eventId ?? ""]); + }, + assignments(scope: string | number | null | undefined, teamId: string, eventId?: string): QueryKey { + return buildCacheParts(scope, "assignments", [teamId, eventId ?? ""]); + }, + eventLineup(scope: string | number | null | undefined, teamId: string, eventId: string): QueryKey { + return buildCacheParts(scope, "eventLineup", [teamId, eventId]); + }, +}; diff --git a/frontend/src/lib/teamsnap.ts b/frontend/src/lib/teamsnap.ts index 79a2bb9..d4bec0b 100644 --- a/frontend/src/lib/teamsnap.ts +++ b/frontend/src/lib/teamsnap.ts @@ -11,6 +11,9 @@ import type { TeamSnapTeam, TeamSnapUser, } from "../api/types"; +import { staleWhileRevalidateTeamSnap, teamSnapQueryKeys } from "./teamSnapCache"; + +export { teamSnapQueryKeys } from "./teamSnapCache"; type TeamSnapSdk = { auth?: (token: string) => Promise | void; @@ -106,81 +109,126 @@ async function ensureAuthorized(): Promise { } export const teamsnapClient = { - async loadMe(): Promise { - const sdk = await ensureAuthorized(); - if (sdk.loadMe) { - return sdk.loadMe(); - } - return null; + async loadMe(cacheScope?: string | number | null): Promise { + return staleWhileRevalidateTeamSnap({ + cacheParts: teamSnapQueryKeys.me(cacheScope), + queryKey: teamSnapQueryKeys.me(cacheScope), + fetchFresh: async () => { + const sdk = await ensureAuthorized(); + if (sdk.loadMe) { + return sdk.loadMe(); + } + return null; + }, + }); }, - async loadTeams(): Promise { - const sdk = await ensureAuthorized(); - if (sdk.loadTeams) { - const teams = await sdk.loadTeams(); - return teams.filter((team) => team.isRetired !== true); - } - return []; + async loadTeams(cacheScope?: string | number | null): Promise { + return staleWhileRevalidateTeamSnap({ + cacheParts: teamSnapQueryKeys.teams(cacheScope), + queryKey: teamSnapQueryKeys.teams(cacheScope), + fetchFresh: async () => { + const sdk = await ensureAuthorized(); + if (sdk.loadTeams) { + const teams = await sdk.loadTeams(); + return teams.filter((team) => team.isRetired !== true); + } + return []; + }, + }); }, - async loadMembers(teamId: string): Promise { - const sdk = await ensureAuthorized(); - if (sdk.loadMembers) { - return sdk.loadMembers({ teamId }); - } - return []; + async loadMembers(teamId: string, cacheScope?: string | number | null): Promise { + return staleWhileRevalidateTeamSnap({ + cacheParts: teamSnapQueryKeys.members(cacheScope, teamId), + queryKey: teamSnapQueryKeys.members(cacheScope, teamId), + fetchFresh: async () => { + const sdk = await ensureAuthorized(); + if (sdk.loadMembers) { + return sdk.loadMembers({ teamId }); + } + return []; + }, + }); }, - async loadEvents(teamId: string): Promise { - const sdk = await ensureAuthorized(); - if (sdk.loadEvents) { - return sdk.loadEvents({ teamId }); - } - return []; + async loadEvents(teamId: string, cacheScope?: string | number | null): Promise { + return staleWhileRevalidateTeamSnap({ + cacheParts: teamSnapQueryKeys.events(cacheScope, teamId), + queryKey: teamSnapQueryKeys.events(cacheScope, teamId), + fetchFresh: async () => { + const sdk = await ensureAuthorized(); + if (sdk.loadEvents) { + return sdk.loadEvents({ teamId }); + } + return []; + }, + }); }, - async loadAvailabilities(teamId: string, eventId?: string): Promise { - const sdk = await ensureAuthorized(); - if (sdk.loadAvailabilities) { - return sdk.loadAvailabilities(eventId ? { teamId, eventId } : { teamId }); - } - return []; + async loadAvailabilities(teamId: string, eventId?: string, cacheScope?: string | number | null): Promise { + return staleWhileRevalidateTeamSnap({ + cacheParts: teamSnapQueryKeys.availabilities(cacheScope, teamId, eventId), + queryKey: teamSnapQueryKeys.availabilities(cacheScope, teamId, eventId), + fetchFresh: async () => { + const sdk = await ensureAuthorized(); + if (sdk.loadAvailabilities) { + return sdk.loadAvailabilities(eventId ? { teamId, eventId } : { teamId }); + } + return []; + }, + }); }, - async loadAssignments(teamId: string, eventId?: string): Promise { - const sdk = await ensureAuthorized(); - if (sdk.loadAssignments) { - return sdk.loadAssignments(eventId ? { teamId, eventId } : { teamId }); - } - return []; + async loadAssignments(teamId: string, eventId?: string, cacheScope?: string | number | null): Promise { + return staleWhileRevalidateTeamSnap({ + cacheParts: teamSnapQueryKeys.assignments(cacheScope, teamId, eventId), + queryKey: teamSnapQueryKeys.assignments(cacheScope, teamId, eventId), + fetchFresh: async () => { + const sdk = await ensureAuthorized(); + if (sdk.loadAssignments) { + return sdk.loadAssignments(eventId ? { teamId, eventId } : { teamId }); + } + return []; + }, + }); }, - async loadEventLineupData(teamId: string, eventId: string): Promise<{ + async loadEventLineupData(teamId: string, eventId: string, cacheScope?: string | number | null): Promise<{ eventLineup: TeamSnapEventLineup | null; entries: TeamSnapEventLineupEntry[]; }> { - const sdk = await ensureAuthorized(); - if (!sdk.loadEventLineups) { - return { eventLineup: null, entries: [] }; - } + return staleWhileRevalidateTeamSnap<{ + eventLineup: TeamSnapEventLineup | null; + entries: TeamSnapEventLineupEntry[]; + }>({ + cacheParts: teamSnapQueryKeys.eventLineup(cacheScope, teamId, eventId), + queryKey: teamSnapQueryKeys.eventLineup(cacheScope, teamId, eventId), + fetchFresh: async () => { + const sdk = await ensureAuthorized(); + if (!sdk.loadEventLineups) { + return { eventLineup: null, entries: [] }; + } - const eventLineups = await sdk.loadEventLineups(eventId); - const eventLineup = eventLineups.length ? eventLineups[eventLineups.length - 1] : null; + const eventLineups = await sdk.loadEventLineups(eventId); + const eventLineup = eventLineups.length ? eventLineups[eventLineups.length - 1] : null; - const eventLineupWithLinks = eventLineup as TeamSnapEventLineup & { - loadItems?: (linkName: string) => Promise; - } | null; - if (!eventLineupWithLinks?.loadItems) { - return { eventLineup, entries: [] }; - } + const eventLineupWithLinks = eventLineup as TeamSnapEventLineup & { + loadItems?: (linkName: string) => Promise; + } | null; + if (!eventLineupWithLinks?.loadItems) { + return { eventLineup, entries: [] }; + } - try { - const rawEntries = await eventLineupWithLinks.loadItems("eventLineupEntries"); - const entries = rawEntries - .filter((item): item is TeamSnapEventLineupEntry => item.type === "eventLineupEntry") - .sort((left, right) => { - const leftSequence = Number(left.sequence ?? Number.MAX_SAFE_INTEGER); - const rightSequence = Number(right.sequence ?? Number.MAX_SAFE_INTEGER); - return leftSequence - rightSequence; - }); + try { + const rawEntries = await eventLineupWithLinks.loadItems("eventLineupEntries"); + const entries = rawEntries + .filter((item): item is TeamSnapEventLineupEntry => item.type === "eventLineupEntry") + .sort((left, right) => { + const leftSequence = Number(left.sequence ?? Number.MAX_SAFE_INTEGER); + const rightSequence = Number(right.sequence ?? Number.MAX_SAFE_INTEGER); + return leftSequence - rightSequence; + }); - return { eventLineup, entries }; - } catch { - return { eventLineup, entries: [] }; - } + return { eventLineup, entries }; + } catch { + return { eventLineup, entries: [] }; + } + }, + }); }, }; diff --git a/frontend/src/pages/GamedayPage.tsx b/frontend/src/pages/GamedayPage.tsx index fc0837d..2dbd680 100644 --- a/frontend/src/pages/GamedayPage.tsx +++ b/frontend/src/pages/GamedayPage.tsx @@ -8,7 +8,7 @@ import { ClipSummaryRow } from "../components/ClipSummaryRow"; import { useClipPlayback } from "../hooks/useClipPlayback"; import { useWalkupContext } from "../hooks/useWalkupContext"; import { loadPreparedGame } from "../lib/offlinePrep"; -import { teamsnapClient } from "../lib/teamsnap"; +import { teamSnapQueryKeys, teamsnapClient } from "../lib/teamsnap"; import { formatGameDate, formatGameTitle, @@ -124,16 +124,16 @@ export function GamedayPage() { const assignmentList = assignmentsQuery.data ?? preparedGame?.assignments ?? []; const eventLineupQuery = useQuery({ - queryKey: ["teamsnap", "eventLineup", teamId, resolvedSelectedGameId], - queryFn: () => teamsnapClient.loadEventLineupData(teamId, resolvedSelectedGameId), + queryKey: teamSnapQueryKeys.eventLineup(walkup.teamSnapCacheScope, teamId, resolvedSelectedGameId), + queryFn: () => teamsnapClient.loadEventLineupData(teamId, resolvedSelectedGameId, walkup.teamSnapCacheScope), enabled: Boolean(teamId && resolvedSelectedGameId), networkMode: "always", retry: 0, }); const availabilityQuery = useQuery({ - queryKey: ["teamsnap", "availabilities", teamId, resolvedSelectedGameId], - queryFn: () => teamsnapClient.loadAvailabilities(teamId, resolvedSelectedGameId), + queryKey: teamSnapQueryKeys.availabilities(walkup.teamSnapCacheScope, teamId, resolvedSelectedGameId), + queryFn: () => teamsnapClient.loadAvailabilities(teamId, resolvedSelectedGameId, walkup.teamSnapCacheScope), enabled: Boolean(teamId && resolvedSelectedGameId), networkMode: "always", retry: 0,