Merge feature/offline-clip-cache into dev
6
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.
|
- 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.
|
- 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
|
## Storage Status
|
||||||
- Backend media persists in the `backend-media` named Docker volume.
|
- Backend media persists in the `backend-media` named Docker volume.
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,8 @@ Walkup is a collaborative baseball walk-up song app built as a React PWA with a
|
|||||||
|
|
||||||
## Frontend Responsibilities
|
## Frontend Responsibilities
|
||||||
- TeamSnap SDK bootstrap with server-issued access tokens
|
- 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
|
- Song upload and clip creation
|
||||||
- Game assignments and gameday console
|
- Game assignments and gameday console
|
||||||
- PWA install/offline shell
|
- 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
@@ -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,
|
||||||
|
)
|
||||||
@@ -23,6 +23,7 @@ from ..auth import (
|
|||||||
)
|
)
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
|
from ..http_cache import set_no_store
|
||||||
from ..models import UserSession
|
from ..models import UserSession
|
||||||
from .teamsnap import build_proxy_api_root
|
from .teamsnap import build_proxy_api_root
|
||||||
from ..schemas import (
|
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")
|
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="TeamSnap is not configured")
|
||||||
state = secrets.token_urlsafe(24)
|
state = secrets.token_urlsafe(24)
|
||||||
response = JSONResponse({"authorize_url": build_teamsnap_authorize_url(state), "state": state})
|
response = JSONResponse({"authorize_url": build_teamsnap_authorize_url(state), "state": state})
|
||||||
|
set_no_store(response)
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
settings.auth_return_cookie_name,
|
settings.auth_return_cookie_name,
|
||||||
normalize_return_to(return_to),
|
normalize_return_to(return_to),
|
||||||
@@ -73,13 +75,18 @@ async def teamsnap_callback(
|
|||||||
db.commit()
|
db.commit()
|
||||||
redirect_target = normalize_return_to(request.cookies.get(settings.auth_return_cookie_name))
|
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)
|
redirect = RedirectResponse(url=redirect_target, status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
set_no_store(redirect)
|
||||||
set_session_cookie(redirect, session.session_token)
|
set_session_cookie(redirect, session.session_token)
|
||||||
redirect.delete_cookie(settings.auth_return_cookie_name)
|
redirect.delete_cookie(settings.auth_return_cookie_name)
|
||||||
return redirect
|
return redirect
|
||||||
|
|
||||||
|
|
||||||
@router.get("/session", response_model=SessionResponse)
|
@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:
|
if session is None:
|
||||||
return SessionResponse(authenticated=False)
|
return SessionResponse(authenticated=False)
|
||||||
return SessionResponse(
|
return SessionResponse(
|
||||||
@@ -96,6 +103,7 @@ def session_status(session: UserSession | None = Depends(get_current_session)) -
|
|||||||
@router.post("/teamsnap/token", response_model=TeamSnapTokenResponse)
|
@router.post("/teamsnap/token", response_model=TeamSnapTokenResponse)
|
||||||
async def teamsnap_token(
|
async def teamsnap_token(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
response: Response,
|
||||||
session: UserSession = Depends(require_session),
|
session: UserSession = Depends(require_session),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> TeamSnapTokenResponse:
|
) -> TeamSnapTokenResponse:
|
||||||
@@ -115,6 +123,7 @@ async def teamsnap_token(
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(session)
|
db.refresh(session)
|
||||||
|
|
||||||
|
set_no_store(response)
|
||||||
return TeamSnapTokenResponse(
|
return TeamSnapTokenResponse(
|
||||||
access_token=session.access_token,
|
access_token=session.access_token,
|
||||||
expires_at=session.token_expires_at,
|
expires_at=session.token_expires_at,
|
||||||
@@ -126,6 +135,7 @@ async def teamsnap_token(
|
|||||||
@router.post("/session/walkup", response_model=SessionResponse)
|
@router.post("/session/walkup", response_model=SessionResponse)
|
||||||
def update_walkup_session_selection(
|
def update_walkup_session_selection(
|
||||||
payload: WalkupSessionSelectionUpdate,
|
payload: WalkupSessionSelectionUpdate,
|
||||||
|
response: Response,
|
||||||
session: UserSession = Depends(require_session),
|
session: UserSession = Depends(require_session),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> SessionResponse:
|
) -> SessionResponse:
|
||||||
@@ -137,6 +147,7 @@ def update_walkup_session_selection(
|
|||||||
db.add(session)
|
db.add(session)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(session)
|
db.refresh(session)
|
||||||
|
set_no_store(response)
|
||||||
return SessionResponse(
|
return SessionResponse(
|
||||||
authenticated=True,
|
authenticated=True,
|
||||||
provider=session.provider,
|
provider=session.provider,
|
||||||
@@ -156,6 +167,7 @@ def admin_login(payload: AdminLoginRequest, response: Response, db: Session = De
|
|||||||
db.add(session)
|
db.add(session)
|
||||||
db.commit()
|
db.commit()
|
||||||
set_session_cookie(response, session.session_token)
|
set_session_cookie(response, session.session_token)
|
||||||
|
set_no_store(response)
|
||||||
return SessionResponse(authenticated=True, provider="local", is_admin=True)
|
return SessionResponse(authenticated=True, provider="local", is_admin=True)
|
||||||
|
|
||||||
|
|
||||||
@@ -169,9 +181,11 @@ def logout(
|
|||||||
db.delete(session)
|
db.delete(session)
|
||||||
db.commit()
|
db.commit()
|
||||||
clear_session_cookie(response)
|
clear_session_cookie(response)
|
||||||
|
set_no_store(response)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/admin/check", response_model=SessionResponse)
|
@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)
|
return SessionResponse(authenticated=True, provider="local", is_admin=True)
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ from __future__ import annotations
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from fastapi import Request, Response
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..auth import require_session
|
from ..auth import require_session
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
|
from ..http_cache import build_etag, is_matching_etag, set_private_revalidate
|
||||||
from ..models import AudioClip, GameAssignment, UserSession
|
from ..models import AudioClip, GameAssignment, UserSession
|
||||||
from ..schemas import (
|
from ..schemas import (
|
||||||
GameAssignmentCreate,
|
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])
|
@router.get("/pins", response_model=list[GameAssignmentResponse])
|
||||||
def list_pins(
|
def list_pins(
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
external_player_id: str | None = Query(default=None),
|
external_player_id: str | None = Query(default=None),
|
||||||
session: UserSession = Depends(require_session),
|
session: UserSession = Depends(require_session),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -52,11 +66,18 @@ def list_pins(
|
|||||||
GameAssignment.external_player_id == player_id,
|
GameAssignment.external_player_id == player_id,
|
||||||
)
|
)
|
||||||
pins = db.scalars(query.order_by(GameAssignment.external_game_id.asc(), AudioClip.sort_order.asc())).all()
|
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])
|
@router.get("/{external_game_id}/assignments", response_model=list[GameAssignmentResponse])
|
||||||
def list_assignments(
|
def list_assignments(
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
external_game_id: str,
|
external_game_id: str,
|
||||||
external_player_id: str | None = Query(default=None),
|
external_player_id: str | None = Query(default=None),
|
||||||
_: UserSession = Depends(require_session),
|
_: UserSession = Depends(require_session),
|
||||||
@@ -69,7 +90,12 @@ def list_assignments(
|
|||||||
if external_player_id:
|
if external_player_id:
|
||||||
query = query.where(GameAssignment.external_player_id == 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()
|
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)
|
@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)
|
@router.get("/{external_game_id}/prep", response_model=GamePrepResponse)
|
||||||
def prepare_game(
|
def prepare_game(
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
external_game_id: str,
|
external_game_id: str,
|
||||||
_: UserSession = Depends(require_session),
|
_: UserSession = Depends(require_session),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -147,9 +175,14 @@ def prepare_game(
|
|||||||
.order_by(AudioClip.sort_order.asc(), GameAssignment.updated_at.desc())
|
.order_by(AudioClip.sort_order.asc(), GameAssignment.updated_at.desc())
|
||||||
).all()
|
).all()
|
||||||
external_team_id = assignments[0].external_team_id if assignments else ""
|
external_team_id = assignments[0].external_team_id if assignments else ""
|
||||||
return GamePrepResponse(
|
payload = GamePrepResponse(
|
||||||
external_game_id=external_game_id,
|
external_game_id=external_game_id,
|
||||||
external_team_id=external_team_id,
|
external_team_id=external_team_id,
|
||||||
prepared_at=datetime.now(timezone.utc),
|
prepared_at=datetime.now(timezone.utc),
|
||||||
assignments=[assignment_to_response(assignment) for assignment in assignments],
|
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
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import secrets
|
|||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
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 fastapi.responses import FileResponse
|
||||||
from sqlalchemy import delete, func, select
|
from sqlalchemy import delete, func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..auth import require_session
|
from ..auth import require_session
|
||||||
from ..database import get_db
|
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 ..models import AudioAsset, AudioClip, GameAssignment, UserSession
|
||||||
from ..schemas import (
|
from ..schemas import (
|
||||||
AudioAssetImportCreate,
|
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:
|
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:
|
if session.is_admin or asset.uploaded_by_session_id == session.id:
|
||||||
return True
|
return True
|
||||||
@@ -211,6 +222,8 @@ def import_audio(
|
|||||||
|
|
||||||
@router.get("/assets", response_model=list[AudioAssetResponse])
|
@router.get("/assets", response_model=list[AudioAssetResponse])
|
||||||
def list_assets(
|
def list_assets(
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
external_team_id: str,
|
external_team_id: str,
|
||||||
owner_external_player_id: str | None = None,
|
owner_external_player_id: str | None = None,
|
||||||
_: UserSession = Depends(require_session),
|
_: UserSession = Depends(require_session),
|
||||||
@@ -220,7 +233,12 @@ def list_assets(
|
|||||||
if owner_external_player_id:
|
if owner_external_player_id:
|
||||||
query = query.where(AudioAsset.owner_external_player_id == 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()
|
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)
|
@router.delete("/assets/{asset_id}", status_code=204)
|
||||||
@@ -363,6 +381,8 @@ def delete_clip(
|
|||||||
|
|
||||||
@router.get("/clips", response_model=list[AudioClipResponse])
|
@router.get("/clips", response_model=list[AudioClipResponse])
|
||||||
def list_clips(
|
def list_clips(
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
external_team_id: str,
|
external_team_id: str,
|
||||||
owner_external_player_id: str | None = None,
|
owner_external_player_id: str | None = None,
|
||||||
include_hidden: bool = False,
|
include_hidden: bool = False,
|
||||||
@@ -380,7 +400,12 @@ def list_clips(
|
|||||||
if not include_hidden:
|
if not include_hidden:
|
||||||
query = query.where(AudioClip.hidden.is_(False))
|
query = query.where(AudioClip.hidden.is_(False))
|
||||||
clips = db.scalars(query).all()
|
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)
|
@router.post("/clips/reorder", status_code=204)
|
||||||
@@ -415,4 +440,16 @@ def media_file(relative_path: str) -> FileResponse:
|
|||||||
path = storage.absolute_path(relative_path)
|
path = storage.absolute_path(relative_path)
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
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
|
||||||
|
|||||||
@@ -108,6 +108,112 @@ def test_teamsnap_token_returns_proxy_api_root() -> None:
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["api_root"] == "https://kif.local.ascorrea.com/api/teamsnap"
|
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:
|
def test_walkup_session_selection_is_persisted_in_session() -> None:
|
||||||
|
|||||||
@@ -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.
|
- The backend owns authentication, persisted app data, and media processing.
|
||||||
- TeamSnap is the source of truth for teams, members, events, lineups, and availability.
|
- 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.
|
- 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
|
||||||
|
|
||||||
- `frontend/` contains the React application.
|
- `frontend/` contains the React application.
|
||||||
- The app uses React Router for navigation and TanStack Query for server state.
|
- 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.
|
- 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 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 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.
|
- 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
|
## Backend
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,28 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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="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>
|
<title>Walkup</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
BIN
frontend/public/apple-splash-1125x2436.png
Normal file
|
After Width: | Height: | Size: 188 KiB |
BIN
frontend/public/apple-splash-1170x2532.png
Normal file
|
After Width: | Height: | Size: 197 KiB |
BIN
frontend/public/apple-splash-1290x2796.png
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
frontend/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
frontend/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
frontend/public/icon-192.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
frontend/public/icon-512.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
@@ -1,8 +1,7 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="none">
|
||||||
<rect width="256" height="256" rx="48" fill="#132238"/>
|
<rect width="256" height="256" rx="48" fill="#132238" />
|
||||||
<circle cx="128" cy="128" r="80" fill="#F4EDE2"/>
|
|
||||||
<path
|
<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"
|
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>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 533 B After Width: | Height: | Size: 482 B |
34
frontend/public/splash-art.svg
Normal 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 |
@@ -1,5 +1,5 @@
|
|||||||
import { Component, useEffect, useState, type ErrorInfo, type ReactElement, type ReactNode } from "react";
|
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 { WalkupProvider, useWalkupContext } from "./hooks/useWalkupContext";
|
||||||
import { useSession } from "./hooks/useSession";
|
import { useSession } from "./hooks/useSession";
|
||||||
@@ -10,6 +10,7 @@ import { ProfilePage } from "./pages/ProfilePage";
|
|||||||
import { AdminPage } from "./pages/AdminPage";
|
import { AdminPage } from "./pages/AdminPage";
|
||||||
import { SignInPage } from "./pages/SignInPage";
|
import { SignInPage } from "./pages/SignInPage";
|
||||||
import { formatTeamLabel } from "./lib/teamsnapHelpers";
|
import { formatTeamLabel } from "./lib/teamsnapHelpers";
|
||||||
|
import { useOnlineStatus } from "./hooks/useOnlineStatus";
|
||||||
|
|
||||||
function getRouteDestinationLabel(pathname: string) {
|
function getRouteDestinationLabel(pathname: string) {
|
||||||
switch (pathname) {
|
switch (pathname) {
|
||||||
@@ -227,6 +228,8 @@ function ShellLayout() {
|
|||||||
const [navOpen, setNavOpen] = useState(false);
|
const [navOpen, setNavOpen] = useState(false);
|
||||||
const walkup = useWalkupContext();
|
const walkup = useWalkupContext();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isOnline = useOnlineStatus();
|
||||||
const currentPageLabel = getNavbarPageLabel(location.pathname);
|
const currentPageLabel = getNavbarPageLabel(location.pathname);
|
||||||
const showNavbar = walkup.sessionQuery.data?.authenticated === true;
|
const showNavbar = walkup.sessionQuery.data?.authenticated === true;
|
||||||
const showTeamSelectionModal = walkup.isTeamSnap && walkup.teamsQuery.isFetched && !walkup.hasSelectedTeam;
|
const showTeamSelectionModal = walkup.isTeamSnap && walkup.teamsQuery.isFetched && !walkup.hasSelectedTeam;
|
||||||
@@ -248,12 +251,17 @@ function ShellLayout() {
|
|||||||
};
|
};
|
||||||
}, [showTeamSelectionModal]);
|
}, [showTeamSelectionModal]);
|
||||||
|
|
||||||
|
function goTo(pathname: string) {
|
||||||
|
setNavOpen(false);
|
||||||
|
navigate(pathname);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={shellClassName}>
|
<div className={shellClassName}>
|
||||||
{showNavbar ? (
|
{showNavbar ? (
|
||||||
<nav className="navbar navbar-expand-lg navbar-dark bg-dark shadow-sm sticky-top px-3 py-2" aria-label="Primary">
|
<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">
|
<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">
|
<span className="site-brand-mark" aria-hidden="true">
|
||||||
<i className="bi bi-person-walking" />
|
<i className="bi bi-person-walking" />
|
||||||
</span>
|
</span>
|
||||||
@@ -263,7 +271,7 @@ function ShellLayout() {
|
|||||||
<span className="navbar-text d-lg-none small lh-1 text-white-50">{currentPageLabel}</span>
|
<span className="navbar-text d-lg-none small lh-1 text-white-50">{currentPageLabel}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
</NavLink>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="navbar-toggler ms-auto"
|
className="navbar-toggler ms-auto"
|
||||||
@@ -277,30 +285,51 @@ function ShellLayout() {
|
|||||||
<div id="primary-nav" className={`navbar-collapse collapse${navOpen ? " show" : ""}`}>
|
<div id="primary-nav" className={`navbar-collapse collapse${navOpen ? " show" : ""}`}>
|
||||||
<ul className="navbar-nav ms-auto gap-2">
|
<ul className="navbar-nav ms-auto gap-2">
|
||||||
<li className="nav-item">
|
<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
|
Home
|
||||||
</NavLink>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li className="nav-item">
|
<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
|
Walkup Clips
|
||||||
</NavLink>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li className="nav-item">
|
<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
|
Gameday
|
||||||
</NavLink>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li className="nav-item">
|
<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
|
Profile
|
||||||
</NavLink>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
) : null}
|
) : 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">
|
<main className="container-fluid py-4">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/signin" element={<SignInRoute />} />
|
<Route path="/signin" element={<SignInRoute />} />
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
SessionResponse,
|
SessionResponse,
|
||||||
TeamSnapTokenResponse,
|
TeamSnapTokenResponse,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
import { cachedJsonRequest, clearOfflineCache } from "../lib/offlineCache";
|
||||||
|
|
||||||
export const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
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> {
|
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}`, {
|
const response = await fetch(`${API_BASE}${path}`, {
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
...init,
|
...init,
|
||||||
headers: {
|
headers,
|
||||||
"Content-Type": "application/json",
|
|
||||||
...(init?.headers ?? {}),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -40,7 +43,18 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
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) =>
|
startTeamSnap: (returnTo: string) =>
|
||||||
request<{ authorize_url: string; state: string }>(`/auth/teamsnap/start?return_to=${encodeURIComponent(returnTo)}`),
|
request<{ authorize_url: string; state: string }>(`/auth/teamsnap/start?return_to=${encodeURIComponent(returnTo)}`),
|
||||||
getTeamSnapToken: () => request<TeamSnapTokenResponse>("/auth/teamsnap/token", { method: "POST" }),
|
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 }) =>
|
updateWalkupSessionSelection: (payload: { external_team_id: string; external_player_id: string }) =>
|
||||||
request<SessionResponse>("/auth/session/walkup", { method: "POST", body: JSON.stringify(payload) }),
|
request<SessionResponse>("/auth/session/walkup", { method: "POST", body: JSON.stringify(payload) }),
|
||||||
listAssets: (teamId: string, playerId?: string) =>
|
listAssets: (teamId: string, playerId?: string) =>
|
||||||
request<AudioAsset[]>(
|
cachedJsonRequest<AudioAsset[]>(
|
||||||
`/media/assets?external_team_id=${encodeURIComponent(teamId)}${
|
["assets", teamId, playerId ?? ""],
|
||||||
|
`${API_BASE}/media/assets?external_team_id=${encodeURIComponent(teamId)}${
|
||||||
playerId ? `&owner_external_player_id=${encodeURIComponent(playerId)}` : ""
|
playerId ? `&owner_external_player_id=${encodeURIComponent(playerId)}` : ""
|
||||||
}`,
|
}`,
|
||||||
),
|
),
|
||||||
@@ -131,8 +146,9 @@ export const api = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
listClips: (teamId: string, playerId?: string, includeHidden = false) =>
|
listClips: (teamId: string, playerId?: string, includeHidden = false) =>
|
||||||
request<AudioClip[]>(
|
cachedJsonRequest<AudioClip[]>(
|
||||||
`/media/clips?external_team_id=${encodeURIComponent(teamId)}${
|
["clips", teamId, playerId ?? "", includeHidden ? "all" : "visible"],
|
||||||
|
`${API_BASE}/media/clips?external_team_id=${encodeURIComponent(teamId)}${
|
||||||
playerId ? `&owner_external_player_id=${encodeURIComponent(playerId)}` : ""
|
playerId ? `&owner_external_player_id=${encodeURIComponent(playerId)}` : ""
|
||||||
}${includeHidden ? "&include_hidden=true" : ""}`,
|
}${includeHidden ? "&include_hidden=true" : ""}`,
|
||||||
),
|
),
|
||||||
@@ -159,13 +175,14 @@ export const api = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
listAssignments: (gameId: string, playerId?: string) =>
|
listAssignments: (gameId: string, playerId?: string) =>
|
||||||
request<GameAssignment[]>(
|
cachedJsonRequest<GameAssignment[]>(
|
||||||
`/games/${encodeURIComponent(gameId)}/assignments${
|
["assignments", gameId, playerId ?? ""],
|
||||||
|
`${API_BASE}/games/${encodeURIComponent(gameId)}/assignments${
|
||||||
playerId ? `?external_player_id=${encodeURIComponent(playerId)}` : ""
|
playerId ? `?external_player_id=${encodeURIComponent(playerId)}` : ""
|
||||||
}`,
|
}`,
|
||||||
),
|
),
|
||||||
listPins: (playerId: string) =>
|
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: (
|
createAssignment: (
|
||||||
gameId: string,
|
gameId: string,
|
||||||
payload: {
|
payload: {
|
||||||
@@ -194,5 +211,6 @@ export const api = {
|
|||||||
throw new Error(await response.text());
|
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`),
|
||||||
};
|
};
|
||||||
|
|||||||
29
frontend/src/hooks/useOnlineStatus.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ export function useSession() {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["session"],
|
queryKey: ["session"],
|
||||||
queryFn: api.getSession,
|
queryFn: api.getSession,
|
||||||
|
networkMode: "always",
|
||||||
|
retry: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { api } from "../api/client";
|
|||||||
import type { TeamSnapEvent, TeamSnapMember } from "../api/types";
|
import type { TeamSnapEvent, TeamSnapMember } from "../api/types";
|
||||||
import { queryClient } from "../lib/queryClient";
|
import { queryClient } from "../lib/queryClient";
|
||||||
import { findCurrentPlayer, findNextGame, sortGames } from "../lib/teamsnapHelpers";
|
import { findCurrentPlayer, findNextGame, sortGames } from "../lib/teamsnapHelpers";
|
||||||
import { teamsnapClient } from "../lib/teamsnap";
|
import { teamSnapQueryKeys, teamsnapClient } from "../lib/teamsnap";
|
||||||
import { useSession } from "./useSession";
|
import { useSession } from "./useSession";
|
||||||
|
|
||||||
const TEAM_STORAGE_KEY = "walkup.selectedTeamId";
|
const TEAM_STORAGE_KEY = "walkup.selectedTeamId";
|
||||||
@@ -24,11 +24,16 @@ const WalkupContext = createContext<WalkupContextValue | null>(null);
|
|||||||
function useBuildWalkupContext() {
|
function useBuildWalkupContext() {
|
||||||
const sessionQuery = useSession();
|
const sessionQuery = useSession();
|
||||||
const isTeamSnap = sessionQuery.data?.authenticated === true && sessionQuery.data?.provider === "teamsnap";
|
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 [selectedTeamId, setSelectedTeamId] = useState(readStoredTeamId);
|
||||||
const teamsQuery = useQuery({
|
const teamsQuery = useQuery({
|
||||||
queryKey: ["teamsnap", "teams"],
|
queryKey: teamSnapQueryKeys.teams(teamSnapCacheScope),
|
||||||
queryFn: () => teamsnapClient.loadTeams(),
|
queryFn: () => teamsnapClient.loadTeams(teamSnapCacheScope),
|
||||||
enabled: isTeamSnap,
|
enabled: isTeamSnap,
|
||||||
|
networkMode: "always",
|
||||||
|
retry: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const teams = teamsQuery.data ?? [];
|
const teams = teamsQuery.data ?? [];
|
||||||
@@ -51,14 +56,18 @@ function useBuildWalkupContext() {
|
|||||||
}, [resolvedTeamId, selectedTeam, selectedTeamId, teams.length]);
|
}, [resolvedTeamId, selectedTeam, selectedTeamId, teams.length]);
|
||||||
|
|
||||||
const membersQuery = useQuery({
|
const membersQuery = useQuery({
|
||||||
queryKey: ["teamsnap", "members", resolvedTeamId],
|
queryKey: teamSnapQueryKeys.members(teamSnapCacheScope, resolvedTeamId),
|
||||||
queryFn: () => teamsnapClient.loadMembers(resolvedTeamId),
|
queryFn: () => teamsnapClient.loadMembers(resolvedTeamId, teamSnapCacheScope),
|
||||||
enabled: isTeamSnap && Boolean(resolvedTeamId),
|
enabled: isTeamSnap && Boolean(resolvedTeamId),
|
||||||
|
networkMode: "always",
|
||||||
|
retry: 0,
|
||||||
});
|
});
|
||||||
const eventsQuery = useQuery({
|
const eventsQuery = useQuery({
|
||||||
queryKey: ["teamsnap", "events", resolvedTeamId],
|
queryKey: teamSnapQueryKeys.events(teamSnapCacheScope, resolvedTeamId),
|
||||||
queryFn: () => teamsnapClient.loadEvents(resolvedTeamId),
|
queryFn: () => teamsnapClient.loadEvents(resolvedTeamId, teamSnapCacheScope),
|
||||||
enabled: isTeamSnap && Boolean(resolvedTeamId),
|
enabled: isTeamSnap && Boolean(resolvedTeamId),
|
||||||
|
networkMode: "always",
|
||||||
|
retry: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const members: TeamSnapMember[] = membersQuery.data ?? [];
|
const members: TeamSnapMember[] = membersQuery.data ?? [];
|
||||||
@@ -106,6 +115,7 @@ function useBuildWalkupContext() {
|
|||||||
isTeamSnap,
|
isTeamSnap,
|
||||||
sessionQuery,
|
sessionQuery,
|
||||||
teamsQuery,
|
teamsQuery,
|
||||||
|
teamSnapCacheScope,
|
||||||
selectedTeam,
|
selectedTeam,
|
||||||
selectedTeamId,
|
selectedTeamId,
|
||||||
hasSelectedTeam: Boolean(resolvedTeamId),
|
hasSelectedTeam: Boolean(resolvedTeamId),
|
||||||
|
|||||||
149
frontend/src/lib/offlineCache.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
86
frontend/src/lib/teamSnapCache.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import type { QueryKey } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { queryClient } from "./queryClient";
|
||||||
|
import { readCachedValue, writeCachedValue } from "./offlineCache";
|
||||||
|
|
||||||
|
const inFlightRequests = new Map<string, Promise<unknown>>();
|
||||||
|
|
||||||
|
export function normalizeTeamSnapCacheScope(scope?: string | number | null): string {
|
||||||
|
const value = scope == null ? "" : String(scope).trim();
|
||||||
|
return value || "anonymous";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCacheParts(scope: string | number | null | undefined, resource: string, parts: readonly unknown[]): readonly unknown[] {
|
||||||
|
return ["teamsnap", normalizeTeamSnapCacheScope(scope), resource, ...parts];
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheKey(parts: readonly unknown[]): string {
|
||||||
|
return JSON.stringify(parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startFreshFetch<T>(
|
||||||
|
cacheParts: readonly unknown[],
|
||||||
|
queryKey: QueryKey,
|
||||||
|
fetchFresh: () => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const key = cacheKey(cacheParts);
|
||||||
|
const existing = inFlightRequests.get(key);
|
||||||
|
if (existing) {
|
||||||
|
return existing as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = fetchFresh()
|
||||||
|
.then((data) => {
|
||||||
|
writeCachedValue(cacheParts, data);
|
||||||
|
queryClient.setQueryData(queryKey, data);
|
||||||
|
return data;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
inFlightRequests.delete(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
inFlightRequests.set(key, request);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function staleWhileRevalidateTeamSnap<T>({
|
||||||
|
cacheParts,
|
||||||
|
queryKey,
|
||||||
|
fetchFresh,
|
||||||
|
}: {
|
||||||
|
cacheParts: readonly unknown[];
|
||||||
|
queryKey: QueryKey;
|
||||||
|
fetchFresh: () => Promise<T>;
|
||||||
|
}): Promise<T> {
|
||||||
|
const cached = readCachedValue<T>(cacheParts);
|
||||||
|
if (cached) {
|
||||||
|
void startFreshFetch(cacheParts, queryKey, fetchFresh).catch(() => undefined);
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return startFreshFetch(cacheParts, queryKey, fetchFresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const teamSnapQueryKeys = {
|
||||||
|
me(scope?: string | number | null): QueryKey {
|
||||||
|
return buildCacheParts(scope, "me", []);
|
||||||
|
},
|
||||||
|
teams(scope?: string | number | null): QueryKey {
|
||||||
|
return buildCacheParts(scope, "teams", []);
|
||||||
|
},
|
||||||
|
members(scope: string | number | null | undefined, teamId: string): QueryKey {
|
||||||
|
return buildCacheParts(scope, "members", [teamId]);
|
||||||
|
},
|
||||||
|
events(scope: string | number | null | undefined, teamId: string): QueryKey {
|
||||||
|
return buildCacheParts(scope, "events", [teamId]);
|
||||||
|
},
|
||||||
|
availabilities(scope: string | number | null | undefined, teamId: string, eventId?: string): QueryKey {
|
||||||
|
return buildCacheParts(scope, "availabilities", [teamId, eventId ?? ""]);
|
||||||
|
},
|
||||||
|
assignments(scope: string | number | null | undefined, teamId: string, eventId?: string): QueryKey {
|
||||||
|
return buildCacheParts(scope, "assignments", [teamId, eventId ?? ""]);
|
||||||
|
},
|
||||||
|
eventLineup(scope: string | number | null | undefined, teamId: string, eventId: string): QueryKey {
|
||||||
|
return buildCacheParts(scope, "eventLineup", [teamId, eventId]);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -11,6 +11,9 @@ import type {
|
|||||||
TeamSnapTeam,
|
TeamSnapTeam,
|
||||||
TeamSnapUser,
|
TeamSnapUser,
|
||||||
} from "../api/types";
|
} from "../api/types";
|
||||||
|
import { staleWhileRevalidateTeamSnap, teamSnapQueryKeys } from "./teamSnapCache";
|
||||||
|
|
||||||
|
export { teamSnapQueryKeys } from "./teamSnapCache";
|
||||||
|
|
||||||
type TeamSnapSdk = {
|
type TeamSnapSdk = {
|
||||||
auth?: (token: string) => Promise<void> | void;
|
auth?: (token: string) => Promise<void> | void;
|
||||||
@@ -158,99 +161,144 @@ async function ensureAuthorized(): Promise<TeamSnapSdk> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const teamsnapClient = {
|
export const teamsnapClient = {
|
||||||
async loadMe(): Promise<TeamSnapUser | null> {
|
async loadMe(cacheScope?: string | number | null): Promise<TeamSnapUser | null> {
|
||||||
const sdk = await ensureAuthorized();
|
return staleWhileRevalidateTeamSnap<TeamSnapUser | null>({
|
||||||
if (sdk.loadMe) {
|
cacheParts: teamSnapQueryKeys.me(cacheScope),
|
||||||
return sdk.loadMe();
|
queryKey: teamSnapQueryKeys.me(cacheScope),
|
||||||
}
|
fetchFresh: async () => {
|
||||||
return null;
|
const sdk = await ensureAuthorized();
|
||||||
|
if (sdk.loadMe) {
|
||||||
|
return sdk.loadMe();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
async loadTeams(): Promise<TeamSnapTeam[]> {
|
async loadTeams(cacheScope?: string | number | null): Promise<TeamSnapTeam[]> {
|
||||||
const sdk = await ensureAuthorized();
|
return staleWhileRevalidateTeamSnap<TeamSnapTeam[]>({
|
||||||
if (sdk.loadTeams) {
|
cacheParts: teamSnapQueryKeys.teams(cacheScope),
|
||||||
const teams = await sdk.loadTeams();
|
queryKey: teamSnapQueryKeys.teams(cacheScope),
|
||||||
return teams.filter((team) => team.isRetired !== true);
|
fetchFresh: async () => {
|
||||||
}
|
const sdk = await ensureAuthorized();
|
||||||
return [];
|
if (sdk.loadTeams) {
|
||||||
|
const teams = await sdk.loadTeams();
|
||||||
|
return teams.filter((team) => team.isRetired !== true);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
async loadMembers(teamId: string): Promise<TeamSnapMember[]> {
|
async loadMembers(teamId: string, cacheScope?: string | number | null): Promise<TeamSnapMember[]> {
|
||||||
const sdk = await ensureAuthorized();
|
return staleWhileRevalidateTeamSnap<TeamSnapMember[]>({
|
||||||
if (sdk.loadMembers) {
|
cacheParts: teamSnapQueryKeys.members(cacheScope, teamId),
|
||||||
return sdk.loadMembers({ teamId });
|
queryKey: teamSnapQueryKeys.members(cacheScope, teamId),
|
||||||
}
|
fetchFresh: async () => {
|
||||||
return [];
|
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[]> {
|
||||||
const sdk = await ensureAuthorized();
|
return staleWhileRevalidateTeamSnap<TeamSnapEvent[]>({
|
||||||
if (sdk.loadEvents) {
|
cacheParts: teamSnapQueryKeys.events(cacheScope, teamId),
|
||||||
return sdk.loadEvents({ teamId });
|
queryKey: teamSnapQueryKeys.events(cacheScope, teamId),
|
||||||
}
|
fetchFresh: async () => {
|
||||||
return [];
|
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[]> {
|
||||||
const sdk = await ensureAuthorized();
|
return staleWhileRevalidateTeamSnap<TeamSnapAvailability[]>({
|
||||||
if (sdk.loadAvailabilities) {
|
cacheParts: teamSnapQueryKeys.availabilities(cacheScope, teamId, eventId),
|
||||||
return sdk.loadAvailabilities(eventId ? { teamId, eventId } : { teamId });
|
queryKey: teamSnapQueryKeys.availabilities(cacheScope, teamId, eventId),
|
||||||
}
|
fetchFresh: async () => {
|
||||||
return [];
|
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[]> {
|
||||||
const sdk = await ensureAuthorized();
|
return staleWhileRevalidateTeamSnap<TeamSnapAssignment[]>({
|
||||||
if (sdk.loadAssignments) {
|
cacheParts: teamSnapQueryKeys.assignments(cacheScope, teamId, eventId),
|
||||||
return sdk.loadAssignments(eventId ? { teamId, eventId } : { teamId });
|
queryKey: teamSnapQueryKeys.assignments(cacheScope, teamId, eventId),
|
||||||
}
|
fetchFresh: async () => {
|
||||||
return [];
|
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;
|
eventLineup: TeamSnapEventLineup | null;
|
||||||
entries: TeamSnapEventLineupEntry[];
|
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) {
|
if (sdk.bulkLoad) {
|
||||||
try {
|
try {
|
||||||
const bulkItems = await sdk.bulkLoad({
|
const bulkItems = await sdk.bulkLoad({
|
||||||
teamId,
|
teamId,
|
||||||
types: ["eventLineup", "eventLineupEntry"],
|
types: ["eventLineup", "eventLineupEntry"],
|
||||||
scopeTo: "event",
|
scopeTo: "event",
|
||||||
event__id: eventId,
|
event__id: eventId,
|
||||||
});
|
});
|
||||||
const lineupData = normalizeBulkLineupData(bulkItems, eventId);
|
const lineupData = normalizeBulkLineupData(bulkItems, eventId);
|
||||||
if (lineupData.eventLineup || lineupData.entries.length) {
|
if (lineupData.eventLineup || lineupData.entries.length) {
|
||||||
return lineupData;
|
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) {
|
if (!sdk.loadEventLineups) {
|
||||||
return { eventLineup: null, entries: [] };
|
return { eventLineup: null, entries: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventLineups = await sdk.loadEventLineups({ eventId });
|
const eventLineups = await sdk.loadEventLineups({ eventId });
|
||||||
const matchingEventLineups = eventLineups.filter((item) => toId(item.eventId) === eventId);
|
const matchingEventLineups = eventLineups.filter((item) => toId(item.eventId) === eventId);
|
||||||
const eventLineup = matchingEventLineups.length ? matchingEventLineups[matchingEventLineups.length - 1] : null;
|
const eventLineup = matchingEventLineups.length ? matchingEventLineups[matchingEventLineups.length - 1] : null;
|
||||||
|
|
||||||
const eventLineupWithLinks = eventLineup as TeamSnapEventLineup & {
|
const eventLineupWithLinks = eventLineup as TeamSnapEventLineup & {
|
||||||
loadItems?: (linkName: string) => Promise<TeamSnapEventLineupEntry[]>;
|
loadItems?: (linkName: string) => Promise<TeamSnapEventLineupEntry[]>;
|
||||||
} | null;
|
} | null;
|
||||||
if (!eventLineupWithLinks?.loadItems) {
|
if (!eventLineupWithLinks?.loadItems) {
|
||||||
return { eventLineup, entries: [] };
|
return { eventLineup, entries: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rawEntries = await eventLineupWithLinks.loadItems("eventLineupEntries");
|
const rawEntries = await eventLineupWithLinks.loadItems("eventLineupEntries");
|
||||||
const lineupId = toId(eventLineupWithLinks.id);
|
const lineupId = toId(eventLineupWithLinks.id);
|
||||||
const entries = sortLineupEntries(
|
const entries = sortLineupEntries(
|
||||||
rawEntries
|
rawEntries
|
||||||
.filter(isEventLineupEntry)
|
.filter(isEventLineupEntry)
|
||||||
.filter((item) => toId(item.eventLineupId) === lineupId),
|
.filter((item) => toId(item.eventLineupId) === lineupId),
|
||||||
);
|
);
|
||||||
|
|
||||||
return { eventLineup, entries };
|
return { eventLineup, entries };
|
||||||
} catch {
|
} catch {
|
||||||
return { eventLineup, entries: [] };
|
return { eventLineup, entries: [] };
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { FormEvent, useState } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
|
import { clearOfflineCache } from "../lib/offlineCache";
|
||||||
import { queryClient } from "../lib/queryClient";
|
import { queryClient } from "../lib/queryClient";
|
||||||
|
|
||||||
export function AdminPage() {
|
export function AdminPage() {
|
||||||
@@ -15,7 +16,8 @@ export function AdminPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await api.adminLogin({ username, password });
|
await api.adminLogin({ username, password });
|
||||||
await queryClient.invalidateQueries({ queryKey: ["session"] });
|
clearOfflineCache();
|
||||||
|
queryClient.clear();
|
||||||
navigate("/");
|
navigate("/");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Unable to sign in");
|
setError(err instanceof Error ? err.message : "Unable to sign in");
|
||||||
|
|||||||
@@ -33,16 +33,22 @@ export function GamePage() {
|
|||||||
queryKey: ["clips", teamId, playerId, "visible"],
|
queryKey: ["clips", teamId, playerId, "visible"],
|
||||||
queryFn: () => api.listClips(teamId, playerId),
|
queryFn: () => api.listClips(teamId, playerId),
|
||||||
enabled: Boolean(teamId && playerId),
|
enabled: Boolean(teamId && playerId),
|
||||||
|
networkMode: "always",
|
||||||
|
retry: 0,
|
||||||
});
|
});
|
||||||
const assignmentsQuery = useQuery({
|
const assignmentsQuery = useQuery({
|
||||||
queryKey: ["assignments", selectedGameId, playerId],
|
queryKey: ["assignments", selectedGameId, playerId],
|
||||||
queryFn: () => api.listAssignments(selectedGameId, playerId),
|
queryFn: () => api.listAssignments(selectedGameId, playerId),
|
||||||
enabled: Boolean(selectedGameId && playerId),
|
enabled: Boolean(selectedGameId && playerId),
|
||||||
|
networkMode: "always",
|
||||||
|
retry: 0,
|
||||||
});
|
});
|
||||||
const prepQuery = useQuery({
|
const prepQuery = useQuery({
|
||||||
queryKey: ["prep", selectedGameId],
|
queryKey: ["prep", selectedGameId],
|
||||||
queryFn: () => api.prepareGame(selectedGameId),
|
queryFn: () => api.prepareGame(selectedGameId),
|
||||||
enabled: Boolean(selectedGameId),
|
enabled: Boolean(selectedGameId),
|
||||||
|
networkMode: "always",
|
||||||
|
retry: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { ClipSummaryRow } from "../components/ClipSummaryRow";
|
|||||||
import { useClipPlayback } from "../hooks/useClipPlayback";
|
import { useClipPlayback } from "../hooks/useClipPlayback";
|
||||||
import { useWalkupContext } from "../hooks/useWalkupContext";
|
import { useWalkupContext } from "../hooks/useWalkupContext";
|
||||||
import { loadPreparedGame } from "../lib/offlinePrep";
|
import { loadPreparedGame } from "../lib/offlinePrep";
|
||||||
import { teamsnapClient } from "../lib/teamsnap";
|
import { teamSnapQueryKeys, teamsnapClient } from "../lib/teamsnap";
|
||||||
import {
|
import {
|
||||||
formatGameDate,
|
formatGameDate,
|
||||||
formatGameTitle,
|
formatGameTitle,
|
||||||
@@ -116,21 +116,26 @@ export function GamedayPage() {
|
|||||||
queryFn: () => api.listAssignments(resolvedSelectedGameId),
|
queryFn: () => api.listAssignments(resolvedSelectedGameId),
|
||||||
enabled: Boolean(resolvedSelectedGameId),
|
enabled: Boolean(resolvedSelectedGameId),
|
||||||
retry: 0,
|
retry: 0,
|
||||||
|
networkMode: "always",
|
||||||
});
|
});
|
||||||
|
|
||||||
const preparedGame = resolvedSelectedGameId ? loadPreparedGame(resolvedSelectedGameId) : null;
|
const preparedGame = resolvedSelectedGameId ? loadPreparedGame(resolvedSelectedGameId) : null;
|
||||||
const assignmentList = assignmentsQuery.data ?? preparedGame?.assignments ?? [];
|
const assignmentList = assignmentsQuery.data ?? preparedGame?.assignments ?? [];
|
||||||
|
|
||||||
const eventLineupQuery = useQuery({
|
const eventLineupQuery = useQuery({
|
||||||
queryKey: ["teamsnap", "eventLineup", teamId, resolvedSelectedGameId],
|
queryKey: teamSnapQueryKeys.eventLineup(walkup.teamSnapCacheScope, teamId, resolvedSelectedGameId),
|
||||||
queryFn: () => teamsnapClient.loadEventLineupData(teamId, resolvedSelectedGameId),
|
queryFn: () => teamsnapClient.loadEventLineupData(teamId, resolvedSelectedGameId, walkup.teamSnapCacheScope),
|
||||||
enabled: Boolean(teamId && resolvedSelectedGameId),
|
enabled: Boolean(teamId && resolvedSelectedGameId),
|
||||||
|
networkMode: "always",
|
||||||
|
retry: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const availabilityQuery = useQuery({
|
const availabilityQuery = useQuery({
|
||||||
queryKey: ["teamsnap", "availabilities", teamId, resolvedSelectedGameId],
|
queryKey: teamSnapQueryKeys.availabilities(walkup.teamSnapCacheScope, teamId, resolvedSelectedGameId),
|
||||||
queryFn: () => teamsnapClient.loadAvailabilities(teamId, resolvedSelectedGameId),
|
queryFn: () => teamsnapClient.loadAvailabilities(teamId, resolvedSelectedGameId, walkup.teamSnapCacheScope),
|
||||||
enabled: Boolean(teamId && resolvedSelectedGameId),
|
enabled: Boolean(teamId && resolvedSelectedGameId),
|
||||||
|
networkMode: "always",
|
||||||
|
retry: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const orderedMembers = useMemo(
|
const orderedMembers = useMemo(
|
||||||
@@ -400,6 +405,8 @@ function LibraryClips({
|
|||||||
queryKey: ["clips", teamId, playerId, "visible"],
|
queryKey: ["clips", teamId, playerId, "visible"],
|
||||||
queryFn: () => api.listClips(teamId, playerId),
|
queryFn: () => api.listClips(teamId, playerId),
|
||||||
enabled: Boolean(teamId && playerId),
|
enabled: Boolean(teamId && playerId),
|
||||||
|
networkMode: "always",
|
||||||
|
retry: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (fallbackClipsQuery.isLoading) {
|
if (fallbackClipsQuery.isLoading) {
|
||||||
|
|||||||
@@ -74,16 +74,22 @@ export function LibraryPage() {
|
|||||||
queryKey: ["assets", teamId, playerId],
|
queryKey: ["assets", teamId, playerId],
|
||||||
queryFn: () => api.listAssets(teamId, playerId),
|
queryFn: () => api.listAssets(teamId, playerId),
|
||||||
enabled: Boolean(teamId && playerId),
|
enabled: Boolean(teamId && playerId),
|
||||||
|
networkMode: "always",
|
||||||
|
retry: 0,
|
||||||
});
|
});
|
||||||
const clipsQuery = useQuery({
|
const clipsQuery = useQuery({
|
||||||
queryKey: clipsQueryKey(teamId, playerId, true),
|
queryKey: clipsQueryKey(teamId, playerId, true),
|
||||||
queryFn: () => api.listClips(teamId, playerId, true),
|
queryFn: () => api.listClips(teamId, playerId, true),
|
||||||
enabled: Boolean(teamId && playerId),
|
enabled: Boolean(teamId && playerId),
|
||||||
|
networkMode: "always",
|
||||||
|
retry: 0,
|
||||||
});
|
});
|
||||||
const pinsQuery = useQuery({
|
const pinsQuery = useQuery({
|
||||||
queryKey: ["pins", teamId, playerId],
|
queryKey: ["pins", teamId, playerId],
|
||||||
queryFn: () => api.listPins(playerId),
|
queryFn: () => api.listPins(playerId),
|
||||||
enabled: Boolean(playerId),
|
enabled: Boolean(playerId),
|
||||||
|
networkMode: "always",
|
||||||
|
retry: 0,
|
||||||
});
|
});
|
||||||
const orderedClips = useMemo(
|
const orderedClips = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
|
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import { useWalkupContext } from "../hooks/useWalkupContext";
|
import { useWalkupContext } from "../hooks/useWalkupContext";
|
||||||
|
import { clearOfflineCache } from "../lib/offlineCache";
|
||||||
import { formatMemberName, formatTeamLabel } from "../lib/teamsnapHelpers";
|
import { formatMemberName, formatTeamLabel } from "../lib/teamsnapHelpers";
|
||||||
import { queryClient } from "../lib/queryClient";
|
import { queryClient } from "../lib/queryClient";
|
||||||
|
|
||||||
@@ -12,8 +13,8 @@ export function ProfilePage() {
|
|||||||
const logoutMutation = useMutation({
|
const logoutMutation = useMutation({
|
||||||
mutationFn: api.logout,
|
mutationFn: api.logout,
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["session"] });
|
clearOfflineCache();
|
||||||
await queryClient.removeQueries({ queryKey: ["teamsnap"] });
|
queryClient.clear();
|
||||||
navigate("/signin");
|
navigate("/signin");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,20 +11,56 @@ export default defineConfig(({ mode }) => {
|
|||||||
react(),
|
react(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
registerType: "autoUpdate",
|
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: {
|
manifest: {
|
||||||
|
id: "/",
|
||||||
name: "Walkup",
|
name: "Walkup",
|
||||||
short_name: "Walkup",
|
short_name: "Walkup",
|
||||||
description: "Collaborative baseball walk-up songs.",
|
description: "Collaborative baseball walk-up songs.",
|
||||||
theme_color: "#132238",
|
theme_color: "#132238",
|
||||||
background_color: "#f4ede2",
|
background_color: "#f4ede2",
|
||||||
display: "standalone",
|
display: "standalone",
|
||||||
|
display_override: ["standalone", "minimal-ui"],
|
||||||
|
scope: "/",
|
||||||
start_url: "/",
|
start_url: "/",
|
||||||
icons: [
|
icons: [
|
||||||
{
|
{
|
||||||
src: "/icon.svg",
|
src: "/icon-192.png",
|
||||||
sizes: "any",
|
sizes: "192x192",
|
||||||
type: "image/svg+xml",
|
type: "image/png",
|
||||||
|
purpose: "any maskable",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "/icon-512.png",
|
||||||
|
sizes: "512x512",
|
||||||
|
type: "image/png",
|
||||||
purpose: "any maskable"
|
purpose: "any maskable"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||