Add cached-first TeamSnap reads

This commit is contained in:
Codex
2026-04-23 14:14:18 -05:00
parent 7023199981
commit abb7cf5184
7 changed files with 216 additions and 76 deletions

View File

@@ -18,6 +18,7 @@
- Client-side clip and assignment reads now persist locally and revalidate against server ETags. - Client-side clip and assignment reads now persist locally and revalidate against server ETags.
- Normalized playback media is cacheable for offline clip playback. - 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. - 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 ## Storage Status
- Backend media persists in the `backend-media` named Docker volume. - Backend media persists in the `backend-media` named Docker volume.

View File

@@ -58,7 +58,7 @@ Walkup is a collaborative baseball walk-up song app built as a React PWA with a
## Frontend Responsibilities ## Frontend Responsibilities
- TeamSnap SDK bootstrap with server-issued access tokens - 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 - Song upload and clip creation
- Game assignments and gameday console - Game assignments and gameday console
- PWA install/offline shell - PWA install/offline shell

View File

@@ -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. - `frontend/` contains the React application.
- The app uses React Router for navigation and TanStack Query for server state. - 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. - 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 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. - 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`. - Normalized playback media is cached by the service worker, and the backend marks those files cacheable while keeping auth/session responses `no-store`.

View File

@@ -5,7 +5,7 @@ import { api } from "../api/client";
import type { TeamSnapEvent, TeamSnapMember } from "../api/types"; import type { TeamSnapEvent, TeamSnapMember } from "../api/types";
import { queryClient } from "../lib/queryClient"; import { queryClient } from "../lib/queryClient";
import { findCurrentPlayer, findNextGame, sortGames } from "../lib/teamsnapHelpers"; import { findCurrentPlayer, findNextGame, sortGames } from "../lib/teamsnapHelpers";
import { teamsnapClient } from "../lib/teamsnap"; import { teamSnapQueryKeys, teamsnapClient } from "../lib/teamsnap";
import { useSession } from "./useSession"; import { useSession } from "./useSession";
const TEAM_STORAGE_KEY = "walkup.selectedTeamId"; const TEAM_STORAGE_KEY = "walkup.selectedTeamId";
@@ -24,10 +24,13 @@ const WalkupContext = createContext<WalkupContextValue | null>(null);
function useBuildWalkupContext() { function useBuildWalkupContext() {
const sessionQuery = useSession(); const sessionQuery = useSession();
const isTeamSnap = sessionQuery.data?.authenticated === true && sessionQuery.data?.provider === "teamsnap"; 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 [selectedTeamId, setSelectedTeamId] = useState(readStoredTeamId);
const teamsQuery = useQuery({ const teamsQuery = useQuery({
queryKey: ["teamsnap", "teams"], queryKey: teamSnapQueryKeys.teams(teamSnapCacheScope),
queryFn: () => teamsnapClient.loadTeams(), queryFn: () => teamsnapClient.loadTeams(teamSnapCacheScope),
enabled: isTeamSnap, enabled: isTeamSnap,
networkMode: "always", networkMode: "always",
retry: 0, retry: 0,
@@ -53,15 +56,15 @@ function useBuildWalkupContext() {
}, [resolvedTeamId, selectedTeam, selectedTeamId, teams.length]); }, [resolvedTeamId, selectedTeam, selectedTeamId, teams.length]);
const membersQuery = useQuery({ const membersQuery = useQuery({
queryKey: ["teamsnap", "members", resolvedTeamId], queryKey: teamSnapQueryKeys.members(teamSnapCacheScope, resolvedTeamId),
queryFn: () => teamsnapClient.loadMembers(resolvedTeamId), queryFn: () => teamsnapClient.loadMembers(resolvedTeamId, teamSnapCacheScope),
enabled: isTeamSnap && Boolean(resolvedTeamId), enabled: isTeamSnap && Boolean(resolvedTeamId),
networkMode: "always", networkMode: "always",
retry: 0, retry: 0,
}); });
const eventsQuery = useQuery({ const eventsQuery = useQuery({
queryKey: ["teamsnap", "events", resolvedTeamId], queryKey: teamSnapQueryKeys.events(teamSnapCacheScope, resolvedTeamId),
queryFn: () => teamsnapClient.loadEvents(resolvedTeamId), queryFn: () => teamsnapClient.loadEvents(resolvedTeamId, teamSnapCacheScope),
enabled: isTeamSnap && Boolean(resolvedTeamId), enabled: isTeamSnap && Boolean(resolvedTeamId),
networkMode: "always", networkMode: "always",
retry: 0, retry: 0,
@@ -112,6 +115,7 @@ function useBuildWalkupContext() {
isTeamSnap, isTeamSnap,
sessionQuery, sessionQuery,
teamsQuery, teamsQuery,
teamSnapCacheScope,
selectedTeam, selectedTeam,
selectedTeamId, selectedTeamId,
hasSelectedTeam: Boolean(resolvedTeamId), hasSelectedTeam: Boolean(resolvedTeamId),

View File

@@ -0,0 +1,86 @@
import type { QueryKey } from "@tanstack/react-query";
import { queryClient } from "./queryClient";
import { readCachedValue, writeCachedValue } from "./offlineCache";
const inFlightRequests = new Map<string, Promise<unknown>>();
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<T>(
cacheParts: readonly unknown[],
queryKey: QueryKey,
fetchFresh: () => Promise<T>,
): Promise<T> {
const key = cacheKey(cacheParts);
const existing = inFlightRequests.get(key);
if (existing) {
return existing as Promise<T>;
}
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<T>({
cacheParts,
queryKey,
fetchFresh,
}: {
cacheParts: readonly unknown[];
queryKey: QueryKey;
fetchFresh: () => Promise<T>;
}): Promise<T> {
const cached = readCachedValue<T>(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]);
},
};

View File

@@ -11,6 +11,9 @@ import type {
TeamSnapTeam, TeamSnapTeam,
TeamSnapUser, TeamSnapUser,
} from "../api/types"; } from "../api/types";
import { staleWhileRevalidateTeamSnap, teamSnapQueryKeys } from "./teamSnapCache";
export { teamSnapQueryKeys } from "./teamSnapCache";
type TeamSnapSdk = { type TeamSnapSdk = {
auth?: (token: string) => Promise<void> | void; auth?: (token: string) => Promise<void> | void;
@@ -106,81 +109,126 @@ async function ensureAuthorized(): Promise<TeamSnapSdk> {
} }
export const teamsnapClient = { export const teamsnapClient = {
async loadMe(): Promise<TeamSnapUser | null> { async loadMe(cacheScope?: string | number | null): Promise<TeamSnapUser | null> {
const sdk = await ensureAuthorized(); return staleWhileRevalidateTeamSnap<TeamSnapUser | null>({
if (sdk.loadMe) { cacheParts: teamSnapQueryKeys.me(cacheScope),
return sdk.loadMe(); queryKey: teamSnapQueryKeys.me(cacheScope),
} fetchFresh: async () => {
return null; const sdk = await ensureAuthorized();
if (sdk.loadMe) {
return sdk.loadMe();
}
return null;
},
});
}, },
async loadTeams(): Promise<TeamSnapTeam[]> { async loadTeams(cacheScope?: string | number | null): Promise<TeamSnapTeam[]> {
const sdk = await ensureAuthorized(); return staleWhileRevalidateTeamSnap<TeamSnapTeam[]>({
if (sdk.loadTeams) { cacheParts: teamSnapQueryKeys.teams(cacheScope),
const teams = await sdk.loadTeams(); queryKey: teamSnapQueryKeys.teams(cacheScope),
return teams.filter((team) => team.isRetired !== true); fetchFresh: async () => {
} const sdk = await ensureAuthorized();
return []; if (sdk.loadTeams) {
const teams = await sdk.loadTeams();
return teams.filter((team) => team.isRetired !== true);
}
return [];
},
});
}, },
async loadMembers(teamId: string): Promise<TeamSnapMember[]> { async loadMembers(teamId: string, cacheScope?: string | number | null): Promise<TeamSnapMember[]> {
const sdk = await ensureAuthorized(); return staleWhileRevalidateTeamSnap<TeamSnapMember[]>({
if (sdk.loadMembers) { cacheParts: teamSnapQueryKeys.members(cacheScope, teamId),
return sdk.loadMembers({ teamId }); queryKey: teamSnapQueryKeys.members(cacheScope, teamId),
} fetchFresh: async () => {
return []; const sdk = await ensureAuthorized();
if (sdk.loadMembers) {
return sdk.loadMembers({ teamId });
}
return [];
},
});
}, },
async loadEvents(teamId: string): Promise<TeamSnapEvent[]> { async loadEvents(teamId: string, cacheScope?: string | number | null): Promise<TeamSnapEvent[]> {
const sdk = await ensureAuthorized(); return staleWhileRevalidateTeamSnap<TeamSnapEvent[]>({
if (sdk.loadEvents) { cacheParts: teamSnapQueryKeys.events(cacheScope, teamId),
return sdk.loadEvents({ teamId }); queryKey: teamSnapQueryKeys.events(cacheScope, teamId),
} fetchFresh: async () => {
return []; const sdk = await ensureAuthorized();
if (sdk.loadEvents) {
return sdk.loadEvents({ teamId });
}
return [];
},
});
}, },
async loadAvailabilities(teamId: string, eventId?: string): Promise<TeamSnapAvailability[]> { async loadAvailabilities(teamId: string, eventId?: string, cacheScope?: string | number | null): Promise<TeamSnapAvailability[]> {
const sdk = await ensureAuthorized(); return staleWhileRevalidateTeamSnap<TeamSnapAvailability[]>({
if (sdk.loadAvailabilities) { cacheParts: teamSnapQueryKeys.availabilities(cacheScope, teamId, eventId),
return sdk.loadAvailabilities(eventId ? { teamId, eventId } : { teamId }); queryKey: teamSnapQueryKeys.availabilities(cacheScope, teamId, eventId),
} fetchFresh: async () => {
return []; const sdk = await ensureAuthorized();
if (sdk.loadAvailabilities) {
return sdk.loadAvailabilities(eventId ? { teamId, eventId } : { teamId });
}
return [];
},
});
}, },
async loadAssignments(teamId: string, eventId?: string): Promise<TeamSnapAssignment[]> { async loadAssignments(teamId: string, eventId?: string, cacheScope?: string | number | null): Promise<TeamSnapAssignment[]> {
const sdk = await ensureAuthorized(); return staleWhileRevalidateTeamSnap<TeamSnapAssignment[]>({
if (sdk.loadAssignments) { cacheParts: teamSnapQueryKeys.assignments(cacheScope, teamId, eventId),
return sdk.loadAssignments(eventId ? { teamId, eventId } : { teamId }); queryKey: teamSnapQueryKeys.assignments(cacheScope, teamId, eventId),
} fetchFresh: async () => {
return []; 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; eventLineup: TeamSnapEventLineup | null;
entries: TeamSnapEventLineupEntry[]; entries: TeamSnapEventLineupEntry[];
}> { }> {
const sdk = await ensureAuthorized(); return staleWhileRevalidateTeamSnap<{
if (!sdk.loadEventLineups) { eventLineup: TeamSnapEventLineup | null;
return { eventLineup: null, entries: [] }; 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 eventLineups = await sdk.loadEventLineups(eventId);
const eventLineup = eventLineups.length ? eventLineups[eventLineups.length - 1] : null; const eventLineup = eventLineups.length ? eventLineups[eventLineups.length - 1] : null;
const eventLineupWithLinks = eventLineup as TeamSnapEventLineup & { const eventLineupWithLinks = eventLineup as TeamSnapEventLineup & {
loadItems?: (linkName: string) => Promise<TeamSnapEventLineupEntry[]>; loadItems?: (linkName: string) => Promise<TeamSnapEventLineupEntry[]>;
} | null; } | null;
if (!eventLineupWithLinks?.loadItems) { if (!eventLineupWithLinks?.loadItems) {
return { eventLineup, entries: [] }; return { eventLineup, entries: [] };
} }
try { try {
const rawEntries = await eventLineupWithLinks.loadItems("eventLineupEntries"); const rawEntries = await eventLineupWithLinks.loadItems("eventLineupEntries");
const entries = rawEntries const entries = rawEntries
.filter((item): item is TeamSnapEventLineupEntry => item.type === "eventLineupEntry") .filter((item): item is TeamSnapEventLineupEntry => item.type === "eventLineupEntry")
.sort((left, right) => { .sort((left, right) => {
const leftSequence = Number(left.sequence ?? Number.MAX_SAFE_INTEGER); const leftSequence = Number(left.sequence ?? Number.MAX_SAFE_INTEGER);
const rightSequence = Number(right.sequence ?? Number.MAX_SAFE_INTEGER); const rightSequence = Number(right.sequence ?? Number.MAX_SAFE_INTEGER);
return leftSequence - rightSequence; return leftSequence - rightSequence;
}); });
return { eventLineup, entries }; return { eventLineup, entries };
} catch { } catch {
return { eventLineup, entries: [] }; return { eventLineup, entries: [] };
} }
},
});
}, },
}; };

View File

@@ -8,7 +8,7 @@ import { ClipSummaryRow } from "../components/ClipSummaryRow";
import { useClipPlayback } from "../hooks/useClipPlayback"; import { useClipPlayback } from "../hooks/useClipPlayback";
import { useWalkupContext } from "../hooks/useWalkupContext"; import { useWalkupContext } from "../hooks/useWalkupContext";
import { loadPreparedGame } from "../lib/offlinePrep"; import { loadPreparedGame } from "../lib/offlinePrep";
import { teamsnapClient } from "../lib/teamsnap"; import { teamSnapQueryKeys, teamsnapClient } from "../lib/teamsnap";
import { import {
formatGameDate, formatGameDate,
formatGameTitle, formatGameTitle,
@@ -124,16 +124,16 @@ export function GamedayPage() {
const assignmentList = assignmentsQuery.data ?? preparedGame?.assignments ?? []; const assignmentList = assignmentsQuery.data ?? preparedGame?.assignments ?? [];
const eventLineupQuery = useQuery({ const eventLineupQuery = useQuery({
queryKey: ["teamsnap", "eventLineup", teamId, resolvedSelectedGameId], queryKey: teamSnapQueryKeys.eventLineup(walkup.teamSnapCacheScope, teamId, resolvedSelectedGameId),
queryFn: () => teamsnapClient.loadEventLineupData(teamId, resolvedSelectedGameId), queryFn: () => teamsnapClient.loadEventLineupData(teamId, resolvedSelectedGameId, walkup.teamSnapCacheScope),
enabled: Boolean(teamId && resolvedSelectedGameId), enabled: Boolean(teamId && resolvedSelectedGameId),
networkMode: "always", networkMode: "always",
retry: 0, retry: 0,
}); });
const availabilityQuery = useQuery({ const availabilityQuery = useQuery({
queryKey: ["teamsnap", "availabilities", teamId, resolvedSelectedGameId], queryKey: teamSnapQueryKeys.availabilities(walkup.teamSnapCacheScope, teamId, resolvedSelectedGameId),
queryFn: () => teamsnapClient.loadAvailabilities(teamId, resolvedSelectedGameId), queryFn: () => teamsnapClient.loadAvailabilities(teamId, resolvedSelectedGameId, walkup.teamSnapCacheScope),
enabled: Boolean(teamId && resolvedSelectedGameId), enabled: Boolean(teamId && resolvedSelectedGameId),
networkMode: "always", networkMode: "always",
retry: 0, retry: 0,