Add cached-first TeamSnap reads
This commit is contained in:
1
PLAN.md
1
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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),
|
||||
|
||||
86
frontend/src/lib/teamSnapCache.ts
Normal file
86
frontend/src/lib/teamSnapCache.ts
Normal 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]);
|
||||
},
|
||||
};
|
||||
@@ -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,81 +109,126 @@ async function ensureAuthorized(): Promise<TeamSnapSdk> {
|
||||
}
|
||||
|
||||
export const teamsnapClient = {
|
||||
async loadMe(): Promise<TeamSnapUser | null> {
|
||||
const sdk = await ensureAuthorized();
|
||||
if (sdk.loadMe) {
|
||||
return sdk.loadMe();
|
||||
}
|
||||
return 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[]> {
|
||||
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<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();
|
||||
return teams.filter((team) => team.isRetired !== true);
|
||||
}
|
||||
return [];
|
||||
},
|
||||
});
|
||||
},
|
||||
async loadMembers(teamId: string): Promise<TeamSnapMember[]> {
|
||||
const sdk = await ensureAuthorized();
|
||||
if (sdk.loadMembers) {
|
||||
return sdk.loadMembers({ teamId });
|
||||
}
|
||||
return [];
|
||||
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[]> {
|
||||
const sdk = await ensureAuthorized();
|
||||
if (sdk.loadEvents) {
|
||||
return sdk.loadEvents({ teamId });
|
||||
}
|
||||
return [];
|
||||
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[]> {
|
||||
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<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[]> {
|
||||
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<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[];
|
||||
}> {
|
||||
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<TeamSnapEventLineupEntry[]>;
|
||||
} | null;
|
||||
if (!eventLineupWithLinks?.loadItems) {
|
||||
return { eventLineup, entries: [] };
|
||||
}
|
||||
const eventLineupWithLinks = eventLineup as TeamSnapEventLineup & {
|
||||
loadItems?: (linkName: string) => Promise<TeamSnapEventLineupEntry[]>;
|
||||
} | 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: [] };
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user