From 3e165ee4d607f1f635901baf7761780d8a467f31 Mon Sep 17 00:00:00 2001
From: Brian Le
Date: Thu, 10 Sep 2026 00:06:47 -0700
Subject: [PATCH] fix: close the pilot-readiness audit P0/P1 findings and
remove demo mode
Frontend: measured report totals (no invented Winners/Unforced errors), timed notes that
persist (debounce no longer cancelled by the poll effect; jsonb strings parsed on read),
visible upload rejections, JSON 401 for anonymous API calls, login shows callback errors,
password recovery reaches the update form, recording header names its player, error
boundary + not-found + error routes, per-route titles, mobile Account tab, demo mode
removed entirely.
API: trigger-process derives the storage key server-side; create-upload validates player
ownership and insert errors; owner-filtered player lookups and fail-closed fallbacks;
PATCH returns 404 on zero rows; onboarding is replay-safe; image proxy limit raised to
1200/h and its responses may cache.
Backend: Modal entrypoint validates match_id/file_key binding and writes a failed status
when startup fails; a lost processed-video upload no longer marks a recording done.
Data: backend/tools/claim_template_players.py (applied for the owner account) and a
cascade migration for players.user_id (apply in the SQL editor).
Audit harness: frontend/web/scripts/audit/functional (84 checks, resume and skip-upload flags).
---
.gitignore | 5 +-
backend/app.py | 141 +++---
backend/pipeline/run.py | 6 +-
backend/pipeline/storage.py | 9 +
backend/tools/claim_template_players.py | 188 ++++++++
frontend/web/app/api/create-upload/route.ts | 13 +-
.../web/app/api/dashboard/summary/route.ts | 5 +-
frontend/web/app/api/heatmaps/latest/route.ts | 5 +-
frontend/web/app/api/onboarding/route.ts | 19 +-
frontend/web/app/api/players/[id]/route.ts | 15 +-
frontend/web/app/api/players/route.ts | 20 +-
frontend/web/app/api/proxy-image/route.ts | 4 +-
frontend/web/app/api/recordings/[id]/route.ts | 47 +-
frontend/web/app/api/recordings/route.ts | 1 +
frontend/web/app/api/trigger-process/route.ts | 135 +++---
.../web/app/auth/forgot-password/page.tsx | 16 +-
frontend/web/app/auth/layout.tsx | 8 +
frontend/web/app/auth/login/page.tsx | 23 +-
.../web/app/auth/update-password/page.tsx | 13 +-
frontend/web/app/error.tsx | 9 +
frontend/web/app/landing/layout.tsx | 8 +
frontend/web/app/layout.tsx | 2 +-
frontend/web/app/not-found.tsx | 16 +
frontend/web/app/onboarding/layout.tsx | 8 +
frontend/web/app/onboarding/page.tsx | 6 +-
frontend/web/app/players/[id]/layout.tsx | 11 +
frontend/web/app/players/[id]/page.tsx | 26 +-
frontend/web/app/players/layout.tsx | 8 +
frontend/web/app/profile/layout.tsx | 8 +
frontend/web/app/recordings/[id]/layout.tsx | 11 +
frontend/web/app/recordings/[id]/page.tsx | 212 ++++++---
frontend/web/app/recordings/layout.tsx | 8 +
frontend/web/app/recordings/page.tsx | 12 +
frontend/web/app/settings/layout.tsx | 8 +
frontend/web/app/upload/layout.tsx | 8 +
frontend/web/app/upload/page.tsx | 33 +-
frontend/web/components/ErrorBoundary.tsx | 18 +-
frontend/web/components/PageError.tsx | 21 +
.../web/components/dashboard/EmptyState.tsx | 6 -
frontend/web/components/demo/DemoToggle.tsx | 98 -----
.../features/recordings/VideoPlayer.tsx | 10 +-
frontend/web/components/layout/AppLayout.tsx | 8 +-
.../components/layout/ConditionalLayout.tsx | 13 +-
frontend/web/components/layout/Sidebar.tsx | 14 +-
.../web/components/players/PlayerCard.tsx | 2 +-
.../components/recordings/CoachInsights.tsx | 4 +-
.../web/components/recordings/NotesPanel.tsx | 14 +-
.../web/components/recordings/RallyTable.tsx | 5 +-
.../web/components/recordings/VizPanel.tsx | 118 ++---
frontend/web/components/viz/ShotMap.tsx | 27 --
frontend/web/components/viz/Spacing.tsx | 26 --
frontend/web/hooks/useVideoUpload.ts | 38 +-
frontend/web/lib/demo/demoData.ts | 246 -----------
frontend/web/lib/hooks/useApiData.ts | 31 +-
frontend/web/lib/routeMetadata.ts | 37 ++
frontend/web/lib/supabase/proxy.ts | 29 +-
frontend/web/next.config.mjs | 3 +-
frontend/web/public/Bounce_Animated.mp4 | Bin 0 -> 1012506 bytes
.../audit/functional/checks/00-public.mjs | 180 ++++++++
.../audit/functional/checks/01-shell.mjs | 120 ++++++
.../audit/functional/checks/02-dashboard.mjs | 58 +++
.../functional/checks/03-recordings-list.mjs | 129 ++++++
.../functional/checks/04-recording-detail.mjs | 235 ++++++++++
.../audit/functional/checks/05-players.mjs | 76 ++++
.../checks/06-upload-validation.mjs | 108 +++++
.../audit/functional/checks/07-upload-e2e.mjs | 172 ++++++++
.../functional/checks/08-profile-settings.mjs | 58 +++
.../checks/09-onboarding-readonly.mjs | 30 ++
.../audit/functional/checks/10-a11y.mjs | 76 ++++
.../audit/functional/checks/11-copy.mjs | 50 +++
.../audit/functional/checks/12-cleanup.mjs | 33 ++
frontend/web/scripts/audit/functional/lib.mjs | 403 ++++++++++++++++++
frontend/web/scripts/audit/functional/run.mjs | 77 ++++
.../web/scripts/audit/verify-viz-data.cjs | 25 +-
...0260910_players_cascade_on_user_delete.sql | 9 +
75 files changed, 2818 insertions(+), 856 deletions(-)
create mode 100644 backend/tools/claim_template_players.py
create mode 100644 frontend/web/app/auth/layout.tsx
create mode 100644 frontend/web/app/error.tsx
create mode 100644 frontend/web/app/landing/layout.tsx
create mode 100644 frontend/web/app/not-found.tsx
create mode 100644 frontend/web/app/onboarding/layout.tsx
create mode 100644 frontend/web/app/players/[id]/layout.tsx
create mode 100644 frontend/web/app/players/layout.tsx
create mode 100644 frontend/web/app/profile/layout.tsx
create mode 100644 frontend/web/app/recordings/[id]/layout.tsx
create mode 100644 frontend/web/app/recordings/layout.tsx
create mode 100644 frontend/web/app/settings/layout.tsx
create mode 100644 frontend/web/app/upload/layout.tsx
create mode 100644 frontend/web/components/PageError.tsx
delete mode 100644 frontend/web/components/demo/DemoToggle.tsx
delete mode 100644 frontend/web/lib/demo/demoData.ts
create mode 100644 frontend/web/lib/routeMetadata.ts
create mode 100644 frontend/web/public/Bounce_Animated.mp4
create mode 100644 frontend/web/scripts/audit/functional/checks/00-public.mjs
create mode 100644 frontend/web/scripts/audit/functional/checks/01-shell.mjs
create mode 100644 frontend/web/scripts/audit/functional/checks/02-dashboard.mjs
create mode 100644 frontend/web/scripts/audit/functional/checks/03-recordings-list.mjs
create mode 100644 frontend/web/scripts/audit/functional/checks/04-recording-detail.mjs
create mode 100644 frontend/web/scripts/audit/functional/checks/05-players.mjs
create mode 100644 frontend/web/scripts/audit/functional/checks/06-upload-validation.mjs
create mode 100644 frontend/web/scripts/audit/functional/checks/07-upload-e2e.mjs
create mode 100644 frontend/web/scripts/audit/functional/checks/08-profile-settings.mjs
create mode 100644 frontend/web/scripts/audit/functional/checks/09-onboarding-readonly.mjs
create mode 100644 frontend/web/scripts/audit/functional/checks/10-a11y.mjs
create mode 100644 frontend/web/scripts/audit/functional/checks/11-copy.mjs
create mode 100644 frontend/web/scripts/audit/functional/checks/12-cleanup.mjs
create mode 100644 frontend/web/scripts/audit/functional/lib.mjs
create mode 100644 frontend/web/scripts/audit/functional/run.mjs
create mode 100644 supabase/migrations/20260910_players_cascade_on_user_delete.sql
diff --git a/.gitignore b/.gitignore
index 672744ab..ed4442e7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -38,6 +38,7 @@ backend/training/runs/
backend/training/__pycache__/
backend/calibration_frames/videos/
*.mp4
+!frontend/web/public/Bounce_Animated.mp4
*.mov
# Node / frontend artifacts
@@ -76,7 +77,7 @@ docs/upgrade-plan*.md
docs/superpowers/
# Audit (responsive screenshots, session state) — local-only
-frontend/web/scripts/audit/storageState.json
+frontend/web/scripts/audit/storageState*.json
frontend/web/scripts/audit/screenshots/
frontend/web/scripts/audit/sign-in-failed.png
@@ -86,3 +87,5 @@ catboost_info/
legacy/
media/
.codegraph/
+frontend/web/scripts/audit/functional/REPORT.md
+frontend/web/scripts/audit/functional/report.json
diff --git a/backend/app.py b/backend/app.py
index ff67cd53..8a890174 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -2,9 +2,11 @@
import os
import tempfile
import time
+import uuid
import modal
import requests
from fastapi import Request, HTTPException
+from fastapi.responses import JSONResponse
app = modal.App("tennis-modal")
@@ -66,18 +68,25 @@ async def process_video(request: Request):
):
raise HTTPException(status_code=401, detail="Unauthorized")
- payload = await request.json()
- file_key = payload["file_key"]
- match_id = payload["match_id"]
-
- from supabase import create_client
-
- supabase = create_client(
- os.environ["SUPABASE_URL"],
- os.environ["SUPABASE_SERVICE_ROLE_KEY"],
- )
-
- def _stage(stage: str, progress: float) -> None:
+ try:
+ payload = await request.json()
+ if not isinstance(payload, dict):
+ raise ValueError("Expected an object")
+ match_id = payload.get("match_id")
+ file_key = payload.get("file_key")
+ if not isinstance(match_id, str):
+ raise ValueError("Missing recording ID")
+ uuid.UUID(match_id)
+ if not isinstance(file_key, str) or not file_key.startswith(f"{match_id}/"):
+ raise ValueError("Storage key does not belong to the recording")
+ if any(part in ("", ".", "..") for part in file_key.split("/")):
+ raise ValueError("Invalid storage key")
+ except (ValueError, TypeError):
+ return JSONResponse({"error": "Invalid recording ID or storage key."}, status_code=400)
+
+ supabase = None
+
+ def _stage(stage: str, progress: float, *, failure: str | None = None) -> None:
"""Write an early-stage breadcrumb to Supabase so the UI shows what's
happening during the 15-90s window between the trigger-process webhook
and run_pipeline's heartbeat. Without this the page sat on
@@ -85,8 +94,14 @@ def _stage(stage: str, progress: float) -> None:
for the entire Modal cold-start + download + import phase.
Defensive on column-missing so an un-migrated environment still
runs to completion."""
+ nonlocal supabase
payload: dict = {"progress": round(float(progress), 3), "processing_stage": stage}
+ if failure:
+ payload = {**payload, "status": "failed", "error": failure}
try:
+ if supabase is None:
+ from supabase import create_client
+ supabase = create_client(os.environ["SUPABASE_URL"], os.environ["SUPABASE_SERVICE_ROLE_KEY"])
supabase.table("matches").update(payload).eq("id", match_id).execute()
print(f"[Stage] {stage} ({progress:.3f})", flush=True)
except Exception as e:
@@ -97,55 +112,67 @@ def _stage(stage: str, progress: float) -> None:
except Exception as e2:
print(f"[Stage] {stage} write failed: {e2}", flush=True)
- _stage("Downloading recording", 0.008)
+ try:
+ from supabase import create_client
- # Download video — preserve original filename so _resolve_camera_id can detect court number.
- # Supabase storage occasionally returns 504 Gateway Timeout under load; retry with a
- # fresh signed URL on each attempt (the URL itself is fine, but a fresh one is cheap insurance).
- original_name = os.path.basename(file_key)
- video_path = os.path.join(tempfile.gettempdir(), original_name)
+ supabase = create_client(
+ os.environ["SUPABASE_URL"],
+ os.environ["SUPABASE_SERVICE_ROLE_KEY"],
+ )
- download_attempts = 4
- last_exc = None
- for attempt in range(1, download_attempts + 1):
- try:
- signed = supabase.storage.from_("raw-videos").create_signed_url(
- file_key,
- expires_in=3600,
- )
- signed_url = signed["signedUrl"]
-
- # (connect, read) timeouts. Most stalls are read-side; 300s read covers
- # multi-GB clips at typical CDN throughput while still failing fast on dead links.
- with requests.get(signed_url, stream=True, timeout=(15, 300)) as r:
- r.raise_for_status()
- with open(video_path, "wb") as f:
- for chunk in r.iter_content(64 * 1024):
- if chunk:
- f.write(chunk)
- break # success
- except (requests.exceptions.HTTPError,
- requests.exceptions.ConnectionError,
- requests.exceptions.ChunkedEncodingError,
- requests.exceptions.Timeout) as exc:
- last_exc = exc
- status = getattr(getattr(exc, "response", None), "status_code", None)
- # 4xx (other than 408/429) are real errors — don't retry
- if status is not None and 400 <= status < 500 and status not in (408, 429):
- raise
- if attempt == download_attempts:
- raise
- backoff = min(60, 2 ** attempt) # 2s, 4s, 8s, 16s (cap 60s)
- print(f"[download] attempt {attempt}/{download_attempts} failed ({type(exc).__name__}: {exc}); retrying in {backoff}s")
- time.sleep(backoff)
- else:
- # Should be unreachable — loop either breaks on success or raises on final attempt.
- if last_exc is not None:
- raise last_exc
+ _stage("Downloading recording", 0.008)
- _stage("Loading models", 0.012)
+ # Download video — preserve original filename so _resolve_camera_id can detect court number.
+ # Supabase storage occasionally returns 504 Gateway Timeout under load; retry with a
+ # fresh signed URL on each attempt (the URL itself is fine, but a fresh one is cheap insurance).
+ original_name = os.path.basename(file_key)
+ video_path = os.path.join(tempfile.gettempdir(), original_name)
+
+ download_attempts = 4
+ last_exc = None
+ for attempt in range(1, download_attempts + 1):
+ try:
+ signed = supabase.storage.from_("raw-videos").create_signed_url(
+ file_key,
+ expires_in=3600,
+ )
+ signed_url = signed["signedUrl"]
+
+ # (connect, read) timeouts. Most stalls are read-side; 300s read covers
+ # multi-GB clips at typical CDN throughput while still failing fast on dead links.
+ with requests.get(signed_url, stream=True, timeout=(15, 300)) as r:
+ r.raise_for_status()
+ with open(video_path, "wb") as f:
+ for chunk in r.iter_content(64 * 1024):
+ if chunk:
+ f.write(chunk)
+ break # success
+ except (requests.exceptions.HTTPError,
+ requests.exceptions.ConnectionError,
+ requests.exceptions.ChunkedEncodingError,
+ requests.exceptions.Timeout) as exc:
+ last_exc = exc
+ status = getattr(getattr(exc, "response", None), "status_code", None)
+ # 4xx (other than 408/429) are real errors — don't retry
+ if status is not None and 400 <= status < 500 and status not in (408, 429):
+ raise
+ if attempt == download_attempts:
+ raise
+ backoff = min(60, 2 ** attempt) # 2s, 4s, 8s, 16s (cap 60s)
+ print(f"[download] attempt {attempt}/{download_attempts} failed ({type(exc).__name__}: {exc}); retrying in {backoff}s")
+ time.sleep(backoff)
+ else:
+ # Should be unreachable — loop either breaks on success or raises on final attempt.
+ if last_exc is not None:
+ raise last_exc
+
+ _stage("Loading models", 0.012)
+
+ from backend.pipeline.run import run_pipeline
+ except Exception:
+ _stage("Processing could not start", 0, failure="Processing could not start. Press Reprocess to try again.")
+ raise
- from backend.pipeline.run import run_pipeline
result = run_pipeline(video_path, match_id)
return result
diff --git a/backend/pipeline/run.py b/backend/pipeline/run.py
index e3f66ea6..3a3770c5 100644
--- a/backend/pipeline/run.py
+++ b/backend/pipeline/run.py
@@ -12,7 +12,7 @@
from backend.vision.heatmaps import generate_minimap_heatmaps, generate_player_shot_dot_map
from backend.vision.postprocess import detect_shot_frames
-from backend.pipeline.storage import upload_processed_video, upload_heatmap_png, get_supabase, make_streamable_mp4, upload_results_parallel
+from backend.pipeline.storage import upload_processed_video, upload_heatmap_png, get_supabase, make_streamable_mp4, upload_results_parallel, ProcessedVideoUploadError, PROCESSED_VIDEO_UPLOAD_ERROR
from backend.pipeline.config import PipelineConfig
from backend.pipeline.rallies import (
RALLY_GAP_SECONDS,
@@ -2561,6 +2561,8 @@ def update_progress(value: float, stage: "str | None" = None):
local_shot_map_path=local_shot_map_path,
)
results_path = upload_results.get("results_path")
+ if not results_path:
+ raise ProcessedVideoUploadError(PROCESSED_VIDEO_UPLOAD_ERROR)
bounce_heatmap_path = upload_results.get("bounce_heatmap_path")
player_heatmap_path = upload_results.get("player_heatmap_path")
player_shot_map_path = upload_results.get("player_shot_map_path")
@@ -2697,7 +2699,7 @@ def update_progress(value: float, stage: "str | None" = None):
logging.exception("Pipeline failed for match %s", match_id)
supabase.table("matches").update({
"status": "failed",
- "error": "Processing failed. Please try again.",
+ "error": PROCESSED_VIDEO_UPLOAD_ERROR if isinstance(e, ProcessedVideoUploadError) else "Processing failed. Please try again.",
"progress": 0
}).eq("id", match_id).execute()
raise
diff --git a/backend/pipeline/storage.py b/backend/pipeline/storage.py
index bed48bc0..143f0a3d 100644
--- a/backend/pipeline/storage.py
+++ b/backend/pipeline/storage.py
@@ -4,6 +4,13 @@
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
+PROCESSED_VIDEO_UPLOAD_ERROR = "The analyzed video could not be saved. Press Reprocess to try again."
+
+
+class ProcessedVideoUploadError(RuntimeError):
+ """The required video artifact could not be stored."""
+
+
def make_streamable_mp4(input_path: str, source_audio_path: str | None = None) -> str:
"""
Re-encode and remux to a browser-streamable MP4 (moov atom first).
@@ -157,4 +164,6 @@ def upload_results_parallel(
print(f"[Storage] Upload failed for {key}: {e}")
results[key] = None
+ if not results.get("results_path"):
+ raise ProcessedVideoUploadError(PROCESSED_VIDEO_UPLOAD_ERROR)
return results
diff --git a/backend/tools/claim_template_players.py b/backend/tools/claim_template_players.py
new file mode 100644
index 00000000..e8ac7d7b
--- /dev/null
+++ b/backend/tools/claim_template_players.py
@@ -0,0 +1,188 @@
+"""Copy template players into Brian's roster and remap only his recordings.
+
+Dry-run by default; --apply performs the printed inserts and updates. Never deletes.
+Uses only Python's standard library and PostgREST.
+
+From the repository root, load credentials before running:
+ set -a; source frontend/web/.env.local; set +a
+ python3 backend/tools/claim_template_players.py
+ python3 backend/tools/claim_template_players.py --apply
+
+Reads SUPABASE_URL (or NEXT_PUBLIC_SUPABASE_URL) and SUPABASE_SERVICE_ROLE_KEY.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+import urllib.error
+import urllib.parse
+import urllib.request
+import uuid
+from collections import Counter
+
+BRIAN_USER_ID = "6e9abfcb-b30d-44ef-9789-0dccd95cacad"
+COPY_FIELDS = ("name", "position", "year", "photo_url", "handedness")
+PAGE_SIZE = 1000
+REQUEST_TIMEOUT_SEC = 30
+
+
+def _load_env() -> tuple[str, str]:
+ url = (os.environ.get("SUPABASE_URL") or os.environ.get("NEXT_PUBLIC_SUPABASE_URL") or "").rstrip("/")
+ key = os.environ.get("SUPABASE_SERVICE_ROLE_KEY", "")
+ parsed = urllib.parse.urlparse(url)
+ if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password:
+ raise ValueError("Set SUPABASE_URL or NEXT_PUBLIC_SUPABASE_URL to your HTTPS Supabase URL.")
+ if not key:
+ raise ValueError("SUPABASE_SERVICE_ROLE_KEY is required; see the environment setup above.")
+ return url, key
+
+
+class Client:
+ def __init__(self, url: str, key: str) -> None:
+ self.url = url
+ self.headers = {
+ "apikey": key, "Authorization": f"Bearer {key}",
+ "Content-Type": "application/json", "Prefer": "return=representation",
+ }
+
+ def call(self, method: str, table: str, params: dict, body=None) -> list[dict]:
+ path = f"/rest/v1/{table}?{urllib.parse.urlencode(params)}"
+ data = json.dumps(body).encode() if body is not None else None
+ request = urllib.request.Request(self.url + path, data=data, headers=self.headers, method=method)
+ try:
+ with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT_SEC) as response:
+ rows = json.load(response)
+ except urllib.error.HTTPError as error:
+ raise RuntimeError(f"{method} {table} failed (HTTP {error.code}): {error.read().decode()[:300]}") from error
+ except urllib.error.URLError as error:
+ raise RuntimeError(f"{method} {table} could not reach Supabase: {error.reason}") from error
+ if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows):
+ raise ValueError(f"Unexpected {table} response; stopped without further changes.")
+ return rows
+
+ def auth_user(self, user_id: str) -> dict:
+ request = urllib.request.Request(f"{self.url}/auth/v1/admin/users/{user_id}", headers=self.headers, method="GET")
+ with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT_SEC) as response:
+ user = json.load(response)
+ if not isinstance(user, dict) or user.get("id") != user_id:
+ raise ValueError("Auth user lookup did not return the expected account; stopped.")
+ return user
+
+ def set_user_metadata(self, user_id: str, metadata: dict) -> dict:
+ data = json.dumps({"user_metadata": metadata}).encode()
+ request = urllib.request.Request(f"{self.url}/auth/v1/admin/users/{user_id}", data=data, headers=self.headers, method="PUT")
+ with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT_SEC) as response:
+ return json.load(response)
+
+ def rows(self, table: str, params: dict) -> list[dict]:
+ result = []
+ while True:
+ page = self.call("GET", table, {**params, "order": "id.asc", "limit": PAGE_SIZE, "offset": len(result)})
+ result.extend(page)
+ if len(page) < PAGE_SIZE:
+ return result
+
+
+def _check_players(rows: list[dict], owner: str | None) -> None:
+ for row in rows:
+ uuid.UUID(row["id"])
+ if "user_id" not in row or row["user_id"] != owner or not isinstance(row.get("name"), str):
+ raise ValueError("Player ownership or name could not be established; stopped.")
+
+
+def _plan(client: Client) -> list[dict]:
+ columns = ",".join(("id", "user_id", *COPY_FIELDS))
+ templates = client.rows("players", {"select": columns, "user_id": "is.null"})
+ owned = client.rows("players", {"select": columns, "user_id": f"eq.{BRIAN_USER_ID}"})
+ _check_players(templates, None)
+ _check_players(owned, BRIAN_USER_ID)
+ matches = client.rows("matches", {"select": "id,user_id,player_id", "user_id": f"eq.{BRIAN_USER_ID}"})
+ if any(row.get("user_id") != BRIAN_USER_ID for row in matches):
+ raise ValueError("Recording ownership could not be established; stopped.")
+ counts = Counter(row.get("player_id") for row in matches)
+ by_name = {}
+ for player in owned:
+ by_name.setdefault(player["name"], player["id"])
+ actions = []
+ for template in templates:
+ name = template["name"]
+ create = name not in by_name
+ target = by_name.get(name) or str(uuid.uuid4())
+ by_name[name] = target
+ actions.append({"template": template, "target": target, "create": create, "matches": counts[template["id"]]})
+ return actions
+
+
+def _print(actions: list[dict], applied: bool = False) -> None:
+ print("Action Player Template ID Owned ID Recordings")
+ for action in actions:
+ verb = ("CREATED" if applied else "CREATE") if action["create"] else "REUSE"
+ print(f"{verb:7} {action['template']['name']:30} {action['template']['id']} {action['target']} {action['matches']:10}")
+ print(f"Templates: {len(actions)}; copies: {sum(a['create'] for a in actions)}; "
+ f"reused: {sum(not a['create'] for a in actions)}; recordings remapped: {sum(a['matches'] for a in actions)}")
+ print("ID mapping:")
+ for action in actions:
+ print(f" {action['template']['id']} -> {action['target']}")
+
+
+def _apply(client: Client, actions: list[dict]) -> list[dict]:
+ applied = []
+ resolved = {}
+ for action in actions:
+ template = action["template"]
+ target = resolved.get(template["name"], action["target"])
+ created = False
+ if action["create"]:
+ # Recheck names so retries after a partial run reuse existing copies.
+ existing = client.rows("players", {"select": "id,name,user_id", "user_id": f"eq.{BRIAN_USER_ID}"})
+ _check_players(existing, BRIAN_USER_ID)
+ same_name = next((row for row in existing if row["name"] == template["name"]), None)
+ if same_name:
+ target = same_name["id"]
+ else:
+ row = {field: template.get(field) for field in COPY_FIELDS}
+ inserted = client.call("POST", "players", {}, {**row, "id": target, "user_id": BRIAN_USER_ID})
+ _check_players(inserted, BRIAN_USER_ID)
+ if len(inserted) != 1 or inserted[0]["id"] != target:
+ raise ValueError("The owned player insert was not confirmed; stopped.")
+ created = True
+ resolved[template["name"]] = target
+ updated = client.call("PATCH", "matches", {
+ "user_id": f"eq.{BRIAN_USER_ID}", "player_id": f"eq.{template['id']}",
+ }, {"player_id": target})
+ if any(row.get("user_id") != BRIAN_USER_ID or row.get("player_id") != target for row in updated):
+ raise ValueError("The recording remap was not confirmed; stopped.")
+ result = {**action, "target": target, "create": created, "matches": len(updated)}
+ applied.append(result)
+ print(f"Applied: {template['id']} -> {target}; {len(updated)} recordings")
+ return applied
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ parser.add_argument("--apply", action="store_true", help="execute the plan (default: dry run)")
+ args = parser.parse_args()
+ try:
+ client = Client(*_load_env())
+ actions = _plan(client)
+ print(f"User: {BRIAN_USER_ID}; {'apply' if args.apply else 'dry run'}")
+ _print(actions)
+ current = (client.auth_user(BRIAN_USER_ID).get("user_metadata") or {})
+ template_flag = current.get("onboarding_template")
+ print(f"user_metadata.onboarding_template: {template_flag!r} -> 'uc-davis' "
+ "(hides the shared template rows once the owned copies exist)")
+ if args.apply:
+ _print(_apply(client, actions), applied=True)
+ if template_flag != "uc-davis":
+ updated = client.set_user_metadata(BRIAN_USER_ID, {**current, "onboarded": True, "onboarding_template": "uc-davis"})
+ print(f"Applied: user_metadata.onboarding_template = {(updated.get('user_metadata') or {}).get('onboarding_template')!r}")
+ else:
+ print("Dry run only. IDs for new copies are proposed; re-run with --apply to execute.")
+ except (ValueError, KeyError, RuntimeError, OSError) as error:
+ sys.exit(f"Stopped: {error}. Review any printed applied actions before retrying.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/frontend/web/app/api/create-upload/route.ts b/frontend/web/app/api/create-upload/route.ts
index 0076a625..fd6c8b5f 100644
--- a/frontend/web/app/api/create-upload/route.ts
+++ b/frontend/web/app/api/create-upload/route.ts
@@ -56,6 +56,13 @@ export async function POST(req: Request) {
const body = await req.json();
const playerId: string | null = typeof body.player_id === 'string' ? body.player_id : null;
+ if (body.player_id != null) {
+ const { data: player, error: playerError } = await supabaseAdmin
+ .from('players').select('id').eq('id', playerId ?? '').eq('user_id', user.id).maybeSingle();
+ if (playerError || !player) {
+ return NextResponse.json({ error: 'That player is not on your roster.' }, { status: 400 });
+ }
+ }
const safeFilename = sanitizeFilename((body.filename as string) || '');
if (!safeFilename) {
return NextResponse.json({ error: 'Invalid file type. Allowed: mp4, mov, avi' }, { status: 400 });
@@ -86,7 +93,11 @@ export async function POST(req: Request) {
player_id: playerId,
};
if (customName) insertRow.name = customName;
- await supabaseAdmin.from("matches").insert([insertRow]);
+ const { error: insertError } = await supabaseAdmin.from("matches").insert([insertRow]);
+ if (insertError) {
+ console.error('Recording insert failed', insertError);
+ return NextResponse.json({ error: 'We could not create the recording. Try the upload again.' }, { status: 500 });
+ }
// match_date column may not exist on the matches table. Attempt a follow-up
// update so a missing column doesn't break the insert.
diff --git a/frontend/web/app/api/dashboard/summary/route.ts b/frontend/web/app/api/dashboard/summary/route.ts
index 32a6f6c3..f4b444d8 100644
--- a/frontend/web/app/api/dashboard/summary/route.ts
+++ b/frontend/web/app/api/dashboard/summary/route.ts
@@ -40,7 +40,8 @@ export async function GET() {
if (error) {
console.error('Dashboard summary fetch error', error);
- return NextResponse.json({ error: error.message }, { status: 500 });
+ console.error('Summary query failed', error);
+ return NextResponse.json({ error: 'Could not load your summary. Refresh to try again.' }, { status: 500 });
}
const matches = data ?? [];
@@ -119,6 +120,6 @@ export async function GET() {
});
} catch (e) {
console.error(e);
- return NextResponse.json({ error: (e as Error).message }, { status: 500 });
+ return NextResponse.json({ error: 'Could not load your summary. Refresh to try again.' }, { status: 500 });
}
}
diff --git a/frontend/web/app/api/heatmaps/latest/route.ts b/frontend/web/app/api/heatmaps/latest/route.ts
index 62d84982..cac7c3d4 100644
--- a/frontend/web/app/api/heatmaps/latest/route.ts
+++ b/frontend/web/app/api/heatmaps/latest/route.ts
@@ -36,7 +36,8 @@ export async function GET() {
if (error) {
console.error('Latest heatmaps fetch error', error);
- return NextResponse.json({ error: error.message }, { status: 500 });
+ console.error('Summary query failed', error);
+ return NextResponse.json({ error: 'Could not load your summary. Refresh to try again.' }, { status: 500 });
}
const latest = data?.[0];
@@ -79,7 +80,7 @@ export async function GET() {
});
} catch (e) {
console.error(e);
- return NextResponse.json({ error: (e as Error).message }, { status: 500 });
+ return NextResponse.json({ error: 'Could not load your summary. Refresh to try again.' }, { status: 500 });
}
}
diff --git a/frontend/web/app/api/onboarding/route.ts b/frontend/web/app/api/onboarding/route.ts
index 0694be51..22e82aa9 100644
--- a/frontend/web/app/api/onboarding/route.ts
+++ b/frontend/web/app/api/onboarding/route.ts
@@ -100,6 +100,16 @@ export async function POST(req: Request) {
return NextResponse.json({ error: 'Unknown template key' }, { status: 400 });
}
+ const { data: ownedPlayers, error: ownedError } = await supabaseAdmin
+ .from('players').select('name').eq('user_id', user.id);
+ if (ownedError) {
+ console.error('[onboarding] read owned roster', ownedError);
+ return NextResponse.json({ error: 'Could not check your roster. Refresh to try again.' }, { status: 500 });
+ }
+ if (user.user_metadata?.onboarded === true || ownedPlayers?.length) {
+ return NextResponse.json({ error: 'Your roster is already set up.' }, { status: 409 });
+ }
+
let clonedCount = 0;
if (template === 'uc-davis') {
const { data: templatePlayers, error: fetchErr } = await fetchTemplatePlayers();
@@ -107,7 +117,14 @@ export async function POST(req: Request) {
console.error('[onboarding] fetch templates for clone', fetchErr);
return NextResponse.json({ error: 'Failed to read template' }, { status: 500 });
}
- const rows = (templatePlayers ?? []).map((p) => ({
+ const { data: currentPlayers, error: currentError } = await supabaseAdmin
+ .from('players').select('name').eq('user_id', user.id);
+ if (currentError) {
+ console.error('[onboarding] check clone names', currentError);
+ return NextResponse.json({ error: 'Could not check your roster. Refresh to try again.' }, { status: 500 });
+ }
+ const existingNames = new Set((currentPlayers ?? []).map((p) => p.name));
+ const rows = (templatePlayers ?? []).filter((p) => !existingNames.has(p.name)).map((p) => ({
name: p.name,
position: p.position,
year: p.year,
diff --git a/frontend/web/app/api/players/[id]/route.ts b/frontend/web/app/api/players/[id]/route.ts
index 4a3798bd..5c90a053 100644
--- a/frontend/web/app/api/players/[id]/route.ts
+++ b/frontend/web/app/api/players/[id]/route.ts
@@ -58,16 +58,20 @@ export async function GET(
const { id } = await params;
+ const includeTemplates = !user.user_metadata?.onboarding_template;
+ const ownerFilter = includeTemplates ? `user_id.is.null,user_id.eq.${user.id}` : `user_id.eq.${user.id}`;
let { data, error } = await supabaseAdmin
.from('players')
.select('id, name, position, year, photo_url, handedness, user_id, created_at')
.eq('id', id)
+ .or(ownerFilter)
.single();
if (error && typeof error.message === 'string' && error.message.includes('does not exist')) {
const retry = await supabaseAdmin
.from('players')
- .select('id, name, position, year, photo_url, created_at')
+ .select('id, name, position, year, photo_url, user_id, created_at')
.eq('id', id)
+ .or(ownerFilter)
.single();
data = retry.data as typeof data;
error = retry.error;
@@ -77,13 +81,6 @@ export async function GET(
return NextResponse.json({ error: 'Player not found' }, { status: 404 });
}
- // Restrict visibility: caller can see demo (user_id null) or own rows only.
- // Using `as` to narrow the type since the legacy retry omits user_id.
- const owner = (data as { user_id?: string | null }).user_id ?? null;
- if (owner !== null && owner !== user.id) {
- return NextResponse.json({ error: 'Player not found' }, { status: 404 });
- }
-
return NextResponse.json({ player: data });
} catch (e) {
console.error(e);
@@ -112,7 +109,7 @@ export async function PATCH(
const { id } = await params;
- // Ownership check: only the row's owner can edit. Demo rows (user_id null)
+ // Ownership check: only the row's owner can edit. Template rows (user_id null)
// are read-only for everyone. Returns 404 (not 403) for non-owned rows
// to avoid leaking existence of other users' players.
const { data: ownerRow, error: ownerErr } = await fetchPlayerForOwnershipCheck(id);
diff --git a/frontend/web/app/api/players/route.ts b/frontend/web/app/api/players/route.ts
index 0a5e87c0..09a15edf 100644
--- a/frontend/web/app/api/players/route.ts
+++ b/frontend/web/app/api/players/route.ts
@@ -39,7 +39,7 @@ async function getAuthenticatedUser() {
// they would either have been cloned ('uc-davis') or intentionally skipped
// ('empty').
// - Legacy users (onboarded but onboarding_template missing, e.g. backfilled
-// from before the onboarding flow existed): see demo + own. Keeps their
+// from before the onboarding flow existed): see templates + own. Keeps their
// existing experience unchanged.
// - Not-yet-onboarded users: see only their own (which is usually nothing).
// Middleware will have redirected them to /onboarding before they hit this.
@@ -51,27 +51,25 @@ export async function GET() {
}
const meta = (user.user_metadata ?? {}) as { onboarding_template?: string };
- const includeDemo = !meta.onboarding_template;
+ const includeTemplates = !meta.onboarding_template;
const SELECT_FULL = 'id, name, position, year, photo_url, handedness, user_id, created_at';
const SELECT_NO_OWNER = 'id, name, position, year, photo_url, handedness, created_at';
const SELECT_LEGACY = 'id, name, position, year, photo_url, created_at';
- async function tryFetch(cols: string, ownerFilter: boolean) {
+ async function tryFetch(cols: string) {
let q = supabaseAdmin.from('players').select(cols).order('name', { ascending: true });
- if (ownerFilter) {
- q = includeDemo
- ? q.or(`user_id.is.null,user_id.eq.${user!.id}`)
- : q.eq('user_id', user!.id);
- }
+ q = includeTemplates
+ ? q.or(`user_id.is.null,user_id.eq.${user!.id}`)
+ : q.eq('user_id', user!.id);
return q;
}
- let { data, error } = await tryFetch(SELECT_FULL, true);
+ let { data, error } = await tryFetch(SELECT_FULL);
if (error && /does not exist/.test(error.message ?? '')) {
- ({ data, error } = await tryFetch(SELECT_NO_OWNER, false));
+ ({ data, error } = await tryFetch(SELECT_NO_OWNER));
if (error && /does not exist/.test(error.message ?? '')) {
- ({ data, error } = await tryFetch(SELECT_LEGACY, false));
+ ({ data, error } = await tryFetch(SELECT_LEGACY));
}
}
diff --git a/frontend/web/app/api/proxy-image/route.ts b/frontend/web/app/api/proxy-image/route.ts
index 2f4ca710..8f71ed17 100644
--- a/frontend/web/app/api/proxy-image/route.ts
+++ b/frontend/web/app/api/proxy-image/route.ts
@@ -53,7 +53,9 @@ export async function GET(req: NextRequest) {
userId: user.id,
ip: clientIp(req),
bucket: 'proxy-image',
- limit: 120,
+ // Roster photos: every roster render fetches one image per player, so 120/hour
+ // was exhausted by a coach browsing a 9-player team for a few minutes.
+ limit: 1200,
windowSec: 3600,
});
if (!rl.ok) return rateLimitResponse(rl.retryAfterSec);
diff --git a/frontend/web/app/api/recordings/[id]/route.ts b/frontend/web/app/api/recordings/[id]/route.ts
index 63bb06e7..1250d3d1 100644
--- a/frontend/web/app/api/recordings/[id]/route.ts
+++ b/frontend/web/app/api/recordings/[id]/route.ts
@@ -9,6 +9,17 @@ import { checkRateLimit, rateLimitResponse, clientIp } from '@/lib/ratelimit';
export const dynamic = 'force-dynamic';
export const revalidate = 0;
+// Older clients wrote these jsonb columns as JSON *strings* (three live rows still
+// hold "[{...}]"), which made every saved note invisible to the array guard in the UI.
+// Accept both shapes; the next PATCH rewrites the column as a real array.
+function parseJsonArray(value: unknown): unknown[] {
+ if (Array.isArray(value)) return value;
+ if (typeof value === 'string') {
+ try { const parsed = JSON.parse(value); return Array.isArray(parsed) ? parsed : []; } catch { return []; }
+ }
+ return [];
+}
+
export async function GET(
req: Request,
{ params }: { params: Promise<{ id: string }> }
@@ -103,12 +114,15 @@ export async function GET(
// Defaults to 'right' (no badge shown frontside) if the player isn't bound
// or the handedness column hasn't been migrated yet.
let playerHandedness: 'right' | 'left' | null = null;
+ let playerName: string | null = null;
if (data.player_id) {
const handednessRes = await supabaseAdmin
.from("players")
- .select("handedness")
+ .select("name, handedness")
.eq("id", data.player_id)
+ .eq("user_id", user.id)
.single();
+ playerName = handednessRes.data?.name ?? null;
const h = (handednessRes.data as { handedness?: string } | null)?.handedness;
if (h === 'left' || h === 'right') {
playerHandedness = h;
@@ -148,6 +162,7 @@ export async function GET(
stage: data.processing_stage || null,
error: data.error || null,
videoUrl,
+ inputPath: data.input_path ?? null,
bounceHeatmapUrl,
playerHeatmapUrl,
playerShotMapUrl,
@@ -168,8 +183,9 @@ export async function GET(
favorited: data.favorited ?? false,
playerId: data.player_id ?? null,
playerHandedness,
- keypoints: data.keypoints ?? [],
- notes: data.notes ?? [],
+ playerName,
+ keypoints: parseJsonArray(data.keypoints),
+ notes: parseJsonArray(data.notes),
shots: Array.isArray(data.shots) ? data.shots : [],
coverageGrid: Array.isArray(data.coverage_grid) ? data.coverage_grid : [],
positionSummary: data.position_summary && typeof data.position_summary === "object" ? data.position_summary : null,
@@ -239,11 +255,16 @@ export async function PATCH(
updates.favorited = body.favorited;
}
+ if (body.notes !== undefined && (!Array.isArray(body.notes) || body.notes.length > 200)) {
+ return NextResponse.json({ error: 'Use up to 200 notes per recording.' }, { status: 400 });
+ }
if (Array.isArray(body.notes)) {
const validNotes = body.notes.every(
(n: unknown) =>
typeof n === 'object' && n !== null &&
typeof (n as { timestamp_sec: number }).timestamp_sec === 'number' &&
+ Number.isFinite((n as { timestamp_sec: number }).timestamp_sec) &&
+ (n as { timestamp_sec: number }).timestamp_sec >= 0 &&
typeof (n as { text: string }).text === 'string'
);
if (!validNotes) return NextResponse.json({ error: 'Invalid notes format' }, { status: 400 });
@@ -253,33 +274,45 @@ export async function PATCH(
}));
}
+ if (body.keypoints !== undefined && (!Array.isArray(body.keypoints) || body.keypoints.length > 500)) {
+ return NextResponse.json({ error: 'Use up to 500 keypoints per recording.' }, { status: 400 });
+ }
if (Array.isArray(body.keypoints)) {
// Validate each keypoint: { type, timestamp_sec }
const valid = body.keypoints.every(
(k: unknown) =>
typeof k === 'object' && k !== null &&
['set_start', 'side_switch', 'cut'].includes((k as { type: string }).type) &&
- typeof (k as { timestamp_sec: number }).timestamp_sec === 'number'
+ typeof (k as { timestamp_sec: number }).timestamp_sec === 'number' &&
+ Number.isFinite((k as { timestamp_sec: number }).timestamp_sec) &&
+ (k as { timestamp_sec: number }).timestamp_sec >= 0
);
if (!valid) return NextResponse.json({ error: 'Invalid keypoints format' }, { status: 400 });
- updates.keypoints = body.keypoints;
+ updates.keypoints = body.keypoints.map((k: { type: string; timestamp_sec: number }) => ({
+ type: k.type,
+ timestamp_sec: k.timestamp_sec,
+ }));
}
if (Object.keys(updates).length === 0) {
return NextResponse.json({ error: 'Nothing to update' }, { status: 400 });
}
- const { error } = await supabaseAdmin
+ const { data: updatedRows, error } = await supabaseAdmin
.from("matches")
.update(updates)
.eq("id", id)
- .eq("user_id", user.id);
+ .eq("user_id", user.id)
+ .select("id");
if (error) {
console.error("PATCH error", error);
return NextResponse.json({ error: "Failed to update" }, { status: 500 });
}
+ if (!updatedRows?.length) {
+ return NextResponse.json({ error: 'Recording not found' }, { status: 404 });
+ }
return NextResponse.json(updates);
} catch (e) {
console.error(e);
diff --git a/frontend/web/app/api/recordings/route.ts b/frontend/web/app/api/recordings/route.ts
index bec7b893..c1c7e1d4 100644
--- a/frontend/web/app/api/recordings/route.ts
+++ b/frontend/web/app/api/recordings/route.ts
@@ -94,6 +94,7 @@ export async function GET() {
const { data: playerRows, error: playerErr } = await supabaseAdmin
.from("players")
.select("id, name")
+ .eq("user_id", user.id)
.in("id", playerIds);
if (playerErr) {
// Non-fatal — fall back to no player names; UI will show "—".
diff --git a/frontend/web/app/api/trigger-process/route.ts b/frontend/web/app/api/trigger-process/route.ts
index 64818f5a..b1ecd217 100644
--- a/frontend/web/app/api/trigger-process/route.ts
+++ b/frontend/web/app/api/trigger-process/route.ts
@@ -2,15 +2,16 @@
//
// Two entry paths:
// 1. First-time processing — called by useVideoUpload after the signed-upload
-// PUT lands. Body MUST include file_key + match_id (legacy contract).
+// PUT lands. Body includes match_id; input_path supplies the storage key.
// 2. Reprocess — called from the recording detail page Reprocess button. Body
-// may omit file_key; the server derives it from match.input_path and
+// includes match_id; the server derives the key from match.input_path and
// verifies the raw video still exists in storage before kicking off.
// Useful after a pipeline algorithm change (e.g. bounce SoT refactor) when
// a coach wants to rerun without re-uploading the source.
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
+import { after } from 'next/server';
import { supabaseAdmin } from '@/lib/supabase/server';
export async function POST(req: Request) {
@@ -42,12 +43,11 @@ export async function POST(req: Request) {
const body = await req.json().catch(() => ({}));
const { match_id } = body;
- let { file_key } = body;
if (!match_id) {
return Response.json({ error: 'Missing match_id' }, { status: 400 });
}
- // Fetch ownership + input_path. Server derives file_key when missing.
+ // Fetch ownership + input_path. Never trust a client-supplied storage key.
const { data: match, error: matchError } = await supabaseAdmin
.from('matches')
.select('user_id, input_path, status')
@@ -58,14 +58,12 @@ export async function POST(req: Request) {
return Response.json({ error: 'Forbidden' }, { status: 403 });
}
+ const file_key = match.input_path;
if (!file_key) {
- if (!match.input_path) {
- return Response.json(
- { error: 'The original upload is no longer available. Upload the video again to reprocess.' },
- { status: 409 },
- );
- }
- file_key = match.input_path as string;
+ return Response.json(
+ { error: 'The original upload is no longer available. Upload the recording again to reprocess.' },
+ { status: 409 },
+ );
}
if (match.status === 'processing') {
@@ -115,7 +113,7 @@ export async function POST(req: Request) {
// error/progress fields from the previous run so the UI doesn't show
// a confusing mix of old and new state.
try {
- await supabaseAdmin
+ const { error: processingError } = await supabaseAdmin
.from('matches')
.update({
status: 'processing',
@@ -123,85 +121,54 @@ export async function POST(req: Request) {
processing_stage: 'Queueing compute',
error: null,
})
- .eq('id', match_id);
+ .eq('id', match_id)
+ .eq('user_id', user.id);
+ if (processingError) throw processingError;
} catch (e) {
console.warn('trigger-process pre-write failed, retrying without stage', e);
- await supabaseAdmin
+ const { error: retryError } = await supabaseAdmin
.from('matches')
.update({ status: 'processing', progress: 0.005, error: null })
- .eq('id', match_id);
+ .eq('id', match_id)
+ .eq('user_id', user.id);
+ if (retryError) {
+ console.error('trigger-process pre-write retry failed', retryError);
+ return Response.json({ error: 'We could not start processing. Press Reprocess to try again.' }, { status: 500 });
+ }
}
- // Fire Modal webhook but DO NOT await the response. Modal's
- // @modal.fastapi_endpoint binds the HTTP connection to the function
- // execution — process_video blocks for the entire pipeline (~5 min).
- // Awaiting it stalls /api/trigger-process from returning to the client,
- // which means pollStatus on the upload page never starts until the
- // pipeline is done. That's the "STARTING for the whole upload" bug.
- //
- // The pre-write above (progress=0.005, stage='Queueing compute') is
- // already in the DB, so polling has something to read the moment we
- // return.
- //
- // CRITICAL — do NOT mark the row `failed` just because this fetch
- // resolves non-ok or rejects. Because process_video holds the connection
- // open for the WHOLE pipeline, this fetch never resolves cleanly within
- // the Vercel function's lifetime on the happy path: when Vercel freezes /
- // kills the function after we return the response (~function timeout, no
- // maxDuration set → ~15-30s), the still-pending socket to Modal is torn
- // down. That teardown previously surfaced as a non-ok / abort and the
- // handler stamped status='failed' ~30s in — which the detail page renders
- // as "Analysis needs attention." That is the "reprocess cancels itself"
- // bug: the pipeline kept running on Modal, but the row was already failed.
- //
- // run_pipeline (backend/pipeline/run.py) is the SINGLE source of truth for
- // terminal status and writes `failed` itself on a real pipeline error. So
- // here we only flag failure when Modal returns a FAST, definitive
- // rejection (an HTTP status arrives within the abort window — e.g. 401
- // auth, 4xx bad request, queue-full 5xx) BEFORE the pipeline started. A
- // timeout / socket teardown is expected and must be ignored.
- const TRIGGER_PROBE_MS = 8000; // release well before Vercel kills the fn
- const probe = new AbortController();
- const probeTimer = setTimeout(() => probe.abort(), TRIGGER_PROBE_MS);
- fetch(process.env.MODAL_FUNCTION_URL!, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'Authorization': `Bearer ${process.env.MODAL_WEBHOOK_SECRET}`,
- },
- body: JSON.stringify({ file_key, match_id }),
- signal: probe.signal,
- })
- .then(async (res) => {
- clearTimeout(probeTimer);
- // A definitive HTTP status came back fast → the trigger itself was
- // rejected before the pipeline ran. Safe to mark failed.
- if (!res.ok) {
- const errorText = await res.text().catch(() => '');
- console.error('Modal rejected trigger', res.status, errorText.slice(0, 200));
- await supabaseAdmin
- .from('matches')
- .update({ status: 'failed', error: 'Failed to start compute' })
- .eq('id', match_id);
- }
- // res.ok within the probe window is unusual (Modal blocks for the
- // whole run) but harmless — leave status as 'processing'.
- })
- .catch((err) => {
+ // Modal holds the connection for the entire pipeline. Keep the dispatch
+ // alive after the response, but treat our probe timeout as expected.
+ const TRIGGER_PROBE_MS = 8000;
+ after(async () => {
+ const probe = new AbortController();
+ const probeTimer = setTimeout(() => probe.abort(), TRIGGER_PROBE_MS);
+ try {
+ const res = await fetch(process.env.MODAL_FUNCTION_URL!, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${process.env.MODAL_WEBHOOK_SECRET}`,
+ },
+ body: JSON.stringify({ file_key, match_id }),
+ signal: probe.signal,
+ });
+ if (!res.ok) throw new Error(`Modal rejected trigger (${res.status})`);
+ } catch (err) {
+ if (probe.signal.aborted) return;
+ console.error('Modal dispatch failed', err);
+ const { error: updateError } = await supabaseAdmin
+ .from('matches')
+ .update({ status: 'failed', error: 'We could not start processing. Press Reprocess to try again.' })
+ .eq('id', match_id)
+ .eq('user_id', user.id)
+ .eq('status', 'processing')
+ .lte('progress', 0.005);
+ if (updateError) console.error('Modal failure status update failed', updateError);
+ } finally {
clearTimeout(probeTimer);
- // AbortError (probe window elapsed) is the EXPECTED happy path: Modal
- // is still holding the connection because the pipeline is running.
- // Do NOT touch status — the pipeline owns it from here.
- if (err instanceof DOMException && err.name === 'AbortError') {
- return;
- }
- // A pre-connection network error (DNS, refused) means the trigger
- // never landed; log it but still don't override status — the row is
- // already 'processing' and run_pipeline will never write, so the user
- // can reprocess again. Marking failed here risks the same teardown
- // false-positive, so we stay conservative and log only.
- console.error('Modal trigger fetch error (status left as processing)', err);
- });
+ }
+ });
return Response.json({ status: 'ok' });
} catch (e) {
diff --git a/frontend/web/app/auth/forgot-password/page.tsx b/frontend/web/app/auth/forgot-password/page.tsx
index 003f9b3f..00af7d0e 100644
--- a/frontend/web/app/auth/forgot-password/page.tsx
+++ b/frontend/web/app/auth/forgot-password/page.tsx
@@ -22,6 +22,7 @@ export default function ForgotPasswordPage() {
const [loading, setLoading] = useState(false)
const [sent, setSent] = useState(false)
const [error, setError] = useState('')
+ const [resent, setResent] = useState(false)
async function sendLink() {
const { error: resetError } = await supabase.auth.resetPasswordForEmail(
@@ -50,14 +51,18 @@ export default function ForgotPasswordPage() {
}
async function handleResend() {
- if (!email) return
+ if (!email || loading) return
setError('')
+ setResent(false)
+ setLoading(true)
try {
await sendLink()
+ setResent(true)
} catch (err) {
- const message =
- err instanceof Error ? err.message : 'Failed to resend.'
- setError(message)
+ console.error('Password reset resend failed', err)
+ setError('We could not send the link. Press Resend to try again.')
+ } finally {
+ setLoading(false)
}
}
@@ -100,11 +105,14 @@ export default function ForgotPasswordPage() {
Resend
+ {error && {error}
}
+ {resent && Sent again.
}
Loading sign in. }>
+}
+
+function LoginForm() {
+ const searchParams = useSearchParams()
const router = useRouter()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
- const [stayIn, setStayIn] = useState(true)
const [loading, setLoading] = useState(false)
- const [error, setError] = useState('')
+ const [error, setError] = useState(() => searchParams.get('error') ?? '')
const { signIn } = useAuth()
async function handleSubmit(e: React.FormEvent) {
@@ -108,16 +112,7 @@ export default function LoginPage() {
-
-
- setStayIn(e.target.checked)}
- className="size-4 accent-court cursor-pointer"
- />
- Stay signed in
-
+
- Other sessions have been signed out for safety.
+ {otherSessionsSignedOut ? 'Other sessions have been signed out for safety.'
+ : 'Your password changed, but other sessions could not be signed out. Sign out on your other devices.'}
void }) {
+ useEffect(() => { console.error('Route render failed', error); }, [error]);
+ return
;
+}
diff --git a/frontend/web/app/landing/layout.tsx b/frontend/web/app/landing/layout.tsx
new file mode 100644
index 00000000..2ee01fe9
--- /dev/null
+++ b/frontend/web/app/landing/layout.tsx
@@ -0,0 +1,8 @@
+import type { Metadata } from 'next';
+import type { ReactNode } from 'react';
+
+export const metadata: Metadata = { title: { absolute: 'CourtCheck' } };
+
+export default function Layout({ children }: { children: ReactNode }) {
+ return children;
+}
diff --git a/frontend/web/app/layout.tsx b/frontend/web/app/layout.tsx
index 1c53e8f2..133d5c4a 100644
--- a/frontend/web/app/layout.tsx
+++ b/frontend/web/app/layout.tsx
@@ -27,7 +27,7 @@ const jetbrainsMono = JetBrains_Mono({
})
export const metadata: Metadata = {
- title: { default: 'CourtCheck', template: '%s | CourtCheck' },
+ title: { default: 'Dashboard · CourtCheck', template: '%s · CourtCheck' },
description:
'Tennis analytics for college coaches. Upload a recording for shot tracking, court maps, stroke breakdown, and a written report.',
icons: {
diff --git a/frontend/web/app/not-found.tsx b/frontend/web/app/not-found.tsx
new file mode 100644
index 00000000..ddd52c3b
--- /dev/null
+++ b/frontend/web/app/not-found.tsx
@@ -0,0 +1,16 @@
+import Link from 'next/link';
+import { BrandMark } from '@/components/brand/BrandMark';
+
+export default function NotFound() {
+ return (
+
+
+
Page not found.
+
Open your dashboard or Recordings to continue.
+
+ Go to Dashboard
+ Go to Recordings
+
+
+ );
+}
diff --git a/frontend/web/app/onboarding/layout.tsx b/frontend/web/app/onboarding/layout.tsx
new file mode 100644
index 00000000..2ee01fe9
--- /dev/null
+++ b/frontend/web/app/onboarding/layout.tsx
@@ -0,0 +1,8 @@
+import type { Metadata } from 'next';
+import type { ReactNode } from 'react';
+
+export const metadata: Metadata = { title: { absolute: 'CourtCheck' } };
+
+export default function Layout({ children }: { children: ReactNode }) {
+ return children;
+}
diff --git a/frontend/web/app/onboarding/page.tsx b/frontend/web/app/onboarding/page.tsx
index 66b2cc9a..05dfc630 100644
--- a/frontend/web/app/onboarding/page.tsx
+++ b/frontend/web/app/onboarding/page.tsx
@@ -82,14 +82,14 @@ export default function OnboardingPage() {
Pick your starting roster.
- You can edit, delete, and add players any time after.
+ You can edit players any time after.
p.name) ?? []}
cta="Use UC Davis"
busy={picking === 'uc-davis'}
@@ -99,7 +99,7 @@ export default function OnboardingPage() {
}) {
+ const { id } = await params;
+ return detailMetadata('players', id, 'Player');
+}
+
+export default function Layout({ children }: { children: ReactNode }) {
+ return children;
+}
diff --git a/frontend/web/app/players/[id]/page.tsx b/frontend/web/app/players/[id]/page.tsx
index 410937bf..e8128133 100644
--- a/frontend/web/app/players/[id]/page.tsx
+++ b/frontend/web/app/players/[id]/page.tsx
@@ -73,7 +73,7 @@ function inPct(inB: number | null, outB: number | null): number | null {
export default function PlayerDetailPage() {
const { id } = useParams<{ id: string }>()
- // SWR-cached roster + recordings (demo data served via the hooks' fallback).
+ // SWR-cached roster + recordings.
// Reusing the same keys as the dashboard/list means navigating here is an
// instant cache hit instead of a fresh round trip.
const { data: pData, error: pErr, isLoading: pLoading, mutate: mutatePlayers } = usePlayersData()
@@ -89,13 +89,13 @@ export default function PlayerDetailPage() {
) ?? null,
[pData, id],
)
- // The list endpoint hides demo/template rows (user_id IS NULL) from onboarded
+ // The list endpoint hides template rows (user_id IS NULL) from onboarded
// users, so a player the by-id endpoint returns can be absent from the cached
// list. Fall back to /api/players/[id] before declaring "Player not found"
// (the 2026-07-05 triage fix, re-applied on top of the SWR cache).
const needById =
- !pLoading && pData != null && listedPlayer == null && !String(id).startsWith('demo-')
- const { data: byIdData, isLoading: byIdLoading } = useSWR<{ player: ApiPlayer | null }>(
+ !pLoading && pData != null && listedPlayer == null
+ const { data: byIdData, isLoading: byIdLoading, mutate: mutateById } = useSWR<{ player: ApiPlayer | null }>(
needById ? `/api/players/${id}` : null,
fetcher,
)
@@ -149,7 +149,7 @@ export default function PlayerDetailPage() {
() => [
{ key: 'forehand', label: 'Forehand', count: totals.fh },
{ key: 'backhand', label: 'Backhand', count: totals.bh },
- { key: 'serve', label: 'Serve', count: totals.sv },
+ { key: 'serve', label: 'Serve/Overhead', count: totals.sv },
// Volley count not in backend yet. Renders as 0% bar with "0 shots".
{ key: 'volley', label: 'Volley', count: 0 },
],
@@ -253,8 +253,9 @@ export default function PlayerDetailPage() {
+ value={(player.handedness as Handedness | null | undefined) ?? null}
+ onChange={(next) => {
+ mutateById((curr) => curr?.player ? { player: { ...curr.player, handedness: next } } : curr, { revalidate: false })
mutatePlayers(
(curr) =>
curr
@@ -266,7 +267,7 @@ export default function PlayerDetailPage() {
: curr,
{ revalidate: false },
)
- }
+ }}
/>
@@ -536,14 +537,14 @@ function HandednessControl({
onChange,
}: {
playerId: string
- value: Handedness
- onChange: (next: Handedness) => void
+ value: Handedness | null
+ onChange: (next: Handedness | null) => void
}) {
const [saving, setSaving] = useState
(null)
const [error, setError] = useState(null)
const update = async (next: Handedness) => {
- if (next === value) return
+ if (next === value || saving) return
setSaving(next)
setError(null)
onChange(next) // optimistic
@@ -576,6 +577,7 @@ function HandednessControl({
Handedness
+ {value === null && Not set }
update(opt)}
className={`rounded-full px-3.5 py-1 text-[0.88rem] font-medium transition-colors duration-150 ${
active
diff --git a/frontend/web/app/players/layout.tsx b/frontend/web/app/players/layout.tsx
new file mode 100644
index 00000000..e6bce1ca
--- /dev/null
+++ b/frontend/web/app/players/layout.tsx
@@ -0,0 +1,8 @@
+import type { Metadata } from 'next';
+import type { ReactNode } from 'react';
+
+export const metadata: Metadata = { title: { absolute: 'Players · CourtCheck' } };
+
+export default function Layout({ children }: { children: ReactNode }) {
+ return children;
+}
diff --git a/frontend/web/app/profile/layout.tsx b/frontend/web/app/profile/layout.tsx
new file mode 100644
index 00000000..c7f7ad24
--- /dev/null
+++ b/frontend/web/app/profile/layout.tsx
@@ -0,0 +1,8 @@
+import type { Metadata } from 'next';
+import type { ReactNode } from 'react';
+
+export const metadata: Metadata = { title: { absolute: 'Profile · CourtCheck' } };
+
+export default function Layout({ children }: { children: ReactNode }) {
+ return children;
+}
diff --git a/frontend/web/app/recordings/[id]/layout.tsx b/frontend/web/app/recordings/[id]/layout.tsx
new file mode 100644
index 00000000..3cf012e1
--- /dev/null
+++ b/frontend/web/app/recordings/[id]/layout.tsx
@@ -0,0 +1,11 @@
+import type { ReactNode } from 'react';
+import { detailMetadata } from '@/lib/routeMetadata';
+
+export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params;
+ return detailMetadata('matches', id, 'Recording');
+}
+
+export default function Layout({ children }: { children: ReactNode }) {
+ return children;
+}
diff --git a/frontend/web/app/recordings/[id]/page.tsx b/frontend/web/app/recordings/[id]/page.tsx
index a80941b8..f1dc63d8 100644
--- a/frontend/web/app/recordings/[id]/page.tsx
+++ b/frontend/web/app/recordings/[id]/page.tsx
@@ -33,13 +33,11 @@ import { STROKE_COLOR_BY_KEY } from '@/components/viz/CourtSVG';
* - unified viz card (3-way toggle: shot map / spacing / coverage)
* - shot breakdown (mix | accuracy)
* - scouting report (6 sections, court-tinted final-line rail)
- * - stats (4 tiles: winners / unforced errors / first serve in / avg rally)
+ * - stats (measured bounce and rally totals)
*
* Backend wiring preserved:
* - GET /api/recordings/[id] polls every 5s until status === done|failed.
- * - videoUrl is LOCKED on first valid value (prev ?? data.recording.videoUrl)
- * so the
doesn't reload on every poll. This is the shipped fix —
- * DO NOT regress.
+ * - videoUrl stays stable during polls and refreshes after a media error.
* - notes PATCH is debounced 800ms; while saving, incoming poll data does
* not clobber local note state.
*/
@@ -52,6 +50,7 @@ type Recording = {
stage: string | null;
error: string | null;
videoUrl: string | null;
+ inputPath: string | null;
bounceHeatmapUrl: string | null;
playerHeatmapUrl: string | null;
playerShotMapUrl: string | null;
@@ -71,6 +70,7 @@ type Recording = {
outBoundsBounces: number | null;
scoutingReport: string | null;
playerId: string | null;
+ playerName: string | null;
/** 'left' if the near player is left-handed; null if unset/unknown. Drives
* the "Left-handed" badge so coaches can sanity-check FH/BH labeling. */
playerHandedness: 'right' | 'left' | null;
@@ -108,6 +108,24 @@ export default function RecordingDetailPage() {
const [error, setError] = useState(null);
const [notes, setNotes] = useState([]);
const [savingNotes, setSavingNotes] = useState(false);
+ const [notesError, setNotesError] = useState(null);
+ const savingNotesRef = useRef(false);
+ const pendingNotesRef = useRef<{ id: string; notes: TimedNote[] } | null>(null);
+ const notesRequestRef = useRef>(Promise.resolve());
+ const flushNotesRef = useRef<() => void>(() => {});
+ const notesUnmountedRef = useRef(false);
+ const [favoriteError, setFavoriteError] = useState(null);
+ const [favoriting, setFavoriting] = useState(false);
+ const videoErroredRef = useRef(false);
+ const videoRefreshRef = useRef(false);
+ const videoResumeRef = useRef(null);
+ const [videoError, setVideoError] = useState(null);
+
+ useEffect(() => {
+ if (!favoriteError) return;
+ const timer = setTimeout(() => setFavoriteError(null), 4500);
+ return () => clearTimeout(timer);
+ }, [favoriteError]);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState(null);
@@ -116,24 +134,26 @@ export default function RecordingDetailPage() {
const [reprocessError, setReprocessError] = useState(null);
const toggleFavorite = useCallback(async () => {
- setRecording((prev) => {
- if (!prev) return prev;
- const next = !prev.favorited;
- // Persist; revert in the catch if it fails.
- fetch(`/api/recordings/${id}`, {
+ if (!recording || favoriting) return;
+ const next = !recording.favorited;
+ setFavoriting(true);
+ setFavoriteError(null);
+ setRecording((prev) => prev ? { ...prev, favorited: next } : prev);
+ try {
+ const res = await fetch(`/api/recordings/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ favorited: next }),
- })
- .then((res) => {
- if (!res.ok) throw new Error('Failed to update favorite');
- })
- .catch(() => {
- setRecording((p) => (p ? { ...p, favorited: !next } : p));
- });
- return { ...prev, favorited: next };
- });
- }, [id]);
+ });
+ if (!res.ok) throw new Error('Failed to update favorite');
+ } catch (err) {
+ console.error('Favorite update failed', err);
+ setRecording((prev) => prev ? { ...prev, favorited: !next } : prev);
+ setFavoriteError("Couldn't update favorite. Try again.");
+ } finally {
+ setFavoriting(false);
+ }
+ }, [id, recording, favoriting]);
const handleDeleteRecording = useCallback(async () => {
setDeleting(true);
@@ -171,19 +191,24 @@ export default function RecordingDetailPage() {
});
if (signal?.aborted) return;
if (!res.ok) {
- setError('Recording not found.');
+ setError(res.status === 404 ? 'Recording not found.' : res.status === 401
+ ? 'Your session expired. Sign in again.'
+ : "We couldn't load that recording. Go back to Recordings and try again.");
return;
}
const data = await res.json();
if (signal?.aborted) return;
- // Lock the videoUrl once set — prevents reload on each poll
+ setError(null);
+ const refreshVideo = videoErroredRef.current;
+ // Keep playback stable during polls; replace an expired URL after an error.
setRecording((prev) => ({
...data.recording,
- videoUrl: prev?.videoUrl ?? data.recording.videoUrl,
+ videoUrl: !prev?.videoUrl || (refreshVideo && prev.videoUrl !== data.recording.videoUrl)
+ ? data.recording.videoUrl : prev.videoUrl,
}));
// Don't clobber local notes while a save is in-flight
setNotes((prev) => {
- if (savingNotes) return prev;
+ if (savingNotesRef.current) return prev;
const incoming = data.recording.notes;
return Array.isArray(incoming) ? incoming : [];
});
@@ -195,12 +220,13 @@ export default function RecordingDetailPage() {
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return;
- setError('Failed to load recording.');
+ console.error('Recording fetch failed', err);
+ setError("We couldn't load that recording. Go back to Recordings and try again.");
} finally {
if (!signal?.aborted) setLoading(false);
}
},
- [id, savingNotes]
+ [id]
);
const handleReprocessRecording = useCallback(async () => {
@@ -259,44 +285,92 @@ export default function RecordingDetailPage() {
return () => {
controller.abort();
if (pollRef.current) clearInterval(pollRef.current);
- if (notesTimeoutRef.current) clearTimeout(notesTimeoutRef.current);
};
}, [fetchRecording]);
- const saveNotes = (updated: TimedNote[]) => {
- if (notesTimeoutRef.current) clearTimeout(notesTimeoutRef.current);
- setSavingNotes(true);
- notesTimeoutRef.current = setTimeout(async () => {
+ const persistNotes = useCallback((keepalive = false) => {
+ const pending = pendingNotesRef.current;
+ if (!pending) return;
+ // Serialize requests so an older response cannot replace newer notes.
+ const request = async () => {
+ if (!keepalive && notesUnmountedRef.current) return;
try {
- await fetch(`/api/recordings/${id}`, {
+ const res = await fetch(`/api/recordings/${pending.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ notes: updated }),
+ body: JSON.stringify({ notes: pending.notes }),
+ keepalive,
});
+ if (!res.ok) throw new Error(`Notes save failed (${res.status})`);
+ if (pendingNotesRef.current === pending) {
+ pendingNotesRef.current = null;
+ savingNotesRef.current = false;
+ setNotesError(null);
+ }
+ } catch (err) {
+ console.error('Notes save failed', err);
+ if (pendingNotesRef.current === pending) setNotesError("Couldn't save. Retry");
} finally {
- setSavingNotes(false);
+ if (!pendingNotesRef.current || pendingNotesRef.current === pending) setSavingNotes(false);
}
+ };
+ if (keepalive) void request();
+ else notesRequestRef.current = notesRequestRef.current.then(request);
+ }, []);
+ flushNotesRef.current = () => persistNotes(true);
+
+ useEffect(() => {
+ notesUnmountedRef.current = false;
+ return () => {
+ notesUnmountedRef.current = true;
+ if (notesTimeoutRef.current) clearTimeout(notesTimeoutRef.current);
+ flushNotesRef.current();
+ };
+ }, []);
+
+ const saveNotes = (updated: TimedNote[]) => {
+ if (notesTimeoutRef.current) clearTimeout(notesTimeoutRef.current);
+ pendingNotesRef.current = { id, notes: updated };
+ savingNotesRef.current = true;
+ setSavingNotes(true);
+ setNotesError(null);
+ notesTimeoutRef.current = setTimeout(() => {
+ notesTimeoutRef.current = null;
+ persistNotes();
}, 800);
};
const handleAddNote = (note: TimedNote) => {
- setNotes((prev) => {
- const arr = Array.isArray(prev) ? prev : [];
- const updated = [...arr, note];
- saveNotes(updated);
- return updated;
- });
+ const updated = [...notes, note];
+ setNotes(updated);
+ saveNotes(updated);
};
const handleDeleteNote = (index: number) => {
- setNotes((prev) => {
- const arr = Array.isArray(prev) ? prev : [];
- const updated = arr.filter((_, i) => i !== index);
- saveNotes(updated);
- return updated;
- });
+ const updated = notes.filter((_, i) => i !== index);
+ setNotes(updated);
+ saveNotes(updated);
};
+ const handleVideoError = useCallback(async () => {
+ if (videoRefreshRef.current) return;
+ videoRefreshRef.current = true;
+ videoErroredRef.current = true;
+ videoResumeRef.current = videoRef.current?.currentTime ?? 0;
+ setVideoError("We couldn't play the recording. Refresh to try again.");
+ await fetchRecording();
+ }, [fetchRecording]);
+
+ const handleVideoLoaded = useCallback(() => {
+ if (videoResumeRef.current !== null && videoRef.current) {
+ videoRef.current.currentTime = videoResumeRef.current;
+ videoResumeRef.current = null;
+ }
+ videoErroredRef.current = false;
+ videoRefreshRef.current = false;
+ setVideoError(null);
+ }, []);
+
// ── Loading ──
if (loading) {
return ;
@@ -398,6 +472,13 @@ export default function RecordingDetailPage() {
{recording.error && (
{recording.error}
)}
+ {recording.inputPath && (
+
+ {reprocessing ? 'Starting…' : 'Reprocess'}
+
+ )}
+ {reprocessError && {reprocessError}
}
0 && inPct !== null
- ? Math.round((realShotCount * inPct) / 100 / 12)
- : null;
- const unforced =
- realShotCount > 0 && inPct !== null
- ? Math.max(0, Math.round((realShotCount * (100 - inPct)) / 100 / 8))
- : null;
+ const rallyLengths = (recording.rallies ?? []).map((r) => r.shot_count).filter(Number.isFinite);
+ const longestRally = rallyLengths.length > 0 ? Math.max(...rallyLengths) : null;
// Avg rally length comes from the rally state machine (build_rallies in
// backend/pipeline/rallies.py). The legacy shot_count/rally_count
// derivation was unreliable — it counts CatBoost trajectory direction
// changes, which split one rally into multiple when the ball tracker
// briefly loses sight of the ball. Show "—" until reprocessed.
const avgRally =
- recording.rallySummary && recording.rallySummary.total > 0
+ recording.rallySummary && recording.rallySummary.total > 0 && Number.isFinite(recording.rallySummary.avg_length)
? recording.rallySummary.avg_length.toFixed(1)
: null;
const decisiveTotal =
@@ -482,7 +554,7 @@ export default function RecordingDetailPage() {
{/* Reprocess — only relevant once a run has settled (done/failed).
While processing, the polling UI is already in control. */}
- {(recording.status === 'done' || recording.status === 'failed') && (
+ {recording.inputPath && (
confirmingReprocess ? (
@@ -594,7 +666,9 @@ export default function RecordingDetailPage() {
{/* Header */}
- Recording
+ Recording · {recording.playerName && recording.playerId ? (
+ {recording.playerName}
+ ) : 'Unassigned'}
+ {favoriteError &&
{favoriteError}
}
{recording.playerHandedness === 'left' && (
{recording.videoUrl ? (
-
+
) : (
)}
+ {videoError &&
{videoError}
}
saveNotes(notes)}
/>
@@ -720,8 +799,8 @@ export default function RecordingDetailPage() {
shots={recording.shots ?? []}
coverageGrid={recording.coverageGrid ?? []}
positionSummary={recording.positionSummary}
- recordingStatus={recording.status}
handedness={recording.playerHandedness}
+ playerId={recording.playerId}
fps={recording.fps}
videoRef={videoRef}
/>
@@ -766,14 +845,15 @@ export default function RecordingDetailPage() {
)}
- {/* Stats — 4 tiles */}
+ {/* Measured totals; rally wins appear only when classified. */}
0 ? realShotCount : undefined}
tiles={[
- { label: 'Winners', value: winners ?? '–' },
- { label: 'Unforced errors', value: unforced ?? '–' },
+ { label: 'In bounds', value: inPct === null ? '–' : `${inPct}%`, unit: inPct === null ? undefined : `${inB} of ${totalBounces}` },
+ { label: 'Rallies', value: recording.rallySummary?.total ?? '–' },
{ label: 'Avg rally length', value: avgRally ?? '–' },
- { label: 'Rallies won', value: ralliesWonTotal ?? '–' },
+ { label: 'Longest rally', value: longestRally ?? '–' },
+ ...(ralliesWonTotal ? [{ label: 'Rallies won', value: ralliesWonTotal }] : []),
]}
/>
diff --git a/frontend/web/app/recordings/layout.tsx b/frontend/web/app/recordings/layout.tsx
new file mode 100644
index 00000000..c134ad78
--- /dev/null
+++ b/frontend/web/app/recordings/layout.tsx
@@ -0,0 +1,8 @@
+import type { Metadata } from 'next';
+import type { ReactNode } from 'react';
+
+export const metadata: Metadata = { title: { absolute: 'Recordings · CourtCheck' } };
+
+export default function Layout({ children }: { children: ReactNode }) {
+ return children;
+}
diff --git a/frontend/web/app/recordings/page.tsx b/frontend/web/app/recordings/page.tsx
index a1ebd71a..1549bbe3 100644
--- a/frontend/web/app/recordings/page.tsx
+++ b/frontend/web/app/recordings/page.tsx
@@ -109,6 +109,13 @@ export default function RecordingsPage() {
const [deletingId, setDeletingId] = useState(null);
const [deleteError, setDeleteError] = useState(null);
+ const [favoriteError, setFavoriteError] = useState<{ id: string; message: string } | null>(null);
+ useEffect(() => {
+ if (!favoriteError) return;
+ const timer = setTimeout(() => setFavoriteError(null), 4500);
+ return () => clearTimeout(timer);
+ }, [favoriteError]);
+
// Multi-select state
const [selectedIds, setSelectedIds] = useState>(new Set());
const [bulkState, setBulkState] = useState<'idle' | 'confirming' | 'deleting'>('idle');
@@ -117,6 +124,7 @@ export default function RecordingsPage() {
// Toggle a recording's favorite flag. Optimistically flip the SWR cache so
// the star reacts instantly, persist via PATCH, and roll back on failure.
async function toggleFavorite(id: string, next: boolean) {
+ setFavoriteError(null);
mutate(
(curr) =>
curr
@@ -136,6 +144,7 @@ export default function RecordingsPage() {
});
if (!res.ok) throw new Error('Failed to update favorite');
} catch {
+ setFavoriteError({ id, message: "Couldn't update favorite. Try again." });
// Revert the optimistic flip.
mutate(
(curr) =>
@@ -596,6 +605,9 @@ export default function RecordingsPage() {
{rec.status !== 'done' &&
` · ${rec.status === 'failed' ? 'Processing failed' : 'Processing'}`}
+ {favoriteError?.id === rec.id && (
+
{favoriteError.message}
+ )}
{/* Desktop status line */}
{rec.status !== 'done' && (
diff --git a/frontend/web/app/settings/layout.tsx b/frontend/web/app/settings/layout.tsx
new file mode 100644
index 00000000..6bc7f69e
--- /dev/null
+++ b/frontend/web/app/settings/layout.tsx
@@ -0,0 +1,8 @@
+import type { Metadata } from 'next';
+import type { ReactNode } from 'react';
+
+export const metadata: Metadata = { title: { absolute: 'Settings · CourtCheck' } };
+
+export default function Layout({ children }: { children: ReactNode }) {
+ return children;
+}
diff --git a/frontend/web/app/upload/layout.tsx b/frontend/web/app/upload/layout.tsx
new file mode 100644
index 00000000..e667253f
--- /dev/null
+++ b/frontend/web/app/upload/layout.tsx
@@ -0,0 +1,8 @@
+import type { Metadata } from 'next';
+import type { ReactNode } from 'react';
+
+export const metadata: Metadata = { title: { absolute: 'Upload · CourtCheck' } };
+
+export default function Layout({ children }: { children: ReactNode }) {
+ return children;
+}
diff --git a/frontend/web/app/upload/page.tsx b/frontend/web/app/upload/page.tsx
index 38f77301..c1996bd0 100644
--- a/frontend/web/app/upload/page.tsx
+++ b/frontend/web/app/upload/page.tsx
@@ -4,7 +4,8 @@ import { Prose } from '@/components/ui/display';
import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from 'react';
import { useRouter } from 'next/navigation';
-import { useDropzone } from 'react-dropzone';
+import Link from 'next/link';
+import { useDropzone, type FileRejection } from 'react-dropzone';
import { useVideoUpload } from '@/hooks';
import { APP_CONFIG } from '@/constants';
import BounceLoader from '@/components/upload/BounceLoader';
@@ -31,6 +32,7 @@ export default function UploadPage() {
// Selected-but-not-yet-uploaded file. Picking a file no longer auto-uploads;
// the user reviews the metadata and presses the confirm button.
const [pendingFile, setPendingFile] = useState(null);
+ const [rejectionError, setRejectionError] = useState(null);
const upload = useVideoUpload(undefined, selectedPlayerId);
const {
@@ -62,11 +64,14 @@ export default function UploadPage() {
const onDrop = useCallback((acceptedFiles: File[]) => {
const file = acceptedFiles[0];
if (!file) return;
+ reset();
+ setRejectionError(null);
setPendingFile(file);
- }, []);
+ }, [reset]);
const confirmUpload = useCallback(() => {
if (!pendingFile) return;
+ setRejectionError(null);
void handleFile(pendingFile, { name: recordingTitle, matchDate });
}, [pendingFile, handleFile, recordingTitle, matchDate]);
@@ -74,12 +79,22 @@ export default function UploadPage() {
// "Try again" returns to a clean idle state.
const handleReset = useCallback(() => {
setPendingFile(null);
+ setRejectionError(null);
reset();
}, [reset]);
const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
onDrop,
- accept: { 'video/*': APP_CONFIG.SUPPORTED_VIDEO_FORMATS },
+ onDropRejected: (rejections: FileRejection[]) => {
+ const codes = rejections.flatMap((file) => file.errors.map((error) => error.code));
+ reset();
+ setPendingFile(null);
+ setRejectionError(codes.includes('too-many-files') ? 'Upload one recording at a time.'
+ : codes.includes('file-too-large') ? 'That file is over 500 MB. Trim the recording or export at a lower bitrate.'
+ : 'That file is not a video we can read. Use an MP4, MOV, or AVI recording.');
+ },
+ accept: { 'video/mp4': ['.mp4'], 'video/quicktime': ['.mov'], 'video/x-msvideo': ['.avi'] },
+ multiple: false,
maxFiles: 1,
maxSize: APP_CONFIG.MAX_VIDEO_SIZE,
disabled: status !== 'idle' && status !== 'failed',
@@ -257,7 +272,7 @@ export default function UploadPage() {
htmlFor="match-date"
className="font-mono text-[0.82rem] uppercase tracking-[0.12em] text-ink-mute"
>
- Match date
+ Recording date
+ {pane === 'idle' && (rejectionError || error) && (
+ {rejectionError || error}
+ )}
+ {pane === 'processing' && error && (
+
+
{error}
+
Open Recordings
+
+ )}
+
{/* Upload card — single container, swaps panes by state.
min-height keeps the idle/uploading/processing/done panes at parity
height-wise so the layout doesn't jump as the state machine
diff --git a/frontend/web/components/ErrorBoundary.tsx b/frontend/web/components/ErrorBoundary.tsx
index f2c4b892..bad703e7 100644
--- a/frontend/web/components/ErrorBoundary.tsx
+++ b/frontend/web/components/ErrorBoundary.tsx
@@ -1,6 +1,7 @@
"use client";
-import { Component, ReactNode } from "react";
+import { Component, ReactNode, type ErrorInfo } from "react";
+import PageError from "@/components/PageError";
interface Props {
children: ReactNode;
@@ -21,20 +22,15 @@ export default class ErrorBoundary extends Component {
return { hasError: true };
}
+ componentDidCatch(error: Error, info: ErrorInfo) {
+ console.error("Page render failed", error, info);
+ }
+
render() {
if (this.state.hasError) {
return (
this.props.fallback ?? (
-
- Something went wrong loading this section.
-
+ this.setState({ hasError: false })} />
)
);
}
diff --git a/frontend/web/components/PageError.tsx b/frontend/web/components/PageError.tsx
new file mode 100644
index 00000000..04a7be7d
--- /dev/null
+++ b/frontend/web/components/PageError.tsx
@@ -0,0 +1,21 @@
+'use client';
+
+import Link from 'next/link';
+import { BrandMark } from '@/components/brand/BrandMark';
+
+export default function PageError({ reset }: { reset: () => void }) {
+ return (
+
+
+
Something went wrong loading this page.
+
+
+ Try again
+
+
+ Go to Recordings
+
+
+
+ );
+}
diff --git a/frontend/web/components/dashboard/EmptyState.tsx b/frontend/web/components/dashboard/EmptyState.tsx
index 2eb152c8..23d08c48 100644
--- a/frontend/web/components/dashboard/EmptyState.tsx
+++ b/frontend/web/components/dashboard/EmptyState.tsx
@@ -66,12 +66,6 @@ export default function EmptyState({ coachName, dateLine }: Props) {
Upload your first recording
-
- See a sample analysis
-
diff --git a/frontend/web/components/demo/DemoToggle.tsx b/frontend/web/components/demo/DemoToggle.tsx
deleted file mode 100644
index 316b59d6..00000000
--- a/frontend/web/components/demo/DemoToggle.tsx
+++ /dev/null
@@ -1,98 +0,0 @@
-'use client';
-
-import { useEffect, useState } from 'react';
-import { getDemoFlag, setDemoFlag } from '@/lib/demo/demoData';
-
-/**
- * Small persistent demo-mode switch. Bottom-left, low-key so it stays out of
- * the way during a screen recording (crop it or it sits in the corner). Flips
- * localStorage and reloads so the dashboard / recordings re-run their mount
- * load and swap in the fabricated season of data.
- *
- * Mounted inside AppLayout, so it only renders for authenticated users (demo
- * data is gated behind auth anyway).
- */
-export default function DemoToggle() {
- const [on, setOn] = useState(false);
- const [mounted, setMounted] = useState(false);
-
- useEffect(() => {
- // Sync a `?demo=1` visit into the persistent flag so it survives
- // navigation, then reflect current state.
- const params = new URLSearchParams(window.location.search);
- const param = params.get('demo');
- if ((param === '1' || param === 'true') && !getDemoFlag()) {
- setDemoFlag(true);
- }
- setOn(getDemoFlag());
- setMounted(true);
- }, []);
-
- if (!mounted) return null;
-
- const toggle = () => {
- const next = !on;
- setDemoFlag(next);
- setOn(next);
- // Hard reload: dashboard/recordings decide demo vs live at mount.
- window.location.reload();
- };
-
- return (
-
{
- e.currentTarget.style.opacity = '1';
- }}
- onMouseLeave={(e) => {
- e.currentTarget.style.opacity = on ? '0.6' : '0.18';
- }}
- >
-
-
-
-
- );
-}
diff --git a/frontend/web/components/features/recordings/VideoPlayer.tsx b/frontend/web/components/features/recordings/VideoPlayer.tsx
index 2a59aa76..48d4e733 100644
--- a/frontend/web/components/features/recordings/VideoPlayer.tsx
+++ b/frontend/web/components/features/recordings/VideoPlayer.tsx
@@ -25,6 +25,8 @@ interface VideoPlayerProps {
title?: string;
/** Optional poster shown before metadata loads. */
poster?: string;
+ onError?: () => void;
+ onLoadedMetadata?: () => void;
}
function formatTime(seconds: number): string {
@@ -45,7 +47,7 @@ const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2] as const;
* panel, keypoint timeline) can seek/observe currentTime without prop drilling.
*/
const VideoPlayer = forwardRef
(function VideoPlayer(
- { src, title, poster },
+ { src, title, poster, onError, onLoadedMetadata },
externalRef,
) {
const videoRef = useRef(null);
@@ -244,7 +246,11 @@ const VideoPlayer = forwardRef(function Vide
if (typeof r === 'number' && Number.isFinite(r)) setSpeed(r);
}}
onTimeUpdate={() => setCurrentTime(videoRef.current?.currentTime || 0)}
- onLoadedMetadata={() => setDuration(videoRef.current?.duration || 0)}
+ onError={onError}
+ onLoadedMetadata={() => {
+ setDuration(videoRef.current?.duration || 0);
+ onLoadedMetadata?.();
+ }}
onContextMenu={(e) => e.preventDefault()}
playsInline
/>
diff --git a/frontend/web/components/layout/AppLayout.tsx b/frontend/web/components/layout/AppLayout.tsx
index cedd3f89..b497584a 100644
--- a/frontend/web/components/layout/AppLayout.tsx
+++ b/frontend/web/components/layout/AppLayout.tsx
@@ -1,15 +1,16 @@
'use client'
import { ReactNode, useEffect } from 'react'
-import { useRouter } from 'next/navigation'
+import { usePathname, useRouter } from 'next/navigation'
import Sidebar from './Sidebar'
import BounceLoader from '@/components/upload/BounceLoader'
-import DemoToggle from '@/components/demo/DemoToggle'
+import ErrorBoundary from '@/components/ErrorBoundary'
import { useAuth } from '@/contexts/AuthContext'
export default function AppLayout({ children }: { children: ReactNode }) {
const { user, loading, signOut } = useAuth()
const router = useRouter()
+ const pathname = usePathname()
useEffect(() => {
if (!loading && !user) {
@@ -53,8 +54,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
user={{ name: displayName, email: displayEmail, initials, imageUrl: displayImage }}
onSignOut={signOut}
/>
- {children}
-
+ {children}
>
)
}
diff --git a/frontend/web/components/layout/ConditionalLayout.tsx b/frontend/web/components/layout/ConditionalLayout.tsx
index 0e614919..9dcfa1c6 100644
--- a/frontend/web/components/layout/ConditionalLayout.tsx
+++ b/frontend/web/components/layout/ConditionalLayout.tsx
@@ -8,18 +8,9 @@ interface ConditionalLayoutProps {
children: ReactNode;
}
-// Map a pathname to a short page label. Falls through to null for unknown
-// routes which renders the bare brand title.
+// Auth pages retain their client labels; app route titles come from metadata.
function pageNameFor(pathname: string | null): string | null {
if (!pathname) return null;
- if (pathname === '/') return 'Dashboard';
- if (pathname.startsWith('/recordings/') && pathname !== '/recordings') return 'Recording';
- if (pathname === '/recordings') return 'Recordings';
- if (pathname.startsWith('/players/') && pathname !== '/players') return 'Player';
- if (pathname === '/players') return 'Players';
- if (pathname === '/upload') return 'Upload';
- if (pathname === '/profile') return 'Profile';
- if (pathname === '/settings') return 'Settings';
if (pathname === '/auth/login') return 'Sign in';
if (pathname === '/auth/signup') return 'Sign up';
if (pathname === '/auth/forgot-password') return 'Reset password';
@@ -32,7 +23,7 @@ export default function ConditionalLayout({ children }: ConditionalLayoutProps)
const pathname = usePathname();
useEffect(() => {
- if (typeof document === 'undefined') return;
+ if (typeof document === 'undefined' || !pathname?.startsWith('/auth')) return;
const page = pageNameFor(pathname);
document.title = page ? `${page} | CourtCheck` : 'CourtCheck';
}, [pathname]);
diff --git a/frontend/web/components/layout/Sidebar.tsx b/frontend/web/components/layout/Sidebar.tsx
index ca1aae15..6b7e7f78 100644
--- a/frontend/web/components/layout/Sidebar.tsx
+++ b/frontend/web/components/layout/Sidebar.tsx
@@ -52,6 +52,16 @@ const NAV_ITEMS: NavItem[] = [
},
]
+const MOBILE_NAV_ITEMS: NavItem[] = [
+ ...NAV_ITEMS,
+ {
+ name: 'Account',
+ href: '/settings',
+ icon: ,
+ match: (p) => p.startsWith('/settings') || p.startsWith('/profile'),
+ },
+]
+
export default function Sidebar({ user, onSignOut }: Props) {
const pathname = usePathname() || '/'
const [collapsed, setCollapsed] = useState(false)
@@ -124,7 +134,7 @@ export default function Sidebar({ user, onSignOut }: Props) {
onClick={toggleCollapse}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
- className="sidebar-collapse absolute z-[51] size-[22px] before:absolute before:-inset-[12px] before:content-[''] rounded-full border border-line bg-paper text-ink-mute hover:border-ink hover:text-ink flex items-center justify-center transition-colors cursor-pointer shadow-sm"
+ className="sidebar-collapse [@media(max-width:1100px)]:hidden absolute z-[51] size-[22px] before:absolute before:-inset-[12px] before:content-[''] rounded-full border border-line bg-paper text-ink-mute hover:border-ink hover:text-ink flex items-center justify-center transition-colors cursor-pointer shadow-sm"
style={{
top: '50%',
right: -11,
@@ -276,7 +286,7 @@ export default function Sidebar({ user, onSignOut }: Props) {
- {NAV_ITEMS.map((item) => {
+ {MOBILE_NAV_ITEMS.map((item) => {
const active = item.match(pathname)
return (
-
+
{/* Recent recordings */}
diff --git a/frontend/web/components/recordings/CoachInsights.tsx b/frontend/web/components/recordings/CoachInsights.tsx
index ed60bc0a..bfb6e03b 100644
--- a/frontend/web/components/recordings/CoachInsights.tsx
+++ b/frontend/web/components/recordings/CoachInsights.tsx
@@ -207,7 +207,7 @@ function NetApproachTile({
Win rate is a heuristic: the rally ended within 3 seconds with the opponent's ball out.
@@ -238,7 +238,7 @@ function ErrorTile({
if (!data || data.total === 0) {
return (
-
+
);
}
diff --git a/frontend/web/components/recordings/NotesPanel.tsx b/frontend/web/components/recordings/NotesPanel.tsx
index d67cf038..b39e8846 100644
--- a/frontend/web/components/recordings/NotesPanel.tsx
+++ b/frontend/web/components/recordings/NotesPanel.tsx
@@ -20,6 +20,8 @@ type Props = {
onAdd: (note: TimedNote) => void;
onDelete: (originalIdx: number) => void;
saving: boolean;
+ error: string | null;
+ onRetry: () => void;
};
/**
@@ -34,6 +36,8 @@ export default function NotesPanel({
onAdd,
onDelete,
saving,
+ error,
+ onRetry,
}: Props) {
const [draft, setDraft] = useState('');
const [nowSec, setNowSec] = useState(0);
@@ -91,11 +95,15 @@ export default function NotesPanel({
-
+
Timed notes
-
+
- {saving ? 'Saving' : 'Saved'} ·{' '}
+ {saving ? 'Saving' : error ? (
+
+ Couldn't save. Retry
+
+ ) : 'Saved'} ·{' '}
Rally {rally.rally_idx + 1} · {rally.shot_count} shots ·{' '}
- {rally.duration_s.toFixed(1)}s
+ {Number.isFinite(rally.duration_s) ? `${rally.duration_s.toFixed(1)}s` : '–'}
{rally.shots.map((s, i) => (
diff --git a/frontend/web/components/recordings/VizPanel.tsx b/frontend/web/components/recordings/VizPanel.tsx
index 20504157..555d8fbe 100644
--- a/frontend/web/components/recordings/VizPanel.tsx
+++ b/frontend/web/components/recordings/VizPanel.tsx
@@ -1,14 +1,14 @@
'use client';
import { useEffect, useMemo, useState, type RefObject } from 'react';
-import ShotMap, { SAMPLE_SHOTS, shotMapCounts, shotMapUnknownCount, type ShotDot } from '../viz/ShotMap';
-import Spacing, { SAMPLE_SPACING, spacingCounts, type SpacingShot } from '../viz/Spacing';
+import ShotMap, { shotMapCounts, shotMapUnknownCount, type ShotDot } from '../viz/ShotMap';
+import Spacing, { spacingCounts, type SpacingShot } from '../viz/Spacing';
import Coverage from '../viz/Coverage';
import Legend from '../viz/Legend';
import { StrokeKey, STROKE_COLOR_BY_KEY } from '../viz/CourtSVG';
import { PositionTile, type PositionSummary } from './CoachInsights';
import CountUp from '@/components/ui/CountUp';
-import { isDemoMode } from '@/lib/demo/demoData';
+import Link from 'next/link';
import { useEntranceReveal } from '../viz/useEntranceReveal';
type VizMode = 'shotMap' | 'spacing' | 'coverage';
@@ -36,9 +36,8 @@ type Props = {
* mode's right rail so coaches see "where she stood" + "how often she
* stood at the baseline" side-by-side. */
positionSummary?: PositionSummary | null;
- /** Recording status — drives the Coverage empty state vs sample fallback. */
- recordingStatus?: string;
handedness?: 'right' | 'left' | null;
+ playerId?: string | null;
/** Source video fps — fallback for seeking when a shot has no time_s. */
fps?: number | null;
/** Optional. When provided, clicking a bounce on the shot map opens a
@@ -153,8 +152,7 @@ function buildSpacingShots(shots: ApiShot[]): SpacingShot[] {
return out;
}
-export default function VizPanel({ shots = [], coverageGrid, positionSummary, recordingStatus, handedness, fps, videoRef }: Props) {
- const demoMode = isDemoMode();
+export default function VizPanel({ shots = [], coverageGrid, positionSummary, handedness, playerId, fps, videoRef }: Props) {
const [mode, setMode] = useState('shotMap');
const [shotFilter, setShotFilter] = useState(null);
const [spacingFilter, setSpacingFilter] = useState(null);
@@ -181,12 +179,11 @@ export default function VizPanel({ shots = [], coverageGrid, positionSummary, re
const head = HEAD[mode];
- // Derive viz-shaped data from real shots; fall back to sample only when empty.
+ // Derive visual data from recorded shots only.
const realDots = useMemo(() => buildShotMapDots(shots), [shots]);
const realSpacing = useMemo(() => buildSpacingShots(shots), [shots]);
- const shotDots: ShotDot[] = realDots.length > 0 ? realDots : SAMPLE_SHOTS;
- const spacingShots: SpacingShot[] =
- realSpacing.length > 0 ? realSpacing : SAMPLE_SPACING;
+ const shotDots: ShotDot[] = realDots;
+ const spacingShots: SpacingShot[] = realSpacing;
const usingReal = realDots.length > 0;
// Coverage: real grid if backend gave us a 12x8 with any non-zero cell.
@@ -267,15 +264,14 @@ export default function VizPanel({ shots = [], coverageGrid, positionSummary, re
return [compute('forehand'), compute('backhand'), compute('serve')];
}, [realDots, usingReal]);
- // Sample spacing can describe the demo, but never a real recording.
const spacingInsight = useMemo(() => {
- if (realSpacing.length === 0 && !demoMode) return null;
+ if (realSpacing.length === 0) return null;
const jammed = spacingShots.filter((s) => s.q === 'jammed').length;
const squeezed = spacingShots.filter((s) => s.q === 'squeezed').length;
const ideal = spacingShots.filter((s) => s.q === 'ideal').length;
const long = spacingShots.filter((s) => s.q === 'long').length;
return { jammed, squeezed, ideal, long };
- }, [spacingShots, realSpacing, demoMode]);
+ }, [spacingShots, realSpacing]);
return (
{head.sub}
{mode === 'shotMap' && handedness == null && (
- Assuming right-handed. Set handedness on the player profile if this player is a lefty; forehand and backhand labels depend on it.
+ Assuming right-handed. {playerId ? Set handedness on the player profile : 'Set handedness on the player profile'} if this player is a lefty; forehand and backhand labels depend on it.
)}
@@ -349,10 +345,14 @@ export default function VizPanel({ shots = [], coverageGrid, positionSummary, re
/>
)}
{mode === 'spacing' && (
-
+ realSpacing.length > 0 ? : (
+
+ Spacing data is unavailable for this recording.
+
+ )
)}
{mode === 'coverage' &&
- (recordingStatus === 'done' && !usingRealCoverage ? (
+ (!usingRealCoverage ? (
) : (
@@ -360,33 +360,11 @@ export default function VizPanel({ shots = [], coverageGrid, positionSummary, re
{head.halfLabel}
- {(() => {
- // What "is sample" means per mode:
- // shotMap: no real bounces produced any dots
- // spacing: no real bounces had valid player+ball contact coords
- // coverage: backend didn't produce a non-empty coverage_grid
- const isSample =
- mode === 'shotMap'
- ? !usingReal
- : mode === 'spacing'
- ? realSpacing.length === 0
- : !usingRealCoverage;
- if (mode === 'coverage' && recordingStatus === 'done' && !usingRealCoverage) {
- return (
-
- · no coverage data
-
- );
- }
- if (isSample) {
- return (
-
- · sample data
-
- );
- }
- return null;
- })()}
+ {mode === 'shotMap' && !usingReal && (
+
+ Fills in once the recording finishes processing.
+
+ )}
@@ -439,24 +417,13 @@ export default function VizPanel({ shots = [], coverageGrid, positionSummary, re
- {(spacingInsight || demoMode) && (
+ {spacingInsight && (
- {spacingInsight ? (
- <>
- {spacingInsight.ideal} shots at ideal
- extension. {spacingInsight.squeezed} {' '}
- squeezed,{' '}
- {spacingInsight.jammed} jammed (ball on
- top), {spacingInsight.long} reaching.
- >
- ) : (
- <>
- 23 shots at ideal extension.{' '}
- 8 squeezed, 2 jammed
- (ball on top), 4 reaching. Step back a
- half-step on returns to clean up the squeezed shots.
- >
- )}
+ {spacingInsight.ideal} shots at ideal
+ extension. {spacingInsight.squeezed} {' '}
+ squeezed,{' '}
+ {spacingInsight.jammed} jammed (ball on
+ top), {spacingInsight.long} reaching.
)}
>
@@ -470,27 +437,16 @@ export default function VizPanel({ shots = [], coverageGrid, positionSummary, re
{positionSummary && positionSummary.n_frames > 0 && (
)}
- {(coverageInsight || demoMode) && (
+ {coverageInsight && (
- {coverageInsight ? (
- <>
- {coverageInsight.baselinePct}% of the
- recording was spent at the baseline third of the court.
- {coverageInsight.sideBias && coverageInsight.sidePct && (
- <>
- {' '}
- Lateral bias toward the{' '}
- {coverageInsight.sideBias} (
- {coverageInsight.sidePct}% of time).
- >
- )}
- >
- ) : (
+ {coverageInsight.baselinePct}% of the
+ recording was spent at the baseline third of the court.
+ {coverageInsight.sideBias && coverageInsight.sidePct && (
<>
- 82% of your recording was within 2 feet of
- the baseline. Only 9% inside the service
- boxes. Stepping in on second-serve returns could shorten
- points.
+ {' '}
+ Lateral bias toward the{' '}
+ {coverageInsight.sideBias} (
+ {coverageInsight.sidePct}% of time).
>
)}
@@ -878,7 +834,7 @@ function StrokeBarsMini({
? 'Forehand'
: row.key === 'backhand'
? 'Backhand'
- : 'Serve';
+ : 'Serve/Overhead';
const value = row[metricKey];
return (
> {
const counts: Partial
> = {};
STROKES.forEach((s) => {
diff --git a/frontend/web/components/viz/Spacing.tsx b/frontend/web/components/viz/Spacing.tsx
index 72321008..3df45806 100644
--- a/frontend/web/components/viz/Spacing.tsx
+++ b/frontend/web/components/viz/Spacing.tsx
@@ -29,32 +29,6 @@ const QUALITY_COLOR: Record = {
long: 'var(--color-amber)',
};
-/** Sample contact spacing — M. Lin contacts in the bottom half-court. */
-export const SAMPLE_SPACING: SpacingShot[] = [
- // Forehand (deuce/right side)
- { stroke: 'forehand', px: 19, py: 73, bx: 22, by: 71, q: 'ideal' },
- { stroke: 'forehand', px: 20, py: 75, bx: 23, by: 73, q: 'ideal' },
- { stroke: 'forehand', px: 21, py: 71, bx: 25, by: 69, q: 'ideal' },
- { stroke: 'forehand', px: 18, py: 76, bx: 19, by: 75, q: 'squeezed' },
- { stroke: 'forehand', px: 22, py: 72, bx: 22.4, by: 71.7, q: 'jammed' },
- { stroke: 'forehand', px: 16, py: 74, bx: 25, by: 71, q: 'long' },
- { stroke: 'forehand', px: 20, py: 75, bx: 23, by: 72, q: 'ideal' },
- { stroke: 'forehand', px: 19, py: 73, bx: 22, by: 70, q: 'ideal' },
- // Backhand (ad/left side)
- { stroke: 'backhand', px: 5, py: 73, bx: 8, by: 70, q: 'ideal' },
- { stroke: 'backhand', px: 6, py: 75, bx: 7, by: 75, q: 'squeezed' },
- { stroke: 'backhand', px: 7, py: 72, bx: 7, by: 71, q: 'squeezed' },
- { stroke: 'backhand', px: 4, py: 74, bx: 7, by: 72, q: 'ideal' },
- { stroke: 'backhand', px: 5, py: 73, bx: 5.4, by: 73.2, q: 'jammed' },
- { stroke: 'backhand', px: 8, py: 71, bx: 11, by: 68, q: 'ideal' },
- { stroke: 'backhand', px: 3, py: 75, bx: 9, by: 72, q: 'long' },
- // Serves (player at baseline)
- { stroke: 'serve', px: 13, py: 77, bx: 13, by: 75, q: 'ideal' },
- { stroke: 'serve', px: 14, py: 77, bx: 14, by: 75, q: 'ideal' },
- { stroke: 'serve', px: 13, py: 77, bx: 15, by: 75, q: 'ideal' },
- { stroke: 'serve', px: 14, py: 77, bx: 14, by: 75, q: 'ideal' },
-];
-
export function spacingCounts(
shots: SpacingShot[]
): Partial> {
diff --git a/frontend/web/hooks/useVideoUpload.ts b/frontend/web/hooks/useVideoUpload.ts
index c7cd189b..40dbfbad 100644
--- a/frontend/web/hooks/useVideoUpload.ts
+++ b/frontend/web/hooks/useVideoUpload.ts
@@ -5,6 +5,8 @@ import { useCallback, useEffect, useRef, useState } from 'react';
// on long rallies. Server has no way to know duration before the upload lands,
// so the gate is client-side; pair with the storage bucket size cap server-side.
const MAX_DURATION_SEC = 15 * 60;
+const POLL_INTERVAL_MS = 1500;
+const MAX_POLL_FAILURES = 20;
async function probeVideoDuration(file: File): Promise {
return new Promise((resolve, reject) => {
@@ -19,12 +21,12 @@ async function probeVideoDuration(file: File): Promise {
video.onloadedmetadata = () => {
const d = video.duration;
cleanup();
- if (!Number.isFinite(d) || d <= 0) reject(new Error('Could not read duration'));
+ if (!Number.isFinite(d) || d <= 0) reject(new Error('We could not read the recording length. Export an MP4 and try again.'));
else resolve(d);
};
video.onerror = () => {
cleanup();
- reject(new Error('Could not load video metadata'));
+ reject(new Error('We could not read that recording. Export an MP4 and try again.'));
};
video.src = url;
});
@@ -182,6 +184,13 @@ export function useVideoUpload(
// let them resolve; concurrent ones will arrive out-of-order but the last
// write wins, which is fine for a monotonically-increasing progress.
let inFlight = false;
+ let consecutiveFailures = 0;
+ const controller = new AbortController();
+ pollAbortRef.current = controller;
+ const stopPolling = () => {
+ if (intervalRef.current !== null) clearInterval(intervalRef.current);
+ intervalRef.current = null;
+ };
intervalRef.current = setInterval(async () => {
if (inFlight) return; // skip tick if previous is still running
inFlight = true;
@@ -189,8 +198,23 @@ export function useVideoUpload(
const t0 = performance.now();
const res = await fetch(`/api/status?match_id=${id}`, {
cache: 'no-store',
+ signal: controller.signal,
});
+ if (controller.signal.aborted) return;
+ if (res.status === 401) {
+ stopPolling();
+ setStatus('failed');
+ setError('Your session expired. Sign in again and open Recordings to find this upload.');
+ return;
+ }
+ if (!res.ok) throw new Error(`Status request failed (${res.status})`);
const data = await res.json();
+ if (controller.signal.aborted) return;
+ if (!['pending', 'processing', 'done', 'failed'].includes(data.status)) {
+ throw new Error('Invalid processing status');
+ }
+ consecutiveFailures = 0;
+ setError(null);
const dtMs = Math.round(performance.now() - t0);
// Diagnostic: log every poll response so we can see in browser devtools
// whether the chain Modal → Supabase → /api/status → hook is alive.
@@ -217,11 +241,17 @@ export function useVideoUpload(
}
}
} catch (err) {
+ if (controller.signal.aborted) return;
console.error('Poll fetch error:', err);
+ consecutiveFailures += 1;
+ if (consecutiveFailures >= MAX_POLL_FAILURES) {
+ stopPolling();
+ setError('We lost contact with processing. Open Recordings to check on it.');
+ }
} finally {
inFlight = false;
}
- }, 1500);
+ }, POLL_INTERVAL_MS);
}, []);
const handleFile = useCallback(
@@ -239,7 +269,7 @@ export function useVideoUpload(
const duration = await probeVideoDuration(file);
if (duration > MAX_DURATION_SEC) {
throw new Error(
- `Video is ${formatMinSec(duration)} — please trim or split into ${formatMinSec(MAX_DURATION_SEC)} or shorter clips. (Long-match support is coming.)`,
+ `Recordings must be 15 minutes or shorter. This one is ${formatMinSec(duration)}. Trim it or split it into shorter recordings.`,
);
}
} catch (err) {
diff --git a/frontend/web/lib/demo/demoData.ts b/frontend/web/lib/demo/demoData.ts
deleted file mode 100644
index 56b6119f..00000000
--- a/frontend/web/lib/demo/demoData.ts
+++ /dev/null
@@ -1,246 +0,0 @@
-/**
- * Demo fixtures — make the dashboard + recordings/players list look like a
- * coach has been using CourtCheck for a season, for screen-recording the
- * pitch. No Supabase writes, no cloned videos: match-detail is still demoed
- * from a real processed recording.
- */
-
-export const DEMO_LS_KEY = 'cc-demo';
-
-// Tri-state: localStorage 'cc-demo' === '1' (on) / '0' (explicit off) /
-// absent (use this default).
-const DEFAULT_DEMO_ON = false;
-
-/** True when demo mode is active. OFF by default; the writes
- * '1' to force demo and '0' to force live, and a `?demo=1`/`?demo=0` param
- * overrides for the current view. */
-export function isDemoMode(search?: URLSearchParams | string | null): boolean {
- if (process.env.NEXT_PUBLIC_DEMO_MODE === '1') return true;
- if (process.env.NEXT_PUBLIC_DEMO_MODE === '0') return false;
- if (typeof window !== 'undefined') {
- try {
- const v = window.localStorage.getItem(DEMO_LS_KEY);
- if (v === '1') return true;
- if (v === '0') return false;
- } catch {
- /* localStorage blocked — fall through */
- }
- }
- if (search) {
- const params =
- typeof search === 'string' ? new URLSearchParams(search) : search;
- const v = params.get('demo');
- if (v === '1' || v === 'true') return true;
- if (v === '0' || v === 'false') return false;
- }
- return DEFAULT_DEMO_ON;
-}
-
-export function getDemoFlag(): boolean {
- if (typeof window === 'undefined') return DEFAULT_DEMO_ON;
- try {
- const v = window.localStorage.getItem(DEMO_LS_KEY);
- if (v === '1') return true;
- if (v === '0') return false;
- } catch {
- /* localStorage blocked */
- }
- return DEFAULT_DEMO_ON;
-}
-
-export function setDemoFlag(on: boolean): void {
- if (typeof window === 'undefined') return;
- try {
- // Persist the explicit choice ('0' = off) so it survives the
- // ON-by-default; clearing the key would just fall back to ON.
- window.localStorage.setItem(DEMO_LS_KEY, on ? '1' : '0');
- } catch {
- /* localStorage blocked — no-op */
- }
-}
-
-function daysAgo(n: number): string {
- const d = new Date();
- d.setDate(d.getDate() - n);
- d.setHours(15, 12, 0, 0);
- return d.toISOString();
-}
-
-export type DemoPlayer = {
- id: string;
- name: string;
- position: string | null;
- year: string | null;
- photo_url: string | null;
- created_at: string;
-};
-
-export type DemoRecording = {
- id: string;
- status: 'done';
- createdAt: string;
- name: string;
- filename: string;
- forehandCount: number;
- backhandCount: number;
- serveCount: number;
- inBoundsBounces: number;
- outBoundsBounces: number;
- shotCount: number;
- bounceCount: number;
- rallyCount: number;
- fps: number;
- numFrames: number;
- player_id: string | null;
-};
-
-// Real UC Davis women's tennis roster (the design partner) + real headshot
-// URLs so the screen recording shows actual faces. Photos resolve through
-// /api/proxy-image (ucdavisaggies.com is allowlisted; the proxy follows its
-// CDN redirect). Recordings/stats below are fabricated.
-const UCD = 'https://ucdavisaggies.com/images/2025/12/2';
-export const DEMO_PLAYERS: DemoPlayer[] = [
- { id: 'demo-p1', name: 'Polina Marakhtanova', position: 'Singles 1', year: 'So.', photo_url: `${UCD}/WTEN_Headshots-1.jpg?width=80&quality=90`, created_at: daysAgo(120) },
- { id: 'demo-p2', name: 'Mika Ikemori', position: 'Singles 2', year: 'Jr.', photo_url: `${UCD}/WTEN_Headshots-3.jpg?width=80&quality=90`, created_at: daysAgo(118) },
- { id: 'demo-p3', name: 'Kaia Wolfe', position: 'Singles 3', year: 'Sr.', photo_url: `${UCD}/WTEN_Headshots-7.jpg?width=80&quality=90`, created_at: daysAgo(110) },
- { id: 'demo-p4', name: 'Sophia Sappa', position: 'Singles 4', year: 'Jr.', photo_url: `${UCD}/WTEN_Headshots-2.jpg?width=80&quality=90`, created_at: daysAgo(108) },
- { id: 'demo-p5', name: 'Aileena Hu', position: 'Singles 5', year: 'So.', photo_url: `${UCD}/WTEN_Headshots-6.jpg?width=80&quality=90`, created_at: daysAgo(96) },
- { id: 'demo-p6', name: 'Isabella Bringas', position: 'Singles 6', year: 'So.', photo_url: `${UCD}/WTEN_Headshots-8.jpg?width=80&quality=90`, created_at: daysAgo(92) },
- { id: 'demo-p7', name: 'Maya Youssef', position: 'Doubles A', year: 'Sr.', photo_url: `${UCD}/WTEN_Headshots.jpg?width=80&quality=90`, created_at: daysAgo(86) },
- { id: 'demo-p8', name: 'Penelope Wong', position: 'Doubles B', year: 'So.', photo_url: `${UCD}/WTEN_Headshots-4.jpg?width=80&quality=90`, created_at: daysAgo(80) },
- { id: 'demo-p9', name: 'Vivian Gallaway', position: 'Reserve', year: 'Fr.', photo_url: `${UCD}/WTEN_Headshots-5.jpg?width=80&quality=90`, created_at: daysAgo(64) },
-];
-
-const OPPONENTS = ['Stanford', 'Cal', 'USC', 'UCLA', 'Pepperdine', 'Saint Mary’s', 'Fresno State', 'Pacific'];
-
-const REC_FPS = 30;
-// 52 min per recording. 18 recordings -> 18 * 52 = 936 min = 15.6 hours.
-const REC_SECONDS = 52 * 60;
-const REC_FRAMES = REC_SECONDS * REC_FPS;
-
-function rec(
- i: number,
- playerIdx: number,
- daysBack: number,
- fh: number,
- bh: number,
- sv: number,
- inB: number,
- outB: number,
-): DemoRecording {
- const p = DEMO_PLAYERS[playerIdx];
- const opp = OPPONENTS[i % OPPONENTS.length];
- const last = p.name.split(' ')[1] ?? p.name;
- const shots = fh + bh + sv;
- return {
- id: `demo-rec-${i}`,
- status: 'done',
- createdAt: daysAgo(daysBack),
- name: `${last} vs ${opp} · Ct ${1 + (i % 4)}`,
- filename: `ucd_${last.toLowerCase()}_${opp.toLowerCase().replace(/\W/g, '')}.mp4`,
- forehandCount: fh,
- backhandCount: bh,
- serveCount: sv,
- inBoundsBounces: inB,
- outBoundsBounces: outB,
- shotCount: shots,
- bounceCount: inB + outB,
- rallyCount: Math.round(shots / 4.3),
- fps: REC_FPS,
- numFrames: REC_FRAMES,
- player_id: p.id,
- };
-}
-
-// 18 recordings (2 per player across the 9-player roster), spread across the
-// last ~7 weeks. 18 * 52 min = 15.6 hours recorded.
-export const DEMO_RECORDINGS: DemoRecording[] = [
- rec(1, 0, 2, 38, 21, 9, 54, 14),
- rec(2, 1, 4, 31, 27, 7, 49, 16),
- rec(3, 2, 5, 44, 18, 11, 61, 12),
- rec(4, 3, 7, 35, 24, 8, 52, 15),
- rec(5, 4, 9, 29, 30, 6, 47, 18),
- rec(6, 5, 11, 41, 16, 12, 58, 11),
- rec(7, 6, 13, 33, 25, 9, 50, 17),
- rec(8, 7, 15, 26, 22, 5, 41, 13),
- rec(9, 8, 17, 30, 19, 7, 44, 14),
- rec(10, 0, 20, 42, 19, 11, 60, 11),
- rec(11, 1, 23, 36, 28, 8, 55, 15),
- rec(12, 2, 26, 47, 20, 13, 65, 10),
- rec(13, 3, 29, 31, 26, 7, 48, 17),
- rec(14, 4, 32, 28, 31, 6, 46, 19),
- rec(15, 5, 35, 43, 17, 12, 60, 12),
- rec(16, 6, 38, 34, 24, 9, 53, 15),
- rec(17, 7, 42, 25, 21, 5, 40, 13),
- rec(18, 8, 46, 32, 18, 8, 45, 14),
-];
-
-const totalSeconds = DEMO_RECORDINGS.reduce((s, r) => s + r.numFrames / r.fps, 0);
-
-export type DemoSummary = {
- totals: { total: number; done: number; processing: number; failed: number };
- tennisStats: {
- totalBounces: number;
- totalShots: number;
- totalRallies: number;
- totalForehands: number;
- totalBackhands: number;
- totalServes: number;
- totalInBounds: number;
- totalOutBounds: number;
- };
- hasTennisStats: boolean;
- totalGameplaySeconds: number;
- withHeatmapsCount: number;
- avgDurationSeconds: number;
- games: Array<{
- id: string;
- createdAt: string;
- durationSeconds: number | null;
- shotCount: number | null;
- forehandCount: number | null;
- backhandCount: number | null;
- serveCount: number | null;
- }>;
-};
-
-const agg = DEMO_RECORDINGS.reduce(
- (a, r) => ({
- fh: a.fh + r.forehandCount,
- bh: a.bh + r.backhandCount,
- sv: a.sv + r.serveCount,
- inB: a.inB + r.inBoundsBounces,
- outB: a.outB + r.outBoundsBounces,
- shots: a.shots + r.shotCount,
- bounces: a.bounces + r.bounceCount,
- rallies: a.rallies + r.rallyCount,
- }),
- { fh: 0, bh: 0, sv: 0, inB: 0, outB: 0, shots: 0, bounces: 0, rallies: 0 },
-);
-
-export const DEMO_SUMMARY: DemoSummary = {
- totals: { total: DEMO_RECORDINGS.length, done: DEMO_RECORDINGS.length, processing: 0, failed: 0 },
- tennisStats: {
- totalBounces: agg.bounces,
- totalShots: agg.shots,
- totalRallies: agg.rallies,
- totalForehands: agg.fh,
- totalBackhands: agg.bh,
- totalServes: agg.sv,
- totalInBounds: agg.inB,
- totalOutBounds: agg.outB,
- },
- hasTennisStats: true,
- totalGameplaySeconds: totalSeconds,
- withHeatmapsCount: DEMO_RECORDINGS.length,
- avgDurationSeconds: Math.round(totalSeconds / DEMO_RECORDINGS.length),
- games: DEMO_RECORDINGS.map((r) => ({
- id: r.id,
- createdAt: r.createdAt,
- durationSeconds: REC_SECONDS,
- shotCount: r.shotCount,
- forehandCount: r.forehandCount,
- backhandCount: r.backhandCount,
- serveCount: r.serveCount,
- })),
-};
diff --git a/frontend/web/lib/hooks/useApiData.ts b/frontend/web/lib/hooks/useApiData.ts
index 5dfbb341..637d0662 100644
--- a/frontend/web/lib/hooks/useApiData.ts
+++ b/frontend/web/lib/hooks/useApiData.ts
@@ -5,18 +5,8 @@
// endpoints, so keying them through SWR gives stale-while-revalidate caching:
// a revisit renders cached data instantly and revalidates in the background,
// and concurrent requests for the same key dedup into one.
-//
-// Demo mode (?demo=1) short-circuits the network: the hook serves the
-// fabricated roster/recordings via `fallbackData` and passes a null key so SWR
-// never fetches.
import useSWR, { type SWRConfiguration, type SWRResponse } from 'swr';
-import {
- isDemoMode,
- DEMO_PLAYERS,
- DEMO_RECORDINGS,
- DEMO_SUMMARY,
-} from '@/lib/demo/demoData';
export const fetcher = async (url: string) => {
const res = await fetch(url);
@@ -24,10 +14,6 @@ export const fetcher = async (url: string) => {
return res.json();
};
-function demoOn(): boolean {
- return isDemoMode(typeof window !== 'undefined' ? window.location.search : null);
-}
-
// Defaults: revalidate on focus/reconnect (SWR defaults) so the UI self-heals
// without a manual refresh; keepPreviousData so navigating between pages shows
// the last-known data instead of flashing a skeleton while revalidating.
@@ -81,13 +67,8 @@ export interface RecordingsData {
/** Players roster. Cached + deduped across the dashboard, list, and detail pages. */
export function usePlayersData(): SWRResponse {
- const demo = demoOn();
- return useSWR(demo ? null : '/api/players', fetcher, {
+ return useSWR('/api/players', fetcher, {
...BASE_CONFIG,
- // Demo roster matches the API shape closely enough for the card views.
- fallbackData: demo
- ? ({ players: DEMO_PLAYERS } as unknown as PlayersData)
- : undefined,
});
}
@@ -98,22 +79,16 @@ export function usePlayersData(): SWRResponse {
* hammering the DB on an idle list.
*/
export function useRecordingsData(): SWRResponse {
- const demo = demoOn();
- return useSWR(demo ? null : '/api/recordings', fetcher, {
+ return useSWR('/api/recordings', fetcher, {
...BASE_CONFIG,
refreshInterval: (latest) =>
latest?.recordings?.some((r) => r.status === 'processing') ? 5000 : 0,
- fallbackData: demo
- ? ({ recordings: DEMO_RECORDINGS } as unknown as RecordingsData)
- : undefined,
});
}
// Loosely typed: the dashboard owns the full summary shape and casts on consume.
export function useDashboardSummary(): SWRResponse {
- const demo = demoOn();
- return useSWR(demo ? null : '/api/dashboard/summary', fetcher, {
+ return useSWR('/api/dashboard/summary', fetcher, {
...BASE_CONFIG,
- fallbackData: demo ? (DEMO_SUMMARY as unknown) : undefined,
});
}
diff --git a/frontend/web/lib/routeMetadata.ts b/frontend/web/lib/routeMetadata.ts
new file mode 100644
index 00000000..5f76eacc
--- /dev/null
+++ b/frontend/web/lib/routeMetadata.ts
@@ -0,0 +1,37 @@
+import type { Metadata } from 'next';
+import { cookies } from 'next/headers';
+import { createServerClient } from '@supabase/ssr';
+import { supabaseAdmin } from '@/lib/supabase/server';
+
+/** Detail titles follow the same ownership rules as their API routes. */
+export async function detailMetadata(table: 'matches' | 'players', id: string, fallback: string): Promise {
+ let name = fallback;
+ try {
+ const cookieStore = await cookies();
+ const supabase = createServerClient(
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
+ process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
+ { cookies: {
+ getAll: () => cookieStore.getAll(),
+ // Server layouts cannot write cookies; the auth proxy refreshes them.
+ setAll: () => {},
+ } },
+ );
+ const { data: { user }, error: authError } = await supabase.auth.getUser();
+ if (authError) console.error('Title authentication failed', authError);
+ if (user) {
+ const columns = table === 'matches' ? 'name, input_path' : 'name';
+ let query = supabaseAdmin.from(table).select(columns).eq('id', id);
+ query = table === 'players' && !user.user_metadata?.onboarding_template
+ ? query.or(`user_id.is.null,user_id.eq.${user.id}`)
+ : query.eq('user_id', user.id);
+ const { data, error } = await query.maybeSingle();
+ if (error) console.error('Detail title lookup failed', error);
+ const row = data as { name?: string | null; input_path?: string | null } | null;
+ if (!error && row) name = row.name || row.input_path?.split('/').pop() || fallback;
+ }
+ } catch (error) {
+ console.error('Detail title failed', error);
+ }
+ return { title: { absolute: `${name} · CourtCheck` } };
+}
diff --git a/frontend/web/lib/supabase/proxy.ts b/frontend/web/lib/supabase/proxy.ts
index 5e739798..3217243a 100644
--- a/frontend/web/lib/supabase/proxy.ts
+++ b/frontend/web/lib/supabase/proxy.ts
@@ -32,27 +32,44 @@ export async function updateSession(request: NextRequest) {
const isAuthenticated = !!session
const path = request.nextUrl.pathname
+ const isApiRoute = path.startsWith('/api/')
const isAuthRoute = path.startsWith('/auth')
const isLandingRoute = path.startsWith('/landing')
const isOnboardingRoute = path === '/onboarding' || path.startsWith('/api/onboarding')
const isProtectedRoute = !isAuthRoute && !isLandingRoute
+ const onboarded = session?.user?.user_metadata?.onboarded === true
+ const redirectWithCookies = (path: string) => {
+ const redirect = NextResponse.redirect(new URL(path, request.url))
+ response.cookies.getAll().forEach(c => redirect.cookies.set(c))
+ return redirect
+ }
+
+ if (!isAuthenticated && isApiRoute) {
+ const unauthorized = NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ response.cookies.getAll().forEach(c => unauthorized.cookies.set(c))
+ return unauthorized
+ }
+
+ if (isAuthenticated && onboarded && path === '/onboarding') {
+ return redirectWithCookies('/')
+ }
+
// If not authenticated and trying to access protected route, redirect to landing
if (!isAuthenticated && isProtectedRoute) {
- return NextResponse.redirect(new URL('/landing', request.url))
+ return redirectWithCookies('/landing')
}
// If authenticated and trying to access auth pages, redirect to dashboard
- if (isAuthenticated && isAuthRoute) {
- return NextResponse.redirect(new URL('/', request.url))
+ if (isAuthenticated && isAuthRoute && path !== '/auth/update-password') {
+ return redirectWithCookies('/')
}
// If authenticated but not yet onboarded, force /onboarding before any
// app surface loads. /onboarding itself and /api/onboarding are always allowed.
- if (isAuthenticated && !isOnboardingRoute && !isAuthRoute && !isLandingRoute) {
- const onboarded = (session?.user?.user_metadata as { onboarded?: boolean } | undefined)?.onboarded === true
+ if (isAuthenticated && !isApiRoute && !isOnboardingRoute && !isAuthRoute && !isLandingRoute) {
if (!onboarded) {
- return NextResponse.redirect(new URL('/onboarding', request.url))
+ return redirectWithCookies('/onboarding')
}
}
diff --git a/frontend/web/next.config.mjs b/frontend/web/next.config.mjs
index 8b1c447c..79d64b60 100644
--- a/frontend/web/next.config.mjs
+++ b/frontend/web/next.config.mjs
@@ -14,7 +14,8 @@ const nextConfig = {
return [
// /api/* — also disable caching so signed URLs + user data never cache
{
- source: '/api/:path*',
+ // proxy-image is excluded: player photos are static and the route sets its own day-long cache.
+ source: '/api/:path((?!proxy-image).*)',
headers: [
{ key: 'Cache-Control', value: 'no-store, no-cache, must-revalidate, proxy-revalidate' },
{ key: 'Pragma', value: 'no-cache' },
diff --git a/frontend/web/public/Bounce_Animated.mp4 b/frontend/web/public/Bounce_Animated.mp4
new file mode 100644
index 0000000000000000000000000000000000000000..929b7a13b5fd82b5ad866073db8b33fb755cf279
GIT binary patch
literal 1012506
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