Merge feature/offline-clip-cache into dev

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

View File

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

View File

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

79
backend/app/http_cache.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

View File

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

Before

Width:  |  Height:  |  Size: 533 B

After

Width:  |  Height:  |  Size: 482 B

View File

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

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ import { api } from "../api/client";
import type { TeamSnapEvent, TeamSnapMember } from "../api/types";
import { queryClient } from "../lib/queryClient";
import { findCurrentPlayer, findNextGame, sortGames } from "../lib/teamsnapHelpers";
import { teamsnapClient } from "../lib/teamsnap";
import { teamSnapQueryKeys, teamsnapClient } from "../lib/teamsnap";
import { useSession } from "./useSession";
const TEAM_STORAGE_KEY = "walkup.selectedTeamId";
@@ -24,11 +24,16 @@ const WalkupContext = createContext<WalkupContextValue | null>(null);
function useBuildWalkupContext() {
const sessionQuery = useSession();
const isTeamSnap = sessionQuery.data?.authenticated === true && sessionQuery.data?.provider === "teamsnap";
const teamSnapCacheScope = isTeamSnap
? String(sessionQuery.data?.external_user_id ?? sessionQuery.data?.external_team_id ?? "teamsnap")
: "anonymous";
const [selectedTeamId, setSelectedTeamId] = useState(readStoredTeamId);
const teamsQuery = useQuery({
queryKey: ["teamsnap", "teams"],
queryFn: () => teamsnapClient.loadTeams(),
queryKey: teamSnapQueryKeys.teams(teamSnapCacheScope),
queryFn: () => teamsnapClient.loadTeams(teamSnapCacheScope),
enabled: isTeamSnap,
networkMode: "always",
retry: 0,
});
const teams = teamsQuery.data ?? [];
@@ -51,14 +56,18 @@ function useBuildWalkupContext() {
}, [resolvedTeamId, selectedTeam, selectedTeamId, teams.length]);
const membersQuery = useQuery({
queryKey: ["teamsnap", "members", resolvedTeamId],
queryFn: () => teamsnapClient.loadMembers(resolvedTeamId),
queryKey: teamSnapQueryKeys.members(teamSnapCacheScope, resolvedTeamId),
queryFn: () => teamsnapClient.loadMembers(resolvedTeamId, teamSnapCacheScope),
enabled: isTeamSnap && Boolean(resolvedTeamId),
networkMode: "always",
retry: 0,
});
const eventsQuery = useQuery({
queryKey: ["teamsnap", "events", resolvedTeamId],
queryFn: () => teamsnapClient.loadEvents(resolvedTeamId),
queryKey: teamSnapQueryKeys.events(teamSnapCacheScope, resolvedTeamId),
queryFn: () => teamsnapClient.loadEvents(resolvedTeamId, teamSnapCacheScope),
enabled: isTeamSnap && Boolean(resolvedTeamId),
networkMode: "always",
retry: 0,
});
const members: TeamSnapMember[] = membersQuery.data ?? [];
@@ -106,6 +115,7 @@ function useBuildWalkupContext() {
isTeamSnap,
sessionQuery,
teamsQuery,
teamSnapCacheScope,
selectedTeam,
selectedTeamId,
hasSelectedTeam: Boolean(resolvedTeamId),

View File

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

View File

@@ -0,0 +1,86 @@
import type { QueryKey } from "@tanstack/react-query";
import { queryClient } from "./queryClient";
import { readCachedValue, writeCachedValue } from "./offlineCache";
const inFlightRequests = new Map<string, Promise<unknown>>();
export function normalizeTeamSnapCacheScope(scope?: string | number | null): string {
const value = scope == null ? "" : String(scope).trim();
return value || "anonymous";
}
function buildCacheParts(scope: string | number | null | undefined, resource: string, parts: readonly unknown[]): readonly unknown[] {
return ["teamsnap", normalizeTeamSnapCacheScope(scope), resource, ...parts];
}
function cacheKey(parts: readonly unknown[]): string {
return JSON.stringify(parts);
}
function startFreshFetch<T>(
cacheParts: readonly unknown[],
queryKey: QueryKey,
fetchFresh: () => Promise<T>,
): Promise<T> {
const key = cacheKey(cacheParts);
const existing = inFlightRequests.get(key);
if (existing) {
return existing as Promise<T>;
}
const request = fetchFresh()
.then((data) => {
writeCachedValue(cacheParts, data);
queryClient.setQueryData(queryKey, data);
return data;
})
.finally(() => {
inFlightRequests.delete(key);
});
inFlightRequests.set(key, request);
return request;
}
export async function staleWhileRevalidateTeamSnap<T>({
cacheParts,
queryKey,
fetchFresh,
}: {
cacheParts: readonly unknown[];
queryKey: QueryKey;
fetchFresh: () => Promise<T>;
}): Promise<T> {
const cached = readCachedValue<T>(cacheParts);
if (cached) {
void startFreshFetch(cacheParts, queryKey, fetchFresh).catch(() => undefined);
return cached.data;
}
return startFreshFetch(cacheParts, queryKey, fetchFresh);
}
export const teamSnapQueryKeys = {
me(scope?: string | number | null): QueryKey {
return buildCacheParts(scope, "me", []);
},
teams(scope?: string | number | null): QueryKey {
return buildCacheParts(scope, "teams", []);
},
members(scope: string | number | null | undefined, teamId: string): QueryKey {
return buildCacheParts(scope, "members", [teamId]);
},
events(scope: string | number | null | undefined, teamId: string): QueryKey {
return buildCacheParts(scope, "events", [teamId]);
},
availabilities(scope: string | number | null | undefined, teamId: string, eventId?: string): QueryKey {
return buildCacheParts(scope, "availabilities", [teamId, eventId ?? ""]);
},
assignments(scope: string | number | null | undefined, teamId: string, eventId?: string): QueryKey {
return buildCacheParts(scope, "assignments", [teamId, eventId ?? ""]);
},
eventLineup(scope: string | number | null | undefined, teamId: string, eventId: string): QueryKey {
return buildCacheParts(scope, "eventLineup", [teamId, eventId]);
},
};

View File

@@ -11,6 +11,9 @@ import type {
TeamSnapTeam,
TeamSnapUser,
} from "../api/types";
import { staleWhileRevalidateTeamSnap, teamSnapQueryKeys } from "./teamSnapCache";
export { teamSnapQueryKeys } from "./teamSnapCache";
type TeamSnapSdk = {
auth?: (token: string) => Promise<void> | void;
@@ -158,14 +161,24 @@ async function ensureAuthorized(): Promise<TeamSnapSdk> {
}
export const teamsnapClient = {
async loadMe(): Promise<TeamSnapUser | null> {
async loadMe(cacheScope?: string | number | null): Promise<TeamSnapUser | null> {
return staleWhileRevalidateTeamSnap<TeamSnapUser | null>({
cacheParts: teamSnapQueryKeys.me(cacheScope),
queryKey: teamSnapQueryKeys.me(cacheScope),
fetchFresh: async () => {
const sdk = await ensureAuthorized();
if (sdk.loadMe) {
return sdk.loadMe();
}
return null;
},
async loadTeams(): Promise<TeamSnapTeam[]> {
});
},
async loadTeams(cacheScope?: string | number | null): Promise<TeamSnapTeam[]> {
return staleWhileRevalidateTeamSnap<TeamSnapTeam[]>({
cacheParts: teamSnapQueryKeys.teams(cacheScope),
queryKey: teamSnapQueryKeys.teams(cacheScope),
fetchFresh: async () => {
const sdk = await ensureAuthorized();
if (sdk.loadTeams) {
const teams = await sdk.loadTeams();
@@ -173,38 +186,71 @@ export const teamsnapClient = {
}
return [];
},
async loadMembers(teamId: string): Promise<TeamSnapMember[]> {
});
},
async loadMembers(teamId: string, cacheScope?: string | number | null): Promise<TeamSnapMember[]> {
return staleWhileRevalidateTeamSnap<TeamSnapMember[]>({
cacheParts: teamSnapQueryKeys.members(cacheScope, teamId),
queryKey: teamSnapQueryKeys.members(cacheScope, teamId),
fetchFresh: async () => {
const sdk = await ensureAuthorized();
if (sdk.loadMembers) {
return sdk.loadMembers({ teamId });
}
return [];
},
async loadEvents(teamId: string): Promise<TeamSnapEvent[]> {
});
},
async loadEvents(teamId: string, cacheScope?: string | number | null): Promise<TeamSnapEvent[]> {
return staleWhileRevalidateTeamSnap<TeamSnapEvent[]>({
cacheParts: teamSnapQueryKeys.events(cacheScope, teamId),
queryKey: teamSnapQueryKeys.events(cacheScope, teamId),
fetchFresh: async () => {
const sdk = await ensureAuthorized();
if (sdk.loadEvents) {
return sdk.loadEvents({ teamId });
}
return [];
},
async loadAvailabilities(teamId: string, eventId?: string): Promise<TeamSnapAvailability[]> {
});
},
async loadAvailabilities(teamId: string, eventId?: string, cacheScope?: string | number | null): Promise<TeamSnapAvailability[]> {
return staleWhileRevalidateTeamSnap<TeamSnapAvailability[]>({
cacheParts: teamSnapQueryKeys.availabilities(cacheScope, teamId, eventId),
queryKey: teamSnapQueryKeys.availabilities(cacheScope, teamId, eventId),
fetchFresh: async () => {
const sdk = await ensureAuthorized();
if (sdk.loadAvailabilities) {
return sdk.loadAvailabilities(eventId ? { teamId, eventId } : { teamId });
}
return [];
},
async loadAssignments(teamId: string, eventId?: string): Promise<TeamSnapAssignment[]> {
});
},
async loadAssignments(teamId: string, eventId?: string, cacheScope?: string | number | null): Promise<TeamSnapAssignment[]> {
return staleWhileRevalidateTeamSnap<TeamSnapAssignment[]>({
cacheParts: teamSnapQueryKeys.assignments(cacheScope, teamId, eventId),
queryKey: teamSnapQueryKeys.assignments(cacheScope, teamId, eventId),
fetchFresh: async () => {
const sdk = await ensureAuthorized();
if (sdk.loadAssignments) {
return sdk.loadAssignments(eventId ? { teamId, eventId } : { teamId });
}
return [];
},
async loadEventLineupData(teamId: string, eventId: string): Promise<{
});
},
async loadEventLineupData(teamId: string, eventId: string, cacheScope?: string | number | null): Promise<{
eventLineup: TeamSnapEventLineup | null;
entries: TeamSnapEventLineupEntry[];
}> {
return staleWhileRevalidateTeamSnap<{
eventLineup: TeamSnapEventLineup | null;
entries: TeamSnapEventLineupEntry[];
}>({
cacheParts: teamSnapQueryKeys.eventLineup(cacheScope, teamId, eventId),
queryKey: teamSnapQueryKeys.eventLineup(cacheScope, teamId, eventId),
fetchFresh: async () => {
const sdk = await ensureAuthorized();
if (sdk.bulkLoad) {
@@ -253,4 +299,6 @@ export const teamsnapClient = {
return { eventLineup, entries: [] };
}
},
});
},
};

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ import { ClipSummaryRow } from "../components/ClipSummaryRow";
import { useClipPlayback } from "../hooks/useClipPlayback";
import { useWalkupContext } from "../hooks/useWalkupContext";
import { loadPreparedGame } from "../lib/offlinePrep";
import { teamsnapClient } from "../lib/teamsnap";
import { teamSnapQueryKeys, teamsnapClient } from "../lib/teamsnap";
import {
formatGameDate,
formatGameTitle,
@@ -116,21 +116,26 @@ export function GamedayPage() {
queryFn: () => api.listAssignments(resolvedSelectedGameId),
enabled: Boolean(resolvedSelectedGameId),
retry: 0,
networkMode: "always",
});
const preparedGame = resolvedSelectedGameId ? loadPreparedGame(resolvedSelectedGameId) : null;
const assignmentList = assignmentsQuery.data ?? preparedGame?.assignments ?? [];
const eventLineupQuery = useQuery({
queryKey: ["teamsnap", "eventLineup", teamId, resolvedSelectedGameId],
queryFn: () => teamsnapClient.loadEventLineupData(teamId, resolvedSelectedGameId),
queryKey: teamSnapQueryKeys.eventLineup(walkup.teamSnapCacheScope, teamId, resolvedSelectedGameId),
queryFn: () => teamsnapClient.loadEventLineupData(teamId, resolvedSelectedGameId, walkup.teamSnapCacheScope),
enabled: Boolean(teamId && resolvedSelectedGameId),
networkMode: "always",
retry: 0,
});
const availabilityQuery = useQuery({
queryKey: ["teamsnap", "availabilities", teamId, resolvedSelectedGameId],
queryFn: () => teamsnapClient.loadAvailabilities(teamId, resolvedSelectedGameId),
queryKey: teamSnapQueryKeys.availabilities(walkup.teamSnapCacheScope, teamId, resolvedSelectedGameId),
queryFn: () => teamsnapClient.loadAvailabilities(teamId, resolvedSelectedGameId, walkup.teamSnapCacheScope),
enabled: Boolean(teamId && resolvedSelectedGameId),
networkMode: "always",
retry: 0,
});
const orderedMembers = useMemo(
@@ -400,6 +405,8 @@ function LibraryClips({
queryKey: ["clips", teamId, playerId, "visible"],
queryFn: () => api.listClips(teamId, playerId),
enabled: Boolean(teamId && playerId),
networkMode: "always",
retry: 0,
});
if (fallbackClipsQuery.isLoading) {

View File

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

View File

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

View File

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