Merge feature/offline-clip-cache into dev

This commit is contained in:
Codex
2026-04-23 16:40:51 -05:00
32 changed files with 897 additions and 136 deletions

View File

@@ -3,7 +3,28 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#132238" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Walkup" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link
rel="apple-touch-startup-image"
href="/apple-splash-1125x2436.png"
media="screen and (device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-1170x2532.png"
media="screen and (device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-1290x2796.png"
media="screen and (device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3)"
/>
<title>Walkup</title>
</head>
<body>

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -1,8 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="none">
<rect width="256" height="256" rx="48" fill="#132238"/>
<circle cx="128" cy="128" r="80" fill="#F4EDE2"/>
<rect width="256" height="256" rx="48" fill="#132238" />
<path
fill="#f4ede2"
d="M144 70a14 14 0 1 1-28 0a14 14 0 0 1 28 0m-18.8 26.6a9 9 0 0 1 8.6-6.6h15a9 9 0 0 1 7.4 3.9l15.2 22.7a9 9 0 1 1-15 10.1l-10.1-15.1l-4.7 21.6l20.8 18.1a9 9 0 0 1-11.8 13.6L132 148.4l-12.4 20.7V192a9 9 0 1 1-18 0v-25.4a9 9 0 0 1 1.3-4.6l14.1-23.5l4.4-20.3l-8 9.8a9 9 0 0 1-7 3.3H87a9 9 0 1 1 0-18h15.2z"
fill="#D94F04"
/>
</svg>

Before

Width:  |  Height:  |  Size: 533 B

After

Width:  |  Height:  |  Size: 482 B

View File

