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() {

+ {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() { -
- +

- 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
{/* 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. + + ) : '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