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

View File

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

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.
- 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`.

View File

@@ -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<WalkupContextValue | null>(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),

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,
TeamSnapUser,
} from "../api/types";
import { staleWhileRevalidateTeamSnap, teamSnapQueryKeys } from "./teamSnapCache";
export { teamSnapQueryKeys } from "./teamSnapCache";
type TeamSnapSdk = {
auth?: (token: string) => Promise<void> | void;
@@ -106,14 +109,24 @@ async function ensureAuthorized(): Promise<TeamSnapSdk> {
}
export const teamsnapClient = {
async loadMe(): Promise<TeamSnapUser | null> {
async loadMe(cacheScope?: string | number | null): Promise<TeamSnapUser | null> {
return staleWhileRevalidateTeamSnap<TeamSnapUser | null>({
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<TeamSnapTeam[]> {
});
},
async loadTeams(cacheScope?: string | number | null): Promise<TeamSnapTeam[]> {
return staleWhileRevalidateTeamSnap<TeamSnapTeam[]>({
cacheParts: teamSnapQueryKeys.teams(cacheScope),
queryKey: teamSnapQueryKeys.teams(cacheScope),
fetchFresh: async () => {
const sdk = await ensureAuthorized();
if (sdk.loadTeams) {
const teams = await sdk.loadTeams();
@@ -121,38 +134,71 @@ export const teamsnapClient = {
}
return [];
},
async loadMembers(teamId: string): Promise<TeamSnapMember[]> {
});
},
async loadMembers(teamId: string, cacheScope?: string | number | null): Promise<TeamSnapMember[]> {
return staleWhileRevalidateTeamSnap<TeamSnapMember[]>({
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<TeamSnapEvent[]> {
});
},
async loadEvents(teamId: string, cacheScope?: string | number | null): Promise<TeamSnapEvent[]> {
return staleWhileRevalidateTeamSnap<TeamSnapEvent[]>({
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<TeamSnapAvailability[]> {
});
},
async loadAvailabilities(teamId: string, eventId?: string, cacheScope?: string | number | null): Promise<TeamSnapAvailability[]> {
return staleWhileRevalidateTeamSnap<TeamSnapAvailability[]>({
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<TeamSnapAssignment[]> {
});
},
async loadAssignments(teamId: string, eventId?: string, cacheScope?: string | number | null): Promise<TeamSnapAssignment[]> {
return staleWhileRevalidateTeamSnap<TeamSnapAssignment[]>({
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[];
}> {
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: [] };
@@ -183,4 +229,6 @@ export const teamsnapClient = {
return { eventLineup, entries: [] };
}
},
});
},
};

View File

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