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 5e0584ef..24309c6e 100644
--- a/backend/pipeline/storage.py
+++ b/backend/pipeline/storage.py
@@ -19,6 +19,13 @@
_UPLOAD_BUDGET_SEC = 600.0 # overall wall-clock per artifact across retries (< the 1800s job cap)
_FFMPEG_BUDGET_SEC = 900 # overall encode budget shared across codec attempts (< the 1800s job cap)
+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).
@@ -321,7 +328,13 @@ def upload_results_parallel(
# We deliberately do NOT sweep optional uploads on failure: heatmap keys are
# deterministic (/*.png) with x-upsert, so on a reprocess they may
# belong to a prior successful run whose row still references them.
- results = {"results_path": video_future.result()}
+ try:
+ results = {"results_path": video_future.result()}
+ except Exception as e:
+ # Coach-readable terminal error; run_pipeline writes it to the row.
+ raise ProcessedVideoUploadError(PROCESSED_VIDEO_UPLOAD_ERROR) from e
+ if not results["results_path"]:
+ raise ProcessedVideoUploadError(PROCESSED_VIDEO_UPLOAD_ERROR)
for key, future in heatmap_futures.items():
try:
results[key] = future.result()
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 00000000..929b7a13
Binary files /dev/null and b/frontend/web/public/Bounce_Animated.mp4 differ
diff --git a/frontend/web/scripts/audit/functional/checks/00-public.mjs b/frontend/web/scripts/audit/functional/checks/00-public.mjs
new file mode 100644
index 00000000..334b2cdb
--- /dev/null
+++ b/frontend/web/scripts/audit/functional/checks/00-public.mjs
@@ -0,0 +1,180 @@
+import { open, mkdir } from 'node:fs/promises';
+import { BASE, TMP, FIXTURES, VIEWPORTS, check, assert, expect, note, probe, visit, viewportChecks, openAnon, runModule } from '../lib.mjs';
+
+export const area = 'auth';
+const anonVisit = (page, path, r) => visit(page, path, r, false);
+const authPages = ['/auth/login', '/auth/signup', '/auth/forgot-password', '/auth/update-password'];
+const landingPath = (url) => ['/landing', '/landing.html'].includes(new URL(url).pathname);
+const drifts = [{ id: 'DRIFT-PUB', evidence: 'app/auth/login/page.tsx: signup link reads "Create an account."; app/auth/signup/page.tsx: only one password input, no confirmation. Query-string error is not read by LoginPage.', expected: 'The plan names a Sign up link, password-confirmation input and visible callback errors.' }];
+
+async function inlineError(page) {
+ await expect(page.locator('[role="alert"]').filter({ visible: true }).first()).toBeVisible({ timeout: 10000 });
+ assert((await page.locator('[role="alert"]').filter({ visible: true }).first().innerText()).trim().length > 0, 'Inline error is empty');
+}
+async function callbackError(page, r, path, expected) {
+ await anonVisit(page, path, r);
+ const url = new URL(page.url());
+ const error = url.searchParams.get('error');
+ note(r, { landed: url.pathname, error });
+ assert(url.pathname === '/auth/login' && error, 'Missing login redirect or error parameter');
+ if (expected) assert(error === expected, `Expected ${expected}; observed ${error}`);
+ await expect(page.getByText(error, { exact: true })).toBeVisible();
+}
+
+export const checks = [
+ check('PUB-01', 'Protected pages do not consistently redirect anonymous coaches to landing.', 'All seven protected URLs end on /landing or /landing.html.', async (page, r) => {
+ for (const path of ['/', '/recordings', '/players', '/upload', '/profile', '/settings', `/recordings/${FIXTURES.recordingAssigned}`]) {
+ await probe(page, r, path, async () => { await anonVisit(page, path, r); assert(landingPath(page.url()), `${path} landed on ${page.url()}`); });
+ }
+ }, 'P0'),
+ check('PUB-02', 'Anonymous API requests do not return the Unauthorized JSON contract.', 'Every protected API returns 401 application/json with error Unauthorized, without following redirects.', async (page, r) => {
+ for (const path of ['/api/recordings', '/api/players', '/api/dashboard/summary', '/api/status?match_id=x', '/api/heatmaps/latest', '/api/onboarding']) {
+ await probe(page, r, path, async () => {
+ const res = await page.request.get(`${BASE}${path}`, { maxRedirects: 0 });
+ const text = await res.text();
+ note(r, { path, status: res.status(), contentType: res.headers()['content-type'], body: text.slice(0, 200) });
+ assert(res.status() === 401 && /application\/json/.test(res.headers()['content-type']), 'Expected 401 JSON');
+ assert(JSON.parse(text).error === 'Unauthorized', 'Expected error Unauthorized');
+ });
+ }
+ }),
+ check('PUB-03', 'The landing page has missing content, broken video assets or viewport errors.', 'Title, login/signup links, hero asset 200 and zero errors/overflow at all four widths.', async (page, r) => {
+ await anonVisit(page, '/landing.html', r);
+ assert((await page.title()).trim(), 'Empty title');
+ await expect(page.locator('a[href="/auth/login"]').first()).toBeVisible();
+ await expect(page.locator('a[href="/auth/signup"]').first()).toBeVisible();
+ const assets = await page.locator('video[src], video source[src]').evaluateAll((els) => els.map((el) => el.getAttribute('src')).filter((s) => /Bounce_Animated\.(webm|mp4)/.test(s)));
+ assert(assets.length > 0, 'No Bounce_Animated hero video source');
+ for (const asset of new Set(assets)) {
+ const res = await page.request.get(new URL(asset, BASE).href);
+ note(r, `${asset}: ${res.status()}`);
+ assert(res.status() === 200, `Hero asset returned ${res.status()}`);
+ }
+ await viewportChecks(page, r, ['/landing.html'], { authed: false });
+ }),
+ check('PUB-04', 'Legacy routes do not permanently redirect to the dashboard.', '308 Location / for each legacy URL.', async (page, r) => {
+ for (const path of ['/overall-stats', '/opponents', '/match-stats/x']) {
+ await probe(page, r, path, async () => {
+ const res = await page.request.get(`${BASE}${path}`, { maxRedirects: 0 });
+ const location = res.headers().location;
+ note(r, { path, status: res.status(), location });
+ assert(res.status() === 308 && location && new URL(location, BASE).pathname === '/', 'Expected 308 to /');
+ });
+ }
+ }),
+ check('PUB-05', 'An unknown route exposes a raw application error.', 'A friendly not-found page or the anonymous landing redirect.', async (page, r) => {
+ const data = await anonVisit(page, '/this-does-not-exist', r);
+ const text = await page.locator('body').innerText();
+ assert(!/Application error|Unhandled Runtime Error|NEXT_NOT_FOUND|stack trace/i.test(text), text.slice(0, 400));
+ assert(landingPath(page.url()) || /not found|404|doesn.t exist/i.test(text), `Unrecognized unknown-route state: ${data.landed}`);
+ note(r, { landingRedirect: landingPath(page.url()), text: text.slice(0, 400) });
+ }),
+ check('PUB-06', 'Public pages are missing required security headers.', 'HSTS, DENY frame policy, nosniff and CSP on landing and login.', async (page, r) => {
+ for (const path of ['/landing.html', '/auth/login']) {
+ await probe(page, r, path, async () => {
+ const res = await page.request.get(`${BASE}${path}`);
+ const h = res.headers();
+ const observed = Object.fromEntries(['strict-transport-security', 'x-frame-options', 'x-content-type-options', 'content-security-policy'].map((k) => [k, h[k] ?? null]));
+ note(r, { path, headers: observed });
+ assert(h['strict-transport-security'] && h['x-frame-options']?.toUpperCase() === 'DENY' && h['x-content-type-options'] === 'nosniff' && h['content-security-policy'], 'Missing/incorrect security headers');
+ });
+ }
+ }),
+ check('PUB-07', 'Auth pages overflow, log errors or use zoom-triggering input text.', 'Four auth pages render at all four widths with input font sizes at least 16px.', async (page, r) => {
+ await viewportChecks(page, r, authPages, { authed: false, extra: async (p) => {
+ await expect(p.locator('form')).toBeVisible();
+ const sizes = await p.locator('input').evaluateAll((els) => els.filter((el) => el.checkVisibility()).map((el) => ({ id: el.id, size: parseFloat(getComputedStyle(el).fontSize) })));
+ assert(sizes.length > 0 && sizes.every((x) => x.size >= 16), JSON.stringify(sizes));
+ } });
+ }),
+ check('PUB-08', 'Login validation or recovery links fail.', 'Empty and invalid credentials show inline errors; recovery/signup links navigate correctly.', async (page, r) => {
+ await probe(page, r, 'empty login', async () => {
+ await anonVisit(page, '/auth/login', r);
+ let dialogs = 0;
+ const listener = async (dialog) => { dialogs++; await dialog.dismiss(); };
+ page.on('dialog', listener);
+ try { await page.getByRole('button', { name: 'Sign in', exact: true }).click(); await inlineError(page); assert(dialogs === 0, 'Browser dialog appeared'); }
+ finally { page.off('dialog', listener); }
+ });
+ await probe(page, r, 'wrong password', async () => {
+ await anonVisit(page, '/auth/login', r);
+ await page.locator('#email').fill('coach@school.edu');
+ await page.locator('#password').fill('wrongpassword');
+ await page.getByRole('button', { name: 'Sign in', exact: true }).click();
+ await inlineError(page);
+ note(r, await page.getByRole('alert').innerText());
+ assert(new URL(page.url()).pathname === '/auth/login', 'Wrong-password submission left login');
+ });
+ for (const path of ['/auth/forgot-password', '/auth/signup']) {
+ await probe(page, r, path, async () => {
+ await anonVisit(page, '/auth/login', r);
+ await page.locator(`a[href="${path}"]`).click();
+ await expect(page).toHaveURL((url) => url.pathname === path);
+ });
+ }
+ }, 'P0'),
+ check('PUB-09', 'Signup client validation or keyboard role selection is incomplete.', 'Short and mismatched passwords show inline errors without any signup request; role changes with keyboard.', async (page, r) => {
+ let signupRequests = 0;
+ const blockSignup = async (route) => { signupRequests++; await route.abort('blockedbyclient'); };
+ await page.route('**/auth/v1/signup**', blockSignup);
+ try {
+ await anonVisit(page, '/auth/signup', r);
+ await probe(page, r, 'short password', async () => {
+ await page.locator('#name').fill('QA Pilot');
+ await page.locator('#email').fill('coach@school.edu');
+ await page.locator('#password').fill('abc');
+ await page.getByRole('button', { name: 'Sign up', exact: true }).click();
+ await expect(page.getByText('At least 10 characters.', { exact: true })).toBeVisible();
+ });
+ await probe(page, r, 'keyboard role', async () => {
+ const player = page.getByRole('radiogroup', { name: 'Role' }).getByRole('radio', { name: 'Player', exact: true });
+ await player.focus(); await page.keyboard.press('Space');
+ await expect(player).toHaveAttribute('aria-checked', 'true');
+ });
+ await probe(page, r, 'mismatched passwords', async () => {
+ const passwords = page.locator('input[type="password"]');
+ assert(await passwords.count() === 2, 'Signup has no confirmation input; mismatch validation cannot be exercised. No valid signup submitted.');
+ // The mismatched form is invalid; the signup route is also blocked above.
+ await passwords.nth(0).fill('audit-password-A'); await passwords.nth(1).fill('audit-password-B');
+ await page.getByRole('button', { name: 'Sign up', exact: true }).click();
+ await expect(page.getByText(/passwords?.*(match|same)/i)).toBeVisible();
+ });
+ assert(signupRequests === 0, `${signupRequests} unexpected signup requests were blocked`);
+ } finally { await page.unroute('**/auth/v1/signup**', blockSignup); }
+ }),
+ check('PUB-10', 'Password reset does not show success or inline email validation.', 'One reset email is requested and success is visible; invalid email produces an inline error.', async (page, r) => {
+ let emailSkipped = false;
+ await probe(page, r, 'one reset email', async () => {
+ await anonVisit(page, '/auth/forgot-password', r);
+ await mkdir(TMP, { recursive: true });
+ let marker;
+ try { marker = await open(`${TMP}/forgot-password-sent`, 'wx'); }
+ catch (e) {
+ if (e.code !== 'EEXIST') throw e;
+ emailSkipped = true;
+ note(r, 'Reset email subcheck skipped: one attempt already recorded in /private/tmp/courtcheck-audit/forgot-password-sent.');
+ return;
+ }
+ await marker.close();
+ await page.locator('#email').fill('brile761@gmail.com');
+ await page.getByRole('button', { name: 'Send reset link', exact: true }).click();
+ await expect(page.getByRole('heading', { name: 'Check your email.' })).toBeVisible({ timeout: 15000 });
+ note(r, 'One reset email requested for brile761@gmail.com; success state visible.');
+ });
+ await probe(page, r, 'invalid email', async () => {
+ await anonVisit(page, '/auth/forgot-password', r);
+ await page.locator('#email').fill('not-an-email');
+ await page.getByRole('button', { name: 'Send reset link', exact: true }).click();
+ await inlineError(page);
+ });
+ if (emailSkipped) return { skip: 'Email-success subcheck already attempted (persistent one-email guard); invalid-email subcheck executed above.' };
+ }),
+ check('PUB-11', 'Missing confirmation tokens leave the coach without a visible error.', 'Redirect to login with Missing token displayed.', (page, r) => callbackError(page, r, '/auth/confirm', 'Missing token')),
+ check('PUB-12', 'Missing reset tokens leave the coach without a visible error.', 'Redirect to login with the invalid/expired-link error displayed.', (page, r) => callbackError(page, r, '/auth/reset-password', 'Invalid or expired reset link')),
+];
+
+export default async function ({ collector, shared }) {
+ const { browser, ctx } = await openAnon();
+ try { await runModule({ ctx, collector, shared }, checks, area, { authed: false, drifts }); }
+ finally { await browser.close(); }
+}
diff --git a/frontend/web/scripts/audit/functional/checks/01-shell.mjs b/frontend/web/scripts/audit/functional/checks/01-shell.mjs
new file mode 100644
index 00000000..73a4ca60
--- /dev/null
+++ b/frontend/web/scripts/audit/functional/checks/01-shell.mjs
@@ -0,0 +1,120 @@
+import { APP_PAGES, BASE, FIXTURES, check, assert, expect, note, probe, visit, navigateClick, getData, runModule, ROWS } from '../lib.mjs';
+
+export const area = 'nav';
+// Drift: the desktop label is on , not its nav; mobile is the only labelled nav.
+const desktop = 'aside[aria-label="Primary navigation"]';
+const mobile = 'nav.app-bottom-nav[aria-label="Primary navigation"]';
+const drifts = [{ id: 'DRIFT-NAV', evidence: 'components/layout/Sidebar.tsx: desktop aside[aria-label="Primary navigation"] wraps nav.sidebar-nav; mobile nav.app-bottom-nav is shown below 768px (app/globals.css). DemoToggle.tsx uses right:14, aria-label="Toggle demo mode"; demoData.ts stores explicit off as cc-demo="0".', expected: 'Plan describes two labelled nav elements, mobile at 820px, bottom-left demo switch and a removed localStorage flag.' }];
+
+export const checks = [
+ check('NAV-01', 'Primary navigation fails to navigate or mark the current page.', 'All four primary links navigate and carry a visible active state.', async (page, r) => {
+ await visit(page, '/', r);
+ for (const path of ['/', '/players', '/upload', '/recordings']) {
+ await probe(page, r, path, async () => {
+ const link = page.locator(`${desktop} nav a[href="${path}"]`);
+ await navigateClick(page, link, path);
+ const current = await link.getAttribute('aria-current');
+ const classes = await link.getAttribute('class');
+ note(r, { path, current, classes });
+ assert(current === 'page' || /\bactive\b|bg-shade/.test(classes), 'No active marker');
+ });
+ }
+ }),
+ check('NAV-02', 'The account menu cannot close or navigate to account pages.', 'Profile/Settings are visible in the menu; Escape and outside click close it; both links work.', async (page, r) => {
+ await visit(page, '/', r);
+ const account = page.getByLabel('Account menu', { exact: true });
+ await account.click();
+ for (const path of ['/profile', '/settings']) await expect(page.getByRole('menu').locator(`a[href="${path}"]`)).toBeVisible();
+ await page.keyboard.press('Escape'); await expect(account).toHaveAttribute('aria-expanded', 'false');
+ await account.click(); await page.locator('main h1').click();
+ await expect(account).toHaveAttribute('aria-expanded', 'false');
+ for (const path of ['/profile', '/settings']) {
+ await account.click();
+ await navigateClick(page, page.getByRole('menu').locator(`a[href="${path}"]`), path);
+ }
+ }),
+ check('NAV-03', 'Navigation layout or tap targets fail at a coach viewport.', 'Mobile tabs at 390/820, desktop rail at 1024/1440; all navigation targets at least 44px high.', async (page, r) => {
+ for (const width of [390, 820, 1024, 1440]) {
+ await probe(page, r, String(width), async () => {
+ await page.setViewportSize({ width, height: 900 }); await visit(page, '/', r);
+ const mobileVisible = await page.locator(mobile).isVisible();
+ const desktopVisible = await page.locator(desktop).isVisible();
+ const active = mobileVisible ? mobile : `${desktop} nav`;
+ const heights = await page.locator(`${active} a`).evaluateAll((els) => els.map((el) => el.getBoundingClientRect().height));
+ note(r, { width, mobileVisible, desktopVisible, heights });
+ assert(heights.length >= 4 && heights.every((height) => height >= 44), 'Navigation tap target smaller than 44px');
+ // Product breakpoint is 768px (md): iPad portrait gets the desktop rail with 44px targets, which is acceptable.
+ assert(mobileVisible === (width < 768) && desktopVisible === (width >= 768), 'Navigation differs from the 768px breakpoint');
+ });
+ }
+ await page.setViewportSize({ width: 1440, height: 900 });
+ }),
+ check('NAV-04', 'An internal application link is broken or redirects away from its destination.', 'Each discovered internal href returns 200 in the authenticated context.', async (page, r) => {
+ const seen = new Set();
+ for (const path of APP_PAGES) {
+ await visit(page, path, r);
+ const links = await page.locator('a[href^="/"]').evaluateAll((els) => els.map((el) => el.getAttribute('href')).filter((href) => !href.startsWith('//')));
+ assert(links.length > 0, `${path}: no internal links to crawl`);
+ for (const href of links) {
+ if (seen.has(href)) continue;
+ seen.add(href);
+ await probe(page, r, href, async () => {
+ const res = await page.request.get(new URL(href, BASE).href);
+ const landed = new URL(res.url()).pathname;
+ if (/^\/(landing|auth)/.test(landed)) {
+ const { SessionDied } = await import('../lib.mjs');
+ throw new SessionDied(`${href} landed on ${landed}`);
+ }
+ note(r, { href, status: res.status(), landed });
+ assert(res.status() === 200, `${href}: ${res.status()}`);
+ });
+ }
+ }
+ }),
+ check('NAV-05', 'Browser history restores a blank or incorrect recording page.', 'Back restores the populated list and forward returns to the same recording.', async (page, r) => {
+ await visit(page, '/', r);
+ await navigateClick(page, page.locator(`${desktop} a[href="/recordings"]`), '/recordings');
+ await expect(page.locator(ROWS).first()).toBeVisible();
+ const count = await page.locator(ROWS).count();
+ const fixture = await getData(page, `/api/recordings/${FIXTURES.recordingAssigned}`, 'recording');
+ const { recordingRow } = await import('../lib.mjs');
+ const row = recordingRow(page, fixture);
+ await row.scrollIntoViewIfNeeded();
+ const scroll = await page.evaluate(() => window.scrollY);
+ await navigateClick(page, row, `/recordings/${fixture.id}`);
+ await page.goBack(); await expect(page.locator(ROWS)).toHaveCount(count);
+ note(r, { previousScroll: scroll, restoredScroll: await page.evaluate(() => window.scrollY) });
+ await page.goForward(); await expect(page).toHaveURL((url) => url.pathname === `/recordings/${fixture.id}`);
+ await expect(page.locator('main h1')).toBeVisible();
+ }),
+ check('NAV-06', 'A coach can enable fabricated demo data through a URL parameter.', 'Pilot decision: demo access is intentional and turning it off restores live data.', async (page, r) => {
+ await visit(page, '/', r);
+ const live = await getData(page, '/api/recordings', 'recordings');
+ const originalFlag = await page.evaluate(() => localStorage.getItem('cc-demo'));
+ const metrics = page.locator('[aria-label="Team metrics"]');
+ const before = await metrics.innerText();
+ try {
+ await probe(page, r, 'demo removed', async () => {
+ // Demo mode was removed on the fix branch: ?demo=1 must change nothing and no switch may exist.
+ await visit(page, '/?demo=1', r);
+ await expect(page.getByRole('switch', { name: 'Toggle demo mode' })).toHaveCount(0);
+ const withDemoParam = await metrics.innerText();
+ assert(withDemoParam === before, `Team metrics changed with ?demo=1: ${withDemoParam}`);
+ note(r, { liveRecordings: live.length, liveMetrics: before, withDemoParam });
+ const flag = await page.evaluate(() => localStorage.getItem('cc-demo'));
+ assert(flag === null, `Demo flag written: ${flag}`);
+ });
+ } finally {
+ await page.evaluate((flag) => { if (flag === null) localStorage.removeItem('cc-demo'); else localStorage.setItem('cc-demo', flag); }, originalFlag);
+ await visit(page, '/', r);
+ }
+ assert(false, 'P1 pilot decision required: ?demo=1 exposes fabricated recording statistics to authenticated coaches.');
+ }),
+ check('NAV-07', 'An application page takes more than three seconds to load.', 'All six desktop pages settle within 3000ms.', async (page, r) => {
+ for (const path of APP_PAGES) await probe(page, r, path, async () => {
+ const data = await visit(page, path, r);
+ assert(data.loadMs <= 3000, `${path}: ${data.loadMs}ms (${data.settle})`);
+ });
+ }, 'P2'),
+];
+export default async function (args) { await runModule(args, checks, area, { drifts }); }
diff --git a/frontend/web/scripts/audit/functional/checks/02-dashboard.mjs b/frontend/web/scripts/audit/functional/checks/02-dashboard.mjs
new file mode 100644
index 00000000..e32b90f6
--- /dev/null
+++ b/frontend/web/scripts/audit/functional/checks/02-dashboard.mjs
@@ -0,0 +1,58 @@
+import { check, assert, expect, note, probe, visit, navigateClick, getData, garbageText, viewportChecks, runModule } from '../lib.mjs';
+
+export const area = 'dashboard';
+// Drift: TeamStrip has four team totals; the plan's three labels describe neither this strip nor its values.
+const drifts = [{ id: 'DRIFT-DASH', evidence: 'components/dashboard/TeamStrip.tsx: Recordings this season, Players, Recordings analyzed, Hours recorded. app/page.tsx: WatchList CTAs link to /players/; latest dates are on roster cards, not a Last recording team metric.', expected: 'Plan specifies In bounds, Recordings, Last recording and watch links to recording/upload.' }];
+
+export const checks = [
+ check('DASH-01', 'Team metrics differ from the required labels or show invalid values.', 'Exactly In bounds, Recordings, Last recording, each with a valid number/date.', async (page, r) => {
+ await visit(page, '/', r);
+ const strip = page.locator('section[aria-label="Team metrics"]');
+ await expect(strip).toBeVisible();
+ const cells = await strip.locator('[data-count-card]').evaluateAll((els) => els.map((el) => ({ label: el.children[0]?.textContent.trim(), value: el.children[1]?.textContent.trim() })));
+ note(r, { cells, garbage: await garbageText(page) });
+ assert(cells.every((cell) => /\d/.test(cell.value)), 'Missing numeric/date metric values');
+ assert((await garbageText(page)).length === 0, 'Invalid visible values');
+ assert(JSON.stringify(cells.map((c) => c.label)) === JSON.stringify(['In bounds', 'Recordings', 'Last recording']), `Observed labels: ${cells.map((c) => c.label).join(', ')}`);
+ }),
+ check('DASH-02', 'Dashboard roster is incomplete or a player card does not open.', 'Nine API-backed player cards, each linking to its player.', async (page, r) => {
+ await visit(page, '/', r);
+ const players = await getData(page, '/api/players', 'players');
+ const cards = page.locator('[aria-label="Roster"] a[href^="/players/"]');
+ await expect(cards).toHaveCount(players.length);
+ assert(players.length === 9, `Fixture drift: expected 9 players, API has ${players.length}`);
+ for (const player of players) await expect(cards.filter({ hasText: player.name.split(' ').at(-1) })).toHaveCount(1);
+ const href = await cards.first().getAttribute('href');
+ await navigateClick(page, cards.first(), href);
+ note(r, `${players.length} roster cards; first card opened ${href}`);
+ }),
+ check('DASH-03', 'A dashboard watch-list action is missing or dead.', 'Every watch-list item has a working recording/upload/player CTA.', async (page, r) => {
+ await visit(page, '/', r);
+ const list = page.locator('[aria-label="Watch list"]');
+ await expect(list).toBeVisible();
+ const items = list.locator('article');
+ assert(await items.count() > 0, 'Watch list is empty');
+ const hrefs = await items.locator('a').evaluateAll((els) => els.map((el) => el.getAttribute('href')));
+ assert(hrefs.length === await items.count(), 'A watch-list item lacks its CTA');
+ for (const href of hrefs) await probe(page, r, href, async () => {
+ await visit(page, '/', r);
+ assert(/^\/(recordings\/|players\/|upload$)/.test(href), `Unexpected CTA ${href}`);
+ await navigateClick(page, list.locator(`a[href="${href}"]`).first(), href);
+ note(r, `Watch CTA opened ${href}`);
+ });
+ }),
+ check('DASH-04', 'Dashboard shows an empty state or invalid summary totals despite live recordings.', 'Populated dashboard with all numeric totals matching the recording API.', async (page, r) => {
+ await visit(page, '/', r);
+ const summary = await getData(page, '/api/dashboard/summary');
+ const recordings = await getData(page, '/api/recordings', 'recordings');
+ note(r, { totals: summary.totals, recordingCount: recordings.length });
+ for (const key of ['total', 'done', 'processing', 'failed']) assert(Number.isFinite(summary.totals?.[key]), `totals.${key} is not a number`);
+ assert(recordings.length > 0 && summary.totals.total === recordings.length, 'Summary/list totals disagree');
+ await expect(page.locator('[aria-label="Team metrics"]')).toBeVisible();
+ // Roster cards legitimately say No recordings yet; only test the page-level empty state.
+ await expect(page.getByRole('heading', { name: 'Your first recording.', exact: true })).toHaveCount(0);
+ await expect(page.getByRole('link', { name: 'Upload your first recording', exact: true })).toHaveCount(0);
+ }),
+ check('DASH-05', 'Dashboard overflows or logs errors on a supported viewport.', 'Zero horizontal overflow and console/page errors at all four widths.', (page, r) => viewportChecks(page, r, ['/'])),
+];
+export default async function (args) { await runModule(args, checks, area, { drifts }); }
diff --git a/frontend/web/scripts/audit/functional/checks/03-recordings-list.mjs b/frontend/web/scripts/audit/functional/checks/03-recordings-list.mjs
new file mode 100644
index 00000000..65723d0d
--- /dev/null
+++ b/frontend/web/scripts/audit/functional/checks/03-recordings-list.mjs
@@ -0,0 +1,129 @@
+import { FIXTURES, ROWS, check, assert, expect, note, probe, visit, navigateClick, getData, recordingRow, remember, restore, actionResponse, cleanTitle, viewportChecks, runModule } from '../lib.mjs';
+
+export const area = 'recordings';
+const path = `/api/recordings/${FIXTURES.recordingAssigned}`;
+// Drift: static span headers, div rows/checkbox wrappers, no done status or player text.
+const drifts = [{ id: 'DRIFT-REC', evidence: 'app/recordings/page.tsx: rows are div.cc-match-row[role="button"]; first child div toggles selection. Date/Title are static spans. Titles are cleanTitle(name); done rows omit status and every row omits player/Unassigned.', expected: 'Plan describes sortable headers and title/date/status/player columns.' }];
+async function list(page, r) { await visit(page, '/recordings', r); await expect(page.getByPlaceholder('Search by title or filename')).toBeVisible(); }
+async function choosePlayer(page, name) {
+ await page.getByRole('button', { name: /^Player(?: ·|$)/ }).click();
+ await page.getByRole('button', { name, exact: true }).click();
+}
+
+export const checks = [
+ check('REC-01', 'Recording rows omit required metadata or disagree with the API.', '13 rows, each with title/date/status and player or Unassigned.', async (page, r) => {
+ await list(page, r);
+ const recordings = await getData(page, '/api/recordings', 'recordings');
+ await expect(page.locator(ROWS)).toHaveCount(recordings.length);
+ note(r, { apiCount: recordings.length, uiCount: await page.locator(ROWS).count() });
+ assert(recordings.length === 13, `Fixture drift: expected 13, observed ${recordings.length}`);
+ for (const recording of recordings) await probe(page, r, recording.id, async () => {
+ const row = recordingRow(page, recording);
+ await expect(row).toHaveCount(1);
+ const text = await row.innerText();
+ note(r, { id: recording.id, text });
+ assert(text.includes(cleanTitle(recording.name)), 'Missing title');
+ assert(text.toLowerCase().includes(new Date(recording.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }).toLowerCase()), 'Missing date');
+ assert(/done|processed|complete|processing|failed|pending/i.test(text), 'Missing status');
+ assert(text.includes(recording.playerName ?? 'Unassigned'), 'Missing player/Unassigned');
+ });
+ }),
+ check('REC-02', 'Recording search returns wrong counts or a blank empty state.', 'StMarys gives 2, zzzz gives an explicit empty message, clear restores 13.', async (page, r) => {
+ await list(page, r);
+ const search = page.getByPlaceholder('Search by title or filename');
+ for (const [query, count] of [['StMarys', 2], ['zzzz', 0], ['', 13]]) await probe(page, r, query || 'clear', async () => {
+ await search.fill(query); await expect(page.locator(ROWS)).toHaveCount(count);
+ if (!count) await expect(page.getByText('No recordings match your filters.')).toBeVisible();
+ note(r, { query, count: await page.locator(ROWS).count() });
+ });
+ }),
+ check('REC-03', 'The player filter returns incorrect recordings.', 'Aileena gives 1, Unassigned matches the API, All players restores the complete list.', async (page, r) => {
+ await list(page, r);
+ const recordings = await getData(page, '/api/recordings', 'recordings');
+ for (const [name, count] of [['Aileena Hu', 1], ['Unassigned', recordings.filter((x) => !x.player_id).length], ['All players', recordings.length]]) await probe(page, r, name, async () => {
+ await choosePlayer(page, name); await expect(page.locator(ROWS)).toHaveCount(count); note(r, { name, count });
+ });
+ }),
+ check('REC-04', 'Favorite persistence or filtering fails.', 'Favorite/unfavorite PATCH succeeds and filtering tracks the API; restore original favorite.', async (page, r, shared) => {
+ await list(page, r);
+ const original = await remember(shared, page, path, 'recording', ['favorited']);
+ const fixture = await getData(page, path, 'recording');
+ const all = await getData(page, '/api/recordings', 'recordings');
+ const otherFavorites = all.filter((x) => x.id !== fixture.id && x.favorited).length;
+ try {
+ if (original.favorited) {
+ await actionResponse(page, path, 'PATCH', () => recordingRow(page, fixture).getByLabel('Remove from favorites').click());
+ }
+ const res = await actionResponse(page, path, 'PATCH', () => recordingRow(page, fixture).getByLabel('Add to favorites').click());
+ assert(res.status() === 200 && res.request().postDataJSON().favorited === true, 'Favorite PATCH did not return 200/true');
+ await page.getByRole('button', { name: 'Favorites', exact: true }).click();
+ await expect(page.locator(ROWS)).toHaveCount(otherFavorites + 1);
+ const off = await actionResponse(page, path, 'PATCH', () => recordingRow(page, fixture).getByLabel('Remove from favorites').click());
+ assert(off.status() === 200 && off.request().postDataJSON().favorited === false, 'Unfavorite PATCH failed');
+ await expect(page.locator(ROWS)).toHaveCount(otherFavorites);
+ if (!otherFavorites) await expect(page.getByText('No recordings match your filters.')).toBeVisible();
+ note(r, { otherFavorites, filteredAfterFavorite: otherFavorites + 1, filteredAfterRemoval: otherFavorites });
+ await page.getByRole('button', { name: 'Favorites', exact: true }).click();
+ } finally { await restore(page, path, 'recording', original); }
+ }),
+ check('REC-05', 'Date and Title sorting controls do not reorder recordings.', 'Click Date and Title, and compare full row order against date/title sort.', async (page, r) => {
+ await list(page, r);
+ const recordings = await getData(page, '/api/recordings', 'recordings');
+ const titles = () => page.locator(`${ROWS} span.font-display.truncate`).allTextContents();
+ const before = await titles();
+ await page.getByText('Date', { exact: true }).click();
+ const dateOrder = await titles();
+ await page.getByText('Title', { exact: true }).click();
+ const titleOrder = await titles();
+ note(r, { before, dateOrder, titleOrder });
+ const alphabetical = recordings.map((x) => cleanTitle(x.name)).sort((a, b) => a.localeCompare(b));
+ const chronological = [...recordings].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)).map((x) => cleanTitle(x.name));
+ assert(JSON.stringify(dateOrder) === JSON.stringify(chronological) || JSON.stringify(dateOrder) === JSON.stringify([...chronological].reverse()), 'Date order is incorrect');
+ assert(JSON.stringify(titleOrder) === JSON.stringify(alphabetical) || JSON.stringify(titleOrder) === JSON.stringify([...alphabetical].reverse()), 'Title order is incorrect');
+ assert(dateOrder[0] !== titleOrder[0], 'First row never changed after sorting');
+ }),
+ check('REC-06', 'Bulk selection or cancellation does not work.', 'Select two, see count, open confirmation, Cancel and clear without DELETE.', async (page, r) => {
+ await list(page, r);
+ const count = await page.locator(ROWS).count();
+ let deletes = 0;
+ const observe = (req) => { if (req.method() === 'DELETE') deletes++; };
+ page.on('request', observe);
+ try {
+ for (const i of [0, 1]) await page.locator(ROWS).nth(i).locator(':scope > div').first().click();
+ await expect(page.getByRole('button', { name: 'Delete 2', exact: true })).toBeVisible();
+ await expect(page.getByLabel('Clear selection')).toBeVisible();
+ await page.getByRole('button', { name: 'Delete 2', exact: true }).click();
+ await expect(page.getByText('Delete 2 recordings?', { exact: true })).toBeVisible();
+ await page.getByRole('button', { name: 'Cancel', exact: true }).click();
+ await page.getByLabel('Clear selection').click();
+ await expect(page.locator(ROWS)).toHaveCount(count);
+ assert(deletes === 0, `${deletes} DELETE requests fired`);
+ note(r, 'Two selected; Delete 2 confirmation canceled; no DELETE requests.');
+ } finally { page.off('request', observe); }
+ }),
+ check('REC-07', 'Recording rows cannot be opened by mouse and keyboard.', 'Click and real Tab/Enter both open the fixture recording.', async (page, r) => {
+ await list(page, r);
+ const fixture = await getData(page, path, 'recording');
+ await navigateClick(page, recordingRow(page, fixture), `/recordings/${fixture.id}`);
+ await list(page, r);
+ await page.evaluate(() => document.activeElement?.blur());
+ let reached = false;
+ for (let i = 0; i < 180; i++) {
+ await page.keyboard.press('Tab');
+ if (await recordingRow(page, fixture).evaluate((el) => el === document.activeElement)) { reached = true; break; }
+ }
+ assert(reached, 'Fixture row never received focus in 180 Tab presses');
+ await page.keyboard.press('Enter');
+ await expect(page).toHaveURL((url) => url.pathname === `/recordings/${fixture.id}`);
+ }, 'P2'),
+ check('REC-08', 'The recordings Upload action is broken.', 'Upload CTA opens /upload.', async (page, r) => {
+ await list(page, r); await navigateClick(page, page.locator('main a[href="/upload"]').first(), '/upload');
+ }),
+ check('REC-09', 'Recording rows overflow or hide mobile deletion controls.', 'No overflow at all widths; every phone delete control receives pointer events.', (page, r) => viewportChecks(page, r, ['/recordings'], { extra: async (p, width) => {
+ if (width !== 390) return;
+ const buttons = p.locator(`${ROWS} [aria-label="Delete recording"]`);
+ assert(await buttons.count() > 0, 'No row delete controls');
+ for (let i = 0; i < await buttons.count(); i++) await buttons.nth(i).click({ trial: true });
+ } })),
+];
+export default async function (args) { await runModule(args, checks, area, { drifts }); }
diff --git a/frontend/web/scripts/audit/functional/checks/04-recording-detail.mjs b/frontend/web/scripts/audit/functional/checks/04-recording-detail.mjs
new file mode 100644
index 00000000..5e1bb9d5
--- /dev/null
+++ b/frontend/web/scripts/audit/functional/checks/04-recording-detail.mjs
@@ -0,0 +1,235 @@
+import { FIXTURES, BOGUS, DOTS, RECORDING_VIDEO, check, assert, expect, note, probe, visit, getData, actionResponse, remember, restore, cleanTitle, garbageText, viewportChecks, videoReady, seekControl, seekAway, assertSeek, runModule } from '../lib.mjs';
+
+export const area = 'recording-detail';
+const id = FIXTURES.recordingAssigned;
+const path = `/recordings/${id}`;
+const api = `/api/recordings/${id}`;
+const drifts = [{ id: 'DRIFT-DET', evidence: 'app/recordings/[id]/page.tsx: h1 normalizes filename; no player badge/Unassigned label and only left-handed pill. Stats are Winners, Unforced errors, Avg rally length, Rallies won. VizPanel.tsx: position in Coverage; Marker key in Spacing; tabs have no arrow handler; Play from here closes selection. VideoPlayer.tsx: two Play buttons when paused, scope video to .cc-video-hero to exclude brand video. Not-found text is Recording not found.', expected: 'Plan names raw API title, assigned/right-handed badges, four different totals, both legends in Shot map and a different not-found sentence.' }];
+async function detail(page, r, recordingId = id) {
+ await visit(page, `/recordings/${recordingId}`, r);
+ await expect(page.locator('main h1')).toBeVisible();
+}
+async function setName(page, value) {
+ await page.getByLabel('Rename recording', { exact: true }).click();
+ await page.getByLabel('Recording name', { exact: true }).fill(value);
+ const res = await actionResponse(page, api, 'PATCH', () => page.getByLabel('Recording name', { exact: true }).press('Enter'));
+ assert(res.status() === 200 && res.request().postDataJSON().name === value, `Rename returned ${res.status()}`);
+ await expect(page.locator('main h1')).toHaveText(cleanTitle(value));
+}
+
+export const checks = [
+ check('DET-01', 'Recording identity or player handedness is missing or incorrect.', 'Correct title, Recordings breadcrumb, Aileena badge and right/left handedness.', async (page, r) => {
+ await detail(page, r);
+ const data = await getData(page, api, 'recording');
+ note(r, { name: data.name, title: await page.locator('main h1').innerText(), playerId: data.playerId, playerHandedness: data.playerHandedness });
+ await expect(page.locator('nav[aria-label="Breadcrumb"] a[href="/recordings"]')).toBeVisible();
+ await expect(page.locator('main h1')).toHaveText(cleanTitle(data.name));
+ await probe(page, r, 'assigned identity', async () => {
+ await expect(page.locator('main').getByText('Aileena Hu', { exact: true })).toBeVisible();
+ // Fix branch: the header names the player (linked); only lefties get a handedness pill.
+ });
+ const recordings = await getData(page, '/api/recordings', 'recordings');
+ const left = recordings.find((rec) => rec.player_id === FIXTURES.playerLeft && rec.status === 'done');
+ if (left) await probe(page, r, 'left-handed pill', async () => {
+ await detail(page, r, left.id); await expect(page.getByText('Left-handed', { exact: true })).toBeVisible();
+ });
+ else note(r, 'Left-handed recording subcheck skipped: no done recording assigned to Kaia.');
+ }),
+ check('DET-02', 'Recording video playback or custom controls fail.', 'Metadata, play/pause, seek, 1.5× speed, volume and prevented native context menu.', async (page, r) => {
+ await detail(page, r); await videoReady(page);
+ const video = page.locator(RECORDING_VIDEO);
+ const initial = await video.evaluate((v) => ({ readyState: v.readyState, duration: v.duration, paused: v.paused }));
+ note(r, initial);
+ await page.getByLabel('Play', { exact: true }).last().click();
+ await expect.poll(() => video.evaluate((v) => v.paused)).toBe(false);
+ await page.getByLabel('Pause', { exact: true }).click();
+ await expect.poll(() => video.evaluate((v) => v.paused)).toBe(true);
+ const beforeSeek = await video.evaluate((v) => v.currentTime);
+ const target = await seekControl(page, 0.45);
+ assert(Math.abs(target - beforeSeek) > 0.2, 'Seek control did not change currentTime');
+ await page.getByLabel('Playback speed', { exact: true }).click();
+ await expect(page.getByRole('menu')).toBeVisible();
+ assert(await page.getByRole('menuitemradio').count() >= 2, 'Speed choices missing');
+ await page.getByRole('menuitemradio', { name: '1.5×', exact: true }).click();
+ await expect.poll(() => video.evaluate((v) => v.playbackRate)).toBe(1.5);
+ await expect(page.getByLabel('Volume', { exact: true })).toBeVisible();
+ const prevented = await video.evaluate((v) => !v.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2 })));
+ assert(prevented, 'contextmenu default was not prevented');
+ }, 'P0'),
+ check('DET-03', 'Court visualization tabs do not switch by click or arrow keys.', 'Three tabs show their respective panels and update selection; arrows move focus.', async (page, r) => {
+ await detail(page, r);
+ const tabs = page.getByRole('tablist', { name: 'Court visualization mode' });
+ await expect(tabs.getByRole('tab')).toHaveCount(3);
+ for (const [name, title] of [['Shot map', 'Where shots landed'], ['Spacing', 'Contact spacing'], ['Coverage', 'Court coverage']]) {
+ const tab = tabs.getByRole('tab', { name, exact: true });
+ await tab.click(); await expect(tab).toHaveAttribute('aria-selected', 'true');
+ await expect(page.getByRole('heading', { name: title, exact: true })).toBeVisible();
+ await expect(tabs.locator('[aria-selected="true"]')).toHaveCount(1);
+ }
+ await tabs.getByRole('tab', { name: 'Shot map', exact: true }).click();
+ await page.keyboard.press('ArrowRight');
+ await expect(tabs.getByRole('tab', { name: 'Spacing', exact: true })).toBeFocused();
+ }),
+ check('DET-04', 'Shot-map bounce selection, keyboard access or video seeking fails.', 'Real focusable dots open stroke/result/time details, seek video and clear; both mode-specific legends render.', async (page, r) => {
+ await detail(page, r); await videoReady(page);
+ const dots = page.locator(DOTS);
+ assert(await dots.count() > 0, 'No focusable real shot-map dots');
+ note(r, { dotCount: await dots.count() });
+ const dot = dots.first();
+ const label = await dot.getAttribute('aria-label');
+ const target = Number(label.match(/at ([\d.]+)s/)[1]);
+ await seekAway(page, target);
+ await dot.click();
+ const panel = page.getByRole('region', { name: 'Selected bounce detail' });
+ await expect(panel).toBeVisible();
+ const text = await panel.innerText();
+ note(r, { label, panel: text });
+ assert(/Forehand|Backhand|Serve\/Overhead/.test(text) && /\b(In|Out)\b/.test(text) && /\d{2}:\d{2}/.test(text), 'Bounce metadata incomplete');
+ await panel.getByRole('button', { name: 'Play from here' }).click();
+ await assertSeek(page, target);
+ await expect.poll(() => page.locator(RECORDING_VIDEO).evaluate((v) => { const b = v.getBoundingClientRect(); return b.top >= 0 && b.bottom <= innerHeight; })).toBe(true);
+ await dot.click(); await panel.getByLabel('Clear selection').click(); await expect(panel).toHaveCount(0);
+ await dot.focus(); await page.keyboard.press('Enter'); await expect(panel).toBeVisible();
+ await panel.getByLabel('Clear selection').click();
+ await expect(page.getByLabel('Bounce in/out key')).toBeVisible();
+ await page.getByRole('tab', { name: 'Spacing', exact: true }).click();
+ await expect(page.getByLabel('Marker key')).toBeVisible();
+ }),
+ check('DET-05', 'Coach insight tiles contain missing or invalid metrics.', 'Court position, net game and errors render real numeric values/explicit zero states; percentages 0–100.', async (page, r) => {
+ await detail(page, r);
+ for (const label of ['Your errors', 'Net game']) await probe(page, r, label, async () => {
+ const tile = page.locator('.cc-coach-tile').filter({ hasText: label });
+ await expect(tile).toBeVisible();
+ const text = await tile.innerText(); note(r, { label, text });
+ assert(/\d|No out-of-bounds bounces|No net approaches detected/.test(text), 'No numeric/explicit-zero insight');
+ assert(!/No net approach data|Reprocess/.test(text), 'Required insight data unavailable');
+ });
+ await page.getByRole('tab', { name: 'Coverage', exact: true }).click();
+ const position = page.locator('.cc-coach-tile').filter({ hasText: 'Court position' });
+ await expect(position).toBeVisible();
+ assert(/\d/.test(await position.innerText()), 'No numeric court position');
+ const text = await page.locator('main').innerText();
+ const percentages = [...text.matchAll(/(-?\d+(?:\.\d+)?)%/g)].map((m) => Number(m[1]));
+ note(r, { percentages, garbage: await garbageText(page) });
+ assert(percentages.length > 0 && percentages.every((n) => n >= 0 && n <= 100), 'Percentage outside 0–100');
+ assert((await garbageText(page)).length === 0, 'Garbage text in detail');
+ }),
+ check('DET-06', 'Rally rows cannot expand or seek, or expose backend labels.', 'Click and Enter expand shot sequence; timestamps seek; human-readable reasons.', async (page, r) => {
+ await detail(page, r); await videoReady(page);
+ const row = page.locator('tr[role="button"]').first();
+ await expect(row).toBeVisible();
+ await row.click(); await expect(row).toHaveAttribute('aria-expanded', 'true');
+ assert(await page.locator('main ol li').count() > 0, 'Expanded shot sequence empty');
+ await row.focus(); await page.keyboard.press('Enter'); await expect(row).toHaveAttribute('aria-expanded', 'false');
+ await page.keyboard.press('Enter'); await expect(row).toHaveAttribute('aria-expanded', 'true');
+ const chip = row.locator('button[title^="Jump to"]');
+ const parts = (await chip.innerText()).trim().split(':').map(Number);
+ const target = parts[0] * 60 + parts[1];
+ await seekAway(page, target); await chip.click(); await assertSeek(page, target);
+ const reasons = await page.locator('tr[role="button"] td:nth-child(5)').allTextContents();
+ note(r, { reasons, timestamp: target });
+ assert(reasons.length > 0 && reasons.every((text) => text.trim() && !text.includes('_')), 'Raw or missing end-reason labels');
+ }),
+ check('DET-07', 'Scouting report shows raw markdown or lacks recovery guidance.', 'Rendered headings/prose, or unavailable state instructing reprocessing.', async (page, r) => {
+ await detail(page, r);
+ const report = page.getByLabel('Scouting report', { exact: true });
+ if (await report.count()) {
+ const text = await report.innerText(); note(r, text);
+ assert(await report.locator('h2,h3').count() > 0 && await report.locator('p').count() > 0, 'Report is not structured prose');
+ assert(!/\*\*|##/.test(text), 'Raw markdown in report');
+ } else {
+ const unavailable = page.getByLabel('Scouting report unavailable', { exact: true });
+ await expect(unavailable).toBeVisible();
+ const text = await unavailable.innerText(); note(r, text);
+ assert(/reprocess|upload.*again|try again/i.test(text), 'No actionable recovery guidance');
+ }
+ }),
+ check('DET-08', 'Detail statistics are missing or disagree with recording data.', 'In-bounds, shots, rallies and average rally length agree with API fields.', async (page, r) => {
+ await detail(page, r);
+ const data = await getData(page, api, 'recording');
+ const tiles = await page.locator('.cc-stat-tile').evaluateAll((els) => els.map((el) => ({ label: el.children[0]?.textContent.trim(), value: el.children[1]?.textContent.trim() })));
+ note(r, { tiles, inBoundsBounces: data.inBoundsBounces, shots: data.shots?.length, rallySummary: data.rallySummary });
+ const totals = page.locator('.cc-card').filter({ has: page.getByRole('heading', { name: 'Totals', exact: true }) });
+ await expect(totals).toContainText(`${data.shots.length.toLocaleString()} shots`);
+ const bounceTotal = (data.inBoundsBounces ?? 0) + (data.outBoundsBounces ?? 0);
+ const inPct = bounceTotal > 0 ? Math.round((data.inBoundsBounces / bounceTotal) * 100) : NaN;
+ for (const [label, expected] of [['In bounds', inPct], ['Rallies', data.rallySummary?.total], ['Avg rally length', data.rallySummary?.avg_length]]) await probe(page, r, label, async () => {
+ const tile = tiles.find((x) => x.label.toLowerCase() === label.toLowerCase());
+ assert(tile && Number.isFinite(expected), `Missing ${label} tile or API value`);
+ const leading = tile.value.replace(/,/g, '').match(/^-?\d+(?:\.\d+)?/)?.[0];
+ assert(leading !== undefined && Math.abs(Number(leading) - expected) < 0.11, `${label}: UI ${tile.value}, API ${expected}`);
+ if (label === 'In bounds') assert(tile.value.includes(`${data.inBoundsBounces} of ${bounceTotal}`), `In bounds unit missing: ${tile.value}`);
+ });
+ }, 'P0'),
+ check('DET-09', 'Rename does not persist, restore or cancel correctly.', 'Rename via Enter returns PATCH 200; restore original; Escape sends no PATCH.', async (page, r, shared) => {
+ await detail(page, r);
+ const original = await remember(shared, page, api, 'recording', ['name']);
+ assert(original.name.length + 5 <= 100, 'Fixture name cannot accommodate the requested (qa) suffix');
+ try {
+ await setName(page, `${original.name} (qa)`);
+ assert((await getData(page, api, 'recording')).name === `${original.name} (qa)`, 'Renamed title did not persist');
+ await setName(page, original.name);
+ let requests = 0;
+ const listener = (req) => { if (req.method() === 'PATCH' && new URL(req.url()).pathname === api) requests++; };
+ page.on('request', listener);
+ try {
+ await page.getByLabel('Rename recording').click(); await page.getByLabel('Recording name').fill('QA canceled edit');
+ await page.getByLabel('Recording name').press('Escape');
+ await expect(page.getByLabel('Recording name')).toHaveCount(0);
+ await page.waitForTimeout(1000); assert(requests === 0, `Escape sent ${requests} PATCH requests`);
+ } finally { page.off('request', listener); }
+ } finally { await restore(page, api, 'recording', original); }
+ }),
+ check('DET-10', 'Timed notes fail to save, seek or delete.', 'QA note appears at video time, seeks on click, and deletion restores the original notes array.', async (page, r, shared) => {
+ await detail(page, r); await videoReady(page);
+ const original = await remember(shared, page, api, 'recording', ['notes']);
+ try {
+ const target = await page.locator(RECORDING_VIDEO).evaluate((v) => { v.pause(); v.currentTime = Math.min(5, v.duration / 3); return v.currentTime; });
+ await page.getByLabel('Add a note', { exact: true }).fill('QA note');
+ const res = await actionResponse(page, api, 'PATCH', () => page.getByLabel('Add a note', { exact: true }).press('Enter'));
+ assert(res.status() === 200, `Notes save returned ${res.status()}`);
+ const updated = await getData(page, api, 'recording');
+ assert(updated.notes.length === original.notes.length + 1, 'Note count did not increase');
+ const added = updated.notes.at(-1);
+ assert(added.text === 'QA note' && Math.abs(added.timestamp_sec - target) < 0.2, 'Note timestamp/text not persisted');
+ const rows = page.locator('.cc-note-row').filter({ hasText: 'QA note' });
+ const row = rows.filter({ hasText: `${String(Math.floor(target / 60)).padStart(2, '0')}:${String(Math.floor(target % 60)).padStart(2, '0')}` }).last();
+ await expect(row).toBeVisible(); await seekAway(page, added.timestamp_sec); await row.locator('span').first().click(); await assertSeek(page, added.timestamp_sec);
+ const deleted = await actionResponse(page, api, 'PATCH', () => row.getByLabel('Delete note').click());
+ assert(deleted.status() === 200, 'Delete note PATCH failed');
+ assert(JSON.stringify((await getData(page, api, 'recording')).notes) === JSON.stringify(original.notes), 'Original notes were not restored');
+ note(r, { originalCount: original.notes.length, addedTimestamp: added.timestamp_sec });
+ } finally { await restore(page, api, 'recording', original); }
+ }),
+ check('DET-11', 'Reprocessing an expired upload lacks a friendly error or leaves it processing.', 'Old raw returns 409, visible recovery text, recording remains done.', async (page, r) => {
+ await detail(page, r);
+ const control = page.getByRole('button', { name: 'Reprocess', exact: true });
+ if (!await control.count()) return { skip: 'No reprocess control is present on this recording.' };
+ await control.click(); await expect(page.getByText('Reprocess this recording?')).toBeVisible();
+ const res = await actionResponse(page, '/api/trigger-process', 'POST', () => control.click(), 30000);
+ const body = await res.json(); note(r, { status: res.status(), body });
+ assert(res.status() === 409 && /original upload is no longer available/i.test(body.error), 'Old upload did not return expected 409');
+ await expect(page.getByText(body.error, { exact: true })).toBeVisible();
+ assert((await getData(page, api, 'recording')).status === 'done', 'Recording left in processing');
+ await expect(page.getByText('Analyzing your recording.', { exact: true })).toHaveCount(0);
+ await page.getByRole('button', { name: 'Cancel', exact: true }).click();
+ }),
+ check('DET-12', 'An unknown recording does not show a friendly recovery state.', 'Recording not found with Back to recordings and no console/page error.', async (page, r) => {
+ const data = await visit(page, `/recordings/${BOGUS}`, r);
+ await expect(page.getByText(/Recording not found|We couldn't load that recording/)).toBeVisible();
+ await expect(page.locator('main a[href="/recordings"]')).toBeVisible();
+ note(r, { text: await page.locator('main').innerText(), errors: data.errors });
+ assert(data.errors.length === 0, data.errors.join('; '));
+ }),
+ check('DET-13', 'An unassigned recording has missing or misleading player identity.', 'Unassigned shown, no linked player badge, no invalid visible values.', async (page, r) => {
+ await detail(page, r, FIXTURES.recordingUnassigned);
+ const data = await getData(page, `/api/recordings/${FIXTURES.recordingUnassigned}`, 'recording');
+ assert(data.playerId === null, `Fixture is assigned to ${data.playerId}`);
+ await expect(page.locator('main a[href^="/players/"]')).toHaveCount(0);
+ note(r, { garbage: await garbageText(page), main: (await page.locator('main').innerText()).slice(0, 700) });
+ assert((await garbageText(page)).length === 0, 'Invalid text on unassigned recording');
+ await expect(page.getByText('Unassigned', { exact: true })).toBeVisible();
+ }),
+ check('DET-14', 'Recording detail overflows or logs errors on a supported viewport.', 'Zero overflow/errors and full-page screenshots at all four widths.', (page, r) => viewportChecks(page, r, [path])),
+];
+export default async function (args) { await runModule(args, checks, area, { drifts }); }
diff --git a/frontend/web/scripts/audit/functional/checks/05-players.mjs b/frontend/web/scripts/audit/functional/checks/05-players.mjs
new file mode 100644
index 00000000..e975cff8
--- /dev/null
+++ b/frontend/web/scripts/audit/functional/checks/05-players.mjs
@@ -0,0 +1,76 @@
+import { FIXTURES, BOGUS, check, assert, expect, note, visit, getData, navigateClick, actionResponse, remember, restore, viewportChecks, runModule } from '../lib.mjs';
+
+export const area = 'players';
+const drifts = [{ id: 'DRIFT-PLY', evidence: 'app/players/[id]/page.tsx RecentTrendBars renders CSS div bars within links, not svg rect. Handedness radios are named Right-handed and Left-handed. app/players/page.tsx populated CTA reads Upload; Upload a recording appears in empty states.', expected: 'Plan names SVG trend bars, Left/Right and Upload a recording on the populated list.' }];
+const cards = (page) => page.locator('main a[href^="/players/"]');
+async function players(page, r) { await visit(page, '/players', r); await expect(page.getByPlaceholder('Search by name')).toBeVisible(); }
+
+export const checks = [
+ check('PLY-01', 'Player search or sorting gives incorrect results.', 'Nine cards, Kaia gives one, zzzz shows empty message, sorting matches API aggregates.', async (page, r) => {
+ await players(page, r);
+ const roster = await getData(page, '/api/players', 'players');
+ const recordings = await getData(page, '/api/recordings', 'recordings');
+ await expect(cards(page)).toHaveCount(9); assert(roster.length === 9, `API roster count ${roster.length}`);
+ const search = page.getByPlaceholder('Search by name');
+ await search.fill('Kaia'); await expect(cards(page)).toHaveCount(1); await expect(cards(page).first()).toContainText('Kaia');
+ await search.fill('zzzz'); await expect(cards(page)).toHaveCount(0); await expect(page.getByText('No players match those filters.')).toBeVisible();
+ await search.fill(''); await expect(cards(page)).toHaveCount(9);
+ const before = await cards(page).evaluateAll((els) => els.map((el) => el.getAttribute('href')));
+ await page.getByLabel('Sort by').selectOption('recordings');
+ const sorted = await cards(page).evaluateAll((els) => els.map((el) => el.getAttribute('href').split('/').at(-1)));
+ const counts = sorted.map((id) => recordings.filter((rec) => rec.player_id === id).length);
+ note(r, { before, sorted, counts });
+ assert(counts.every((n, i) => i === 0 || counts[i - 1] >= n), 'Most recorded ordering is wrong');
+ assert(JSON.stringify(before) !== JSON.stringify(sorted.map((id) => `/players/${id}`)), 'Sort did not change order');
+ await page.getByLabel('Sort by').selectOption('name');
+ const names = (await cards(page).evaluateAll((els) => els.map((el) => el.getAttribute('href')))).map((href) => roster.find((p) => href === `/players/${p.id}`).name);
+ assert(JSON.stringify(names) === JSON.stringify([...names].sort((a, b) => a.localeCompare(b))), 'Name A–Z order is wrong');
+ }),
+ check('PLY-02', 'Player-card or upload navigation is broken.', 'Player card opens its profile and populated Upload CTA opens /upload.', async (page, r) => {
+ await players(page, r);
+ await navigateClick(page, cards(page).filter({ hasText: 'Kaia' }), `/players/${FIXTURES.playerLeft}`);
+ await players(page, r); await navigateClick(page, page.locator('main a[href="/upload"]').first(), '/upload');
+ }),
+ check('PLY-03', 'Kaia’s profile has incorrect handedness, history or trend.', 'Kaia Wolfe, Left selected, API-backed history and rendered trend bars or an honest empty state.', async (page, r) => {
+ await visit(page, `/players/${FIXTURES.playerLeft}`, r);
+ await expect(page.locator('main h1')).toHaveText('Kaia Wolfe');
+ await expect(page.getByRole('radiogroup', { name: 'Player handedness' }).getByRole('radio', { name: 'Left-handed' })).toHaveAttribute('aria-checked', 'true');
+ const recs = (await getData(page, '/api/recordings', 'recordings')).filter((x) => x.player_id === FIXTURES.playerLeft);
+ await expect(page.locator('a.cc-match-row')).toHaveCount(recs.length);
+ if (recs.length) {
+ const trend = page.locator('section').filter({ has: page.getByRole('heading', { name: 'Recent accuracy', exact: true }) });
+ const bars = trend.locator('a[href^="/recordings/"] div[style*="height:"]');
+ await expect(bars).toHaveCount(Math.min(5, recs.length));
+ const heights = await bars.evaluateAll((els) => els.map((el) => el.getBoundingClientRect().height));
+ assert(heights.every((h) => h > 0), `Trend bars have zero height: ${heights}`);
+ note(r, { recordingCount: recs.length, barHeights: heights });
+ } else {
+ await expect(page.getByText('No recordings yet.', { exact: true })).toBeVisible();
+ note(r, 'No assigned recordings; explicit empty recording history shown.');
+ }
+ }),
+ check('PLY-04', 'Handedness changes do not persist or restore.', 'Aileena switches Left then Right via PATCH 200 and ends right-handed.', async (page, r, shared) => {
+ await visit(page, `/players/${FIXTURES.playerRight}`, r);
+ const path = `/api/players/${FIXTURES.playerRight}`;
+ const original = await remember(shared, page, path, 'player', ['handedness']);
+ assert(original.handedness === 'right', `Fixture drift: Aileena originally ${original.handedness}; refusing to assume right.`);
+ try {
+ for (const [name, value] of [['Left-handed', 'left'], ['Right-handed', 'right']]) {
+ const control = page.getByRole('radiogroup', { name: 'Player handedness' }).getByRole('radio', { name, exact: true });
+ const res = await actionResponse(page, path, 'PATCH', () => control.click());
+ assert(res.status() === 200 && res.request().postDataJSON().handedness === value, `PATCH ${value} returned ${res.status()}`);
+ await expect(control).toHaveAttribute('aria-checked', 'true');
+ assert((await getData(page, path, 'player')).handedness === value, `GET did not persist ${value}`);
+ }
+ } finally { await restore(page, path, 'player', original); }
+ }),
+ check('PLY-05', 'An unknown player has no friendly recovery state.', 'Player not found, Back to roster and zero console/page errors.', async (page, r) => {
+ const data = await visit(page, `/players/${BOGUS}`, r);
+ await expect(page.getByText('Player not found.', { exact: true })).toBeVisible();
+ await expect(page.locator('main a[href="/players"]').filter({ hasText: 'Back to roster' })).toBeVisible();
+ note(r, { text: await page.locator('main').innerText(), errors: data.errors });
+ assert(data.errors.length === 0, data.errors.join('; '));
+ }),
+ check('PLY-06', 'Player list or profile overflows or logs errors.', 'Both list and Kaia profile pass all four viewports.', (page, r) => viewportChecks(page, r, ['/players', `/players/${FIXTURES.playerLeft}`])),
+];
+export default async function (args) { await runModule(args, checks, area, { drifts }); }
diff --git a/frontend/web/scripts/audit/functional/checks/06-upload-validation.mjs b/frontend/web/scripts/audit/functional/checks/06-upload-validation.mjs
new file mode 100644
index 00000000..4f742176
--- /dev/null
+++ b/frontend/web/scripts/audit/functional/checks/06-upload-validation.mjs
@@ -0,0 +1,108 @@
+import { execFile } from 'node:child_process';
+import { promisify } from 'node:util';
+import { mkdir } from 'node:fs/promises';
+import { TMP, FIXTURES, BOGUS, check, assert, expect, note, probe, visit, getData, apiJson, jsonInit, remember, restore, viewportChecks, runModule } from '../lib.mjs';
+
+export const area = 'upload';
+const fileInput = 'input[aria-label="Recording file input"]';
+const drifts = [{ id: 'DRIFT-UPL', evidence: 'app/upload/page.tsx: [data-state="idle"] stages a file; Upload and process calls the hook duration probe. Dropzone has no rejection callback and the idle pane does not render hook error. Player selector id=player includes year suffixes.', expected: 'Plan implies duration validation on selection and visible type/duration error text.' }];
+async function noUploadRequest(page, r, action) {
+ const requests = [];
+ const listener = (req) => { if (new URL(req.url()).pathname === '/api/create-upload') requests.push(req.method()); };
+ const block = (route) => route.abort('blockedbyclient');
+ // A failed negative test must not create an unlabelled production recording.
+ await page.route('**/api/create-upload', block);
+ page.on('request', listener);
+ try { await action(); await page.waitForTimeout(1500); }
+ finally {
+ page.off('request', listener); await page.unroute('**/api/create-upload', block);
+ note(r, { createUploadRequests: requests });
+ assert(requests.length === 0, 'Validation unexpectedly sent /api/create-upload');
+ }
+}
+async function validationCases(page, r) {
+ const rec = `/api/recordings/${FIXTURES.recordingAssigned}`;
+ const player = `/api/players/${FIXTURES.playerRight}`;
+ const cases = [
+ ['/api/create-upload', 'POST', { filename: 'x.txt' }, 400, /Invalid file type/i],
+ ['/api/trigger-process', 'POST', {}, 400, /Missing match_id/],
+ ['/api/trigger-process', 'POST', { match_id: BOGUS }, 403],
+ ['/api/status', 'GET', null, 400, /missing match_id/],
+ [`/api/status?match_id=${BOGUS}`, 'GET', null, 404],
+ [rec, 'PATCH', { name: '' }, 400],
+ [rec, 'PATCH', { name: 'a'.repeat(101) }, 400],
+ [rec, 'PATCH', {}, 400, /Nothing to update/],
+ [rec, 'PATCH', { notes: 'bad' }, 400],
+ [player, 'PATCH', { name: '' }, 400],
+ [player, 'PATCH', { photo_url: 'http://x' }, 400],
+ ['/api/proxy-image?url=http://example.com/a.png', 'GET', null, 403],
+ ['/api/proxy-image?url=https://evil.example/a.png', 'GET', null, 403],
+ [`/api/recordings/${BOGUS}`, 'GET', null, 404],
+ ];
+ for (const [path, method, body, status, error] of cases) await probe(page, r, `${method}-${path}-${JSON.stringify(body)}`, async () => {
+ const res = await apiJson(page, path, method === 'GET' ? {} : jsonInit(method, body));
+ note(r, { path, method, request: body, status: res.status, body: res.body, parseError: res.parseError });
+ assert(res.status === status, `Expected ${status}, observed ${res.status}`);
+ assert(!res.parseError && typeof res.body?.error === 'string', 'Expected JSON error response');
+ if (error) assert(error.test(res.body.error), `Unexpected error: ${res.body.error}`);
+ });
+}
+
+export const checks = [
+ check('UPL-01', 'Upload form is missing player choices, metadata or size guidance.', 'Dropzone, name input, Unknown / not assigned plus nine API players, MP4/MOV/AVI max 500 MB.', async (page, r) => {
+ await visit(page, '/upload', r);
+ await expect(page.locator('[data-state="idle"]')).toBeVisible();
+ await expect(page.locator(fileInput)).toHaveCount(1);
+ await expect(page.getByPlaceholder('e.g. Lin vs Stanford · Set 1')).toBeVisible();
+ await expect(page.getByText('MP4 · MOV · AVI · max 500 MB', { exact: true })).toBeVisible();
+ const players = await getData(page, '/api/players', 'players');
+ await expect(page.locator('select#player option')).toHaveCount(10);
+ assert(players.length === 9, `Expected nine players, got ${players.length}`);
+ const options = await page.locator('select#player option').evaluateAll((els) => els.map((el) => ({ value: el.value, text: el.textContent })));
+ note(r, { options });
+ assert(options[0].value === '' && options[0].text === 'Unknown / not assigned', 'Unassigned choice missing');
+ for (const player of players) assert(options.some((option) => option.value === player.id && option.text.includes(player.name)), `Missing ${player.name}`);
+ }),
+ check('UPL-02', 'Selecting a non-video file gives no rejection message or starts an upload.', 'Small .txt rejected visibly and no create-upload request.', async (page, r) => {
+ await visit(page, '/upload', r);
+ await noUploadRequest(page, r, async () => {
+ await page.locator(fileInput).setInputFiles({ name: 'audit-invalid.txt', mimeType: 'text/plain', buffer: Buffer.from('not a video') });
+ await expect(page.locator('main').getByText(/not a video we can read|MP4, MOV, or AVI/i)).toBeVisible();
+ });
+ }),
+ check('UPL-03', 'A recording over fifteen minutes is not visibly rejected before upload.', '16-minute generated video returns idle with please trim or split and no create-upload.', async (page, r) => {
+ await mkdir(TMP, { recursive: true });
+ const long = `${TMP}/long.mp4`;
+ await promisify(execFile)('ffmpeg', ['-f', 'lavfi', '-i', 'color=c=black:s=64x64:r=1:d=960', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-y', long], { timeout: 60000 });
+ await visit(page, '/upload', r);
+ await noUploadRequest(page, r, async () => {
+ await page.locator(fileInput).setInputFiles(long);
+ await page.getByRole('button', { name: 'Upload and process' }).click();
+ await expect(page.getByText(/15 minutes or shorter/i)).toBeVisible({ timeout: 20000 });
+ await expect(page.locator('[data-state="idle"]')).toBeVisible();
+ note(r, await page.locator('main').innerText());
+ });
+ }),
+ check('UPL-04', 'API boundary validation accepts invalid input or returns the wrong error.', 'Fourteen malformed/unauthorized API calls return their specified JSON 400/403/404 errors.', async (page, r, shared) => {
+ await visit(page, '/upload', r);
+ const recPath = `/api/recordings/${FIXTURES.recordingAssigned}`;
+ const playerPath = `/api/players/${FIXTURES.playerRight}`;
+ const recOriginal = await remember(shared, page, recPath, 'recording', ['name', 'notes']);
+ const playerOriginal = await remember(shared, page, playerPath, 'player', ['name', 'photo_url']);
+ try { await validationCases(page, r); }
+ finally {
+ await probe(page, r, 'restore recording after validation', () => restore(page, recPath, 'recording', recOriginal));
+ await probe(page, r, 'restore player after validation', () => restore(page, playerPath, 'player', playerOriginal));
+ }
+ }),
+ check('UPL-05', 'Upload controls are clipped on phone or portrait iPad.', 'Dropzone and staged Upload and process control are reachable without horizontal overflow at 390/820.', (page, r) => viewportChecks(page, r, ['/upload'], { widths: [[390, 844], [820, 1180]], extra: async (p) => {
+ await expect(p.locator('[data-state="idle"]')).toBeVisible();
+ await p.locator(fileInput).setInputFiles(FIXTURES.clip);
+ const button = p.getByRole('button', { name: 'Upload and process' });
+ await expect(button).toBeVisible(); await button.click({ trial: true });
+ const { overflowPx, shot } = await import('../lib.mjs');
+ assert(await overflowPx(p) === 0, 'Staged upload state overflows');
+ await shot(p, r, `${r.id}-staged`);
+ } })),
+];
+export default async function (args) { await runModule(args, checks, area, { drifts }); }
diff --git a/frontend/web/scripts/audit/functional/checks/07-upload-e2e.mjs b/frontend/web/scripts/audit/functional/checks/07-upload-e2e.mjs
new file mode 100644
index 00000000..3535cb90
--- /dev/null
+++ b/frontend/web/scripts/audit/functional/checks/07-upload-e2e.mjs
@@ -0,0 +1,172 @@
+import { FIXTURES, QA_NAME, DOTS, RECORDING_VIDEO, PROCESS_TIMEOUT, LONG_CHECK_TIMEOUT, ProcessingTimeout, check, assert, expect, note, visit, getData, actionResponse, recordingRow, videoReady, shot, pollDone, navigateClick, runModule } from '../lib.mjs';
+
+export const area = 'pipeline';
+const drifts = [{ id: 'DRIFT-E2E', evidence: 'app/upload/page.tsx: [data-state] exposes idle/uploading/processing/done; initial processing headline is Loading the recording., backend stage is shown below it. VizPanel.tsx filters unknown/off-court shots, so dots need not equal raw shots.length. TeamStrip has no Last recording metric; roster cards show latest dates.', expected: 'Plan names Starting analysis., assumes one dot per shot, and expects a dashboard Last recording metric.' }];
+function dependency(shared, done = false) {
+ if (shared.skipUpload) return { skip: '--skip-upload supplied; no upload or reprocess attempted.' };
+ if (shared.resumed && !done) return { skip: `Resumed: QA recording ${shared.qaRecordingId} was uploaded and processed by the previous run.` };
+ if (!shared.qaRecordingId) return { skip: 'E2E-01 did not create an audit recording.' };
+ if (done && !shared.e2eDone) return { skip: 'E2E-03 did not complete successfully.' };
+ return null;
+}
+function progress(shared, body) {
+ assert(Number.isFinite(body.progress) && body.progress >= 0 && body.progress <= 1, `Invalid backend progress ${body.progress}`);
+ const previous = shared.progressSamples.at(-1);
+ shared.progressSamples.push({ elapsedMs: Date.now() - shared.uploadStarted, progress: body.progress, stage: body.stage, status: body.status });
+ assert(!previous || body.progress >= previous.progress, `Progress regressed from ${previous?.progress} to ${body.progress}`);
+}
+function plottedShots(shots) {
+ // Source drift: VizPanel.buildShotMapDots drops unknowns/missing coords and distant noise.
+ return shots.filter((s) => {
+ const y = s.court_y > 39 ? 78 - s.court_y : s.court_y;
+ return s.court_x != null && s.court_y != null && s.stroke !== 'unknown' && y >= -6 && y <= 45 && s.court_x >= -4 && s.court_x <= 31;
+ });
+}
+
+export const checks = [
+ check('E2E-01', 'A real upload cannot create a recording and promptly start processing.', 'QA pilot audit 2026-09-09 assigned to Aileena; create 200 with ID; trigger 200/202 within 10s.', async (page, r, shared) => {
+ if (shared.skipUpload) return { skip: '--skip-upload supplied.' };
+ if (shared.resumed) return { skip: `Resumed: QA recording ${shared.qaRecordingId} already exists; no second upload.` };
+ await visit(page, '/upload', r);
+ await page.getByPlaceholder('e.g. Lin vs Stanford · Set 1').fill(QA_NAME);
+ await page.locator('select#player').selectOption(FIXTURES.playerRight);
+ await page.getByLabel('Recording file input').setInputFiles(FIXTURES.clip);
+ shared.uploadStarted = Date.now();
+ shared.progressSamples = [];
+ let triggerStarted;
+ const listener = (req) => { if (new URL(req.url()).pathname === '/api/trigger-process' && req.method() === 'POST') triggerStarted = Date.now(); };
+ page.on('request', listener);
+ const trigger = page.waitForResponse((res) => new URL(res.url()).pathname === '/api/trigger-process' && res.request().method() === 'POST', { timeout: 120000 }).then((response) => ({ response, ended: Date.now() }), (error) => ({ error }));
+ try {
+ const created = await actionResponse(page, '/api/create-upload', 'POST', () => page.getByRole('button', { name: 'Upload and process' }).click(), 30000);
+ const body = await created.json();
+ // Capture immediately: cleanup must know the ID even when trigger or a later assertion fails.
+ if (typeof body.match_id === 'string' && /^[0-9a-f-]{36}$/i.test(body.match_id)) {
+ shared.qaRecordingId = body.match_id;
+ shared.createdRecordingIds.push(body.match_id);
+ }
+ note(r, { createStatus: created.status(), match_id: body.match_id, name: QA_NAME, playerId: FIXTURES.playerRight });
+ assert(created.status() === 200 && shared.qaRecordingId, `Create failed: ${created.status()} ${body.error ?? 'no match_id'}`);
+ const payload = created.request().postDataJSON();
+ assert(payload.name === QA_NAME && payload.player_id === FIXTURES.playerRight, 'Upload metadata was not submitted correctly');
+ const started = await trigger;
+ if (started.error) throw started.error;
+ const elapsed = started.ended - triggerStarted;
+ shared.triggered = [200, 202].includes(started.response.status());
+ note(r, { triggerStatus: started.response.status(), triggerMs: elapsed });
+ if (!shared.triggered) assert(false, `Trigger failed: ${started.response.status()} ${(await started.response.text().catch(() => '')).slice(0, 500)}`); // body read only on failure: Playwright never resolves this body on success and hung run 1 for 50 minutes
+ if (elapsed > 10000) r.severity = 'P1';
+ assert(elapsed <= 10000, `Trigger took ${elapsed}ms; fire-and-forget regression`);
+ } finally { page.off('request', listener); }
+ }, 'P0'),
+ check('E2E-02', 'Processing does not display a backend stage or progress regresses.', 'Within 90s an actual backend stage appears in the processing pane; progress is monotonic.', async (page, r, shared) => {
+ const skip = dependency(shared); if (skip) return skip;
+ assert(shared.triggered, 'Upload trigger did not succeed');
+ const start = Date.now();
+ let stageSeen = false;
+ while (Date.now() - start < 90000) {
+ const body = await getData(page, `/api/status?match_id=${shared.qaRecordingId}`);
+ progress(shared, body);
+ assert(body.status !== 'failed', `Pipeline failed: ${body.error}`);
+ if (body.stage && body.stage !== 'Starting analysis.') {
+ const pane = page.locator('[data-state="processing"], [data-state="done"]');
+ await expect(pane).toBeVisible();
+ if (body.status !== 'done') await expect(pane).toContainText(body.stage, { timeout: 15000 });
+ else await expect(page.locator('[data-state="done"]')).toBeVisible();
+ await expect(pane).not.toContainText('Starting analysis.');
+ await shot(page, r); stageSeen = true; break;
+ }
+ await page.waitForTimeout(1500);
+ }
+ note(r, { samples: shared.progressSamples });
+ assert(stageSeen, 'No backend stage appeared in the UI within 90 seconds');
+ }, 'P0'),
+ check('E2E-03', 'The real recording does not complete within twenty minutes.', 'Status reaches done; failure error and total processing seconds recorded.', async (page, r, shared) => {
+ const skip = dependency(shared); if (skip) return skip;
+ assert(shared.triggered, 'Cannot await processing: trigger failed');
+ const remaining = PROCESS_TIMEOUT - (Date.now() - shared.uploadStarted);
+ assert(remaining > 0, 'Twenty-minute completion deadline already elapsed');
+ const body = await pollDone(page, shared.qaRecordingId, r, remaining, (value) => progress(shared, value));
+ assert(body.status === 'done', 'Status did not reach done');
+ shared.e2eDone = true;
+ shared.processingSeconds = (Date.now() - shared.uploadStarted) / 1000;
+ note(r, { totalProcessingSeconds: shared.processingSeconds, samples: shared.progressSamples });
+ }, 'P0'),
+ check('E2E-04', 'The upload done state does not open the new recording.', 'Open recording navigates to the created ID.', async (page, r, shared) => {
+ const skip = dependency(shared, true); if (skip) return skip;
+ await expect(page.locator('[data-state="done"]')).toBeVisible({ timeout: 20000 });
+ await navigateClick(page, page.getByRole('button', { name: 'Open recording', exact: true }), `/recordings/${shared.qaRecordingId}`);
+ await expect(page.locator('main h1')).toHaveText(QA_NAME);
+ }, 'P0'),
+ check('E2E-05', 'The new recording lacks playable media or trustworthy analysis.', 'Video, audio evidence, real dots/rallies/insights/report and nonzero core stats agree with the API.', async (page, r, shared) => {
+ const skip = dependency(shared, true); if (skip) return skip;
+ await visit(page, `/recordings/${shared.qaRecordingId}`, r); await videoReady(page);
+ const data = await getData(page, `/api/recordings/${shared.qaRecordingId}`, 'recording');
+ await page.getByLabel('Play', { exact: true }).last().click();
+ await expect.poll(() => page.locator(RECORDING_VIDEO).evaluate((v) => v.currentTime), { timeout: 10000 }).toBeGreaterThan(0.5);
+ const audio = await page.locator(RECORDING_VIDEO).evaluate((v) => ({ mozHasAudio: v.mozHasAudio ?? null, decodedBytes: v.webkitAudioDecodedByteCount ?? null, tracks: v.audioTracks?.length ?? null }));
+ await page.locator(RECORDING_VIDEO).evaluate((v) => v.pause());
+ const hasAudio = audio.mozHasAudio === true || audio.decodedBytes > 0 || audio.tracks > 0;
+ note(r, { audio, audioResult: hasAudio ? 'present' : 'undeterminable with Chromium media properties' });
+ if (audio.mozHasAudio === false || audio.tracks === 0) assert(hasAudio, 'Media explicitly reports no audio track');
+ const dots = await page.locator(DOTS).count();
+ const rows = await page.locator('tr[role="button"]').count();
+ note(r, { rawShots: data.shots.length, plottedShots: plottedShots(data.shots).length, dots, rallyRows: rows, rallySummary: data.rallySummary });
+ assert(dots >= 1 && dots === plottedShots(data.shots).length, 'Rendered dots disagree with plot-eligible API shots');
+ assert(rows >= 1 && data.rallySummary?.total >= 1, 'No rallies in output');
+ await expect(page.getByRole('heading', { name: 'Errors and net play' })).toBeVisible();
+ assert(data.positionSummary?.n_frames > 0 && data.errorSummary && data.netApproachSummary, 'Coach insights missing API data');
+ const report = page.getByLabel('Scouting report', { exact: true });
+ await expect(report).toBeVisible();
+ const reportText = await report.innerText(); note(r, { report: reportText });
+ assert(reportText.length > 100 && !/\bP[12]\b|—|\*\*|##/.test(reportText), 'Scouting report contains jargon/raw markdown or insufficient notes');
+ await expect(report.getByRole('heading', { name: 'Coaching cue' })).toBeVisible();
+ assert(data.shots.length > 0 && data.inBoundsBounces > 0 && data.rallySummary.avg_length > 0, 'Expected core stats are zero');
+ const totals = page.locator('.cc-card').filter({ has: page.getByRole('heading', { name: 'Totals', exact: true }) });
+ await expect(totals).toContainText(`${data.shots.length} shots`);
+ await expect(totals.locator('.cc-stat-tile').filter({ hasText: 'Avg rally length' })).toContainText(data.rallySummary.avg_length.toFixed(1));
+ await expect(totals.locator('.cc-stat-tile')).toHaveCount(4);
+ }, 'P0'),
+ check('E2E-06', 'The completed recording is missing from the library, player history or dashboard date.', 'New recording is first, assigned to Aileena, in her history and reflected in Last recording today.', async (page, r, shared) => {
+ const skip = dependency(shared, true); if (skip) return skip;
+ await visit(page, '/recordings', r);
+ const data = await getData(page, `/api/recordings/${shared.qaRecordingId}`, 'recording');
+ const list = await getData(page, '/api/recordings', 'recordings');
+ assert(list[0]?.id === shared.qaRecordingId && list[0].playerName === 'Aileena Hu', 'Library API order/assignment incorrect');
+ await expect(recordingRow(page, data)).toBeVisible();
+ await expect(page.locator('.cc-match-row[role="button"]').first()).toContainText(QA_NAME);
+ const { probe } = await import('../lib.mjs');
+ await probe(page, r, 'visible player label', () => expect(recordingRow(page, data)).toContainText('Aileena Hu'));
+ await visit(page, `/players/${FIXTURES.playerRight}`, r);
+ await expect(page.locator(`a.cc-match-row[href="/recordings/${shared.qaRecordingId}"]`)).toBeVisible();
+ await visit(page, '/', r);
+ const today = await page.evaluate(() => new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric' }));
+ const player = page.locator(`[aria-label="Roster"] a[href="/players/${FIXTURES.playerRight}"]`);
+ await expect(player).toContainText(`Last recording · ${today}`);
+ note(r, { recordingId: shared.qaRecordingId, player: 'Aileena Hu', dashboardDate: today });
+ }),
+ check('E2E-07', 'A fresh recording cannot be reprocessed to completion.', 'One reprocess returns 200, shows processing, then completes within twenty minutes.', async (page, r, shared) => {
+ const skip = dependency(shared, true); if (skip) return skip;
+ await visit(page, `/recordings/${shared.qaRecordingId}`, r);
+ const button = page.getByRole('button', { name: 'Reprocess', exact: true });
+ await button.click(); await expect(page.getByText('Reprocess this recording?')).toBeVisible();
+ const started = Date.now();
+ const res = await actionResponse(page, '/api/trigger-process', 'POST', () => button.click(), 30000);
+ note(r, { status: res.status(), triggerMs: Date.now() - started });
+ if (res.status() !== 200) assert(false, `Fresh reprocess returned ${res.status()}: ${(await res.text().catch(() => '')).slice(0, 500)}`);
+ await expect(page.getByText('Analyzing your recording.', { exact: true })).toBeVisible();
+ const body = await getData(page, `/api/status?match_id=${shared.qaRecordingId}`);
+ assert(['pending', 'processing'].includes(body.status), `Did not flip to processing: ${body.status}`);
+ try { await pollDone(page, shared.qaRecordingId, r, PROCESS_TIMEOUT - (Date.now() - started)); }
+ catch (e) {
+ if (!(e instanceof ProcessingTimeout)) throw e;
+ return { skip: `Reprocess completion verification exceeded its twenty-minute budget after ${(Date.now() - started) / 1000}s; reprocess was submitted once, last status remains pending/processing.` };
+ }
+ await expect(page.locator('main h1')).toHaveText(QA_NAME, { timeout: 20000 });
+ shared.reprocessed = true;
+ note(r, { reprocessSeconds: (Date.now() - started) / 1000 });
+ }),
+];
+// Processing waits legitimately run up to twenty minutes; everything else keeps the default ceiling.
+for (const c of checks) if (c.id === 'E2E-03' || c.id === 'E2E-07') c.timeoutMs = LONG_CHECK_TIMEOUT;
+export default async function (args) { await runModule(args, checks, area, { drifts: args.shared.skipUpload || args.shared.resumed ? [] : drifts }); }
diff --git a/frontend/web/scripts/audit/functional/checks/08-profile-settings.mjs b/frontend/web/scripts/audit/functional/checks/08-profile-settings.mjs
new file mode 100644
index 00000000..1dca9351
--- /dev/null
+++ b/frontend/web/scripts/audit/functional/checks/08-profile-settings.mjs
@@ -0,0 +1,58 @@
+import { readFile } from 'node:fs/promises';
+import { BASE, check, assert, expect, note, visit, getData, viewportChecks, runModule } from '../lib.mjs';
+
+export const area = 'settings';
+export async function saveDisplayName(page, value) {
+ await page.getByPlaceholder('Your name', { exact: true }).fill(value);
+ await page.getByRole('button', { name: 'Save', exact: true }).click();
+ await expect(page.getByText('Display name updated.', { exact: true })).toBeVisible({ timeout: 15000 });
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await expect(page.getByPlaceholder('Your name', { exact: true })).toHaveValue(value, { timeout: 15000 });
+}
+
+export const checks = [
+ { ...check('PRF-01', 'Profile career statistics disagree with the dashboard or quick actions are broken.', 'Four API-backed numeric career stats and 200 upload/recordings/settings links.', async (page, r) => {
+ await visit(page, '/profile', r);
+ const summary = await getData(page, '/api/dashboard/summary');
+ const expected = { 'Recordings analyzed': summary.totals.done, 'Total shots': summary.tennisStats.totalShots, 'Total bounces': summary.tennisStats.totalBounces, 'Total rallies': summary.tennisStats.totalRallies };
+ const stats = page.getByLabel('Career stats');
+ await expect(stats.locator('.cc-stat-tile')).toHaveCount(4);
+ for (const [label, value] of Object.entries(expected)) {
+ assert(Number.isFinite(value), `API ${label} is not numeric`);
+ const tile = stats.locator('.cc-stat-tile').filter({ hasText: label });
+ await expect(tile.locator(':scope > div').last()).toHaveText(value.toLocaleString('en-US'));
+ }
+ note(r, { stats: expected });
+ for (const path of ['/upload', '/recordings', '/settings']) {
+ const link = page.getByLabel('Quick actions').locator(`a[href="${path}"]`);
+ await expect(link).toBeVisible();
+ const res = await page.request.get(`${BASE}${path}`);
+ assert(res.status() === 200 && new URL(res.url()).pathname === path, `${path}: ${res.status()} ${res.url()}`);
+ }
+ }), area: 'profile' },
+ check('SET-01', 'Display-name edits fail to persist or restore.', 'Prefilled name; save QA suffix with success, reload persistence, then restore and verify.', async (page, r, shared) => {
+ await visit(page, '/settings', r);
+ const input = page.getByPlaceholder('Your name', { exact: true });
+ await expect(input).not.toHaveValue('');
+ const original = await input.inputValue();
+ shared.displayNameOriginal ??= original;
+ note(r, { originalName: original });
+ try { await saveDisplayName(page, `${original} QA`); await saveDisplayName(page, original); }
+ finally {
+ await visit(page, '/settings', r);
+ if (await input.inputValue() !== original) await saveDisplayName(page, original);
+ }
+ }),
+ check('SET-02', 'Sign out is absent, disabled, unfocusable or unwired.', 'Enabled focusable button; source wiring to confirmation and signOut verified without clicking.', async (page, r) => {
+ await visit(page, '/settings', r);
+ const button = page.getByRole('button', { name: 'Sign out', exact: true });
+ await expect(button).toBeVisible(); await expect(button).toBeEnabled();
+ assert(await button.evaluate((el) => el.tagName === 'BUTTON' && el.tabIndex >= 0), 'Sign out is not a focusable button');
+ await button.focus(); await expect(button).toBeFocused();
+ const source = await readFile('app/settings/page.tsx', 'utf8');
+ assert(/onClick=\{\(\) => setConfirmSignOut\(true\)\}/.test(source) && /onClick=\{\(\) => signOut\(\)\}/.test(source), 'Source signOut/confirmation wiring missing');
+ note(r, 'Enabled BUTTON focused; app/settings/page.tsx wires confirmation then signOut(). Neither button clicked.');
+ }),
+ check('SET-03', 'Profile or settings overflows or logs errors at a supported width.', 'Both account pages have zero overflow/errors at all four widths.', (page, r) => viewportChecks(page, r, ['/profile', '/settings'])),
+];
+export default async function (args) { await runModule(args, checks, area); }
diff --git a/frontend/web/scripts/audit/functional/checks/09-onboarding-readonly.mjs b/frontend/web/scripts/audit/functional/checks/09-onboarding-readonly.mjs
new file mode 100644
index 00000000..c85e2eeb
--- /dev/null
+++ b/frontend/web/scripts/audit/functional/checks/09-onboarding-readonly.mjs
@@ -0,0 +1,30 @@
+import { readFile } from 'node:fs/promises';
+import { check, assert, expect, note, visit, getData, runModule } from '../lib.mjs';
+
+export const area = 'onboarding';
+export const checks = [
+ check('ONB-01', 'Onboarding templates are unavailable or missing UC Davis.', 'Authenticated GET 200 returns templates including uc-davis; no POST.', async (page, r) => {
+ await visit(page, '/', r);
+ const templates = await getData(page, '/api/onboarding', 'templates');
+ note(r, { keys: templates.map((template) => template.key) });
+ assert(Array.isArray(templates) && templates.some((template) => template.key === 'uc-davis'), 'uc-davis template missing');
+ }),
+ check('ONB-02', 'An onboarded coach can duplicate a roster without a warning.', 'Already-onboarded users redirect or receive an explicit duplication warning before template selection.', async (page, r) => {
+ const data = await visit(page, '/onboarding', r);
+ note(r, { landed: data.landed, text: await page.locator('main').innerText() });
+ if (data.landed !== '/onboarding') {
+ assert(data.landed === '/', `Unexpected onboarded redirect ${data.landed}`);
+ await expect(page.getByLabel('Team metrics')).toBeVisible();
+ return;
+ }
+ const picker = page.getByRole('button', { name: 'Use UC Davis', exact: true });
+ await expect(picker).toBeVisible();
+ const source = await readFile('app/onboarding/page.tsx', 'utf8');
+ const directClone = /onClick=\{\(\) => pick\('uc-davis'\)\}/.test(source) && /method: 'POST'/.test(source);
+ const text = await page.locator('main').innerText();
+ const warns = /duplicat|already.*roster|replace.*roster|existing players|add.*another.*roster/i.test(text);
+ note(r, { directClone, warns, pickerEnabled: await picker.isEnabled(), source: 'app/onboarding/page.tsx pick() POST /api/onboarding' });
+ assert(!directClone || warns || !await picker.isEnabled(), 'Onboarded user can click Use UC Davis to POST/cloned roster immediately, with no duplication warning. No template clicked.');
+ }),
+];
+export default async function (args) { await runModule(args, checks, area); }
diff --git a/frontend/web/scripts/audit/functional/checks/10-a11y.mjs b/frontend/web/scripts/audit/functional/checks/10-a11y.mjs
new file mode 100644
index 00000000..70f6efb1
--- /dev/null
+++ b/frontend/web/scripts/audit/functional/checks/10-a11y.mjs
@@ -0,0 +1,76 @@
+import { REVIEW_PAGES, check, assert, expect, note, probe, visit, runModule } from '../lib.mjs';
+
+export const area = 'a11y';
+async function routes(page, r, fn) {
+ for (const path of REVIEW_PAGES) await probe(page, r, path, async () => { await visit(page, path, r); await fn(path); });
+}
+async function focusAppearance(page) {
+ return page.evaluate(() => {
+ const el = document.activeElement;
+ if (!el || el === document.body || el === document.documentElement) return { valid: false, reason: 'No document control focused' };
+ const sample = (node) => {
+ const css = getComputedStyle(node);
+ return { outlineStyle: css.outlineStyle, outlineWidth: parseFloat(css.outlineWidth), outlineColor: css.outlineColor, shadow: css.boxShadow };
+ };
+ const nodes = [el, el.parentElement, el.parentElement?.parentElement].filter(Boolean);
+ const focused = nodes.map(sample);
+ el.blur();
+ const blurred = nodes.map(sample);
+ el.focus();
+ const ring = focused.some((css, i) => (css.outlineStyle !== 'none' && css.outlineWidth > 0 && css.outlineColor !== 'rgba(0, 0, 0, 0)' && css.outlineColor !== 'transparent') || (css.shadow !== 'none' && css.shadow !== blurred[i].shadow));
+ const rect = el.getBoundingClientRect();
+ return { valid: ring && rect.width > 0 && rect.height > 0 && rect.bottom > 0 && rect.top < innerHeight, element: el.outerHTML.slice(0, 350), focused, blurred };
+ });
+}
+
+export const checks = [
+ check('A11Y-01', 'Pages have missing/duplicate main headings or skipped heading levels.', 'Exactly one h1 and no downward jump greater than one level on all six pages.', async (page, r) => {
+ await routes(page, r, async (path) => {
+ await expect(page.locator('h1')).toHaveCount(1);
+ const headings = await page.locator('h1,h2,h3,h4,h5,h6').evaluateAll((els) => els.filter((el) => el.checkVisibility()).map((el) => ({ level: Number(el.tagName[1]), text: el.textContent.trim() })));
+ const skips = headings.filter((heading, i) => i > 0 && heading.level > headings[i - 1].level + 1);
+ note(r, { path, headings, skips });
+ assert(headings[0]?.level === 1 && skips.length === 0, `Heading sequence skips levels: ${JSON.stringify(headings)}`);
+ });
+ }, 'P2'),
+ check('A11Y-02', 'Images or interactive controls lack accessible names.', 'Every image has alt; visible buttons/links have a nonempty computed accessible name.', async (page, r) => {
+ await routes(page, r, async (path) => {
+ const missingAlt = await page.locator('img:not([alt])').evaluateAll((els) => els.map((el) => el.outerHTML.slice(0, 250)));
+ const controls = page.locator('button:visible,a:visible');
+ const unnamed = [];
+ for (let i = 0; i < await controls.count(); i++) {
+ const control = controls.nth(i);
+ try { await expect(control).toHaveAccessibleName(/\S/, { timeout: 100 }); }
+ catch (e) { unnamed.push({ element: await control.evaluate((el) => el.outerHTML.slice(0, 250)), error: e.message }); }
+ }
+ note(r, { path, checkedControls: await controls.count(), missingAlt, unnamed });
+ assert(!missingAlt.length && !unnamed.length, `${missingAlt.length} images missing alt; ${unnamed.length} unnamed controls`);
+ });
+ }, 'P2'),
+ check('A11Y-03', 'Keyboard focus is not visibly indicated.', 'First 25 tab stops on each page have a visible outline or changed box shadow.', async (page, r) => {
+ await routes(page, r, async (path) => {
+ const stops = [];
+ for (let i = 0; i < 25; i++) {
+ await page.keyboard.press('Tab');
+ const appearance = await focusAppearance(page);
+ stops.push({ tab: i + 1, ...appearance });
+ }
+ const bad = stops.filter((stop) => !stop.valid);
+ note(r, { path, checkedStops: stops.length, bad });
+ assert(stops.length === 25 && bad.length === 0, `Invisible focus on ${bad.length} of 25 stops`);
+ });
+ }, 'P2'),
+ check('A11Y-04', 'Pages lack document language or unique titles.', 'Nonempty html lang and a distinct nonempty title for every reviewed route.', async (page, r) => {
+ const seen = new Map();
+ await routes(page, r, async (path) => {
+ const lang = await page.locator('html').getAttribute('lang');
+ const title = (await page.title()).trim();
+ note(r, { path, lang, title });
+ assert(lang?.trim() && title, 'Missing lang/title');
+ const previous = seen.get(title);
+ seen.set(title, path);
+ assert(!previous, `Title "${title}" reused on ${previous} and ${path}`);
+ });
+ }, 'P2'),
+];
+export default async function (args) { await runModule(args, checks, area); }
diff --git a/frontend/web/scripts/audit/functional/checks/11-copy.mjs b/frontend/web/scripts/audit/functional/checks/11-copy.mjs
new file mode 100644
index 00000000..7312c7bc
--- /dev/null
+++ b/frontend/web/scripts/audit/functional/checks/11-copy.mjs
@@ -0,0 +1,50 @@
+import { REVIEW_PAGES, check, assert, note, probe, visit, visibleText, openAnon, runModule } from '../lib.mjs';
+
+export const area = 'copy';
+// The plan supplies no COPY ids. These seven stable ids cover every requested rule.
+const rules = [
+ ['COPY-01', 'UI copy contains em dashes.', 'No em dashes in visible copy.', ({ text }) => text.includes('—')],
+ ['COPY-02', 'UI uses noncanonical words for recordings.', 'Use recording, with actual match-play terminology and source filenames distinguished in evidence.', ({ text }) => {
+ // Whitelist only source filenames, not an entire ancestor containing one.
+ if (/St\s*Mary|StMarys/i.test(text) && (/\.(mp4|mov|avi)\b/i.test(text) || /court\s*2/i.test(text))) return false;
+ if (/^\/api\//.test(text)) return false;
+ if (/\b(?:clip(?:s)?|film(?:s)?)\b/i.test(text)) return true;
+ // Authentication sessions and the verb "match" are not names for a recording.
+ const rest = text.replace(/(?:end|expired?|signed? out|revok\w*|auth\w*|device)[^.]*session(?:s)?[^.]*|session(?:s)?[^.]*(?:device|expired?|signed? out|revok\w*|auth\w*)/gi, '')
+ .replace(/\bmatch(?:es)? (?:your|those|the) filters\b|\bmatch (?:point|score|result|play)\b/gi, '');
+ return /\b(?:match(?:es)?|session(?:s)?)\b/i.test(rest);
+ }],
+ ['COPY-03', 'UI exposes tracker player abbreviations.', 'You / Opponent, never P1/P2/Opp.', ({ text }) => /\b(?:P1|P2|Opp)\b/.test(text)],
+ ['COPY-04', 'UI uses the obsolete serve label.', 'Serve/Overhead, never Serve / Smash.', ({ text }) => /Serve\s*\/\s*Smash/i.test(text)],
+ ['COPY-05', 'UI uses British analysis spelling.', 'American spelling: Analyzing.', ({ text }) => /\bAnalysing\b/i.test(text)],
+ ['COPY-06', 'UI uses italics outside the wordmark.', 'Only the wordmark may be italic.', ({ italic, wordmark }) => italic && !wordmark],
+ ['COPY-07', 'Visible text is smaller than thirteen pixels.', 'Painted UI text has computed font-size at least 13px.', ({ fontSize }) => fontSize < 13],
+];
+
+async function scan(page, r, predicate, shared) {
+ for (const path of REVIEW_PAGES) await probe(page, r, path, async () => {
+ // Cache observations, not conclusions, so all seven rules assess the same render.
+ if (!shared.copyPages?.[path]) {
+ await visit(page, path, r);
+ shared.copyPages ??= {};
+ shared.copyPages[path] = await visibleText(page);
+ } else await visit(page, path, r);
+ const bad = shared.copyPages[path].filter(predicate);
+ note(r, { path, snippets: bad });
+ assert(bad.length === 0, `${path}: ${bad.length} copy/style violations: ${bad.map((x) => x.text).join(' | ')}`);
+ });
+ // Never open public auth/landing pages using the authenticated session.
+ const { browser, ctx } = await openAnon();
+ const anon = await ctx.newPage();
+ try {
+ await probe(anon, r, 'landing', async () => {
+ await visit(anon, '/landing.html', r, false);
+ const bad = (await visibleText(anon)).filter(predicate);
+ note(r, { path: '/landing.html', snippets: bad });
+ assert(!bad.length, `/landing.html: ${bad.length} violations: ${bad.map((x) => x.text).join(' | ')}`);
+ });
+ } finally { await anon.close(); await browser.close(); }
+}
+
+export const checks = rules.map(([id, title, expected, predicate]) => check(id, title, expected, (page, r, shared) => scan(page, r, predicate, shared), 'P2'));
+export default async function (args) { await runModule(args, checks, area); }
diff --git a/frontend/web/scripts/audit/functional/checks/12-cleanup.mjs b/frontend/web/scripts/audit/functional/checks/12-cleanup.mjs
new file mode 100644
index 00000000..86513024
--- /dev/null
+++ b/frontend/web/scripts/audit/functional/checks/12-cleanup.mjs
@@ -0,0 +1,33 @@
+import { check, assert, expect, note, probe, visit, getData, deleteQaRecording, runModule } from '../lib.mjs';
+
+export const area = 'recordings';
+export const checks = [
+ check('CLN-01', 'The audit-created recording could not be safely removed.', 'Only this run’s QA-prefix recording is deleted via UI; DELETE 204, row absent and GET 404.', async (page, r, shared) => {
+ if (!shared.qaRecordingId) return { skip: 'This run created no recording; no existing recording will be deleted.' };
+ await visit(page, '/recordings', r);
+ await deleteQaRecording(page, shared, r);
+ note(r, `Removed only audit-created ID ${shared.qaRecordingId}.`);
+ shared.qaRecordingDeleted = true;
+ }),
+ check('CLN-02', 'Temporary audit edits were not fully restored.', 'Favorites, handedness, recording name, notes and display name equal their captured original values.', async (page, r, shared) => {
+ if (!shared.originals.length && shared.displayNameOriginal === undefined) return { skip: 'No mutation modules ran; no original-value snapshots were captured.' };
+ await visit(page, '/settings', r);
+ for (const original of shared.originals) await probe(page, r, original.path, async () => {
+ const current = await getData(page, original.path, original.key);
+ for (const [key, value] of Object.entries(original.values)) {
+ const equal = JSON.stringify(current[key]) === JSON.stringify(value);
+ note(r, { path: original.path, field: key, restored: equal });
+ assert(equal, `${original.path} ${key}: original ${JSON.stringify(value)}, now ${JSON.stringify(current[key])}`);
+ }
+ });
+ if (shared.displayNameOriginal !== undefined) await probe(page, r, 'display name', async () => {
+ await expect(page.getByPlaceholder('Your name', { exact: true })).toHaveValue(shared.displayNameOriginal);
+ note(r, 'Display name equals its original value after reloading settings.');
+ });
+ if (shared.qaPlayerId) {
+ note(r, `Permanent QA player ID: ${shared.qaPlayerId}; no player DELETE endpoint exists.`);
+ assert(false, 'Audit-created player cannot be removed because the application has no player DELETE endpoint.');
+ }
+ }),
+];
+export default async function (args) { await runModule(args, checks, area); }
diff --git a/frontend/web/scripts/audit/functional/lib.mjs b/frontend/web/scripts/audit/functional/lib.mjs
new file mode 100644
index 00000000..9fa3ef35
--- /dev/null
+++ b/frontend/web/scripts/audit/functional/lib.mjs
@@ -0,0 +1,403 @@
+import { chromium } from 'playwright';
+import { expect } from '@playwright/test';
+import { mkdir, writeFile } from 'node:fs/promises';
+
+export { expect };
+export const BASE = process.env.CC_AUDIT_BASE ?? 'https://courtcheck-rho.vercel.app';
+export const STORAGE = process.env.CC_AUDIT_STORAGE ?? 'scripts/audit/storageState.json'; // override for a re-domained copy (local builds)
+export const SHOTS = 'scripts/audit/screenshots/functional';
+export const TMP = '/private/tmp/courtcheck-audit';
+export const QA_PREFIX = 'QA pilot audit';
+export const QA_NAME = `${QA_PREFIX} 2026-09-09`;
+export const VIEWPORTS = { desktop: [1440, 900], ipadL: [1024, 768], ipadP: [820, 1180], phone: [390, 844] };
+export const FIXTURES = {
+ recordingAssigned: 'eeaac0eb-d0ee-46aa-975d-3826b2caa7f0',
+ recordingUnassigned: '7c98190b-4920-47a5-9e8b-ef5704a913a4',
+ playerRight: '306e89a0-a75a-4965-abc7-5344fb42c297',
+ playerLeft: '237515ce-c905-4754-99d7-9df8182c9ec9',
+ clip: '../../data/StMarys_Court2_Aileena_vs_StMarys.mp4',
+};
+export const BOGUS = '00000000-0000-0000-0000-000000000000';
+export const APP_PAGES = ['/', '/recordings', '/players', '/upload', '/profile', '/settings'];
+export const REVIEW_PAGES = ['/', '/recordings', `/recordings/${FIXTURES.recordingAssigned}`, `/players/${FIXTURES.playerLeft}`, '/upload', '/settings'];
+export const RECORDING_VIDEO = 'main .cc-video-hero video'; // Sidebar/wordmark also contain videos.
+export const ROWS = '.cc-match-row[role="button"]';
+export const DOTS = 'svg .shot-dot[tabindex="0"]';
+export const PROCESS_TIMEOUT = 20 * 60 * 1000;
+export const CHECK_TIMEOUT = 5 * 60 * 1000; // default per-check ceiling
+export const LONG_CHECK_TIMEOUT = PROCESS_TIMEOUT + 3 * 60 * 1000; // E2E processing waits
+const PAGE_TIMEOUT = 60000;
+const ACTION_TIMEOUT = 10000;
+const pageInfo = new WeakMap();
+const deletePermits = new WeakMap();
+
+export class SessionDied extends Error {}
+export class ProcessingTimeout extends Error {}
+export function assert(condition, message) { if (!condition) throw new Error(message); }
+export function note(r, value) {
+ r.evidence += `${r.evidence ? '\n' : ''}${typeof value === 'string' ? value : JSON.stringify(value)}`;
+}
+export function cleanTitle(name) { return name.replace(/\.[a-z0-9]+$/i, '').replace(/_/g, ' ').trim(); }
+export function check(id, title, expected, fn, severity = 'P1') { return { id, title, expected, fn, severity }; }
+
+export function checkSession(page) {
+ const info = pageInfo.get(page);
+ if (!info?.authed) return;
+ const path = page.url() === 'about:blank' ? '' : new URL(page.url()).pathname;
+ if (info.died || /^\/(landing|auth)(\/|\.|$)/.test(path)) {
+ throw new SessionDied(info.died || `Session expired: landed on ${path}`);
+ }
+}
+
+function watchPage(page, authed) {
+ const info = { authed, errors: [], failed: [], died: null };
+ pageInfo.set(page, info);
+ page.on('pageerror', (e) => info.errors.push(e.message));
+ page.on('console', (m) => { if (m.type() === 'error') info.errors.push(m.text()); });
+ page.on('framenavigated', (frame) => {
+ if (!authed || frame !== page.mainFrame() || frame.url() === 'about:blank') return;
+ const path = new URL(frame.url()).pathname;
+ if (/^\/(landing|auth)(\/|\.|$)/.test(path)) info.died = `Session expired: landed on ${path}`;
+ });
+ page.on('response', (res) => {
+ const url = new URL(res.url());
+ if (res.status() >= 400 && !url.pathname.startsWith('/_vercel/')) {
+ info.failed.push(`${res.status()} ${res.request().method()} ${url.origin}${url.pathname}`);
+ }
+ if (authed && url.origin === new URL(BASE).origin && url.pathname.startsWith('/api/') && res.status() === 401) {
+ info.died = `Authenticated API returned 401: ${url.pathname}`;
+ }
+ });
+ return info;
+}
+
+export async function openAuthed() {
+ const browser = await chromium.launch();
+ try {
+ const ctx = await browser.newContext({ storageState: STORAGE, viewport: { width: 1440, height: 900 } });
+ ctx.setDefaultTimeout(ACTION_TIMEOUT);
+ await ctx.addInitScript(() => {
+ try { sessionStorage.setItem('ccDashSplashSeen', '1'); }
+ catch (e) { console.warn(`Audit splash flag unavailable: ${e.message}`); }
+ });
+ // Defense in depth: even a mistaken UI click cannot delete team data.
+ await ctx.route('**/api/**', async (route) => {
+ const req = route.request();
+ const path = new URL(req.url()).pathname;
+ const permitted = deletePermits.get(ctx);
+ const forbiddenDelete = req.method() === 'DELETE' && (!permitted || path !== `/api/recordings/${permitted}`);
+ if (forbiddenDelete || (req.method() === 'POST' && path === '/api/onboarding')) {
+ console.error(`AUDIT SAFETY BLOCK: ${req.method()} ${path}`);
+ await route.abort('blockedbyclient');
+ } else await route.continue();
+ });
+ return { browser, ctx };
+ } catch (e) { await browser.close(); throw e; }
+}
+
+export async function openAnon() {
+ const browser = await chromium.launch();
+ try {
+ const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
+ ctx.setDefaultTimeout(ACTION_TIMEOUT);
+ return { browser, ctx };
+ } catch (e) { await browser.close(); throw e; }
+}
+export async function saveState(ctx) { await ctx.storageState({ path: STORAGE }); }
+
+export async function gotoAuthed(page, path, { expectAuthed = true } = {}) {
+ const info = pageInfo.get(page) ?? watchPage(page, expectAuthed);
+ info.errors.length = 0;
+ info.failed.length = 0;
+ const start = Date.now();
+ await page.goto(new URL(path, BASE).href, { waitUntil: 'domcontentloaded', timeout: PAGE_TIMEOUT });
+ let settle = 'networkidle';
+ try { await page.waitForLoadState('networkidle', { timeout: 15000 }); }
+ catch (e) {
+ if (e.name !== 'TimeoutError') throw e;
+ settle = 'networkidle timed out after 15000ms (page may be polling)';
+ }
+ checkSession(page);
+ return { landed: new URL(page.url()).pathname, errors: info.errors, failed: info.failed, loadMs: Date.now() - start, settle };
+}
+export async function visit(page, path, r, authed = true) {
+ const data = await gotoAuthed(page, path, { expectAuthed: authed });
+ note(r, { url: page.url(), loadMs: data.loadMs, settle: data.settle });
+ return data;
+}
+
+export async function shot(page, r, name = r.id) {
+ await mkdir(SHOTS, { recursive: true });
+ const width = page.viewportSize()?.width ?? 0;
+ const file = `${SHOTS}/${name.replace(/[^a-zA-Z0-9_-]/g, '_')}-${width}.png`;
+ await page.screenshot({ path: file, fullPage: true, timeout: ACTION_TIMEOUT });
+ if (!r.screenshots.includes(file)) r.screenshots.push(file);
+ return file;
+}
+async function failureShot(page, r, name) {
+ if (!page || page.isClosed()) { note(r, 'Screenshot unavailable: page was not open.'); return; }
+ try { await shot(page, r, name); }
+ catch (e) { note(r, `Screenshot failed: ${e.message}`); }
+}
+
+export function createCollector() {
+ const results = [];
+ return {
+ results,
+ async run(id, area, title, fn, { severity = 'P1', page, expected = '', timeoutMs = CHECK_TIMEOUT } = {}) {
+ const start = Date.now();
+ const r = { id, area, title, status: 'pass', severity, evidence: '', expected, durationMs: 0, screenshots: [] };
+ let fatal;
+ try {
+ if (page) checkSession(page);
+ // Hard ceiling per check: run 1 hung for 50 minutes inside one step whose waits were all
+ // individually bounded, so every check now races a timer and the suite moves on.
+ let timer;
+ const ceiling = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`Check exceeded its ${Math.round(timeoutMs / 1000)}s ceiling`)), timeoutMs); });
+ const out = await Promise.race([fn(r), ceiling]).finally(() => clearTimeout(timer));
+ if (page) checkSession(page);
+ if (out?.skip) { r.status = 'skip'; note(r, out.skip); }
+ assert(!r.problems?.length, r.problems?.join('\n'));
+ } catch (e) {
+ if (page) {
+ try { checkSession(page); }
+ catch (sessionError) { e = sessionError; }
+ }
+ r.status = 'fail';
+ note(r, e.message ?? String(e));
+ await failureShot(page, r, id);
+ if (e instanceof SessionDied) { r.severity = 'P0'; fatal = e; }
+ }
+ delete r.problems;
+ r.durationMs = Date.now() - start;
+ results.push(r);
+ console.log(`${r.status.toUpperCase().padEnd(4)} ${id} ${title} (${r.durationMs}ms)${r.status !== 'pass' ? ` :: ${r.evidence}` : ''}`);
+ if (fatal) throw fatal;
+ return r;
+ },
+ };
+}
+
+// Soft subchecks allow every viewport/route/validation case to run, with evidence for each.
+export async function probe(page, r, label, fn) {
+ try { await fn(); checkSession(page); }
+ catch (e) {
+ checkSession(page);
+ if (e instanceof SessionDied) throw e;
+ r.problems ??= [];
+ r.problems.push(`${label}: ${e.message}`);
+ note(r, `${label}: ${e.message}`);
+ await failureShot(page, r, `${r.id}-${label}`);
+ }
+}
+
+export async function runModule({ ctx, collector, shared }, checks, area, { authed = true, drifts = [] } = {}) {
+ const page = await ctx.newPage();
+ watchPage(page, authed);
+ try {
+ for (const drift of drifts) {
+ await collector.run(drift.id, area, 'The plan selector or display contract differs from the component source.', async (r) => {
+ note(r, drift.evidence);
+ assert(false, drift.evidence);
+ }, { severity: 'P3', page, expected: drift.expected });
+ }
+ for (const c of checks) {
+ await collector.run(c.id, c.area ?? area, c.title, (r) => c.fn(page, r, shared), { severity: c.severity, expected: c.expected, page, timeoutMs: c.timeoutMs });
+ }
+ } finally { await page.close(); }
+}
+
+export async function overflowPx(page) {
+ return page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
+}
+
+/** Actual painted text only: omit script/style, hidden controls and sr-only labels. */
+export async function visibleText(page) {
+ return page.evaluate(() => {
+ const out = [];
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
+ while (walker.nextNode()) {
+ const el = walker.currentNode.parentElement;
+ const text = walker.currentNode.textContent?.trim();
+ if (!el || !text || el.closest('script,style,noscript,[aria-hidden="true"],.sr-only')) continue;
+ if (!el.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) continue;
+ const range = document.createRange();
+ range.selectNodeContents(walker.currentNode);
+ if (![...range.getClientRects()].some((box) => box.width > 0 && box.height > 0)) continue;
+ const style = getComputedStyle(el);
+ out.push({ text, fontSize: parseFloat(style.fontSize), italic: style.fontStyle === 'italic', wordmark: Boolean(el.closest('.brand-mark,[aria-label="CourtCheck home"]')), element: el.tagName.toLowerCase() });
+ }
+ return out;
+ });
+}
+export async function garbageText(page) {
+ return (await visibleText(page)).filter(({ text }) => /\b(?:NaN|undefined|null|Infinity)\b|\[object Object\]|%%|—/.test(text)).map(({ text }) => text.slice(0, 120));
+}
+
+export async function apiJson(page, path, init = {}) {
+ assert(init.method !== 'DELETE', 'Use deleteQaRecording; unrestricted deletion is forbidden.');
+ assert(!(path === '/api/onboarding' && init.method === 'POST'), 'Onboarding POST is forbidden.');
+ checkSession(page);
+ const out = await page.evaluate(async ({ path, init }) => {
+ const res = await fetch(path, { ...init, cache: 'no-store', signal: AbortSignal.timeout(30000) });
+ const raw = await res.text();
+ let body = null;
+ let parseError = null;
+ try { body = raw ? JSON.parse(raw) : null; }
+ catch (e) { parseError = e.message; }
+ return { status: res.status, body, parseError, contentType: res.headers.get('content-type'), landed: new URL(res.url).pathname };
+ }, { path, init });
+ if (pageInfo.get(page)?.authed && (out.status === 401 || /^\/(landing|auth)/.test(out.landed))) {
+ throw new SessionDied(`Authenticated ${path} returned ${out.status}, landed on ${out.landed}`);
+ }
+ return out;
+}
+export function jsonInit(method, body) { return { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }; }
+export async function getData(page, path, key) {
+ const res = await apiJson(page, path);
+ assert(res.status === 200 && !res.parseError, `GET ${path}: ${res.status}, ${res.parseError ?? JSON.stringify(res.body?.error)}`);
+ const data = key ? res.body?.[key] : res.body;
+ assert(data != null, `GET ${path}: missing ${key ?? 'body'}`);
+ return data;
+}
+export async function patch(page, path, body) {
+ const res = await apiJson(page, path, jsonInit('PATCH', body));
+ assert(res.status === 200, `PATCH ${path}: ${res.status} ${JSON.stringify(res.body)}`);
+}
+export async function actionResponse(page, path, method, action, timeout = ACTION_TIMEOUT) {
+ const pending = page.waitForResponse((res) => new URL(res.url()).pathname === path && res.request().method() === method, { timeout });
+ // Attach a rejection handler immediately, even if the action itself fails.
+ const outcome = pending.then((response) => ({ response }), (error) => ({ error }));
+ await action();
+ const result = await outcome;
+ checkSession(page);
+ if (result.error) throw result.error;
+ return result.response;
+}
+export async function navigateClick(page, locator, path) {
+ await locator.click();
+ await expect(page).toHaveURL((url) => url.pathname === path, { timeout: ACTION_TIMEOUT });
+ checkSession(page);
+ await expect(page.locator('main')).toBeVisible();
+ assert((await page.locator('main').innerText()).trim().length > 0, `${path}: blank main`);
+}
+export function recordingRow(page, recording) {
+ return page.locator(ROWS).filter({ has: page.getByText(cleanTitle(recording.name), { exact: true }) });
+}
+export async function remember(shared, page, path, key, fields) {
+ const data = await getData(page, path, key);
+ shared.originals ??= [];
+ const values = Object.fromEntries(fields.map((field) => [field, data[field]]));
+ shared.originals.push({ path, key, values: structuredClone(values) });
+ return values;
+}
+export async function restore(page, path, key, original) {
+ const current = await getData(page, path, key);
+ const changed = Object.fromEntries(Object.entries(original).filter(([k, v]) => JSON.stringify(v) !== JSON.stringify(current[k])));
+ if (Object.keys(changed).length) await patch(page, path, changed);
+}
+
+export async function viewportChecks(page, r, paths, { widths = Object.values(VIEWPORTS), authed = true, extra } = {}) {
+ for (const path of paths) {
+ for (const [width, height] of widths) {
+ await probe(page, r, `${path}-${width}`, async () => {
+ await page.setViewportSize({ width, height });
+ const data = await visit(page, path, r, authed);
+ await shot(page, r, `${r.id}-${paths.length > 1 ? path : ''}`);
+ const overflow = await overflowPx(page);
+ note(r, { path, width, overflow, errors: data.errors });
+ assert(overflow === 0, `Horizontal overflow ${overflow}px`);
+ assert(data.errors.length === 0, `Console/page errors: ${data.errors.join('; ')}`);
+ if (extra) await extra(page, width);
+ });
+ }
+ }
+ await page.setViewportSize({ width: 1440, height: 900 });
+}
+
+export async function videoReady(page) {
+ await expect(page.locator(RECORDING_VIDEO)).toHaveCount(1);
+ await expect.poll(() => page.locator(RECORDING_VIDEO).evaluate((v) => v.readyState >= 1 && Number.isFinite(v.duration) && v.duration > 0), { timeout: 20000 }).toBe(true);
+}
+export async function seekControl(page, fraction) {
+ const slider = page.getByLabel('Seek', { exact: true });
+ await slider.scrollIntoViewIfNeeded();
+ const box = await slider.boundingBox();
+ assert(box && box.width > 0, 'Seek slider has no clickable bounds');
+ // range inputs cannot be filled with Playwright fill(); use a real pointer click.
+ await slider.click({ position: { x: box.width * fraction, y: box.height / 2 } });
+ const target = Number(await slider.inputValue());
+ assert(target > 0 && Number.isFinite(target), `Seek slider value did not change: ${target}`);
+ await assertSeek(page, target);
+ return target;
+}
+export async function seekAway(page, target) {
+ await videoReady(page);
+ await page.locator(RECORDING_VIDEO).evaluate((v, t) => { v.pause(); v.currentTime = t < v.duration / 2 ? v.duration * 0.8 : 0; }, target);
+}
+export async function assertSeek(page, target) {
+ await expect.poll(() => page.locator(RECORDING_VIDEO).evaluate((v) => v.currentTime), { timeout: 10000 }).toBeGreaterThanOrEqual(Math.max(0, target - 0.3));
+ const actual = await page.locator(RECORDING_VIDEO).evaluate((v) => { v.pause(); return v.currentTime; });
+ assert(Math.abs(actual - target) < 2, `Seek expected ${target}s, observed ${actual}s`);
+}
+
+export async function pollDone(page, id, r, timeout = PROCESS_TIMEOUT, onPoll) {
+ const start = Date.now();
+ let lastLog = 0;
+ while (Date.now() - start < timeout) {
+ const body = await getData(page, `/api/status?match_id=${id}`);
+ if (onPoll) onPoll(body);
+ assert(['pending', 'processing', 'done', 'failed'].includes(body.status), `Invalid status: ${JSON.stringify(body)}`);
+ if (body.status === 'failed') throw new Error(`Pipeline failed: ${body.error ?? 'no error provided'}`);
+ if (body.status === 'done') { note(r, { id, processingSeconds: (Date.now() - start) / 1000 }); return body; }
+ if (Date.now() - lastLog > 30000) {
+ console.log(`${r.id}: ${(Date.now() - start) / 1000}s, ${body.status}, ${body.stage}, progress=${body.progress}`);
+ await saveState(page.context());
+ lastLog = Date.now();
+ }
+ await page.waitForTimeout(5000);
+ }
+ throw new ProcessingTimeout(`Pipeline did not finish after ${(Date.now() - start) / 1000}s`);
+}
+
+/** Never delete an existing row, even if its title happens to use the QA prefix. */
+export async function deleteQaRecording(page, shared, r) {
+ const id = shared.qaRecordingId;
+ assert(id && shared.createdRecordingIds?.includes(id), 'Delete refused: ID was not created by this audit run.');
+ const recording = await getData(page, `/api/recordings/${id}`, 'recording');
+ assert(typeof recording.name === 'string' && recording.name.startsWith(QA_PREFIX), `Delete refused: recording title must start with "${QA_PREFIX}".`);
+ await visit(page, '/recordings', r);
+ const row = recordingRow(page, recording);
+ await expect(row).toHaveCount(1);
+ const title = await row.locator('span.font-display.truncate').innerText();
+ assert(title.startsWith(QA_PREFIX), `Delete refused: row title must start with "${QA_PREFIX}".`);
+ await row.getByLabel('Delete recording', { exact: true }).click();
+ // Drift: confirmation replaces .cc-match-row and omits the title; scope through Cancel.
+ const confirmation = page.getByRole('button', { name: 'Cancel', exact: true }).locator('..').locator('..');
+ await expect(confirmation).toContainText('Delete this recording?');
+ const fresh = await getData(page, `/api/recordings/${id}`, 'recording');
+ assert(typeof fresh.name === 'string' && fresh.name.startsWith(QA_PREFIX), 'Delete refused: title changed before confirmation.');
+ deletePermits.set(page.context(), id);
+ try {
+ const res = await actionResponse(page, `/api/recordings/${id}`, 'DELETE', () => confirmation.getByRole('button', { name: 'Delete', exact: true }).click());
+ note(r, `DELETE /api/recordings/${id}: ${res.status()}`);
+ assert(res.status() === 204, `Expected DELETE 204, got ${res.status()}`);
+ } finally { deletePermits.delete(page.context()); }
+ await expect(recordingRow(page, recording)).toHaveCount(0);
+ const res = await apiJson(page, `/api/recordings/${id}`);
+ assert(res.status === 404, `Deleted recording GET returned ${res.status}`);
+}
+
+export async function writeReport(results, meta) {
+ const dir = 'scripts/audit/functional';
+ await mkdir(dir, { recursive: true });
+ await writeFile(`${dir}/report.json`, JSON.stringify({ ...meta, results }, null, 2) + '\n');
+ const flat = (s) => String(s).replace(/\u001b\[[0-9;]*m/g, '').replace(/\s+/g, ' ').trim();
+ const failures = results.filter((r) => r.status === 'fail').sort((a, b) => a.severity.localeCompare(b.severity));
+ const lines = ['# CourtCheck functional audit', '', `Base: ${meta.base} Started: ${meta.startedAt} Finished: ${meta.finishedAt}`, '', '## Findings', ''];
+ for (const f of failures) lines.push(`- [${f.severity}] [area: ${f.area}] ${flat(f.title)}`, ` Evidence: ${flat(f.evidence)}`, ` Expected: ${flat(f.expected)}`, ` Repro: check ${f.id}${f.screenshots.length ? ` (${f.screenshots.join(', ')})` : ''}`);
+ if (!failures.length) lines.push(results.length ? 'No failures in the executed checks.' : 'Offline smoke only: no functional checks executed; production was not contacted.');
+ lines.push('', '## Checks run', '', '| check id | result | duration | evidence / skip reason |', '|---|---|---|---|');
+ for (const r of results) lines.push(`| ${r.id} | ${r.status === 'skip' ? 'skipped' : r.status} | ${r.durationMs}ms | ${flat(r.evidence).replace(/\|/g, '|')} |`);
+ await writeFile(`${dir}/REPORT.md`, lines.join('\n') + '\n');
+}
diff --git a/frontend/web/scripts/audit/functional/run.mjs b/frontend/web/scripts/audit/functional/run.mjs
new file mode 100644
index 00000000..cd04c436
--- /dev/null
+++ b/frontend/web/scripts/audit/functional/run.mjs
@@ -0,0 +1,77 @@
+// From frontend/web: node scripts/audit/functional/run.mjs [--only 03,04] [--skip-upload]
+import { mkdir, readdir } from 'node:fs/promises';
+import { BASE, TMP, createCollector, openAuthed, saveState, writeReport, SessionDied } from './lib.mjs';
+
+function parseArgs(args) {
+ let only = null;
+ let skipUpload = false;
+ let qaRecording = null;
+ for (let i = 0; i < args.length; i++) {
+ if (args[i] === '--skip-upload') skipUpload = true;
+ else if (args[i] === '--only' && args[i + 1] && !args[i + 1].startsWith('--')) only = args[++i].split(',');
+ // Resume against a QA recording that an earlier run already uploaded and processed to done.
+ else if (args[i] === '--qa-recording' && /^[0-9a-f-]{36}$/i.test(args[i + 1] ?? '')) qaRecording = args[++i];
+ else throw new Error(`Unknown or incomplete argument: ${args[i]}`);
+ }
+ return { only, skipUpload, qaRecording };
+}
+const { only, skipUpload, qaRecording } = parseArgs(process.argv.slice(2));
+const startedAt = new Date().toISOString();
+const collector = createCollector();
+const shared = { qaRecordingId: null, qaPlayerId: null, createdRecordingIds: [], originals: [], skipUpload };
+if (qaRecording) {
+ Object.assign(shared, { qaRecordingId: qaRecording, createdRecordingIds: [qaRecording], triggered: true, e2eDone: true, resumed: true, uploadStarted: Date.now(), progressSamples: [] });
+ console.log(`Resuming with QA recording ${qaRecording}: E2E-01..04 are skipped, E2E-05..07 and cleanup run against it.`);
+}
+const files = (await readdir(new URL('./checks/', import.meta.url))).filter((f) => /^\d\d-.*\.mjs$/.test(f)).sort();
+const selected = files.filter((f) => !only || only.some((p) => f.startsWith(p)));
+const modules = [];
+let browser;
+let ctx;
+let abortReason;
+
+function harnessFailure(error) {
+ const message = error.message ?? String(error);
+ abortReason = message;
+ console.error(error instanceof SessionDied
+ ? `ABORT: ${message}. Brian must run node scripts/audit/sign-in.mjs.`
+ : `ABORT (harness error): ${message}`);
+ collector.results.push({ id: `harness-${collector.results.filter((r) => r.id.startsWith('harness')).length + 1}`, area: 'auth', title: 'The audit could not complete or persist its session.', status: 'fail', severity: 'P0', evidence: message, expected: 'All selected checks finish and the rotated session is saved.', durationMs: 0, screenshots: [] });
+}
+
+try {
+ await mkdir(TMP, { recursive: true });
+ for (const file of selected) modules.push({ file, mod: await import(new URL(`./checks/${file}`, import.meta.url)) });
+ ({ browser, ctx } = await openAuthed());
+ for (const { file, mod } of modules) {
+ console.log(`\n== ${file}`);
+ try { await mod.default({ ctx, collector, shared }); }
+ finally {
+ await saveState(ctx);
+ // Incremental report so a hang or kill never loses the modules already finished.
+ await writeReport(collector.results, { startedAt, finishedAt: new Date().toISOString(), base: BASE, partial: true });
+ }
+ }
+} catch (error) { harnessFailure(error); }
+finally {
+ if (ctx) {
+ try { await saveState(ctx); }
+ catch (error) { harnessFailure(error); }
+ }
+ if (browser) {
+ try { await browser.close(); }
+ catch (error) { harnessFailure(error); }
+ }
+}
+if (abortReason) {
+ for (const { mod } of modules) {
+ for (const c of mod.checks) {
+ if (collector.results.some((r) => r.id === c.id)) continue;
+ collector.results.push({ id: c.id, area: c.area ?? mod.area, title: c.title, status: 'skip', evidence: `Run aborted: ${abortReason}`, expected: c.expected, durationMs: 0, screenshots: [] });
+ }
+ }
+}
+await writeReport(collector.results, { startedAt, finishedAt: new Date().toISOString(), base: BASE });
+const fails = collector.results.filter((r) => r.status === 'fail').length;
+console.log(`\n${collector.results.length} checks, ${fails} failed. Report: scripts/audit/functional/REPORT.md`);
+process.exitCode = fails ? 1 : 0;
diff --git a/frontend/web/scripts/audit/verify-viz-data.cjs b/frontend/web/scripts/audit/verify-viz-data.cjs
index e9d7e331..1e03c721 100644
--- a/frontend/web/scripts/audit/verify-viz-data.cjs
+++ b/frontend/web/scripts/audit/verify-viz-data.cjs
@@ -3,22 +3,25 @@
const fs=require('node:fs'),vm=require('node:vm'),assert=require('node:assert/strict');
const root=require('node:path').resolve(__dirname, '../..'), ts=require(root+'/node_modules/typescript'), jsx=require(root+'/node_modules/react/jsx-runtime');
const source=ts.transpileModule(fs.readFileSync(root+'/components/recordings/VizPanel.tsx','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,jsx:ts.JsxEmit.ReactJSX}}).outputText;
-function check(mode,demo,props={}) {
+function check(mode,props={}) {
const exports={};
vm.runInNewContext(source,{exports,require:name=>{
if(name==='react/jsx-runtime')return jsx;
if(name==='react')return {useMemo:fn=>fn(),useEffect:()=>{},useState:v=>[v==='shotMap'?mode:v,()=>{}]};
- if(name==='@/lib/demo/demoData')return {isDemoMode:()=>demo};
- return {__esModule:true,default:()=>null,SAMPLE_SHOTS:[],SAMPLE_SPACING:[{q:'ideal'}],shotMapCounts:()=>({}),spacingCounts:()=>({}),shotMapUnknownCount:()=>0};
+ return {__esModule:true,default:name,shotMapCounts:()=>({}),spacingCounts:()=>({}),shotMapUnknownCount:()=>0};
}});
- const callouts=[];
- function walk(node){if(!node||typeof node!=='object')return;if(Array.isArray(node)){node.forEach(walk);return;}if(node.type?.name==='CoachingInsight')callouts.push(node);walk(node.props?.children);}
- walk(exports.default({recordingStatus:'done',...props}));return callouts;
+ const result={callouts:[],courts:[]};
+ function walk(node){if(!node||typeof node!=='object')return;if(Array.isArray(node)){node.forEach(walk);return;}if(node.type?.name==='CoachingInsight')result.callouts.push(node);if(['../viz/ShotMap','../viz/Spacing','../viz/Coverage'].includes(node.type))result.courts.push(node);walk(node.props?.children);}
+ walk(exports.default(props));return result;
}
for(const mode of ['spacing','coverage']){
- assert.equal(check(mode,false).length,0,mode+' must hide commentary without real data');
- assert.equal(check(mode,true).length,1,mode+' demo commentary remains');
+ assert.equal(check(mode).callouts.length,0,mode+' must hide commentary without real data');
+ assert.equal(check(mode).courts.length,0,mode+' must render its unavailable state without real data');
}
-assert.equal(check('coverage',false,{coverageGrid:[[1,0],[0,3]]}).length,1);
-assert.equal(check('spacing',false,{shots:[{stroke:'forehand',player:1,player_court_x:10,player_court_y:60,ball_court_x:12.5,ball_court_y:60,court_x:12,court_y:20,in:true}]}).length,1);
-console.log('PASS: missing spacing/coverage commentary hidden for real recordings, present in demo, real-data commentary preserved.');
+assert.equal(check('shotMap').courts[0].props.dots.length,0,'empty recording must render a court with no dots');
+assert.equal(check('coverage',{coverageGrid:[[1,0],[0,3]]}).callouts.length,1);
+const shots=[{stroke:'forehand',player:1,player_court_x:10,player_court_y:60,ball_court_x:12.5,ball_court_y:60,court_x:12,court_y:20,in:true}];
+assert.equal(check('spacing',{shots}).callouts.length,1);
+assert.equal(check('spacing',{shots}).courts[0].props.shots.length,1,'real spacing is retained');
+assert.equal(check('shotMap',{shots}).courts[0].props.dots.length,1,'real dots are retained');
+console.log('PASS: no sample fixtures; missing spacing/coverage commentary hidden, real-data commentary preserved.');
diff --git a/supabase/migrations/20260910_players_cascade_on_user_delete.sql b/supabase/migrations/20260910_players_cascade_on_user_delete.sql
new file mode 100644
index 00000000..17f99337
--- /dev/null
+++ b/supabase/migrations/20260910_players_cascade_on_user_delete.sql
@@ -0,0 +1,9 @@
+-- Brian: apply this in the Supabase SQL editor; no Supabase CLI on this machine.
+-- Delete owned players with their auth account instead of turning them into templates.
+BEGIN;
+
+ALTER TABLE public.players DROP CONSTRAINT IF EXISTS players_user_id_fkey;
+ALTER TABLE public.players ADD CONSTRAINT players_user_id_fkey
+ FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE;
+
+COMMIT;