diff --git a/PLAN.md b/PLAN.md index 846e8b7..423d5cd 100644 --- a/PLAN.md +++ b/PLAN.md @@ -20,6 +20,12 @@ - Game titles in the UI now include a day parenthetical such as `(sun 5/3)` wherever the shared formatter is used. - TeamSnap gameday lineup reads now prefer the SDK `bulkLoad` path for `eventLineup` and `eventLineupEntry`, with rel-based fallback for accounts where bulk results are incomplete. +## Completed Offline Cache Work +- 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 e4f8e5a..ca29696 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,8 @@ 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 +- Read-only offline clip cache with HTTP revalidation and cached normalized playback media diff --git a/backend/app/http_cache.py b/backend/app/http_cache.py new file mode 100644 index 0000000..01bfd9d --- /dev/null +++ b/backend/app/http_cache.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import hashlib +import json +from collections.abc import Mapping, Sequence +from datetime import datetime +from typing import Any + +from fastapi import Request, Response +from fastapi.encoders import jsonable_encoder + + +def _strip_keys(value: Any, excluded_keys: set[str]) -> Any: + if isinstance(value, Mapping): + return { + key: _strip_keys(item, excluded_keys) + for key, item in value.items() + if key not in excluded_keys + } + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + return [_strip_keys(item, excluded_keys) for item in value] + return value + + +def build_etag(payload: Any, *, exclude_keys: set[str] | None = None) -> str: + encoded = jsonable_encoder(payload) + if exclude_keys: + encoded = _strip_keys(encoded, exclude_keys) + serialized = json.dumps(encoded, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + digest = hashlib.sha1(serialized.encode("utf-8")).hexdigest() + return f'"{digest}"' + + +def is_matching_etag(request: Request, etag: str) -> bool: + header = request.headers.get("if-none-match") + if not header: + return False + + for candidate in header.split(","): + if candidate.strip() in {etag, "*"}: + return True + return False + + +def apply_cache_headers( + response: Response, + *, + cache_control: str, + etag: str | None = None, + last_modified: datetime | None = None, +) -> None: + response.headers["Cache-Control"] = cache_control + response.headers["Vary"] = "Cookie, Authorization" + if etag is not None: + response.headers["ETag"] = etag + if last_modified is not None: + response.headers["Last-Modified"] = last_modified.strftime("%a, %d %b %Y %H:%M:%S GMT") + + +def set_no_store(response: Response) -> None: + apply_cache_headers(response, cache_control="no-store") + + +def set_private_revalidate(response: Response, *, etag: str | None = None, last_modified: datetime | None = None) -> None: + apply_cache_headers( + response, + cache_control="private, max-age=0, must-revalidate", + etag=etag, + last_modified=last_modified, + ) + + +def set_public_immutable(response: Response, *, etag: str | None = None, last_modified: datetime | None = None) -> None: + apply_cache_headers( + response, + cache_control="public, max-age=31536000, immutable", + etag=etag, + last_modified=last_modified, + ) diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index 4bf409e..6fa0ab8 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -23,6 +23,7 @@ from ..auth import ( ) from ..config import settings from ..database import get_db +from ..http_cache import set_no_store from ..models import UserSession from .teamsnap import build_proxy_api_root from ..schemas import ( @@ -47,6 +48,7 @@ def teamsnap_start(return_to: str | None = Query(default="/")) -> Response: raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="TeamSnap is not configured") state = secrets.token_urlsafe(24) response = JSONResponse({"authorize_url": build_teamsnap_authorize_url(state), "state": state}) + set_no_store(response) response.set_cookie( settings.auth_return_cookie_name, normalize_return_to(return_to), @@ -73,13 +75,18 @@ async def teamsnap_callback( db.commit() redirect_target = normalize_return_to(request.cookies.get(settings.auth_return_cookie_name)) redirect = RedirectResponse(url=redirect_target, status_code=status.HTTP_303_SEE_OTHER) + set_no_store(redirect) set_session_cookie(redirect, session.session_token) redirect.delete_cookie(settings.auth_return_cookie_name) return redirect @router.get("/session", response_model=SessionResponse) -def session_status(session: UserSession | None = Depends(get_current_session)) -> SessionResponse: +def session_status( + response: Response, + session: UserSession | None = Depends(get_current_session), +) -> SessionResponse: + set_no_store(response) if session is None: return SessionResponse(authenticated=False) return SessionResponse( @@ -96,6 +103,7 @@ def session_status(session: UserSession | None = Depends(get_current_session)) - @router.post("/teamsnap/token", response_model=TeamSnapTokenResponse) async def teamsnap_token( request: Request, + response: Response, session: UserSession = Depends(require_session), db: Session = Depends(get_db), ) -> TeamSnapTokenResponse: @@ -115,6 +123,7 @@ async def teamsnap_token( db.commit() db.refresh(session) + set_no_store(response) return TeamSnapTokenResponse( access_token=session.access_token, expires_at=session.token_expires_at, @@ -126,6 +135,7 @@ async def teamsnap_token( @router.post("/session/walkup", response_model=SessionResponse) def update_walkup_session_selection( payload: WalkupSessionSelectionUpdate, + response: Response, session: UserSession = Depends(require_session), db: Session = Depends(get_db), ) -> SessionResponse: @@ -137,6 +147,7 @@ def update_walkup_session_selection( db.add(session) db.commit() db.refresh(session) + set_no_store(response) return SessionResponse( authenticated=True, provider=session.provider, @@ -156,6 +167,7 @@ def admin_login(payload: AdminLoginRequest, response: Response, db: Session = De db.add(session) db.commit() set_session_cookie(response, session.session_token) + set_no_store(response) return SessionResponse(authenticated=True, provider="local", is_admin=True) @@ -169,9 +181,11 @@ def logout( db.delete(session) db.commit() clear_session_cookie(response) + set_no_store(response) return {"ok": True} @router.get("/admin/check", response_model=SessionResponse) -def admin_check(_: UserSession = Depends(require_admin)) -> SessionResponse: +def admin_check(response: Response, _: UserSession = Depends(require_admin)) -> SessionResponse: + set_no_store(response) return SessionResponse(authenticated=True, provider="local", is_admin=True) diff --git a/backend/app/routes/games.py b/backend/app/routes/games.py index fea62af..8619a18 100644 --- a/backend/app/routes/games.py +++ b/backend/app/routes/games.py @@ -3,11 +3,13 @@ from __future__ import annotations from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import Request, Response from sqlalchemy import select from sqlalchemy.orm import Session from ..auth import require_session from ..database import get_db +from ..http_cache import build_etag, is_matching_etag, set_private_revalidate from ..models import AudioClip, GameAssignment, UserSession from ..schemas import ( GameAssignmentCreate, @@ -37,8 +39,20 @@ def assignment_to_response(assignment: GameAssignment) -> GameAssignmentResponse ) +def prepare_conditional_response( + request: Request, + payload: object, + *, + exclude_keys: set[str] | None = None, +) -> tuple[str, bool]: + etag = build_etag(payload, exclude_keys=exclude_keys) + return etag, is_matching_etag(request, etag) + + @router.get("/pins", response_model=list[GameAssignmentResponse]) def list_pins( + request: Request, + response: Response, external_player_id: str | None = Query(default=None), session: UserSession = Depends(require_session), db: Session = Depends(get_db), @@ -52,11 +66,18 @@ def list_pins( GameAssignment.external_player_id == player_id, ) pins = db.scalars(query.order_by(GameAssignment.external_game_id.asc(), AudioClip.sort_order.asc())).all() - return [assignment_to_response(assignment) for assignment in pins] + payload = [assignment_to_response(assignment) for assignment in pins] + etag, not_modified = prepare_conditional_response(request, payload) + set_private_revalidate(response, etag=etag) + if not_modified: + return Response(status_code=304, headers=dict(response.headers)) + return payload @router.get("/{external_game_id}/assignments", response_model=list[GameAssignmentResponse]) def list_assignments( + request: Request, + response: Response, external_game_id: str, external_player_id: str | None = Query(default=None), _: UserSession = Depends(require_session), @@ -69,7 +90,12 @@ def list_assignments( if external_player_id: query = query.where(GameAssignment.external_player_id == external_player_id) assignments = db.scalars(query.order_by(AudioClip.sort_order.asc(), GameAssignment.updated_at.desc())).all() - return [assignment_to_response(assignment) for assignment in assignments] + payload = [assignment_to_response(assignment) for assignment in assignments] + etag, not_modified = prepare_conditional_response(request, payload) + set_private_revalidate(response, etag=etag) + if not_modified: + return Response(status_code=304, headers=dict(response.headers)) + return payload @router.post("/{external_game_id}/assignments", response_model=GameAssignmentResponse) @@ -136,6 +162,8 @@ def delete_assignment( @router.get("/{external_game_id}/prep", response_model=GamePrepResponse) def prepare_game( + request: Request, + response: Response, external_game_id: str, _: UserSession = Depends(require_session), db: Session = Depends(get_db), @@ -147,9 +175,14 @@ def prepare_game( .order_by(AudioClip.sort_order.asc(), GameAssignment.updated_at.desc()) ).all() external_team_id = assignments[0].external_team_id if assignments else "" - return GamePrepResponse( + payload = GamePrepResponse( external_game_id=external_game_id, external_team_id=external_team_id, prepared_at=datetime.now(timezone.utc), assignments=[assignment_to_response(assignment) for assignment in assignments], ) + etag, not_modified = prepare_conditional_response(request, payload, exclude_keys={"prepared_at"}) + set_private_revalidate(response, etag=etag) + if not_modified: + return Response(status_code=304, headers=dict(response.headers)) + return payload diff --git a/backend/app/routes/media.py b/backend/app/routes/media.py index ac6e086..900fbe9 100644 --- a/backend/app/routes/media.py +++ b/backend/app/routes/media.py @@ -4,13 +4,14 @@ import secrets import shutil from pathlib import Path -from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile +from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile from fastapi.responses import FileResponse from sqlalchemy import delete, func, select from sqlalchemy.orm import Session from ..auth import require_session from ..database import get_db +from ..http_cache import build_etag, is_matching_etag, set_no_store, set_private_revalidate, set_public_immutable from ..models import AudioAsset, AudioClip, GameAssignment, UserSession from ..schemas import ( AudioAssetImportCreate, @@ -50,6 +51,16 @@ def clip_to_response(clip: AudioClip) -> AudioClipResponse: ) +def prepare_conditional_response( + request: Request, + payload: object, + *, + exclude_keys: set[str] | None = None, +) -> tuple[str, bool]: + etag = build_etag(payload, exclude_keys=exclude_keys) + return etag, is_matching_etag(request, etag) + + def can_manage_asset(session: UserSession, asset: AudioAsset, owner_external_player_id: str | None = None) -> bool: if session.is_admin or asset.uploaded_by_session_id == session.id: return True @@ -211,6 +222,8 @@ def import_audio( @router.get("/assets", response_model=list[AudioAssetResponse]) def list_assets( + request: Request, + response: Response, external_team_id: str, owner_external_player_id: str | None = None, _: UserSession = Depends(require_session), @@ -220,7 +233,12 @@ def list_assets( if owner_external_player_id: query = query.where(AudioAsset.owner_external_player_id == owner_external_player_id) assets = db.scalars(query.order_by(AudioAsset.created_at.desc())).all() - return [AudioAssetResponse.model_validate(asset, from_attributes=True) for asset in assets] + payload = [AudioAssetResponse.model_validate(asset, from_attributes=True) for asset in assets] + etag, not_modified = prepare_conditional_response(request, payload) + set_private_revalidate(response, etag=etag) + if not_modified: + return Response(status_code=304, headers=dict(response.headers)) + return payload @router.delete("/assets/{asset_id}", status_code=204) @@ -363,6 +381,8 @@ def delete_clip( @router.get("/clips", response_model=list[AudioClipResponse]) def list_clips( + request: Request, + response: Response, external_team_id: str, owner_external_player_id: str | None = None, include_hidden: bool = False, @@ -380,7 +400,12 @@ def list_clips( if not include_hidden: query = query.where(AudioClip.hidden.is_(False)) clips = db.scalars(query).all() - return [clip_to_response(clip) for clip in clips] + payload = [clip_to_response(clip) for clip in clips] + etag, not_modified = prepare_conditional_response(request, payload) + set_private_revalidate(response, etag=etag) + if not_modified: + return Response(status_code=304, headers=dict(response.headers)) + return payload @router.post("/clips/reorder", status_code=204) @@ -415,4 +440,16 @@ def media_file(relative_path: str) -> FileResponse: path = storage.absolute_path(relative_path) if not path.exists(): raise HTTPException(status_code=404, detail="File not found") - return FileResponse(path) + stat = path.stat() + if path.is_relative_to(storage.normalized_dir): + etag = build_etag({"path": str(path.relative_to(storage.root)), "size": stat.st_size, "mtime_ns": stat.st_mtime_ns}) + response = FileResponse( + path, + stat_result=stat, + ) + set_public_immutable(response, etag=etag) + return response + + response = FileResponse(path, stat_result=stat) + set_no_store(response) + return response diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 0a833b6..0fc4f7a 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -108,6 +108,112 @@ def test_teamsnap_token_returns_proxy_api_root() -> None: assert response.status_code == 200 assert response.json()["api_root"] == "https://kif.local.ascorrea.com/api/teamsnap" + assert response.headers["cache-control"] == "no-store" + + +def test_session_and_clip_reads_use_cache_validators() -> None: + login = client.post("/auth/admin/login", json={"username": "admin", "password": "admin"}) + assert login.status_code == 200 + + session_response = client.get("/auth/session") + assert session_response.status_code == 200 + assert session_response.headers["cache-control"] == "no-store" + + db = SessionLocal() + asset = AudioAsset( + external_team_id="team-cache", + owner_external_player_id="player-cache", + title="Cache Song", + original_filename="cache-song.mp3", + mime_type="audio/mpeg", + size_bytes=123, + storage_path="uploads/cache-song.mp3", + ) + db.add(asset) + db.flush() + clip = AudioClip( + asset_id=asset.id, + label="Cache clip", + start_ms=0, + end_ms=10000, + normalization_status="ready", + normalized_path="normalized/cache-clip.mp3", + ) + db.add(clip) + db.commit() + db.refresh(clip) + db.close() + + clips = client.get("/media/clips", params={"external_team_id": "team-cache", "owner_external_player_id": "player-cache"}) + assert clips.status_code == 200 + assert clips.headers["etag"] + assert clips.headers["cache-control"] == "private, max-age=0, must-revalidate" + assert clips.headers["vary"] == "Cookie, Authorization" + + revalidated = client.get( + "/media/clips", + params={"external_team_id": "team-cache", "owner_external_player_id": "player-cache"}, + headers={"if-none-match": clips.headers["etag"]}, + ) + assert revalidated.status_code == 304 + + +def test_game_prep_uses_stable_etag_for_cached_assignments() -> None: + login = client.post("/auth/admin/login", json={"username": "admin", "password": "admin"}) + assert login.status_code == 200 + + db = SessionLocal() + asset = AudioAsset( + external_team_id="team-prep", + owner_external_player_id="player-prep", + title="Prep Song", + original_filename="prep-song.mp3", + mime_type="audio/mpeg", + size_bytes=123, + storage_path="uploads/prep-song.mp3", + ) + db.add(asset) + db.flush() + clip = AudioClip( + asset_id=asset.id, + label="Prep clip", + start_ms=0, + end_ms=10000, + normalization_status="ready", + normalized_path="normalized/prep-clip.mp3", + ) + db.add(clip) + db.flush() + assignment = GameAssignment( + external_team_id="team-prep", + external_game_id="game-prep", + external_player_id="player-prep", + clip_id=clip.id, + batting_slot=3, + status="ready", + ) + db.add(assignment) + db.commit() + db.close() + + prep = client.get("/games/game-prep/prep") + assert prep.status_code == 200 + assert prep.headers["etag"] + + revalidated = client.get("/games/game-prep/prep", headers={"if-none-match": prep.headers["etag"]}) + assert revalidated.status_code == 304 + + +def test_normalized_media_files_are_cacheable() -> None: + media_file = settings.media_root / "normalized" / "cacheable.mp3" + media_file.parent.mkdir(parents=True, exist_ok=True) + media_file.write_bytes(make_test_wav_bytes()) + + response = client.get("/media/files/normalized/cacheable.mp3") + + assert response.status_code == 200 + assert response.headers["cache-control"] == "public, max-age=31536000, immutable" + assert response.headers["etag"] def test_walkup_session_selection_is_persisted_in_session() -> None: diff --git a/docs/architecture.md b/docs/architecture.md index 8271f08..777c94e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,15 +8,18 @@ Walkup is a baseball walk-up song app with a React PWA frontend and a FastAPI ba - The backend owns authentication, persisted app data, and media processing. - TeamSnap is the source of truth for teams, members, events, lineups, and availability. - The backend stores only app-owned data plus TeamSnap external IDs and tokens needed for the auth flow. +- Clip and gameday reads are cached on the client with HTTP validators so the app can keep working when reception is poor or absent. ## Frontend - `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 home page is a lightweight landing page that orients users and links to the Library and Gameday views. - 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`. ## Backend diff --git a/frontend/index.html b/frontend/index.html index c751f94..1d9b0d9 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,7 +3,28 @@ + + + + + + + + + Walkup diff --git a/frontend/public/apple-splash-1125x2436.png b/frontend/public/apple-splash-1125x2436.png new file mode 100644 index 0000000..314ea2a Binary files /dev/null and b/frontend/public/apple-splash-1125x2436.png differ diff --git a/frontend/public/apple-splash-1170x2532.png b/frontend/public/apple-splash-1170x2532.png new file mode 100644 index 0000000..ef29d24 Binary files /dev/null and b/frontend/public/apple-splash-1170x2532.png differ diff --git a/frontend/public/apple-splash-1290x2796.png b/frontend/public/apple-splash-1290x2796.png new file mode 100644 index 0000000..ac07922 Binary files /dev/null and b/frontend/public/apple-splash-1290x2796.png differ diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png new file mode 100644 index 0000000..7850407 Binary files /dev/null and b/frontend/public/apple-touch-icon.png differ diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..3ad463f Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/public/icon-192.png b/frontend/public/icon-192.png new file mode 100644 index 0000000..137c746 Binary files /dev/null and b/frontend/public/icon-192.png differ diff --git a/frontend/public/icon-512.png b/frontend/public/icon-512.png new file mode 100644 index 0000000..c34709e Binary files /dev/null and b/frontend/public/icon-512.png differ diff --git a/frontend/public/icon.svg b/frontend/public/icon.svg index fbba96b..c25e4a6 100644 --- a/frontend/public/icon.svg +++ b/frontend/public/icon.svg @@ -1,8 +1,7 @@ - - + diff --git a/frontend/public/splash-art.svg b/frontend/public/splash-art.svg new file mode 100644 index 0000000..059fe28 --- /dev/null +++ b/frontend/public/splash-art.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + Walkup + + + Offline clip cache for the dugout + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a84d8c6..9a65804 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( -
+
{showNavbar ? (
) : null} + {!isOnline ? ( +
+ Offline mode: showing cached clips and previously loaded game data until the connection returns. +
+ ) : null}
} /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 0427bdf..3fe783d 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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(path: string, init?: RequestInit): Promise { + 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(path: string, init?: RequestInit): Promise { } export const api = { - getSession: () => request("/auth/session"), + getSession: () => + cachedJsonRequest( + ["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("/auth/teamsnap/token", { method: "POST" }), @@ -50,8 +64,9 @@ export const api = { updateWalkupSessionSelection: (payload: { external_team_id: string; external_player_id: string }) => request("/auth/session/walkup", { method: "POST", body: JSON.stringify(payload) }), listAssets: (teamId: string, playerId?: string) => - request( - `/media/assets?external_team_id=${encodeURIComponent(teamId)}${ + cachedJsonRequest( + ["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( - `/media/clips?external_team_id=${encodeURIComponent(teamId)}${ + cachedJsonRequest( + ["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( - `/games/${encodeURIComponent(gameId)}/assignments${ + cachedJsonRequest( + ["assignments", gameId, playerId ?? ""], + `${API_BASE}/games/${encodeURIComponent(gameId)}/assignments${ playerId ? `?external_player_id=${encodeURIComponent(playerId)}` : "" }`, ), listPins: (playerId: string) => - request(`/games/pins?external_player_id=${encodeURIComponent(playerId)}`), + cachedJsonRequest(["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(`/games/${encodeURIComponent(gameId)}/prep`), + prepareGame: (gameId: string) => + cachedJsonRequest(["prep", gameId], `${API_BASE}/games/${encodeURIComponent(gameId)}/prep`), }; diff --git a/frontend/src/hooks/useOnlineStatus.ts b/frontend/src/hooks/useOnlineStatus.ts new file mode 100644 index 0000000..92b0e29 --- /dev/null +++ b/frontend/src/hooks/useOnlineStatus.ts @@ -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; +} diff --git a/frontend/src/hooks/useSession.ts b/frontend/src/hooks/useSession.ts index 759bc26..7fe136b 100644 --- a/frontend/src/hooks/useSession.ts +++ b/frontend/src/hooks/useSession.ts @@ -6,6 +6,7 @@ export function useSession() { return useQuery({ queryKey: ["session"], queryFn: api.getSession, + networkMode: "always", + retry: 0, }); } - diff --git a/frontend/src/hooks/useWalkupContext.tsx b/frontend/src/hooks/useWalkupContext.tsx index 7095bcf..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,11 +24,16 @@ 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, }); 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), diff --git a/frontend/src/lib/offlineCache.ts b/frontend/src/lib/offlineCache.ts new file mode 100644 index 0000000..3a1543e --- /dev/null +++ b/frontend/src/lib/offlineCache.ts @@ -0,0 +1,149 @@ +type CacheEntry = { + cachedAt: string; + data: T; + etag?: string; +}; + +type CacheStore = { + version: 1; + entries: Record>; +}; + +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; + if (parsed.version !== 1 || !parsed.entries || typeof parsed.entries !== "object") { + return { version: 1, entries: {} }; + } + return { + version: 1, + entries: parsed.entries as Record>, + }; + } 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(parts: readonly unknown[]): CacheEntry | null { + const store = readStore(); + const entry = store.entries[cacheKeyFromParts(parts)]; + return entry ? (entry as CacheEntry) : null; +} + +export function writeCachedValue(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 { + try { + return await response.text(); + } catch { + return ""; + } +} + +export async function cachedJsonRequest( + parts: readonly unknown[], + url: string, + init?: RequestInit, + options?: { + onUnauthorized?: () => T; + }, +): Promise { + const cached = readCachedValue(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; + } +} 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 52e2396..4171476 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; @@ -158,99 +161,144 @@ 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(); + 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; - } | 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 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: [] }; + } + }, + }); }, }; diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index e5c6d6a..4a48e7d 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -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"); diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index 4fd5b52..d8b423e 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -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({ diff --git a/frontend/src/pages/GamedayPage.tsx b/frontend/src/pages/GamedayPage.tsx index ffc9a29..9144af9 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, @@ -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) { diff --git a/frontend/src/pages/LibraryPage.tsx b/frontend/src/pages/LibraryPage.tsx index 5906875..a2d173d 100644 --- a/frontend/src/pages/LibraryPage.tsx +++ b/frontend/src/pages/LibraryPage.tsx @@ -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( () => diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index fd829dd..98bacf4 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -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"); }, }); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index d94fb73..4fcc263 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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" } ]