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 @@
+
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"
}
]