@@ -0,0 +1,34 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1290 2796" fill="none">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1290" y2="2796" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#132238" />
<stop offset="0.55" stop-color="#18324b" />
<stop offset="1" stop-color="#0f1a2a" />
</linearGradient>
<radialGradient id="glow" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(645 940) rotate(90) scale(600 520)">
<stop offset="0" stop-color="#f4ede2" stop-opacity="0.18" />
<stop offset="1" stop-color="#f4ede2" stop-opacity="0" />
</radialGradient>
<filter id="shadow" x="0" y="0" width="1290" height="2796" filterUnits="userSpaceOnUse">
<feDropShadow dx="0" dy="42" stdDeviation="46" flood-color="#09111b" flood-opacity="0.42" />
</filter>
</defs>
<rect width="1290" height="2796" fill="url(#bg)" />
<circle cx="645" cy="940" r="560" fill="url(#glow)" />
<circle cx="200" cy="430" r="220" fill="#d94f04" fill-opacity="0.08" />
<circle cx="1090" cy="2260" r="260" fill="#f4ede2" fill-opacity="0.06" />
<g filter="url(#shadow)">
<rect x="220" y="662" width="850" height="850" rx="170" fill="#f4ede2" />
<path
transform="translate(397 839) scale(27.5)"
fill="#132238"
d="M5.25 0a1.5 1.5 0 1 0 0 3a1.5 1.5 0 0 0 0-3Zm-1.91 2.252a.75.75 0 0 0-.69.448l-.5 1.25A.75.75 0 1 0 3.55 4.45l.27-.672l.54.616l-.156 1.501a.75.75 0 0 0 .173.577l1.33 1.52a.75.75 0 1 0 1.13-.988L5.83 6.15l.235-2.25a.75.75 0 0 0-.735-.828H4.43a.75.75 0 0 0-.637.352l-.453.67l.144-1.086a.75.75 0 0 0-.145-.58a.75.75 0 0 0-.571-.226Zm3.875 5.493a.75.75 0 0 0-.207.042a.75.75 0 0 0-.418.965l.44 1.1l.96.96a.75.75 0 1 0 1.06-1.06l-.86-.86l-.413-1.03a.75.75 0 0 0-.562-.117Zm3.69-2.245a.75.75 0 0 0-.53 1.28h.86l-.288-.288l.66.661a.75.75 0 0 0 1.06-1.06l-.98-.98H11.7a.75.75 0 0 0-.487.187Z"
/>
</g>
<text x="645" y="1848" text-anchor="middle" fill="#f4ede2" font-size="108" font-weight="700" letter-spacing="0.02em" font-family="Arial, Helvetica, sans-serif">
Walkup
</text>
<text x="645" y="1920" text-anchor="middle" fill="#b9c6d3" font-size="40" font-weight="500" letter-spacing="0.04em" font-family="Arial, Helvetica, sans-serif">
Offline clip cache for the dugout
</text>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1,5 +1,5 @@
import { Component, useEffect, useState, type ErrorInfo, type ReactElement, type ReactNode } from "react";
import { NavLink, Navigate, Route, Routes, useLocation } from "react-router-dom";
import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
import { WalkupProvider, useWalkupContext } from "./hooks/useWalkupContext";
import { useSession } from "./hooks/useSession";
@@ -10,6 +10,7 @@ import { ProfilePage } from "./pages/ProfilePage";
import { AdminPage } from "./pages/AdminPage";
import { SignInPage } from "./pages/SignInPage";
import { formatTeamLabel } from "./lib/teamsnapHelpers";
import { useOnlineStatus } from "./hooks/useOnlineStatus";
function getRouteDestinationLabel(pathname: string) {
switch (pathname) {
@@ -227,6 +228,8 @@ function ShellLayout() {
const [navOpen, setNavOpen] = useState(false);
const walkup = useWalkupContext();
const location = useLocation();
const navigate = useNavigate();
const isOnline = useOnlineStatus();
const currentPageLabel = getNavbarPageLabel(location.pathname);
const showNavbar = walkup.sessionQuery.data?.authenticated === true;
const showTeamSelectionModal = walkup.isTeamSnap && walkup.teamsQuery.isFetched && !walkup.hasSelectedTeam;
@@ -248,12 +251,17 @@ function ShellLayout() {
};
}, [showTeamSelectionModal]);
function goTo(pathname: string) {
setNavOpen(false);
navigate(pathname);
}
return (
<div className={shellClassName}>
<div className={shellClassName}>
{showNavbar ? (
<nav className="navbar navbar-expand-lg navbar-dark bg-dark shadow-sm sticky-top px-3 py-2" aria-label="Primary">
<div className="container-fluid">
<NavLink to="/" className="navbar-brand d-flex align-items-center gap-3 mb-0">
<button type="button" className="navbar-brand d-flex align-items-center gap-3 mb-0 btn btn-link p-0 text-decoration-none" onClick={() => goTo("/")}>
<span className="site-brand-mark" aria-hidden="true">
<i className="bi bi-person-walking" />
</span>
@@ -263,7 +271,7 @@ function ShellLayout() {
<span className="navbar-text d-lg-none small lh-1 text-white-50">{currentPageLabel}</span>
) : null}
</span>
</NavLink>
</button>
<button
type="button"
className="navbar-toggler ms-auto"
@@ -277,30 +285,51 @@ function ShellLayout() {
<div id="primary-nav" className={`navbar-collapse collapse${navOpen ? " show" : ""}`}>
<ul className="navbar-nav ms-auto gap-2">
<li className="nav-item">
<NavLink to="/" className={({ isActive }) => `nav-link${isActive ? " active" : ""}`}>
<button
type="button"
className={`nav-link btn btn-link${location.pathname === "/" ? " active" : ""}`}
onClick={() => goTo("/")}
>
Home
</NavLink>
</button>
</li>
<li className="nav-item">
<NavLink to="/library" className={({ isActive }) => `nav-link${isActive ? " active" : ""}`}>
<button
type="button"
className={`nav-link btn btn-link${location.pathname === "/library" ? " active" : ""}`}
onClick={() => goTo("/library")}
>
Walkup Clips
</NavLink>
</button>
</li>
<li className="nav-item">
<NavLink to="/gameday" className={({ isActive }) => `nav-link${isActive ? " active" : ""}`}>
<button
type="button"
className={`nav-link btn btn-link${location.pathname === "/gameday" ? " active" : ""}`}
onClick={() => goTo("/gameday")}
>
Gameday
</NavLink>
</button>
</li>
<li className="nav-item">
<NavLink to="/profile" className={({ isActive }) => `nav-link${isActive ? " active" : ""}`}>
<button
type="button"
className={`nav-link btn btn-link${location.pathname === "/profile" ? " active" : ""}`}
onClick={() => goTo("/profile")}
>
Profile
</NavLink>
</button>
</li>
</ul>
</div>
</div>
</nav>
) : null}
{!isOnline ? (
<div className="alert alert-warning rounded-0 border-0 mb-0 py-2 text-center" role="status">
Offline mode: showing cached clips and previously loaded game data until the connection returns.
</div>
) : null}
<main className="container-fluid py-4">
<Routes>
<Route path="/signin" element={<SignInRoute />} />

View File

@@ -9,6 +9,7 @@ import type {
SessionResponse,
TeamSnapTokenResponse,
} from "./types";
import { cachedJsonRequest, clearOfflineCache } from "../lib/offlineCache";
export const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
@@ -22,13 +23,15 @@ type UploadAssetPayload = {
};
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const headers = new Headers(init?.headers);
if (init?.body != null && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
const response = await fetch(`${API_BASE}${path}`, {
credentials: "include",
...init,
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
headers,
});
if (!response.ok) {
@@ -40,7 +43,18 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
}
export const api = {
getSession: () => request<SessionResponse>("/auth/session"),
getSession: () =>
cachedJsonRequest<SessionResponse>(
["session"],
`${API_BASE}/auth/session`,
undefined,
{
onUnauthorized: () => {
clearOfflineCache();
return { authenticated: false, is_admin: false };
},
},
),
startTeamSnap: (returnTo: string) =>
request<{ authorize_url: string; state: string }>(`/auth/teamsnap/start?return_to=${encodeURIComponent(returnTo)}`),
getTeamSnapToken: () => request<TeamSnapTokenResponse>("/auth/teamsnap/token", { method: "POST" }),
@@ -50,8 +64,9 @@ export const api = {
updateWalkupSessionSelection: (payload: { external_team_id: string; external_player_id: string }) =>
request<SessionResponse>("/auth/session/walkup", { method: "POST", body: JSON.stringify(payload) }),
listAssets: (teamId: string, playerId?: string) =>
request<AudioAsset[]>(
`/media/assets?external_team_id=${encodeURIComponent(teamId)}${
cachedJsonRequest<AudioAsset[]>(
["assets", teamId, playerId ?? ""],
`${API_BASE}/media/assets?external_team_id=${encodeURIComponent(teamId)}${
playerId ? `&owner_external_player_id=${encodeURIComponent(playerId)}` : ""
}`,
),
@@ -131,8 +146,9 @@ export const api = {
}
},
listClips: (teamId: string, playerId?: string, includeHidden = false) =>
request<AudioClip[]>(
`/media/clips?external_team_id=${encodeURIComponent(teamId)}${
cachedJsonRequest<AudioClip[]>(
["clips", teamId, playerId ?? "", includeHidden ? "all" : "visible"],
`${API_BASE}/media/clips?external_team_id=${encodeURIComponent(teamId)}${
playerId ? `&owner_external_player_id=${encodeURIComponent(playerId)}` : ""
}${includeHidden ? "&include_hidden=true" : ""}`,
),
@@ -159,13 +175,14 @@ export const api = {
}
},
listAssignments: (gameId: string, playerId?: string) =>
request<GameAssignment[]>(
`/games/${encodeURIComponent(gameId)}/assignments${
cachedJsonRequest<GameAssignment[]>(
["assignments", gameId, playerId ?? ""],
`${API_BASE}/games/${encodeURIComponent(gameId)}/assignments${
playerId ? `?external_player_id=${encodeURIComponent(playerId)}` : ""
}`,
),
listPins: (playerId: string) =>
request<GameAssignment[]>(`/games/pins?external_player_id=${encodeURIComponent(playerId)}`),
cachedJsonRequest<GameAssignment[]>(["pins", playerId], `${API_BASE}/games/pins?external_player_id=${encodeURIComponent(playerId)}`),
createAssignment: (
gameId: string,
payload: {
@@ -194,5 +211,6 @@ export const api = {
throw new Error(await response.text());
}
}),
prepareGame: (gameId: string) => request<GamePrepResponse>(`/games/${encodeURIComponent(gameId)}/prep`),
prepareGame: (gameId: string) =>
cachedJsonRequest<GamePrepResponse>(["prep", gameId], `${API_BASE}/games/${encodeURIComponent(gameId)}/prep`),
};

View File

@@ -0,0 +1,29 @@
import { useEffect, useState } from "react";
export function useOnlineStatus(): boolean {
const [isOnline, setIsOnline] = useState(() => {
if (typeof navigator === "undefined") {
return true;
}
return navigator.onLine;
});
useEffect(() => {
function handleOnline() {
setIsOnline(true);
}
function handleOffline() {
setIsOnline(false);
}
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
};
}, []);
return isOnline;
}

View File

@@ -6,6 +6,7 @@ export function useSession() {
return useQuery({
queryKey: ["session"],
queryFn: api.getSession,
networkMode: "always",
retry: 0,
});
}

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,11 +24,16 @@ 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,
});
const teams = teamsQuery.data ?? [];
@@ -51,14 +56,18 @@ 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,
});
const members: TeamSnapMember[] = membersQuery.data ?? [];
@@ -106,6 +115,7 @@ function useBuildWalkupContext() {
isTeamSnap,
sessionQuery,
teamsQuery,
teamSnapCacheScope,
selectedTeam,
selectedTeamId,
hasSelectedTeam: Boolean(resolvedTeamId),

View File

@@ -0,0 +1,149 @@
type CacheEntry<T> = {
cachedAt: string;
data: T;
etag?: string;
};
type CacheStore = {
version: 1;
entries: Record<string, CacheEntry<unknown>>;
};
const STORAGE_KEY = "walkup.offlineCache:v1";
function safeLocalStorage(): Storage | null {
if (typeof window === "undefined") {
return null;
}
try {
const { localStorage } = window;
const probeKey = "__walkup_cache_probe__";
localStorage.setItem(probeKey, "1");
localStorage.removeItem(probeKey);
return localStorage;
} catch {
return null;
}
}
function readStore(): CacheStore {
const storage = safeLocalStorage();
if (!storage) {
return { version: 1, entries: {} };
}
const raw = storage.getItem(STORAGE_KEY);
if (!raw) {
return { version: 1, entries: {} };
}
try {
const parsed = JSON.parse(raw) as Partial<CacheStore>;
if (parsed.version !== 1 || !parsed.entries || typeof parsed.entries !== "object") {
return { version: 1, entries: {} };
}
return {
version: 1,
entries: parsed.entries as Record<string, CacheEntry<unknown>>,
};
} catch {
return { version: 1, entries: {} };
}
}
function writeStore(store: CacheStore): void {
const storage = safeLocalStorage();
if (!storage) {
return;
}
try {
storage.setItem(STORAGE_KEY, JSON.stringify(store));
} catch {
// Ignore quota and storage errors. The app can still operate online.
}
}
function cacheKeyFromParts(parts: readonly unknown[]): string {
return JSON.stringify(parts);
}
export function readCachedValue<T>(parts: readonly unknown[]): CacheEntry<T> | null {
const store = readStore();
const entry = store.entries[cacheKeyFromParts(parts)];
return entry ? (entry as CacheEntry<T>) : null;
}
export function writeCachedValue<T>(parts: readonly unknown[], data: T, etag?: string): void {
const store = readStore();
store.entries[cacheKeyFromParts(parts)] = {
cachedAt: new Date().toISOString(),
data,
etag,
};
writeStore(store);
}
export function clearOfflineCache(): void {
const storage = safeLocalStorage();
if (!storage) {
return;
}
storage.removeItem(STORAGE_KEY);
}
async function readResponseText(response: Response): Promise<string> {
try {
return await response.text();
} catch {
return "";
}
}
export async function cachedJsonRequest<T>(
parts: readonly unknown[],
url: string,
init?: RequestInit,
options?: {
onUnauthorized?: () => T;
},
): Promise<T> {
const cached = readCachedValue<T>(parts);
const headers = new Headers(init?.headers);
if (cached?.etag) {
headers.set("If-None-Match", cached.etag);
}
try {
const response = await fetch(url, {
...init,
headers,
cache: "no-cache",
credentials: "include",
});
if (response.status === 304) {
if (!cached) {
throw new Error("Cache validation returned 304 without a cached response.");
}
return cached.data;
}
if (!response.ok) {
if ((response.status === 401 || response.status === 403) && options?.onUnauthorized) {
return options.onUnauthorized();
}
throw new Error((await readResponseText(response)) || `Request failed: ${response.status}`);
}
const data = (await response.json()) as T;
writeCachedValue(parts, data, response.headers.get("etag") ?? undefined);
return data;
} catch (error) {
if (cached && error instanceof TypeError) {
return cached.data;
}
throw error;
}
}

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;
@@ -158,99 +161,144 @@ 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();
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.bulkLoad) {
try {
const bulkItems = await sdk.bulkLoad({
teamId,
types: ["eventLineup", "eventLineupEntry"],
scopeTo: "event",
event__id: eventId,
});
const lineupData = normalizeBulkLineupData(bulkItems, eventId);
if (lineupData.eventLineup || lineupData.entries.length) {
return lineupData;
if (sdk.bulkLoad) {
try {
const bulkItems = await sdk.bulkLoad({
teamId,
types: ["eventLineup", "eventLineupEntry"],
scopeTo: "event",
event__id: eventId,
});
const lineupData = normalizeBulkLineupData(bulkItems, eventId);
if (lineupData.eventLineup || lineupData.entries.length) {
return lineupData;
}
} catch {
// Fall back to rel-driven reads when bulk load does not return lineup data.
}
}
} catch {
// Fall back to rel-driven reads when bulk load does not return lineup data.
}
}
if (!sdk.loadEventLineups) {
return { eventLineup: null, entries: [] };
}
if (!sdk.loadEventLineups) {
return { eventLineup: null, entries: [] };
}
const eventLineups = await sdk.loadEventLineups({ eventId });
const matchingEventLineups = eventLineups.filter((item) => toId(item.eventId) === eventId);
const eventLineup = matchingEventLineups.length ? matchingEventLineups[matchingEventLineups.length - 1] : null;
const eventLineups = await sdk.loadEventLineups({ eventId });
const matchingEventLineups = eventLineups.filter((item) => toId(item.eventId) === eventId);
const eventLineup = matchingEventLineups.length ? matchingEventLineups[matchingEventLineups.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 lineupId = toId(eventLineupWithLinks.id);
const entries = sortLineupEntries(
rawEntries
.filter(isEventLineupEntry)
.filter((item) => toId(item.eventLineupId) === lineupId),
);
try {
const rawEntries = await eventLineupWithLinks.loadItems("eventLineupEntries");
const lineupId = toId(eventLineupWithLinks.id);
const entries = sortLineupEntries(
rawEntries
.filter(isEventLineupEntry)
.filter((item) => toId(item.eventLineupId) === lineupId),
);
return { eventLineup, entries };
} catch {
return { eventLineup, entries: [] };
}
return { eventLineup, entries };
} catch {
return { eventLineup, entries: [] };
}
},
});
},
};

View File

@@ -2,6 +2,7 @@ import { FormEvent, useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../api/client";
import { clearOfflineCache } from "../lib/offlineCache";
import { queryClient } from "../lib/queryClient";
export function AdminPage() {
@@ -15,7 +16,8 @@ export function AdminPage() {
setError(null);
try {
await api.adminLogin({ username, password });
await queryClient.invalidateQueries({ queryKey: ["session"] });
clearOfflineCache();
queryClient.clear();
navigate("/");
} catch (err) {
setError(err instanceof Error ? err.message : "Unable to sign in");

View File

@@ -33,16 +33,22 @@ export function GamePage() {
queryKey: ["clips", teamId, playerId, "visible"],
queryFn: () => api.listClips(teamId, playerId),
enabled: Boolean(teamId && playerId),
networkMode: "always",
retry: 0,
});
const assignmentsQuery = useQuery({
queryKey: ["assignments", selectedGameId, playerId],
queryFn: () => api.listAssignments(selectedGameId, playerId),
enabled: Boolean(selectedGameId && playerId),
networkMode: "always",
retry: 0,
});
const prepQuery = useQuery({
queryKey: ["prep", selectedGameId],
queryFn: () => api.prepareGame(selectedGameId),
enabled: Boolean(selectedGameId),
networkMode: "always",
retry: 0,
});
const saveMutation = useMutation({

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,
@@ -116,21 +116,26 @@ export function GamedayPage() {
queryFn: () => api.listAssignments(resolvedSelectedGameId),
enabled: Boolean(resolvedSelectedGameId),
retry: 0,
networkMode: "always",
});
const preparedGame = resolvedSelectedGameId ? loadPreparedGame(resolvedSelectedGameId) : null;
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,
});
const orderedMembers = useMemo(
@@ -400,6 +405,8 @@ function LibraryClips({
queryKey: ["clips", teamId, playerId, "visible"],
queryFn: () => api.listClips(teamId, playerId),
enabled: Boolean(teamId && playerId),
networkMode: "always",
retry: 0,
});
if (fallbackClipsQuery.isLoading) {

View File

@@ -74,16 +74,22 @@ export function LibraryPage() {
queryKey: ["assets", teamId, playerId],
queryFn: () => api.listAssets(teamId, playerId),
enabled: Boolean(teamId && playerId),
networkMode: "always",
retry: 0,
});
const clipsQuery = useQuery({
queryKey: clipsQueryKey(teamId, playerId, true),
queryFn: () => api.listClips(teamId, playerId, true),
enabled: Boolean(teamId && playerId),
networkMode: "always",
retry: 0,
});
const pinsQuery = useQuery({
queryKey: ["pins", teamId, playerId],
queryFn: () => api.listPins(playerId),
enabled: Boolean(playerId),
networkMode: "always",
retry: 0,
});
const orderedClips = useMemo(
() =>

View File

@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
import { api } from "../api/client";
import { useWalkupContext } from "../hooks/useWalkupContext";
import { clearOfflineCache } from "../lib/offlineCache";
import { formatMemberName, formatTeamLabel } from "../lib/teamsnapHelpers";
import { queryClient } from "../lib/queryClient";
@@ -12,8 +13,8 @@ export function ProfilePage() {
const logoutMutation = useMutation({
mutationFn: api.logout,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["session"] });
await queryClient.removeQueries({ queryKey: ["teamsnap"] });
clearOfflineCache();
queryClient.clear();
navigate("/signin");
},
});

View File

@@ -11,20 +11,56 @@ export default defineConfig(({ mode }) => {
react(),
VitePWA({
registerType: "autoUpdate",
includeAssets: ["icon.svg"],
includeAssets: [
"icon.svg",
"favicon.ico",
"apple-touch-icon.png",
"icon-192.png",
"icon-512.png",
"apple-splash-1125x2436.png",
"apple-splash-1170x2532.png",
"apple-splash-1290x2796.png",
],
workbox: {
runtimeCaching: [
{
urlPattern: ({ url }) => url.pathname.startsWith("/api/media/files/"),
handler: "CacheFirst",
options: {
cacheName: "walkup-media",
cacheableResponse: {
statuses: [200],
},
expiration: {
maxEntries: 200,
maxAgeSeconds: 60 * 60 * 24 * 30,
},
},
},
],
},
manifest: {
id: "/",
name: "Walkup",
short_name: "Walkup",
description: "Collaborative baseball walk-up songs.",
theme_color: "#132238",
background_color: "#f4ede2",
display: "standalone",
display_override: ["standalone", "minimal-ui"],
scope: "/",
start_url: "/",
icons: [
{
src: "/icon.svg",
sizes: "any",
type: "image/svg+xml",
src: "/icon-192.png",
sizes: "192x192",
type: "image/png",
purpose: "any maskable",
},
{
src: "/icon-512.png",
sizes: "512x512",
type: "image/png",
purpose: "any maskable"
}
]