diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6bdd9ad2b..1ed1c6551 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,11 +8,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- **On-screen keyboard for profile names.** Adding or renaming a profile on the + Pi kiosk now shows a full-screen keyboard. Chromium in `--kiosk` mode does not + surface a system keyboard, so the native text field was unusable on the + touchscreen. +- **Clear-session confirmation is a modal again.** The overlay, scrim, and + centered dialog styles were missing after the class-name rename, so at the + 800×480 kiosk size the prompt rendered as inline page content. - **Attack angle no longer inflated by 1/cos(club path).** The camera club delivery divided vertical speed by the forward component alone instead of the full horizontal speed, overstating attack angle on any shot with club path. ### Added +- **Profiles replace players.** Shots are now attributed to a server-owned profile + (a person *or* a place) with a stable id, persisted to + `~/.config/openflight/profiles.json` (override with `OPENFLIGHT_PROFILES_PATH` + or `--profiles-path`). Profiles can be renamed without orphaning their shots. + Removing a profile is refused while it still has session rows. The socket + exposes a single authoritative `profiles` snapshot plus + `set_active_profile` / `add_profile` / `rename_profile` / `remove_profile`. + Breaking: `set_player` / `player_changed` are gone, `Shot.player_name` is replaced + by `profile_id` + `profile_name`, and existing browser-local player rosters are + discarded. - **Automatic OV9281 exposure control.** High-speed camera capture now measures the impact area every five seconds, restores the last known-good setting at startup, and selects a shutter/gain combination that preserves club contrast @@ -21,7 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 shot analysis is withheld when lighting is unsuitable, but radar processing and shot display continue normally with an operator-facing lighting warning. - **Instrument-panel kiosk UI.** The dashboard is a tabbed shell (Live, Stats, - Shots, Camera, Players, Debug) instead of the previous stacked shot and stats + Shots, Camera, Profiles, Debug) instead of the previous stacked shot and stats views. Tap a Live metric to pin it top-left while keeping all ten metrics visible. The footer logo opens units, dark/light theme, language, simulator, and ball-detection status; a persistent footer power button opens the shutdown diff --git a/src/openflight/launch_monitor.py b/src/openflight/launch_monitor.py index cb2d7b62f..bba748e11 100644 --- a/src/openflight/launch_monitor.py +++ b/src/openflight/launch_monitor.py @@ -289,7 +289,8 @@ class Shot: spin_rejection_reason: Optional[str] = None carry_spin_adjusted: Optional[float] = None mode: str = "rolling-buffer" - player_name: str = "Player 1" + profile_id: str = "" + profile_name: str = "" readings_data: Optional[list] = None camera_replay: Optional[dict] = None angle_source: Optional[str] = None # "radar", "camera", "estimated", or None diff --git a/src/openflight/profiles.py b/src/openflight/profiles.py new file mode 100644 index 000000000..9aba0a004 --- /dev/null +++ b/src/openflight/profiles.py @@ -0,0 +1,243 @@ +"""Persistent profile roster: the named contexts shots are attributed to. + +A profile is deliberately untyped. It may denote a person ("Cormac") or a +place ("Home Range"), because both are things you want shots recorded +against. Later features attach data via the open ``settings`` dict, which +this module round-trips untouched and never interprets. + +The store is the single source of truth for both the roster and which +profile is active; the UI mirrors it and holds no copy of its own. +""" + +from __future__ import annotations + +import json +import logging +import os +import threading +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional, Union + +logger = logging.getLogger(__name__) + +DEFAULT_PROFILES_PATH = Path.home() / ".config" / "openflight" / "profiles.json" +PROFILES_PATH_ENV = "OPENFLIGHT_PROFILES_PATH" +DEFAULT_PROFILE_NAME = "Profile 1" +MAX_PROFILES = 12 +MAX_NAME_LENGTH = 40 + + +def resolve_profiles_path(path: Union[str, Path, None] = None) -> Path: + """Constructor argument, then ``OPENFLIGHT_PROFILES_PATH``, then the user default. + + Tests and CI point the env var at a temp file so they cannot create or + rewrite ``~/.config/openflight/profiles.json``. + """ + if path is not None and str(path).strip(): + return Path(path).expanduser() + env_path = (os.environ.get(PROFILES_PATH_ENV) or "").strip() + if env_path: + return Path(env_path).expanduser() + return DEFAULT_PROFILES_PATH + + +def clean_profile_name(raw: Any) -> str: + """Trim and cap a candidate name. Returns "" when unusable.""" + if raw is None: + return "" + return str(raw).strip()[:MAX_NAME_LENGTH] + + +def _utc_now_iso() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +@dataclass +class Profile: + """One named context that shots are attributed to.""" + + id: str + name: str + created_at: str + settings: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict: + """Wire/disk representation. + + ``settings`` is shallow-copied so external callers can't mutate store-owned + state (and bypass the store's lock / persistence path). + """ + return { + "id": self.id, + "name": self.name, + "created_at": self.created_at, + "settings": dict(self.settings), + } + + @classmethod + def from_dict(cls, raw: Any) -> Optional["Profile"]: + """Parse one stored record, or None when it is unusable.""" + if not isinstance(raw, dict): + return None + profile_id = str(raw.get("id") or "").strip() + name = clean_profile_name(raw.get("name")) + if not profile_id or not name: + return None + settings = raw.get("settings") + return cls( + id=profile_id, + name=name, + created_at=str(raw.get("created_at") or _utc_now_iso()), + settings=settings if isinstance(settings, dict) else {}, + ) + + +class ProfileStore: + """Load, mutate, and atomically persist the profile roster. + + Mutators return a falsy value and change nothing when they would break + an invariant: at least one profile always exists, and + ``active_profile_id`` always names a live profile. + """ + + def __init__(self, path: Union[str, Path, None] = None): + self._path = resolve_profiles_path(path) + # One kiosk, one writer -- an in-process lock is enough; no file locking. + self._lock = threading.Lock() + self._profiles: List[Profile] = [] + self._active_id: str = "" + self._load() + + # -- reads --------------------------------------------------------- + + def list(self) -> List[Profile]: # pylint: disable=redefined-builtin + """All profiles, in insertion order.""" + return list(self._profiles) + + def get_active(self) -> Profile: + """The active profile. Always present.""" + for profile in self._profiles: + if profile.id == self._active_id: + return profile + return self._profiles[0] + + def snapshot(self) -> dict: + """The authoritative payload broadcast on the socket.""" + with self._lock: + return self._payload() + + # -- mutations ----------------------------------------------------- + + def add(self, name: Any) -> Optional[Profile]: + """Append a profile and make it active. None when rejected.""" + cleaned = clean_profile_name(name) + if not cleaned or len(self._profiles) >= MAX_PROFILES: + return None + + with self._lock: + profile = Profile(id=uuid.uuid4().hex, name=cleaned, created_at=_utc_now_iso()) + self._profiles.append(profile) + self._active_id = profile.id + self.save() + return profile + + def rename(self, profile_id: Any, name: Any) -> bool: + """Change a profile's name. Its id and shots are unaffected.""" + cleaned = clean_profile_name(name) + if not cleaned: + return False + + with self._lock: + profile = self._find(profile_id) + if profile is None: + return False + profile.name = cleaned + self.save() + return True + + def remove(self, profile_id: Any) -> bool: + """Delete a profile. Refused for the active or the last one.""" + with self._lock: + profile = self._find(profile_id) + if profile is None or profile.id == self._active_id or len(self._profiles) <= 1: + return False + self._profiles.remove(profile) + self.save() + return True + + def set_active(self, profile_id: Any) -> bool: + """Change the active profile. Refused for an unknown id.""" + with self._lock: + profile = self._find(profile_id) + if profile is None: + return False + self._active_id = profile.id + self.save() + return True + + # -- persistence --------------------------------------------------- + + def save(self) -> None: + """Write the roster atomically. Never raises into a caller.""" + with self._lock: + payload = self._payload() + temp_path = self._path.with_name(f"{self._path.name}.{uuid.uuid4().hex}.tmp") + try: + self._path.parent.mkdir(parents=True, exist_ok=True) + with open(temp_path, "w", encoding="utf-8") as handle: + json.dump(payload, handle, indent=2) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temp_path, self._path) + except OSError as error: + logger.error("[profiles] could not save profiles to %s: %s", self._path, error) + try: + temp_path.unlink() + except OSError: + pass + + def _payload(self) -> dict: + """The disk/wire representation of the whole roster.""" + return { + "profiles": [profile.to_dict() for profile in self._profiles], + "active_profile_id": self.get_active().id, + } + + def _find(self, profile_id: Any) -> Optional[Profile]: + wanted = str(profile_id or "").strip() + if not wanted: + return None + return next((profile for profile in self._profiles if profile.id == wanted), None) + + def _load(self) -> None: + """Read the roster, seeding a default when absent or unusable.""" + raw: Any = None + try: + with open(self._path, "r", encoding="utf-8") as handle: + raw = json.load(handle) + except FileNotFoundError: + raw = None + except (OSError, json.JSONDecodeError) as error: + logger.warning("[profiles] could not read %s: %s", self._path, error) + raw = None + + entries = raw.get("profiles") if isinstance(raw, dict) else None + parsed = ( + [Profile.from_dict(entry) for entry in entries] if isinstance(entries, list) else [] + ) + self._profiles = [profile for profile in parsed if profile is not None][:MAX_PROFILES] + + if not self._profiles: + self._profiles = [ + Profile(id=uuid.uuid4().hex, name=DEFAULT_PROFILE_NAME, created_at=_utc_now_iso()) + ] + self._active_id = self._profiles[0].id + self.save() + return + + stored_active = str(raw.get("active_profile_id") or "") if isinstance(raw, dict) else "" + known = {profile.id for profile in self._profiles} + self._active_id = stored_active if stored_active in known else self._profiles[0].id diff --git a/src/openflight/server.py b/src/openflight/server.py index 472e51df3..2f0c859af 100644 --- a/src/openflight/server.py +++ b/src/openflight/server.py @@ -34,6 +34,7 @@ set_show_raw_readings, ) from .power import SUPPORTED_BATTERY_PROVIDERS, PowerMonitor, PowerStatus +from .profiles import ProfileStore from .rolling_buffer.monitor import estimate_carry_with_spin, get_optimal_spin_for_ball_speed from .session_logger import get_session_logger, init_session_logger, log_session_error from .sim import ( @@ -91,7 +92,18 @@ mock_swing_speed_mode: bool = False debug_log_file = None debug_log_path: Optional[Path] = None -current_player_name: str = "Player 1" +# Created lazily so importing the server (in tests, in tooling) never writes +# to the real config directory. +profile_store: Optional[ProfileStore] = None + + +def get_profile_store() -> ProfileStore: + """The profile roster. Single source of truth for the active selection.""" + global profile_store # pylint: disable=global-statement + if profile_store is None: + profile_store = ProfileStore() + return profile_store + TRAINING_IMPLEMENT_LABELS = { "driver": "Driver", @@ -1137,7 +1149,8 @@ def shot_to_dict(shot: Shot) -> dict: round(shot.estimated_carry_range[1]), ], "club": shot.club.value, - "player_name": shot.player_name, + "profile_id": shot.profile_id, + "profile_name": shot.profile_name, "timestamp": shot.timestamp.isoformat(), "impact_timestamp": shot.impact_timestamp, "peak_magnitude": shot.peak_magnitude, @@ -2201,7 +2214,6 @@ def _session_state_payload(*, include_runtime_meta: bool = False) -> dict: payload = { "stats": monitor.get_session_stats() if monitor else {}, "shots": _session_shots(), - "player_name": current_player_name, "club": _current_club_id(), } if include_runtime_meta: @@ -2321,6 +2333,7 @@ def handle_connect(): """Handle client connection.""" print("Client connected") _emit_sim_snapshot() + _emit_profiles() if power_monitor and power_monitor.status: socketio.emit("power_status", power_monitor.status.to_dict()) if monitor: @@ -2353,15 +2366,57 @@ def handle_set_club(data): pass -@socketio.on("set_player") -def handle_set_player(data): - """Handle active player selection changes.""" - global current_player_name # pylint: disable=global-statement +def _payload_dict(data) -> dict: + """Normalize a socket payload to a dict, ignoring anything else.""" + return data if isinstance(data, dict) else {} + + +def _emit_profiles() -> None: + """Broadcast the authoritative roster + selection. + + Sent after every mutation, including rejected ones, so a stale client + self-heals on the next round trip instead of needing an error event. + """ + socketio.emit("profiles", get_profile_store().snapshot()) + + +@socketio.on("get_profiles") +def handle_get_profiles(): + """Send the roster to a client that asked for it.""" + _emit_profiles() + + +@socketio.on("set_active_profile") +def handle_set_active_profile(data=None): + """Change which profile shots are attributed to.""" + get_profile_store().set_active(_payload_dict(data).get("profile_id")) + _emit_profiles() + + +@socketio.on("add_profile") +def handle_add_profile(data=None): + """Add a profile and make it active.""" + get_profile_store().add(_payload_dict(data).get("name")) + _emit_profiles() + + +@socketio.on("rename_profile") +def handle_rename_profile(data=None): + """Rename a profile. Its shots keep their id and stay attached.""" + payload = _payload_dict(data) + get_profile_store().rename(payload.get("profile_id"), payload.get("name")) + _emit_profiles() - raw_name = data.get("player_name", "Player 1") if isinstance(data, dict) else "Player 1" - player_name = str(raw_name).strip()[:40] or "Player 1" - current_player_name = player_name - socketio.emit("player_changed", {"player_name": current_player_name}) + +@socketio.on("remove_profile") +def handle_remove_profile(data=None): + """Delete a profile. Refused for the active, the last, or one with session rows.""" + profile_id = str(_payload_dict(data).get("profile_id") or "").strip() + if profile_id and _profile_has_session_rows(profile_id): + _emit_profiles() + return + get_profile_store().remove(profile_id) + _emit_profiles() @socketio.on("set_training_implement") @@ -2381,44 +2436,44 @@ def handle_set_training_implement(data): ) -def _normalize_player_name(name) -> str: - """Match UI player scoping: trim, default Player 1, case-insensitive.""" - text = str(name).strip() if name is not None else "" - return (text or "Player 1").lower() +def _profile_has_session_rows(profile_id: str) -> bool: + """True when the live session still has rows stamped with this profile.""" + from .swing_speed import SwingSpeedMonitor # pylint: disable=import-outside-toplevel + + if not monitor or not profile_id: + return False + + if isinstance(monitor, (SwingSpeedMonitor, MockSwingSpeedMonitor)): + return any(getattr(event, "profile_id", "") == profile_id for event in monitor.get_events()) + if hasattr(monitor, "get_shots"): + return any(getattr(shot, "profile_id", "") == profile_id for shot in monitor.get_shots()) + return False -def _player_matches(stored_name, player_name: str) -> bool: - """True when a shot/event belongs to player_name.""" - return _normalize_player_name(stored_name) == _normalize_player_name(player_name) +def _clear_profile_rows(profile_id: str) -> None: + """Remove one profile's shots or swing-speed reps from the active monitor. -def _clear_player_rows(player_name: str) -> None: - """Remove one player's shots or swing-speed reps from the active monitor.""" + Matching is exact on the id. The old name-keyed code folded case, so two + profiles whose names differed only in case cleared each other. + """ from .swing_speed import SwingSpeedMonitor # pylint: disable=import-outside-toplevel - if not monitor: + if not monitor or not profile_id: return if isinstance(monitor, (SwingSpeedMonitor, MockSwingSpeedMonitor)): events = getattr(monitor, "_events", None) if events is not None: events[:] = [ - event for event in events if not _player_matches(event.player_name, player_name) + event for event in events if getattr(event, "profile_id", "") != profile_id ] return shots = getattr(monitor, "_shots", None) if shots is not None: - removed = [ - shot - for shot in shots - if _player_matches(getattr(shot, "player_name", None), player_name) - ] - shots[:] = [ - shot - for shot in shots - if not _player_matches(getattr(shot, "player_name", None), player_name) - ] + removed = [shot for shot in shots if getattr(shot, "profile_id", "") == profile_id] + shots[:] = [shot for shot in shots if getattr(shot, "profile_id", "") != profile_id] for shot in removed: _unregister_camera_replay(shot) return @@ -2429,14 +2484,13 @@ def _clear_player_rows(player_name: str) -> None: @socketio.on("clear_session") def handle_clear_session(data=None): - """Clear recorded shots for the active player only.""" - raw_name = data.get("player_name") if isinstance(data, dict) else None - player_name = str(raw_name).strip()[:40] if raw_name else current_player_name - player_name = player_name or current_player_name - _clear_player_rows(player_name) + """Clear recorded rows for one profile only.""" + raw_id = _payload_dict(data).get("profile_id") + profile_id = str(raw_id).strip() if raw_id else get_profile_store().get_active().id + _clear_profile_rows(profile_id) socketio.emit( "session_cleared", - {"player_name": player_name, "shots": _session_shots()}, + {"profile_id": profile_id, "shots": _session_shots()}, ) @@ -3893,7 +3947,8 @@ def _finalize_shot_detected( experimental_camera_iwr_delta_deg=shot.experimental_camera_iwr_delta_deg, spin_axis_deg=shot.spin_axis_deg, impact_timestamp=shot.impact_timestamp, - player_name=shot.player_name, + profile_id=shot.profile_id, + profile_name=shot.profile_name, inclinometer=shot.inclinometer, pipeline_ms={ "initial_ui": (round(initial_ui_ms, 1) if initial_ui_ms is not None else None), @@ -4181,7 +4236,9 @@ def on_shot_detected(shot: Shot) -> None: def _handle_shot_detected(shot: Shot) -> None: """Publish OPS metrics promptly, then enrich optional hardware data.""" _assign_shot_number(shot) - shot.player_name = current_player_name + active_profile = get_profile_store().get_active() + shot.profile_id = active_profile.id + shot.profile_name = active_profile.name logger.info("[SERVER] Shot callback: %.1f mph", shot.ball_speed_mph) if not _has_slow_shot_enrichment(shot): @@ -4255,7 +4312,8 @@ def swing_speed_to_dict(event: SwingSpeedEvent) -> dict: "peak_magnitude": event.peak_magnitude, "training_implement": event.training_implement, "training_implement_label": event.training_implement_label, - "player_name": event.player_name, + "profile_id": event.profile_id, + "profile_name": event.profile_name, "unit": event.unit, "mode": event.mode, } @@ -4272,7 +4330,8 @@ def swing_speed_to_shot_dict(event: SwingSpeedEvent) -> dict: "estimated_carry_yards": 0, "carry_range": [0, 0], "club": event.training_implement_label, - "player_name": event.player_name, + "profile_id": event.profile_id, + "profile_name": event.profile_name, "timestamp": event.timestamp.isoformat(), "peak_magnitude": event.peak_magnitude, "launch_angle_vertical": None, @@ -4317,7 +4376,9 @@ def swing_speed_to_shot_dict(event: SwingSpeedEvent) -> dict: def on_swing_speed_detected(event: SwingSpeedEvent): """Handle swing speed training reps and emit them to connected clients.""" - event.player_name = current_player_name + active_profile = get_profile_store().get_active() + event.profile_id = active_profile.id + event.profile_name = active_profile.name event_data = swing_speed_to_dict(event) shot_data = swing_speed_to_shot_dict(event) stats = monitor.get_session_stats() if monitor else {} @@ -5119,6 +5180,14 @@ def main(): parser.add_argument( "--log-dir", help="Directory for session logs (default: ~/openflight_sessions)" ) + parser.add_argument( + "--profiles-path", + default=None, + help=( + "Path to profiles.json (default: OPENFLIGHT_PROFILES_PATH or " + "~/.config/openflight/profiles.json)" + ), + ) parser.add_argument("--no-logging", action="store_true", help="Disable session logging") _add_battery_arguments(parser) parser.add_argument( @@ -5540,6 +5609,7 @@ def main(): global active_kld7_radc_tuning global ballistics_enabled global battery_provider + global profile_store experimental_kld7_raw_radc_logging = args.experimental_kld7_raw_radc_logging experimental_kld7_radc_tuning = args.experimental_kld7_radc_tuning global ball_speed_correction_enabled @@ -5556,6 +5626,7 @@ def main(): calculated_spin_enabled = args.calculated_spin ballistics_enabled = args.ballistics battery_provider = args.battery + profile_store = ProfileStore(args.profiles_path) kld7_radc_tuning_kwargs = _kld7_radc_tuning_kwargs(args) active_kld7_radc_tuning = dict(kld7_radc_tuning_kwargs) startup_status = StartupStatusReporter( diff --git a/src/openflight/session_logger.py b/src/openflight/session_logger.py index e04498d97..2fe33816d 100644 --- a/src/openflight/session_logger.py +++ b/src/openflight/session_logger.py @@ -380,7 +380,8 @@ def log_shot( spin_axis_deg: Optional[float] = None, pipeline_ms: Optional[Dict] = None, impact_timestamp: Optional[float] = None, - player_name: Optional[str] = None, + profile_id: Optional[str] = None, + profile_name: Optional[str] = None, inclinometer: Optional[Dict] = None, shot_number: Optional[int] = None, ): @@ -435,7 +436,8 @@ def log_shot( "smash_factor": smash_factor, "estimated_carry_yards": estimated_carry_yards, "club": club, - "player_name": player_name, + "profile_id": profile_id, + "profile_name": profile_name, "peak_magnitude": peak_magnitude, "readings_count": readings_count, "readings": readings, diff --git a/src/openflight/swing_speed.py b/src/openflight/swing_speed.py index d3cb254cd..1ae1bae2b 100644 --- a/src/openflight/swing_speed.py +++ b/src/openflight/swing_speed.py @@ -26,7 +26,8 @@ class SwingSpeedEvent: peak_magnitude: Optional[float] = None training_implement: str = "driver" training_implement_label: str = "Driver" - player_name: str = "Player 1" + profile_id: str = "" + profile_name: str = "" unit: str = "mph" mode: str = "swing-speed" diff --git a/tests/conftest.py b/tests/conftest.py index 3844cd4cc..949eeee01 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -106,3 +106,10 @@ def mock_sim(): server = MockSimServer() yield server server.stop() + + +@pytest.fixture(autouse=True) +def _isolate_profile_store(tmp_path_factory, monkeypatch): + """Keep ProfileStore() off ~/.config/openflight/profiles.json during tests.""" + roster = tmp_path_factory.mktemp("profiles") / "profiles.json" + monkeypatch.setenv("OPENFLIGHT_PROFILES_PATH", str(roster)) diff --git a/tests/test_e2e_profile_isolation.py b/tests/test_e2e_profile_isolation.py new file mode 100644 index 000000000..1c4c39cef --- /dev/null +++ b/tests/test_e2e_profile_isolation.py @@ -0,0 +1,51 @@ +"""E2E backends must not read or write the developer's real profile roster.""" + +from pathlib import Path + +from openflight import server as server_module +from openflight.profiles import PROFILES_PATH_ENV, ProfileStore + +REPO_ROOT = Path(__file__).resolve().parents[1] +PLAYWRIGHT_CONFIG = REPO_ROOT / "ui" / "playwright.config.ts" +ISOLATE_HELPER = REPO_ROOT / "ui" / "tests" / "e2e" / "isolateProfilesPath.ts" + + +def test_playwright_does_not_collect_vitest_files(): + """Playwright defaults to *(test|spec).ts, which would execute Vitest describe().""" + config = PLAYWRIGHT_CONFIG.read_text(encoding="utf-8") + e2e_dir = REPO_ROOT / "ui" / "tests" / "e2e" + + assert "testMatch:" in config + assert "**/*.spec.ts" in config + assert list(e2e_dir.glob("*.test.ts")) == [] + + +def test_playwright_config_points_the_backend_at_a_unique_temp_path(): + config = PLAYWRIGHT_CONFIG.read_text(encoding="utf-8") + helper = ISOLATE_HELPER.read_text(encoding="utf-8") + + assert "uniqueE2eProfilesPath" in config + assert "PROFILES_PATH_ENV" in config + assert "env: backendEnv()" in config + assert "OPENFLIGHT_PROFILES_PATH" in helper + assert "mkdtempSync" in helper + assert "openflight-e2e-w" in helper + assert "~/.config/openflight/profiles.json" not in config + + +def test_get_profile_store_with_env_never_touches_the_user_roster(tmp_path, monkeypatch): + isolated = tmp_path / "worker-7" / "profiles.json" + user_roster = tmp_path / "home" / ".config" / "openflight" / "profiles.json" + user_roster.parent.mkdir(parents=True) + user_roster.write_text("developer-roster", encoding="utf-8") + monkeypatch.setenv(PROFILES_PATH_ENV, str(isolated)) + monkeypatch.setattr("openflight.profiles.DEFAULT_PROFILES_PATH", user_roster) + monkeypatch.setattr(server_module, "profile_store", None) + + store = server_module.get_profile_store() + store.add("Alex") + + assert isinstance(store, ProfileStore) + assert isolated.exists() + assert user_roster.read_text(encoding="utf-8") == "developer-roster" + assert store._path == isolated # pylint: disable=protected-access diff --git a/tests/test_profiles.py b/tests/test_profiles.py new file mode 100644 index 000000000..7ebff05dd --- /dev/null +++ b/tests/test_profiles.py @@ -0,0 +1,427 @@ +"""Tests for the persistent profile roster.""" + +import json +from pathlib import Path + +import pytest + +from openflight.profiles import ( + DEFAULT_PROFILE_NAME, + DEFAULT_PROFILES_PATH, + MAX_PROFILES, + PROFILES_PATH_ENV, + Profile, + ProfileStore, + resolve_profiles_path, +) + + +@pytest.fixture(name="store_path") +def fixture_store_path(tmp_path): + return tmp_path / "config" / "profiles.json" + + +class TestSeeding: + """A store always yields a usable roster.""" + + def test_missing_file_seeds_one_default_profile(self, store_path): + store = ProfileStore(store_path) + + profiles = store.list() + assert len(profiles) == 1 + assert profiles[0].name == DEFAULT_PROFILE_NAME + assert store.get_active().id == profiles[0].id + + def test_seeded_store_is_written_to_disk(self, store_path): + ProfileStore(store_path) + + data = json.loads(store_path.read_text(encoding="utf-8")) + assert len(data["profiles"]) == 1 + assert data["active_profile_id"] == data["profiles"][0]["id"] + + def test_corrupt_file_falls_back_to_seeded_default(self, store_path): + store_path.parent.mkdir(parents=True, exist_ok=True) + store_path.write_text("{not json at all", encoding="utf-8") + + store = ProfileStore(store_path) + + assert [profile.name for profile in store.list()] == [DEFAULT_PROFILE_NAME] + + def test_file_with_no_valid_profiles_falls_back_to_seeded_default(self, store_path): + store_path.parent.mkdir(parents=True, exist_ok=True) + store_path.write_text( + json.dumps({"profiles": [{"nope": 1}, "banana"], "active_profile_id": "x"}), + encoding="utf-8", + ) + + store = ProfileStore(store_path) + + assert [profile.name for profile in store.list()] == [DEFAULT_PROFILE_NAME] + + def test_active_id_pointing_at_missing_profile_falls_back_to_first(self, store_path): + store_path.parent.mkdir(parents=True, exist_ok=True) + store_path.write_text( + json.dumps( + { + "profiles": [ + {"id": "aaa", "name": "Home", "created_at": "2026-01-01T00:00:00Z"}, + {"id": "bbb", "name": "Range", "created_at": "2026-01-01T00:00:00Z"}, + ], + "active_profile_id": "ghost", + } + ), + encoding="utf-8", + ) + + store = ProfileStore(store_path) + + assert store.get_active().id == "aaa" + + +class TestAdd: + """Adding a profile.""" + + def test_add_appends_and_makes_active(self, store_path): + store = ProfileStore(store_path) + + added = store.add("Home Range") + + assert added is not None + assert [profile.name for profile in store.list()] == [DEFAULT_PROFILE_NAME, "Home Range"] + assert store.get_active().id == added.id + + def test_add_generates_a_unique_id(self, store_path): + store = ProfileStore(store_path) + + first = store.add("Range") + second = store.add("Range") + + assert first.id != second.id + + def test_add_allows_duplicate_names(self, store_path): + store = ProfileStore(store_path) + + store.add("Range") + store.add("Range") + + assert [profile.name for profile in store.list()].count("Range") == 2 + + def test_add_trims_and_caps_name_at_40_characters(self, store_path): + store = ProfileStore(store_path) + + added = store.add(" " + "x" * 60 + " ") + + assert added.name == "x" * 40 + + def test_add_rejects_blank_name(self, store_path): + store = ProfileStore(store_path) + + assert store.add(" ") is None + assert len(store.list()) == 1 + + def test_add_rejects_beyond_the_roster_cap(self, store_path): + store = ProfileStore(store_path) + for index in range(MAX_PROFILES - 1): + assert store.add(f"Profile {index + 2}") is not None + + assert store.add("One too many") is None + assert len(store.list()) == MAX_PROFILES + + def test_add_persists_across_reload(self, store_path): + store = ProfileStore(store_path) + added = store.add("Home Range") + + reloaded = ProfileStore(store_path) + + assert [profile.name for profile in reloaded.list()] == [DEFAULT_PROFILE_NAME, "Home Range"] + assert reloaded.get_active().id == added.id + + +class TestRename: + """Renaming never changes identity.""" + + def test_rename_changes_name_but_not_id(self, store_path): + store = ProfileStore(store_path) + added = store.add("Rnage") + + assert store.rename(added.id, "Range") is True + + renamed = next(profile for profile in store.list() if profile.id == added.id) + assert renamed.name == "Range" + + def test_rename_trims_and_caps_name(self, store_path): + store = ProfileStore(store_path) + added = store.add("Range") + + store.rename(added.id, " " + "y" * 60) + + assert store.list()[-1].name == "y" * 40 + + def test_rename_rejects_blank_name(self, store_path): + store = ProfileStore(store_path) + added = store.add("Range") + + assert store.rename(added.id, " ") is False + assert store.list()[-1].name == "Range" + + def test_rename_rejects_unknown_id(self, store_path): + store = ProfileStore(store_path) + + assert store.rename("ghost", "Range") is False + + def test_rename_persists_across_reload(self, store_path): + store = ProfileStore(store_path) + added = store.add("Rnage") + store.rename(added.id, "Range") + + assert ProfileStore(store_path).list()[-1].name == "Range" + + +class TestRemove: + """Removal is refused when it would break an invariant.""" + + def test_remove_deletes_an_inactive_profile(self, store_path): + store = ProfileStore(store_path) + doomed = store.add("Doomed") + keeper = store.add("Keeper") + + assert store.remove(doomed.id) is True + + assert [profile.id for profile in store.list()] == [store.list()[0].id, keeper.id] + + def test_remove_rejects_the_active_profile(self, store_path): + store = ProfileStore(store_path) + active = store.add("Active") + + assert store.remove(active.id) is False + assert store.get_active().id == active.id + assert len(store.list()) == 2 + + def test_remove_rejects_the_last_profile(self, store_path): + store = ProfileStore(store_path) + only = store.list()[0] + + assert store.remove(only.id) is False + assert store.list() == [only] + + def test_remove_rejects_unknown_id(self, store_path): + store = ProfileStore(store_path) + + assert store.remove("ghost") is False + assert len(store.list()) == 1 + + def test_remove_persists_across_reload(self, store_path): + store = ProfileStore(store_path) + doomed = store.add("Doomed") + store.add("Keeper") + store.remove(doomed.id) + + assert [profile.name for profile in ProfileStore(store_path).list()] == [ + DEFAULT_PROFILE_NAME, + "Keeper", + ] + + +class TestSetActive: + """Active selection always points at a live profile.""" + + def test_set_active_switches_selection(self, store_path): + store = ProfileStore(store_path) + first = store.list()[0] + store.add("Second") + + assert store.set_active(first.id) is True + assert store.get_active().id == first.id + + def test_set_active_rejects_unknown_id(self, store_path): + store = ProfileStore(store_path) + before = store.get_active().id + + assert store.set_active("ghost") is False + assert store.get_active().id == before + + def test_set_active_persists_across_reload(self, store_path): + store = ProfileStore(store_path) + first = store.list()[0] + store.add("Second") + store.set_active(first.id) + + assert ProfileStore(store_path).get_active().id == first.id + + +class TestSettings: + """The open settings dict is the extension point for later features.""" + + def test_settings_default_to_empty_dict(self, store_path): + store = ProfileStore(store_path) + + assert store.add("Range").settings == {} + + def test_settings_round_trip_unchanged(self, store_path): + store = ProfileStore(store_path) + added = store.add("Range") + payload = {"altitude_m": 120, "nested": {"a": [1, 2, 3]}, "flag": True} + added.settings.update(payload) + store.save() + + reloaded = next( + profile for profile in ProfileStore(store_path).list() if profile.id == added.id + ) + assert reloaded.settings == payload + + def test_rename_preserves_settings(self, store_path): + store = ProfileStore(store_path) + added = store.add("Rnage") + added.settings["altitude_m"] = 120 + store.save() + + store.rename(added.id, "Range") + + assert ProfileStore(store_path).list()[-1].settings == {"altitude_m": 120} + + def test_to_dict_returns_a_copy_of_settings(self): + profile = Profile( + id="abc", + name="Range", + created_at="2026-01-01T00:00:00Z", + settings={"altitude_m": 120}, + ) + + payload = profile.to_dict() + payload["settings"]["altitude_m"] = 999 + payload["settings"]["extra"] = True + + assert profile.settings == {"altitude_m": 120} + + def test_snapshot_settings_cannot_mutate_store_state(self, store_path): + store = ProfileStore(store_path) + added = store.add("Range") + added.settings["altitude_m"] = 120 + store.save() + + snapshot = store.snapshot() + snapshot["profiles"][-1]["settings"]["altitude_m"] = 999 + + assert store.list()[-1].settings == {"altitude_m": 120} + + +class TestSnapshot: + """snapshot() is the socket payload.""" + + def test_snapshot_shape(self, store_path): + store = ProfileStore(store_path) + added = store.add("Range") + + snapshot = store.snapshot() + + assert snapshot["active_profile_id"] == added.id + assert [entry["name"] for entry in snapshot["profiles"]] == [ + DEFAULT_PROFILE_NAME, + "Range", + ] + assert set(snapshot["profiles"][0]) == {"id", "name", "created_at", "settings"} + + +class TestAtomicWrite: + """A crash mid-write must not truncate the roster.""" + + def test_failed_write_leaves_previous_file_intact(self, store_path, monkeypatch): + store = ProfileStore(store_path) + store.add("Keeper") + before = store_path.read_text(encoding="utf-8") + + def boom(*_args, **_kwargs): + raise OSError("disk full") + + monkeypatch.setattr("openflight.profiles.os.replace", boom) + store.add("Never persisted") + + assert store_path.read_text(encoding="utf-8") == before + + def test_no_temp_files_left_behind(self, store_path): + store = ProfileStore(store_path) + store.add("Range") + + assert [path.name for path in store_path.parent.iterdir()] == [store_path.name] + + def test_save_builds_payload_while_holding_the_lock(self, store_path): + store = ProfileStore(store_path) + + def observing_payload(): + assert store._lock.locked() + return { + "profiles": [profile.to_dict() for profile in store.list()], + "active_profile_id": store.get_active().id, + } + + store._payload = observing_payload + store.save() + + def test_snapshot_builds_payload_while_holding_the_lock(self, store_path): + store = ProfileStore(store_path) + + def observing_payload(): + assert store._lock.locked() + return { + "profiles": [profile.to_dict() for profile in store.list()], + "active_profile_id": store.get_active().id, + } + + store._payload = observing_payload + store.snapshot() + + +class TestResolvePath: + """Roster location is constructor, then env, then the user default.""" + + def test_explicit_path_wins_over_env(self, store_path, tmp_path, monkeypatch): + monkeypatch.setenv(PROFILES_PATH_ENV, str(tmp_path / "from-env.json")) + + store = ProfileStore(store_path) + store.add("Range") + + assert store_path.exists() + assert not (tmp_path / "from-env.json").exists() + + def test_env_is_used_when_no_path_is_given(self, tmp_path, monkeypatch): + isolated = tmp_path / "worker-0" / "profiles.json" + monkeypatch.setenv(PROFILES_PATH_ENV, str(isolated)) + + store = ProfileStore() + store.add("Alex") + + assert isolated.exists() + names = [ + entry["name"] for entry in json.loads(isolated.read_text(encoding="utf-8"))["profiles"] + ] + assert "Alex" in names + + def test_blank_env_falls_back_to_default(self, tmp_path, monkeypatch): + monkeypatch.setenv(PROFILES_PATH_ENV, " ") + monkeypatch.setattr("openflight.profiles.DEFAULT_PROFILES_PATH", tmp_path / "default.json") + + assert resolve_profiles_path() == tmp_path / "default.json" + + def test_default_constant_is_the_user_config_location(self): + assert DEFAULT_PROFILES_PATH == Path.home() / ".config" / "openflight" / "profiles.json" + + +class TestEnvDoesNotTouchUserConfig: + """E2E-style construction (no path argument) must not rewrite the user roster.""" + + def test_store_with_env_never_creates_or_rewrites_default_path(self, tmp_path, monkeypatch): + isolated = tmp_path / "e2e-worker" / "profiles.json" + user_roster = tmp_path / "home" / ".config" / "openflight" / "profiles.json" + user_roster.parent.mkdir(parents=True) + user_roster.write_text("do-not-touch", encoding="utf-8") + monkeypatch.setenv(PROFILES_PATH_ENV, str(isolated)) + monkeypatch.setattr("openflight.profiles.DEFAULT_PROFILES_PATH", user_roster) + + store = ProfileStore() + store.add("Alex") + store.add("Range") + store.remove(store.list()[0].id) + + assert user_roster.read_text(encoding="utf-8") == "do-not-touch" + assert isolated.exists() + assert isolated != user_roster diff --git a/tests/test_server.py b/tests/test_server.py index b609151ab..3457d7379 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1443,7 +1443,7 @@ def test_basic_conversion(self): assert result["ball_speed_mph"] == 150.5 assert result["club_speed_mph"] == 103.2 assert result["club"] == "driver" - assert result["player_name"] == "Player 1" + assert result["profile_name"] == "" assert result["timestamp"] == "2024-01-15T10:30:00" assert "estimated_carry_yards" in result assert "carry_range" in result @@ -1822,7 +1822,8 @@ def test_swing_speed_to_dict(self): "peak_magnitude": 42, "training_implement": "driver", "training_implement_label": "Driver", - "player_name": "Player 1", + "profile_id": "", + "profile_name": "", "unit": "mph", "mode": "swing-speed", } @@ -1850,29 +1851,7 @@ def test_swing_speed_to_shot_dict_supports_existing_ui(self): assert result["swing_speed_trigger_mph"] == 32.2 assert result["training_implement"] == "driver" assert result["training_implement_label"] == "Driver" - assert result["player_name"] == "Player 1" - - def test_set_player_updates_future_swing_speed_payloads(self, monkeypatch): - """Selected UI player should be stamped on subsequent swing speed reps.""" - emitted = [] - monkeypatch.setattr(server_module, "current_player_name", "Player 1") - monkeypatch.setattr( - server_module.socketio, "emit", lambda *args, **kwargs: emitted.append(args) - ) - - server_module.handle_set_player({"player_name": "David"}) - event = SwingSpeedEvent( - peak_speed_mph=101.44, - timestamp=datetime(2024, 1, 15, 10, 30, 0), - duration_ms=347.8, - reading_count=9, - trigger_speed_mph=32.25, - ) - server_module.on_swing_speed_detected(event) - - assert server_module.current_player_name == "David" - shot_payload = next(payload for name, payload in emitted if name == "shot") - assert shot_payload["shot"]["player_name"] == "David" + assert result["profile_name"] == "" def test_start_monitor_uses_swing_speed_monitor(self, monkeypatch): """Swing speed mode should start a club-only monitor and callback.""" @@ -2122,6 +2101,58 @@ class StubMonitor: assert server_module.radar_config["max_speed"] == 0 +class TestProfileStamping: + """Shot and swing-speed payloads carry profile id plus a name snapshot.""" + + def test_shot_to_dict_emits_profile_fields(self): + shot = Shot( + ball_speed_mph=140.0, + club_speed_mph=100.0, + club=ClubType.DRIVER, + timestamp=datetime(2026, 8, 27, 10, 0, 0), + ) + shot.profile_id = "abc123" + shot.profile_name = "Home Range" + + payload = shot_to_dict(shot) + + assert payload["profile_id"] == "abc123" + assert payload["profile_name"] == "Home Range" + assert "player_name" not in payload + + def test_unstamped_shot_has_empty_profile_fields(self): + shot = Shot( + ball_speed_mph=140.0, + club_speed_mph=100.0, + club=ClubType.DRIVER, + timestamp=datetime(2026, 8, 27, 10, 0, 0), + ) + + assert shot.profile_id == "" + assert shot.profile_name == "" + + def test_swing_speed_dicts_emit_profile_fields(self): + event = SwingSpeedEvent( + peak_speed_mph=101.4, + timestamp=datetime(2026, 8, 27, 10, 0, 0), + duration_ms=347.8, + reading_count=9, + trigger_speed_mph=32.2, + ) + event.profile_id = "abc123" + event.profile_name = "Home Range" + + event_payload = swing_speed_to_dict(event) + shot_payload = swing_speed_to_shot_dict(event) + + assert event_payload["profile_id"] == "abc123" + assert event_payload["profile_name"] == "Home Range" + assert shot_payload["profile_id"] == "abc123" + assert shot_payload["profile_name"] == "Home Range" + assert "player_name" not in event_payload + assert "player_name" not in shot_payload + + class TestEstimateLaunchAngle: """Tests for launch angle estimation from club type and ball speed.""" @@ -2345,122 +2376,386 @@ def test_clear_session(self): assert monitor.get_session_stats()["shot_count"] == 0 +class TestProfileSocketHandlers: + """Every mutation answers with the authoritative snapshot.""" + + @pytest.fixture(name="store") + def fixture_store(self, tmp_path, monkeypatch): + from openflight.profiles import ProfileStore + + store = ProfileStore(tmp_path / "profiles.json") + monkeypatch.setattr(server_module, "profile_store", store) + return store + + @pytest.fixture(name="emitted") + def fixture_emitted(self, monkeypatch): + captured = [] + monkeypatch.setattr( + server_module.socketio, "emit", lambda *args, **kwargs: captured.append(args) + ) + return captured + + @staticmethod + def _last_snapshot(emitted): + return next(payload for name, payload in reversed(emitted) if name == "profiles") + + def test_get_profiles_emits_snapshot(self, store, emitted): + server_module.handle_get_profiles() + + snapshot = self._last_snapshot(emitted) + assert snapshot["active_profile_id"] == store.get_active().id + assert len(snapshot["profiles"]) == 1 + + def test_add_profile_adds_and_broadcasts(self, store, emitted): + server_module.handle_add_profile({"name": "Home Range"}) + + snapshot = self._last_snapshot(emitted) + assert [entry["name"] for entry in snapshot["profiles"]][-1] == "Home Range" + assert snapshot["active_profile_id"] == store.list()[-1].id + + def test_add_profile_with_blank_name_broadcasts_unchanged_snapshot(self, store, emitted): + server_module.handle_add_profile({"name": " "}) + + assert len(self._last_snapshot(emitted)["profiles"]) == 1 + + def test_set_active_profile_switches(self, store, emitted): + first = store.list()[0] + store.add("Second") + + server_module.handle_set_active_profile({"profile_id": first.id}) + + assert self._last_snapshot(emitted)["active_profile_id"] == first.id + + def test_set_active_profile_with_unknown_id_broadcasts_unchanged_snapshot( + self, store, emitted + ): + before = store.get_active().id + + server_module.handle_set_active_profile({"profile_id": "ghost"}) + + assert self._last_snapshot(emitted)["active_profile_id"] == before + + def test_rename_profile_broadcasts_new_name(self, store, emitted): + added = store.add("Rnage") + + server_module.handle_rename_profile({"profile_id": added.id, "name": "Range"}) + + assert self._last_snapshot(emitted)["profiles"][-1]["name"] == "Range" + + def test_remove_profile_deletes_inactive(self, store, emitted): + doomed = store.add("Doomed") + store.add("Keeper") + + server_module.handle_remove_profile({"profile_id": doomed.id}) + + names = [entry["name"] for entry in self._last_snapshot(emitted)["profiles"]] + assert "Doomed" not in names + + def test_remove_profile_refuses_the_active_one(self, store, emitted): + active = store.add("Active") + + server_module.handle_remove_profile({"profile_id": active.id}) + + snapshot = self._last_snapshot(emitted) + assert snapshot["active_profile_id"] == active.id + assert len(snapshot["profiles"]) == 2 + + def test_remove_profile_refuses_when_profile_has_shots(self, store, emitted, monkeypatch): + doomed = store.add("Doomed") + store.add("Keeper") + monitor = MockLaunchMonitor() + monitor.connect() + monitor.start() + shot = monitor.simulate_shot() + shot.profile_id = doomed.id + monkeypatch.setattr(server_module, "monitor", monitor) + + server_module.handle_remove_profile({"profile_id": doomed.id}) + + names = [entry["name"] for entry in self._last_snapshot(emitted)["profiles"]] + assert "Doomed" in names + assert [row.profile_id for row in monitor.get_shots()] == [doomed.id] + + def test_remove_profile_succeeds_after_session_rows_are_cleared( + self, store, emitted, monkeypatch + ): + doomed = store.add("Doomed") + store.add("Keeper") + monitor = MockLaunchMonitor() + monitor.connect() + monitor.start() + shot = monitor.simulate_shot() + shot.profile_id = doomed.id + monkeypatch.setattr(server_module, "monitor", monitor) + + server_module.handle_clear_session({"profile_id": doomed.id}) + server_module.handle_remove_profile({"profile_id": doomed.id}) + + names = [entry["name"] for entry in self._last_snapshot(emitted)["profiles"]] + assert "Doomed" not in names + assert monitor.get_shots() == [] + + def test_remove_profile_refuses_when_profile_has_swing_speed_events( + self, store, emitted, monkeypatch + ): + doomed = store.add("Doomed") + store.add("Keeper") + monitor = MockSwingSpeedMonitor() + monitor.connect() + monitor.start() + event = SwingSpeedEvent( + peak_speed_mph=100.0, + timestamp=datetime(2026, 8, 27, 10, 0, 0), + duration_ms=300.0, + reading_count=8, + trigger_speed_mph=32.0, + ) + event.profile_id = doomed.id + monitor._events[:] = [event] # pylint: disable=protected-access + monkeypatch.setattr(server_module, "monitor", monitor) + + server_module.handle_remove_profile({"profile_id": doomed.id}) + + names = [entry["name"] for entry in self._last_snapshot(emitted)["profiles"]] + assert "Doomed" in names + assert [row.profile_id for row in monitor.get_events()] == [doomed.id] + + def test_handlers_tolerate_non_dict_payloads(self, store, emitted): + server_module.handle_set_active_profile(None) + server_module.handle_add_profile("not a dict") + server_module.handle_rename_profile(None) + server_module.handle_remove_profile(None) + + assert len(self._last_snapshot(emitted)["profiles"]) == 1 + + def test_switching_active_profile_via_handler_is_seen_by_later_events( + self, store, emitted, monkeypatch + ): + """A mutation handler must change what later events are stamped with. + + Regression guard for a gap a reviewer found: earlier coverage only + called ``store.add()`` directly (which sets the new profile active as + a side effect of the mutator) and never actually went through + ``handle_set_active_profile``. This drives the real handler, then a + real event callback, so it would catch either side caching a stale + selection. + """ + first = store.list()[0] + second = store.add("Second") + monitor = MockLaunchMonitor() + monitor.connect() + monitor.start() + monkeypatch.setattr(server_module, "monitor", monitor) + + server_module.handle_set_active_profile({"profile_id": first.id}) + shot = monitor.simulate_shot(ball_speed=140.0) + on_shot_detected(shot) + + assert shot.profile_id == first.id + assert shot.profile_id != second.id + + +class TestShotProfileStamping: + """Shots take their attribution from the active profile.""" + + def test_shot_is_stamped_with_active_profile(self, tmp_path, monkeypatch): + from openflight.profiles import ProfileStore + + store = ProfileStore(tmp_path / "profiles.json") + active = store.add("Home Range") + monkeypatch.setattr(server_module, "profile_store", store) + monkeypatch.setattr(server_module.socketio, "emit", lambda *args, **kwargs: None) + + monitor = MockLaunchMonitor() + monitor.connect() + monitor.start() + monkeypatch.setattr(server_module, "monitor", monitor) + shot = monitor.simulate_shot(ball_speed=140.0) + + on_shot_detected(shot) + + assert shot.profile_id == active.id + assert shot.profile_name == "Home Range" + + def test_swing_speed_event_is_stamped_with_active_profile(self, tmp_path, monkeypatch): + from openflight.profiles import ProfileStore + + store = ProfileStore(tmp_path / "profiles.json") + active = store.add("David") + monkeypatch.setattr(server_module, "profile_store", store) + emitted = [] + monkeypatch.setattr( + server_module.socketio, "emit", lambda *args, **kwargs: emitted.append(args) + ) + + event = SwingSpeedEvent( + peak_speed_mph=101.44, + timestamp=datetime(2026, 8, 27, 10, 30, 0), + duration_ms=347.8, + reading_count=9, + trigger_speed_mph=32.25, + ) + server_module.on_swing_speed_detected(event) + + shot_payload = next(payload for name, payload in emitted if name == "shot") + assert shot_payload["shot"]["profile_id"] == active.id + assert shot_payload["shot"]["profile_name"] == "David" + + class TestHandleClearSession: - """Clear session removes only the active player's shots.""" + """Clear session removes only the active profile's rows, matched by id.""" + + @pytest.fixture(name="store") + def fixture_store(self, tmp_path, monkeypatch): + from openflight.profiles import ProfileStore - def test_removes_only_named_player_shots(self, monkeypatch): - """Other players' shots must remain after a clear.""" + store = ProfileStore(tmp_path / "profiles.json") + monkeypatch.setattr(server_module, "profile_store", store) + return store + + def test_removes_only_that_profiles_shots(self, store, monkeypatch): + james = store.add("James") + alex = store.add("Alex") monitor = MockLaunchMonitor() monitor.connect() monitor.start() - james = monitor.simulate_shot(ball_speed=140.0) - james.player_name = "James" - alex = monitor.simulate_shot(ball_speed=150.0) - alex.player_name = "Alex" + james_shot = monitor.simulate_shot(ball_speed=140.0) + james_shot.profile_id = james.id + james_shot.profile_name = "James" + alex_shot = monitor.simulate_shot(ball_speed=150.0) + alex_shot.profile_id = alex.id + alex_shot.profile_name = "Alex" emitted = [] monkeypatch.setattr(server_module, "monitor", monitor) - monkeypatch.setattr(server_module, "current_player_name", "James") monkeypatch.setattr( server_module.socketio, "emit", lambda *args, **kwargs: emitted.append(args) ) - server_module.handle_clear_session({"player_name": "James"}) + server_module.handle_clear_session({"profile_id": james.id}) - assert [shot.player_name for shot in monitor.get_shots()] == ["Alex"] + assert [shot.profile_name for shot in monitor.get_shots()] == ["Alex"] _event, payload = next(args for args in emitted if args[0] == "session_cleared") - assert payload["player_name"] == "James" - assert len(payload["shots"]) == 1 - assert payload["shots"][0]["player_name"] == "Alex" + assert payload["profile_id"] == james.id + assert [entry["profile_name"] for entry in payload["shots"]] == ["Alex"] - def test_uses_current_player_when_payload_omits_name(self, monkeypatch): - """Socket clients that omit player_name still clear the active player.""" + def test_uses_active_profile_when_payload_omits_id(self, store, monkeypatch): + alex = store.add("Alex") + james = store.add("James") + store.set_active(alex.id) monitor = MockLaunchMonitor() monitor.connect() monitor.start() first = monitor.simulate_shot() - first.player_name = "Alex" + first.profile_id = alex.id second = monitor.simulate_shot() - second.player_name = "James" + second.profile_id = james.id monkeypatch.setattr(server_module, "monitor", monitor) - monkeypatch.setattr(server_module, "current_player_name", "Alex") monkeypatch.setattr(server_module.socketio, "emit", lambda *args, **kwargs: None) server_module.handle_clear_session() - assert [shot.player_name for shot in monitor.get_shots()] == ["James"] + assert [shot.profile_id for shot in monitor.get_shots()] == [james.id] - def test_matches_player_name_case_insensitively(self, monkeypatch): - """UI and radar casing should not leave a player's shots behind.""" + def test_profiles_with_names_differing_only_in_case_do_not_collide(self, store, monkeypatch): + """The old name-keyed code folded case and cleared both. Ids must not.""" + lower = store.add("james") + upper = store.add("James") monitor = MockLaunchMonitor() monitor.connect() monitor.start() - shot = monitor.simulate_shot() - shot.player_name = "james" - other = monitor.simulate_shot() - other.player_name = "Alex" + lower_shot = monitor.simulate_shot() + lower_shot.profile_id = lower.id + lower_shot.profile_name = "james" + upper_shot = monitor.simulate_shot() + upper_shot.profile_id = upper.id + upper_shot.profile_name = "James" monkeypatch.setattr(server_module, "monitor", monitor) - monkeypatch.setattr(server_module, "current_player_name", "James") monkeypatch.setattr(server_module.socketio, "emit", lambda *args, **kwargs: None) - server_module.handle_clear_session({"player_name": " JAMES "}) + server_module.handle_clear_session({"profile_id": lower.id}) - assert [shot.player_name for shot in monitor.get_shots()] == ["Alex"] + assert [shot.profile_name for shot in monitor.get_shots()] == ["James"] - def test_treats_missing_player_name_as_player_1(self, monkeypatch): - """Unstamped shots belong to the default player.""" + def test_unstamped_shots_belong_to_no_profile(self, store, monkeypatch): + active = store.get_active() monitor = MockLaunchMonitor() monitor.connect() monitor.start() - unstamped = monitor.simulate_shot() - unstamped.player_name = "Player 1" - named = monitor.simulate_shot() - named.player_name = "Alex" + monitor.simulate_shot() monkeypatch.setattr(server_module, "monitor", monitor) - monkeypatch.setattr(server_module, "current_player_name", "Player 1") monkeypatch.setattr(server_module.socketio, "emit", lambda *args, **kwargs: None) - server_module.handle_clear_session({"player_name": "Player 1"}) + server_module.handle_clear_session({"profile_id": active.id}) - assert [shot.player_name for shot in monitor.get_shots()] == ["Alex"] - - def test_clears_only_that_player_swing_speed_events(self, monkeypatch): - """Swing-speed mode stores reps, not ball-flight shots.""" - monitor = MockSwingSpeedMonitor() - james = monitor.simulate_shot(peak_speed=95.0) - james.player_name = "James" - alex = monitor.simulate_shot(peak_speed=100.0) - alex.player_name = "Alex" + assert len(monitor.get_shots()) == 1 + def test_emits_cleared_payload_without_monitor(self, store, monkeypatch): + """UI still gets an ack so the confirm dialog can close.""" + active = store.get_active() emitted = [] - monkeypatch.setattr(server_module, "monitor", monitor) - monkeypatch.setattr(server_module, "current_player_name", "James") + monkeypatch.setattr(server_module, "monitor", None) monkeypatch.setattr( server_module.socketio, "emit", lambda *args, **kwargs: emitted.append(args) ) - server_module.handle_clear_session({"player_name": "James"}) + server_module.handle_clear_session({"profile_id": active.id}) - assert [event.player_name for event in monitor.get_events()] == ["Alex"] _event, payload = next(args for args in emitted if args[0] == "session_cleared") - assert payload["shots"][0]["player_name"] == "Alex" + assert payload["profile_id"] == active.id - def test_emits_cleared_payload_without_monitor(self, monkeypatch): - """UI still gets an ack so the confirm dialog can close.""" - emitted = [] - monkeypatch.setattr(server_module, "monitor", None) - monkeypatch.setattr(server_module, "current_player_name", "James") - monkeypatch.setattr( - server_module.socketio, "emit", lambda *args, **kwargs: emitted.append(args) - ) + def test_clears_only_that_profiles_swing_speed_events(self, store, monkeypatch): + james = store.add("James") + alex = store.add("Alex") + monitor = MockSwingSpeedMonitor() + monitor.connect() + monitor.start() + first = SwingSpeedEvent( + peak_speed_mph=100.0, + timestamp=datetime(2026, 8, 27, 10, 0, 0), + duration_ms=300.0, + reading_count=8, + trigger_speed_mph=32.0, + ) + first.profile_id = james.id + second = SwingSpeedEvent( + peak_speed_mph=105.0, + timestamp=datetime(2026, 8, 27, 10, 1, 0), + duration_ms=310.0, + reading_count=8, + trigger_speed_mph=32.0, + ) + second.profile_id = alex.id + monitor._events[:] = [first, second] # pylint: disable=protected-access - server_module.handle_clear_session({"player_name": "James"}) + monkeypatch.setattr(server_module, "monitor", monitor) + monkeypatch.setattr(server_module.socketio, "emit", lambda *args, **kwargs: None) - _event, payload = next(args for args in emitted if args[0] == "session_cleared") - assert payload == {"player_name": "James", "shots": []} + server_module.handle_clear_session({"profile_id": james.id}) + + assert [ + event.profile_id + for event in monitor._events # pylint: disable=protected-access + ] == [alex.id] + + +class TestSessionStatePayload: + """session_state no longer carries a selection, so it cannot race.""" + + def test_payload_has_no_selection_field(self, monkeypatch): + monitor = MockLaunchMonitor() + monitor.connect() + monitor.start() + monkeypatch.setattr(server_module, "monitor", monitor) + + payload = server_module._session_state_payload() # pylint: disable=protected-access + + assert "player_name" not in payload + assert "profile_id" not in payload + assert "active_profile_id" not in payload class TestRadarLaunchGuard: diff --git a/ui/README.md b/ui/README.md index be6969fc1..9321b75ae 100644 --- a/ui/README.md +++ b/ui/README.md @@ -2,7 +2,7 @@ The OpenFlight dashboard: a React + TypeScript + Vite app that connects to the backend over `socket.io`. The kiosk is a tabbed instrument panel (Live, Stats, -Shots, Camera, Players, Debug) plus a screen-mounted display mode at `/display`. +Shots, Camera, Profiles, Debug) plus a screen-mounted display mode at `/display`. This README covers frontend development. For the hardware, the radar pipeline, and how the whole system fits together, see the [root README](../README.md). @@ -23,7 +23,7 @@ npm run dev:mock Open `http://localhost:5173`. Use **Simulate** to generate shots. The mock speaks the same Socket.IO events as the real backend (`shot`, `session_state`, -club/player changes, clear/delete, stub cloud upload and shutdown). +club/profile changes, clear/delete, stub cloud upload and shutdown). ### Against the real / Python mock backend @@ -81,10 +81,43 @@ The app is entirely client-side. Everything flows through one socket connection. `simulate_shot`, and `toggle_camera`. Read it before assuming what the backend emits. **`mock-server/`** implements that contract in Node for `npm run dev:mock`. -- **State** lives in `stores/` (Zustand: shots, system, camera, debug, …). +- **State** lives in `stores/` (Zustand: shots, system, camera, debug, profiles, …). - **Shutdown** posts to `/api/shutdown` to stop the connected backend (stubbed as a no-op by the Node mock). +### Profiles socket contract + +Shots are attributed to a server-owned **profile** — a person or a place — with +a stable id. There is no browser-local roster: the server is the only source of +truth, sent as one snapshot plus five mutations. + +Server → client: + +- `profiles` — `{ profiles: Profile[], active_profile_id: string }`. Sent on + connect and after every mutation below. `Profile` is + `{ id, name, created_at, settings }`. +- `session_cleared` — `{ profile_id: string, shots: Shot[] }`, sent after + `clear_session`. + +Client → server: + +- `get_profiles` — request a fresh `profiles` snapshot. +- `set_active_profile` — `{ profile_id }`. Switches which profile new shots + attribute to. +- `add_profile` — `{ name }`. Creates a profile and makes it active. +- `rename_profile` — `{ profile_id, name }`. Renames in place; the id (and + every shot already attributed to it) is unchanged. +- `remove_profile` — `{ profile_id }`. The server refuses to remove the active + profile, the last remaining profile, or a profile that still has session + shots. Clear that profile's session first. +- `clear_session` — `{ profile_id }` (defaults to the active profile). + Deletes that profile's shots; other profiles are untouched. + +Shots carry `profile_id` and `profile_name` (a snapshot at capture time) for +logging / external consumers. The kiosk UI renders the current profile name +from the `profiles` roster (joined by `profile_id`), so renaming updates the +on-screen name for past shots. + **Kiosk shell.** Footer tabs switch views. The footer logo opens a sheet for units (MPH/YDS vs KMH/M), dark/light theme, language, simulator and ball-detection status. The footer power icon is always visible and opens a @@ -129,7 +162,7 @@ is stored in `localStorage` under `openflight.locale:v1`. - Import the catalog and add it to `catalogs`. 4. Run `npm test` — a catalog that drifts from English keys fails. -Do not translate player names, club tile codes (`7i`, `DR`), or unit +Do not translate profile names, club tile codes (`7i`, `DR`), or unit abbreviations (`MPH` / `YDS`). ## Project layout @@ -146,9 +179,9 @@ src/ services/socketService.ts # socket connection, events, backend commands hooks/useSocket.ts # connects on mount utils/serverOrigin.ts # backend origin resolution - stores/ # Zustand (shots, system, camera, players, …) + stores/ # Zustand (shots, system, camera, profiles, …) components/ - panel/ # Live, Stats, Shots, Camera, Players, chrome + panel/ # Live, Stats, Shots, Camera, Profiles, chrome ui/ # MetricCard, TabBar, Button, SegmentedControl DisplayMode.tsx # /display DebugPanel.tsx diff --git a/ui/mock-server/handlers.ts b/ui/mock-server/handlers.ts index a3cb9f7d2..496111fd0 100644 --- a/ui/mock-server/handlers.ts +++ b/ui/mock-server/handlers.ts @@ -26,6 +26,7 @@ export function registerHandlers(io: Server, session: MockSession): void { console.log('[mock-server] client connected'); socket.emit('session_state', session.sessionStatePayload(true)); + socket.emit('profiles', session.snapshot()); socket.emit('trigger_status', session.triggerStatus()); socket.emit('radar_config', session.radarConfig); socket.emit('camera_status', session.cameraStatus()); @@ -78,11 +79,6 @@ export function registerHandlers(io: Server, session: MockSession): void { io.emit('club_changed', { club }); }); - socket.on('set_player', (data: { player_name?: string }) => { - const playerName = session.setPlayer(data?.player_name); - io.emit('player_changed', { player_name: playerName }); - }); - socket.on('set_training_implement', (data: { implement?: string }) => { const implement = session.setTrainingImplement(data?.implement ?? 'driver'); io.emit('training_implement_changed', { @@ -91,14 +87,34 @@ export function registerHandlers(io: Server, session: MockSession): void { }); }); - socket.on('clear_session', (data?: { player_name?: string }) => { - const playerName = - typeof data?.player_name === 'string' && data.player_name.trim() - ? data.player_name - : session.playerName; - session.clearPlayer(playerName); - io.emit('session_cleared', { player_name: playerName, shots: session.shots }); - io.emit('session_state', session.sessionStatePayload(true)); + const emitProfiles = () => io.emit('profiles', session.snapshot()); + + socket.on('get_profiles', emitProfiles); + + socket.on('set_active_profile', (data: { profile_id?: string }) => { + session.setActiveProfile(data?.profile_id); + emitProfiles(); + }); + + socket.on('add_profile', (data: { name?: string }) => { + session.addProfile(data?.name); + emitProfiles(); + }); + + socket.on('rename_profile', (data: { profile_id?: string; name?: string }) => { + session.renameProfile(data?.profile_id, data?.name); + emitProfiles(); + }); + + socket.on('remove_profile', (data: { profile_id?: string }) => { + session.removeProfile(data?.profile_id); + emitProfiles(); + }); + + socket.on('clear_session', (data?: { profile_id?: string }) => { + const profileId = data?.profile_id || session.activeProfile.id; + session.clearProfile(profileId); + io.emit('session_cleared', { profile_id: profileId, shots: session.shots }); }); socket.on('delete_shot', (data: { timestamp?: string }) => { diff --git a/ui/mock-server/session.ts b/ui/mock-server/session.ts index 4b1c90dd5..03d61d0b4 100644 --- a/ui/mock-server/session.ts +++ b/ui/mock-server/session.ts @@ -1,9 +1,10 @@ /** - * In-memory mock session: shots, club/player, and stats recompute. + * In-memory mock session: shots, club/profile, and stats recompute. */ import type { SessionStats, Shot, TriggerStatus } from '../src/types/shot.js'; import type { RadarConfig } from '../src/types/socket.js'; +import type { Profile } from '../src/types/profile.js'; import { generateShot } from './shotGenerator.js'; function mean(values: number[]): number { @@ -58,7 +59,11 @@ export function computeSessionStats(shots: Shot[]): SessionStats { export class MockSession { shots: Shot[] = []; club = 'driver'; - playerName = 'Player 1'; + profiles: Profile[] = [ + { id: 'mock-profile-1', name: 'Profile 1', created_at: '2026-01-01T00:00:00Z', settings: {} }, + ]; + activeProfileId = 'mock-profile-1'; + private nextProfileNumber = 2; trainingImplement = 'driver'; debugMode = false; radarConfig: RadarConfig = { @@ -82,11 +87,56 @@ export class MockSession { return computeSessionStats(this.shots); } + get activeProfile(): Profile { + return this.profiles.find((profile) => profile.id === this.activeProfileId) ?? this.profiles[0]!; + } + + snapshot() { + return { profiles: this.profiles, active_profile_id: this.activeProfile.id }; + } + + addProfile(rawName: unknown): void { + const name = String(rawName ?? '').trim().slice(0, 40); + if (!name || this.profiles.length >= 12) return; + const profile: Profile = { + id: `mock-profile-${this.nextProfileNumber++}`, + name, + created_at: new Date().toISOString(), + settings: {}, + }; + this.profiles.push(profile); + this.activeProfileId = profile.id; + } + + renameProfile(profileId: unknown, rawName: unknown): void { + const name = String(rawName ?? '').trim().slice(0, 40); + const profile = this.profiles.find((entry) => entry.id === profileId); + if (!name || !profile) return; + profile.name = name; + } + + removeProfile(profileId: unknown): void { + // Same refusals as the real store: never the active one, never the last, + // never one that still has session rows (those would be orphaned). + if (profileId === this.activeProfileId || this.profiles.length <= 1) return; + if (this.shots.some((shot) => shot.profile_id === profileId)) return; + this.profiles = this.profiles.filter((entry) => entry.id !== profileId); + } + + setActiveProfile(profileId: unknown): void { + if (this.profiles.some((entry) => entry.id === profileId)) { + this.activeProfileId = String(profileId); + } + } + + clearProfile(profileId: string): void { + this.shots = this.shots.filter((shot) => shot.profile_id !== profileId); + } + sessionStatePayload(includeMeta = true) { const base = { stats: this.getStats(), shots: this.shots, - player_name: this.playerName, club: this.club, }; if (!includeMeta) { @@ -120,32 +170,23 @@ export class MockSession { return this.club; } - setPlayer(rawName: unknown): string { - const name = String(rawName ?? 'Player 1').trim().slice(0, 40) || 'Player 1'; - this.playerName = name; - return this.playerName; - } - setTrainingImplement(implement: string): string { this.trainingImplement = implement || 'driver'; return this.trainingImplement; } simulateShot(): { shot: Shot; stats: SessionStats } { - const shot = generateShot({ club: this.club, playerName: this.playerName }); + const shot = generateShot({ + club: this.club, + profileId: this.activeProfile.id, + profileName: this.activeProfile.name, + }); this.shots.push(shot); this.triggersTotal += 1; this.triggersAccepted += 1; return { shot, stats: this.getStats() }; } - clearPlayer(playerName: string): void { - const key = (playerName.trim() || 'Player 1').toLowerCase(); - this.shots = this.shots.filter( - (shot) => (shot.player_name?.trim() || 'Player 1').toLowerCase() !== key - ); - } - clear(): void { this.shots = []; } diff --git a/ui/mock-server/shotGenerator.ts b/ui/mock-server/shotGenerator.ts index 21f4333b9..f93331a67 100644 --- a/ui/mock-server/shotGenerator.ts +++ b/ui/mock-server/shotGenerator.ts @@ -111,7 +111,8 @@ export function estimateCarryYards(ballSpeedMph: number): number { export interface GenerateShotOptions { club: string; - playerName: string; + profileId: string; + profileName: string; ballSpeed?: number; } @@ -143,7 +144,8 @@ export function generateShot(options: GenerateShotOptions): Shot { estimated_carry_yards: carry, carry_range: [Math.round(carry * 0.95), Math.round(carry * 1.05)], club, - player_name: options.playerName, + profile_id: options.profileId, + profile_name: options.profileName, timestamp: new Date().toISOString(), peak_magnitude: null, launch_angle_vertical: Math.round(launchV * 10) / 10, diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts index 8a94eac29..b453304f2 100644 --- a/ui/playwright.config.ts +++ b/ui/playwright.config.ts @@ -1,5 +1,6 @@ import { defineConfig, devices } from '@playwright/test'; import { fileURLToPath } from 'node:url'; +import { PROFILES_PATH_ENV, uniqueE2eProfilesPath } from './tests/e2e/isolateProfilesPath'; const PORT = 5173; const HOST = '127.0.0.1'; @@ -11,8 +12,20 @@ const BACKEND_COMMAND = ? `python -m openflight.server ${BACKEND_ARGS}` : `uv run openflight-server ${BACKEND_ARGS}`; +const E2E_PROFILES_PATH = uniqueE2eProfilesPath(); + +function backendEnv(): { [key: string]: string } { + const env: { [key: string]: string } = {}; + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined) env[key] = value; + } + env[PROFILES_PATH_ENV] = E2E_PROFILES_PATH; + return env; +} + export default defineConfig({ testDir: './tests/e2e', + testMatch: '**/*.spec.ts', fullyParallel: false, workers: 1, retries: process.env.CI ? 2 : 0, @@ -27,8 +40,9 @@ export default defineConfig({ { command: BACKEND_COMMAND, url: `http://${HOST}:8080`, - reuseExistingServer: !process.env.CI, + reuseExistingServer: false, cwd: fileURLToPath(new URL('..', import.meta.url)), + env: backendEnv(), }, { command: `npm run dev -- --host ${HOST} --port ${PORT} --mode test`, diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx index 1edcce85a..f98b2501e 100644 --- a/ui/src/App.test.tsx +++ b/ui/src/App.test.tsx @@ -20,13 +20,13 @@ describe('App shell', () => { expect(html).not.toContain('unit-toggle'); }); - it('renders every panel tab, with Players as a first-class view', () => { + it('renders every panel tab, with Profiles as a first-class view', () => { const html = renderToString(); for (const view of PANEL_VIEWS) { expect(html).toContain(`${view.label}`); } - expect(PANEL_VIEWS.map((view) => view.id)).toEqual(['live', 'stats', 'shots', 'camera', 'players', 'debug']); + expect(PANEL_VIEWS.map((view) => view.id)).toEqual(['live', 'stats', 'shots', 'camera', 'profiles', 'debug']); }); it('marks the Live tab pressed and shows the Live panel', () => { @@ -76,8 +76,8 @@ describe('App shell', () => { it('does not ask to clear a session until the stats action is used', () => { const html = renderToString(); - expect(html).not.toContain("Clear Player 1's session?"); - expect(html).not.toContain('Clear Player 1's session?'); + expect(html).not.toContain("Clear Profile 1's session?"); + expect(html).not.toContain('Clear Profile 1's session?'); expect(html).not.toContain('clear-session-title'); }); }); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 07e1357d6..9345bcf6c 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -5,11 +5,10 @@ import { useSystemStore } from './stores/useSystemStore'; import { useShotStore } from './stores/useShotStore'; import { useCameraStore } from './stores/useCameraStore'; import { useDebugStore } from './stores/useDebugStore'; -import { usePlayerStore } from './stores/usePlayerStore'; +import { useProfileStore } from './stores/useProfileStore'; import { useHeroMetricStore } from './stores/useHeroMetricStore'; import { useCameraReplayController } from './hooks/useCameraReplayController'; import { socketService } from './services/socketService'; -import { shouldEchoSelectionToServer } from './services/playerSocketSync'; import { DebugPanel } from './components/DebugPanel'; import { DisplayMode } from './components/DisplayMode'; import { SimShotBadges } from './components/SimShotBadges'; @@ -19,7 +18,8 @@ import { CameraReplayDialog } from './components/CameraReplayDialog'; import { CameraPanel, LivePanel, - AddPlayerDialog, + ProfileNameDialog, + ProfilesPanel, ClearSessionDialog, SimulateBubble, MenuSheet, @@ -27,7 +27,6 @@ import { PanelHeader, PanelAction, PickerOverlay, - PlayersPanel, ShotsPanel, StatsPanel, clubSections, @@ -35,7 +34,8 @@ import { type PanelView, } from './components/panel'; import { shouldEnableLiveBallWarning } from './components/panel/liveMetrics'; -import { filterShotsByPlayer } from './types/shot'; +import { filterShotsByProfile } from './types/shot'; +import type { Profile } from './types/profile'; import { getClubName } from './data/clubs'; import { getTrainingImplementLabel } from './data/trainingImplements'; import { unlockAudioCue } from './utils/audioCue'; @@ -72,16 +72,15 @@ function AppContent() { captureSettingsError: state.captureSettingsError, })) ); - const { selectedPlayer, players, selectPlayer, addPlayer, removePlayer } = usePlayerStore( + const { profiles, activeProfileId, profilesLoaded } = useProfileStore( useShallow((state) => ({ - selectedPlayer: state.selectedPlayer, - players: state.players, - selectPlayer: state.selectPlayer, - addPlayer: state.addPlayer, - removePlayer: state.removePlayer, + profiles: state.profiles, + activeProfileId: state.activeProfileId, + profilesLoaded: state.loaded, })) ); - const serverPlayerName = useSystemStore((state) => state.serverPlayerName); + const activeProfile = profiles.find((profile) => profile.id === activeProfileId) ?? null; + const activeProfileName = activeProfile?.name ?? ''; const { heroMetricId, setHeroMetricId } = useHeroMetricStore( useShallow((state) => ({ heroMetricId: state.heroMetricId, setHeroMetricId: state.setHeroMetricId })) ); @@ -115,8 +114,8 @@ function AppContent() { // shot; dismissing keeps the default. The /display route returns early below, // so this never appears in the passive TV view. const [pickerOpen, setPickerOpen] = useState(true); - const [addPlayerOpen, setAddPlayerOpen] = useState(false); - const [newPlayerName, setNewPlayerName] = useState(''); + const [profileDialog, setProfileDialog] = useState<{ mode: 'add' | 'rename'; target: Profile | null } | null>(null); + const [profileDialogName, setProfileDialogName] = useState(''); const [clearSessionOpen, setClearSessionOpen] = useState(false); const { activeReplay, openReplay, closeReplay, reportPlaybackError } = useCameraReplayController(); @@ -128,15 +127,6 @@ function AppContent() { setAppliedServerClub(serverClub); setSelectedClub(serverClub); } - // Same pattern for a server-pushed player change. This used to live in - // PlayerPicker, which the menu sheet replaced. - const [appliedServerPlayer, setAppliedServerPlayer] = useState(null); - if (serverPlayerName && serverPlayerName !== appliedServerPlayer) { - setAppliedServerPlayer(serverPlayerName); - if (serverPlayerName !== selectedPlayer) { - selectPlayer(serverPlayerName); - } - } const { isLaunchDaddyMode, isExploding, triggerExplosion } = useLaunchDaddy(); const isDisplayRoute = typeof window !== 'undefined' && window.location.pathname.replace(/\/$/, '') === '/display'; @@ -145,14 +135,6 @@ function AppContent() { ? getTrainingImplementLabel(selectedTrainingImplement) : getClubName(selectedClub); - // Push the local player to the server once connected, so a reload restores it. - // Do not re-emit when selectedPlayer changes: that echoes player_changed back - // as set_player and races with the connect-time session_state snapshot. - useEffect(() => { - if (!connected || !shouldEchoSelectionToServer('became-connected')) return; - socketService.setPlayer(usePlayerStore.getState().selectedPlayer); - }, [connected]); - useEffect(() => { return socketService.onSessionCleared(() => { setClearSessionOpen(false); @@ -179,23 +161,41 @@ function AppContent() { }; }, []); - const handleSelectPlayer = (playerName: string) => { - selectPlayer(playerName); - socketService.setPlayer(playerName); + const handleSelectProfile = (profileId: string) => { + socketService.setActiveProfile(profileId); setCurrentView('live'); }; - const handleRemovePlayer = (playerName: string) => { - if (playerName === usePlayerStore.getState().selectedPlayer) return; - removePlayer(playerName); + const handleRemoveProfile = (profileId: string) => { + // The server refuses to remove the active profile; don't offer it either. + if (profileId === activeProfileId) return; + socketService.removeProfile(profileId); + }; + + const openAddProfile = () => { + setProfileDialog({ mode: 'add', target: null }); + setProfileDialogName(''); }; - const handleAddPlayer = () => { - if (!newPlayerName.trim()) return; - const playerName = addPlayer(newPlayerName); - socketService.setPlayer(playerName); - setNewPlayerName(''); - setAddPlayerOpen(false); + const openRenameProfile = (profile: Profile) => { + setProfileDialog({ mode: 'rename', target: profile }); + setProfileDialogName(profile.name); + }; + + const closeProfileDialog = () => { + setProfileDialog(null); + setProfileDialogName(''); + }; + + const handleConfirmProfileDialog = () => { + const name = profileDialogName.trim(); + if (!name || !profileDialog) return; + if (profileDialog.mode === 'add') { + socketService.addProfile(name); + } else if (profileDialog.target) { + socketService.renameProfile(profileDialog.target.id, name); + } + closeProfileDialog(); }; const handlePickerSelect = (id: string) => { @@ -223,10 +223,10 @@ function AppContent() { setShutdownState('confirm'); }; - const playerShots = filterShotsByPlayer(shots, selectedPlayer); - const playerLatestShot = playerShots[playerShots.length - 1] ?? null; - const playerIsNewShot = Boolean( - isNewShot && latestShot && playerLatestShot && latestShot.timestamp === playerLatestShot.timestamp + const profileShots = filterShotsByProfile(shots, activeProfileId); + const profileLatestShot = profileShots[profileShots.length - 1] ?? null; + const profileIsNewShot = Boolean( + isNewShot && latestShot && profileLatestShot && latestShot.timestamp === profileLatestShot.timestamp ); if (isDisplayRoute) { @@ -238,7 +238,7 @@ function AppContent() { {isSwingSpeedMode ? t('app.changeImplement') : t('app.changeClub')} ); - const latestReplay = playerLatestShot?.camera_replay; + const latestReplay = profileLatestShot?.camera_replay; const liveHeaderActions = ( <> @@ -251,7 +251,7 @@ function AppContent() { ); - const addPlayerAction = setAddPlayerOpen(true)}>{t('menu.addPlayer')}; + const addProfileAction = {t('menu.addProfile')}; const clearSessionAction = ( setClearSessionOpen(true)}> @@ -301,14 +301,15 @@ function AppContent() { } )} - {currentView === 'players' && ( - )} {currentView === 'stats' && ( )} {currentView === 'shots' && ( socketService.deleteShot(timestamp)} onReplayShot={(shot) => { @@ -403,22 +408,20 @@ function AppContent() { /> ) : null} - {addPlayerOpen ? ( - { - setNewPlayerName(''); - setAddPlayerOpen(false); - }} + {profileDialog ? ( + ) : null} {clearSessionOpen ? ( socketService.clearSession(selectedPlayer)} + profileName={activeProfileName} + onConfirm={() => socketService.clearSession(activeProfileId)} onCancel={() => setClearSessionOpen(false)} /> ) : null} @@ -439,7 +442,7 @@ function AppContent() { onChangeView={setCurrentView} onOpenMenu={() => setMenuOpen((open) => !open)} menuOpen={menuOpen} - shotCount={playerShots.length} + shotCount={profileShots.length} cameraStreaming={cameraStatus.streaming} ballDetected={cameraStatus.ball_detected} debugRecording={debugMode} diff --git a/ui/src/components/panel/AddPlayerDialog.test.tsx b/ui/src/components/panel/AddPlayerDialog.test.tsx deleted file mode 100644 index c8f85a1f3..000000000 --- a/ui/src/components/panel/AddPlayerDialog.test.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { renderToString } from 'react-dom/server'; -import { describe, expect, it } from 'vitest'; -import { AddPlayerDialog } from './AddPlayerDialog'; - -describe('AddPlayerDialog', () => { - it('is a modal with an app-styled name field and Add player action', () => { - const html = renderToString( {}} onAdd={() => {}} onCancel={() => {}} />); - - expect(html).toContain('add-player-modal'); - expect(html).toContain('role="dialog"'); - expect(html).toContain('aria-modal="true"'); - expect(html).toContain('aria-label="Add player"'); - expect(html).toContain('add-player-modal__input'); - expect(html).toContain('placeholder="Name"'); - expect(html).toContain('>Add player<'); - expect(html).toContain('>Cancel<'); - expect(html).not.toContain('shutdown-dialog'); - }); - - it('disables Add player until a name is entered', () => { - const empty = renderToString( - {}} onAdd={() => {}} onCancel={() => {}} /> - ); - const filled = renderToString( - {}} onAdd={() => {}} onCancel={() => {}} /> - ); - - expect(empty).toMatch(/disabled[^>]*>Add player]*>Add player void; - onAdd: () => void; - onCancel: () => void; -} - -export function AddPlayerDialog({ name, onChange, onAdd, onCancel }: AddPlayerDialogProps) { - const { t } = useI18n(); - const canAdd = Boolean(name.trim()); - - return ( -
-
- ); -} diff --git a/ui/src/components/panel/ClearSessionDialog.test.tsx b/ui/src/components/panel/ClearSessionDialog.test.tsx index 2814180e9..d6a3384c3 100644 --- a/ui/src/components/panel/ClearSessionDialog.test.tsx +++ b/ui/src/components/panel/ClearSessionDialog.test.tsx @@ -5,24 +5,24 @@ import { ClearSessionDialog } from './ClearSessionDialog'; const noop = () => {}; describe('ClearSessionDialog', () => { - it('asks before clearing and names the player', () => { - const html = renderToString(); + it('asks before clearing and names the profile', () => { + const html = renderToString(); expect(html).toContain('role="dialog"'); expect(html).toContain('aria-modal="true"'); expect(html).toContain('id="clear-session-title"'); expect(html).toContain('Clear Alex's session?'); - expect(html).toContain('This removes Alex's shots. Other players are kept.'); + expect(html).toContain('This removes Alex's shots. Other profiles are kept.'); expect(html).toContain('>Clear session<'); expect(html).toContain('>Cancel<'); - expect(html).toContain('add-player-modal'); + expect(html).toContain('clear-session-modal'); expect(html).toContain('panel-action--danger'); }); it('keeps the confirm control as Clear session, matching the header action', () => { - const html = renderToString(); + const html = renderToString(); - expect(html).toContain('Clear Player 1's session?'); + expect(html).toContain('Clear Profile 1's session?'); expect(html).toMatch(/panel-action--danger[^>]*>Clear session void; onCancel: () => void; } -export function ClearSessionDialog({ playerName, onConfirm, onCancel }: ClearSessionDialogProps) { +export function ClearSessionDialog({ profileName, onConfirm, onCancel }: ClearSessionDialogProps) { const { t } = useI18n(); return ( -
+
- ), - }); - const header = html.match(/
[\s\S]*?<\/header>/)?.[0] ?? ''; - - expect(header).toContain('Add player'); - }); - - it('lays out player cards in a vertically scrollable grid', () => { - const html = render({ players: ['James', 'Alex'] }); - - expect(html).toContain('players-panel__grid'); - expect(html).toContain('aria-label="Players"'); - expect(html).toContain('James'); - expect(html).toContain('Alex'); - }); - - it('marks the selected player and hides remove when only one remains', () => { - const html = render(); - - expect(html).toContain('aria-pressed="true"'); - expect(html).not.toContain('Remove James'); - }); - - it('does not offer remove on the active player', () => { - const html = render({ players: ['James', 'Alex'], selectedPlayer: 'James' }); - - expect(html).not.toContain('aria-label="Remove James"'); - expect(html).toContain('aria-label="Remove Alex"'); - }); - - it('counts shots for each player independently', () => { - const html = render({ - players: ['James', 'Alex'], - shots: [ - makeShot({ player_name: 'James', timestamp: 'a' }), - makeShot({ player_name: 'James', timestamp: 'b' }), - makeShot({ player_name: 'Alex', timestamp: 'c' }), - ], - }); - - expect(html).toContain('2 shots'); - expect(html).toContain('1 shot'); - }); -}); diff --git a/ui/src/components/panel/PlayersPanel.tsx b/ui/src/components/panel/PlayersPanel.tsx deleted file mode 100644 index a07a33ccb..000000000 --- a/ui/src/components/panel/PlayersPanel.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { useMemo, useRef, type ReactNode } from 'react'; -import type { Shot } from '../../types/shot'; -import { filterShotsByPlayer } from '../../types/shot'; -import { useDragScroll } from '../../hooks/useDragScroll'; -import { useI18n } from '../../i18n/useI18n'; -import { PanelHeader } from './PanelHeader'; - -interface PlayersPanelProps { - players: string[]; - selectedPlayer: string; - shots: Shot[]; - onSelectPlayer: (name: string) => void; - onRemovePlayer: (name: string) => void; - /** Pinned header control, e.g. Add player. */ - headerAction?: ReactNode; -} - -export function PlayersPanel({ - players, - selectedPlayer, - shots, - onSelectPlayer, - onRemovePlayer, - headerAction, -}: PlayersPanelProps) { - const { t } = useI18n(); - const rosterRef = useRef(null); - const dragScroll = useDragScroll(rosterRef); - const canRemove = players.length > 1; - const shotCounts = useMemo(() => { - const counts: Record = {}; - for (const playerName of players) { - counts[playerName] = filterShotsByPlayer(shots, playerName).length; - } - return counts; - }, [players, shots]); - - return ( -
- -
- {players.map((playerName) => { - const selected = playerName === selectedPlayer; - const count = shotCounts[playerName] ?? 0; - const shotLabel = t(count === 1 ? 'players.shot' : 'players.shots', { count }); - - return ( -
- - {canRemove && !selected ? ( - - ) : null} -
- ); - })} -
-
- ); -} diff --git a/ui/src/components/panel/ProfileNameDialog.test.tsx b/ui/src/components/panel/ProfileNameDialog.test.tsx new file mode 100644 index 000000000..8f17603f4 --- /dev/null +++ b/ui/src/components/panel/ProfileNameDialog.test.tsx @@ -0,0 +1,69 @@ +import { renderToString } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { ProfileNameDialog } from './ProfileNameDialog'; + +/** React SSR splits interpolated text with comment markers; drop them. */ +function text(html: string): string { + return html.replace(//g, ''); +} + +function render(overrides: Partial[0]> = {}) { + return text( + renderToString( + {}} + onConfirm={() => {}} + onCancel={() => {}} + {...overrides} + /> + ) + ); +} + +describe('ProfileNameDialog', () => { + it('titles itself for the add mode', () => { + const html = render(); + + expect(html).toContain('aria-label="Add profile"'); + expect(html).toContain('>Add profile<'); + }); + + it('titles itself for the rename mode', () => { + const html = render({ mode: 'rename', name: 'Home' }); + + expect(html).toContain('aria-label="Rename profile"'); + expect(html).toContain('>Rename profile<'); + }); + + it('caps the name at 40 characters', () => { + const html = render(); + + expect(html).toContain('maxLength="40"'); + }); + + it('disables confirm for a blank name', () => { + const html = render({ name: ' ' }); + const confirmButton = html.match(/]*panel-action--primary[^>]*>[\s\S]*?<\/button>/)?.[0] ?? ''; + + expect(confirmButton).toContain('disabled=""'); + }); + + it('does not disable confirm when the name has content', () => { + const html = render({ name: 'Range' }); + const confirmButton = html.match(/]*panel-action--primary[^>]*>[\s\S]*?<\/button>/)?.[0] ?? ''; + + expect(confirmButton).not.toContain('disabled=""'); + }); + + it('ships an on-screen keyboard and keeps the native OSK down', () => { + const html = render(); + + expect(html).toContain('aria-label="Keyboard"'); + expect(html).toContain('>Q<'); + expect(html).toContain('aria-label="Backspace"'); + expect(html).toContain('aria-label="Space"'); + expect(html).toContain('inputMode="none"'); + }); +}); diff --git a/ui/src/components/panel/ProfileNameDialog.tsx b/ui/src/components/panel/ProfileNameDialog.tsx new file mode 100644 index 000000000..4ae88897a --- /dev/null +++ b/ui/src/components/panel/ProfileNameDialog.tsx @@ -0,0 +1,152 @@ +import { useState } from 'react'; +import { PanelAction } from './PanelAction'; +import { useI18n } from '../../i18n/useI18n'; + +const PROFILE_NAME_MAX = 40; + +const LETTER_ROWS = [ + ['Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P'], + ['A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L'], + ['Z', 'X', 'C', 'V', 'B', 'N', 'M'], +] as const; + +const NUMBER_ROWS = [ + ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'], + ['-', "'", '.', '_'], +] as const; + +function appendName(name: string, chunk: string): string { + const room = PROFILE_NAME_MAX - name.length; + if (room <= 0) return name; + return name + chunk.slice(0, room); +} + +interface ProfileNameDialogProps { + /** Add and rename differ only in copy and initial value, so one dialog serves both. */ + mode: 'add' | 'rename'; + name: string; + onChange: (name: string) => void; + onConfirm: () => void; + onCancel: () => void; +} + +export function ProfileNameDialog({ mode, name, onChange, onConfirm, onCancel }: ProfileNameDialogProps) { + const { t } = useI18n(); + const [shifted, setShifted] = useState(true); + const [symbols, setSymbols] = useState(false); + const canConfirm = Boolean(name.trim()); + const title = mode === 'add' ? t('menu.addProfile') : t('menu.renameProfile'); + const rows = symbols ? NUMBER_ROWS : LETTER_ROWS; + + const insertChar = (raw: string) => { + const isLetter = /^[a-z]$/i.test(raw); + const chunk = isLetter ? (shifted ? raw.toUpperCase() : raw.toLowerCase()) : raw; + onChange(appendName(name, chunk)); + if (isLetter && shifted) setShifted(false); + }; + + const insertSpace = () => { + onChange(appendName(name, ' ')); + setShifted(true); + }; + + return ( +
+
+ + {title} + + +
+ onChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter' && canConfirm) onConfirm(); + }} + /> +
+ {rows.map((row) => ( +
+ {row.map((key) => ( + + ))} +
+ ))} +
+ {symbols ? ( + + ) : ( + <> + + + + )} + +
+
+
+ + {title} + + + {t('shutdown.cancel')} + +
+
+ ); +} diff --git a/ui/src/components/panel/ProfilesPanel.test.tsx b/ui/src/components/panel/ProfilesPanel.test.tsx new file mode 100644 index 000000000..cf62eca3f --- /dev/null +++ b/ui/src/components/panel/ProfilesPanel.test.tsx @@ -0,0 +1,100 @@ +import { renderToString } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import type { Profile } from '../../types/profile'; +import type { Shot } from '../../types/shot'; +import { ProfilesPanel } from './ProfilesPanel'; + +/** React SSR splits interpolated text with comment markers; drop them. */ +function text(html: string): string { + return html.replace(//g, ''); +} + +function profile(id: string, name: string): Profile { + return { id, name, created_at: '2026-08-27T10:00:00Z', settings: {} }; +} + +function shot(profileId: string): Shot { + return { profile_id: profileId, ball_speed_mph: 100 } as Shot; +} + +function render(overrides: Partial[0]> = {}) { + return text( + renderToString( + {}} + onRenameProfile={() => {}} + onRemoveProfile={() => {}} + {...overrides} + /> + ) + ); +} + +describe('ProfilesPanel', () => { + it('renders every profile with its shot count', () => { + const html = render(); + + expect(html).toContain('Home'); + expect(html).toContain('Range'); + expect(html).toContain('2 shots'); + expect(html).toContain('1 shot'); + }); + + it('marks the active profile pressed and the others not', () => { + const html = render(); + const homeCard = html.match(/]*profiles-panel__card[^>]*>[\s\S]*?Home[\s\S]*?<\/button>/)?.[0] ?? ''; + const rangeCard = html.match(/]*profiles-panel__card[^>]*>[\s\S]*?Range[\s\S]*?<\/button>/)?.[0] ?? ''; + + expect(homeCard).toContain('aria-pressed="true"'); + expect(rangeCard).toContain('aria-pressed="false"'); + }); + + it('hides remove on the active profile, shows it on an empty inactive one', () => { + const html = render({ shots: [shot('aaa'), shot('aaa')] }); + + expect(html).not.toContain('aria-label="Remove Home"'); + expect(html).toContain('aria-label="Remove Range"'); + }); + + it('hides remove on an inactive profile that still has shots', () => { + const html = render(); + + expect(html).not.toContain('aria-label="Remove Range"'); + }); + + it('hides remove entirely when only one profile exists', () => { + const html = render({ profiles: [profile('aaa', 'Home')] }); + + expect(html).not.toContain('aria-label="Remove Home"'); + }); + + it('offers rename for every profile, including the active one', () => { + const html = render(); + + expect(html).toContain('aria-label="Rename Home"'); + expect(html).toContain('aria-label="Rename Range"'); + }); + + it('groups rename and remove in a right-aligned actions cluster', () => { + const html = render({ shots: [shot('aaa')] }); + const clusters = [...html.matchAll(/
[\s\S]*?<\/div>/g)].map( + (match) => match[0] + ); + + expect(clusters).toHaveLength(2); + expect(clusters.some((cluster) => cluster.includes('Rename Range') && cluster.includes('Remove Range'))).toBe(true); + expect(clusters.some((cluster) => cluster.includes('Rename Home') && !cluster.includes('Remove Home'))).toBe(true); + }); + + it('shows a skeleton until the roster arrives, with no profile names in the output', () => { + const html = render({ loaded: false, profiles: [], activeProfileId: '' }); + + expect(html).toContain('aria-busy="true"'); + expect(html).not.toContain('Home'); + expect(html).not.toContain('Range'); + }); +}); diff --git a/ui/src/components/panel/ProfilesPanel.tsx b/ui/src/components/panel/ProfilesPanel.tsx new file mode 100644 index 000000000..6174781bc --- /dev/null +++ b/ui/src/components/panel/ProfilesPanel.tsx @@ -0,0 +1,110 @@ +import { useMemo, useRef, type ReactNode } from 'react'; +import type { Profile } from '../../types/profile'; +import type { Shot } from '../../types/shot'; +import { useDragScroll } from '../../hooks/useDragScroll'; +import { useI18n } from '../../i18n/useI18n'; +import { PanelHeader } from './PanelHeader'; + +interface ProfilesPanelProps { + profiles: Profile[]; + activeProfileId: string; + shots: Shot[]; + /** False until the server's first roster snapshot arrives. */ + loaded: boolean; + onSelectProfile: (profileId: string) => void; + onRenameProfile: (profile: Profile) => void; + onRemoveProfile: (profileId: string) => void; + /** Pinned header control, e.g. Add profile. */ + headerAction?: ReactNode; +} + +export function ProfilesPanel({ + profiles, + activeProfileId, + shots, + loaded, + onSelectProfile, + onRenameProfile, + onRemoveProfile, + headerAction, +}: ProfilesPanelProps) { + const { t } = useI18n(); + const rosterRef = useRef(null); + const dragScroll = useDragScroll(rosterRef); + // Active profiles cannot be removed, nor can an inactive one that still + // has session rows — the server refuses both, which would otherwise + // orphan shots under an id that can never be selected again. + const canRemove = profiles.length > 1; + const activeProfile = profiles.find((profile) => profile.id === activeProfileId) ?? null; + const shotCounts = useMemo(() => { + const counts: Record = {}; + for (const shot of shots) { + const id = shot.profile_id; + if (!id) continue; + counts[id] = (counts[id] ?? 0) + 1; + } + return counts; + }, [shots]); + + return ( +
+ +
+ {!loaded ? ( + +
+ ); +} diff --git a/ui/src/components/panel/ShotsPanel.test.tsx b/ui/src/components/panel/ShotsPanel.test.tsx index 6e1765d2a..addf59c58 100644 --- a/ui/src/components/panel/ShotsPanel.test.tsx +++ b/ui/src/components/panel/ShotsPanel.test.tsx @@ -19,7 +19,8 @@ function makeShot(overrides: Partial = {}): Shot { estimated_carry_yards: 210, carry_range: [205, 215], club: 'driver', - player_name: 'James', + profile_id: 'james', + profile_name: 'James', timestamp: '2026-08-19T10:00:00Z', peak_magnitude: 100, launch_angle_vertical: 13.4, @@ -40,7 +41,11 @@ function makeShot(overrides: Partial = {}): Shot { } const render = (shots: Shot[]) => - text(renderToString( {}} onReplayShot={() => {}} />)); + text( + renderToString( + {}} onReplayShot={() => {}} /> + ) + ); describe('ShotsPanel', () => { it('shows an empty state before any shots', () => { @@ -73,7 +78,7 @@ describe('ShotsPanel', () => { it('renders the seven columns the mockup draws', () => { const html = render([makeShot()]); - for (const column of ['Shot', 'Player', 'Ball', 'Club', 'Launch', 'Spin', 'Carry']) { + for (const column of ['Shot', 'Profile', 'Ball', 'Club', 'Launch', 'Spin', 'Carry']) { expect(html).toContain(`>${column}<`); } }); @@ -159,10 +164,10 @@ describe('ShotsPanel', () => { expect(html).toContain('>120<'); }); - it("lists only the current player's shots", () => { + it("lists only the current profile's shots", () => { const html = render([ makeShot({ timestamp: 'a', ball_speed_mph: 92 }), - makeShot({ player_name: 'Alex', timestamp: 'b', ball_speed_mph: 140 }), + makeShot({ profile_id: 'alex', profile_name: 'Alex', timestamp: 'b', ball_speed_mph: 140 }), ]); const indexes = [...html.matchAll(/shots-panel__index">(\d+) m[1]); @@ -173,8 +178,8 @@ describe('ShotsPanel', () => { expect(indexes).toEqual(['1']); }); - it('shows the empty state when only other players have shots', () => { - const html = render([makeShot({ player_name: 'Alex' })]); + it('shows the empty state when only other profiles have shots', () => { + const html = render([makeShot({ profile_id: 'alex', profile_name: 'Alex' })]); expect(html).toContain('No shots yet'); expect(html).not.toContain('shots-panel__row-main'); diff --git a/ui/src/components/panel/ShotsPanel.tsx b/ui/src/components/panel/ShotsPanel.tsx index 34aea6fb3..fce287206 100644 --- a/ui/src/components/panel/ShotsPanel.tsx +++ b/ui/src/components/panel/ShotsPanel.tsx @@ -1,7 +1,7 @@ import { useMemo, useRef, useState } from 'react'; import { useShallow } from 'zustand/react/shallow'; import type { Shot } from '../../types/shot'; -import { filterShotsByPlayer, getSwingSpeedMph, isSwingSpeedShot } from '../../types/shot'; +import { filterShotsByProfile, getSwingSpeedMph, isSwingSpeedShot } from '../../types/shot'; import { useDragScroll } from '../../hooks/useDragScroll'; import { useUnitPreference } from '../../state/useUnitPreference'; import { useSystemStore } from '../../stores/useSystemStore'; @@ -17,7 +17,8 @@ import { useI18n } from '../../i18n/useI18n'; interface ShotsPanelProps { shots: Shot[]; - playerName: string; + profileId: string; + profileName: string; clubLabel?: string; onDeleteShot: (timestamp: string) => void; onReplayShot?: (shot: Shot) => void; @@ -119,7 +120,7 @@ function ValidationEditor({ * inline, so a row expands on tap to reveal them — the mockup's own "make the * shot rows tappable to open shot detail" follow-up. */ -export function ShotsPanel({ shots, playerName, clubLabel, onDeleteShot, onReplayShot }: ShotsPanelProps) { +export function ShotsPanel({ shots, profileId, profileName, clubLabel, onDeleteShot, onReplayShot }: ShotsPanelProps) { const { t } = useI18n(); const { unitSystem } = useUnitPreference(); const { entries, updateEntry, removeEntry } = useValidationStore(); @@ -133,16 +134,16 @@ export function ShotsPanel({ shots, playerName, clubLabel, onDeleteShot, onRepla })) ); - const playerShots = useMemo(() => filterShotsByPlayer(shots, playerName), [shots, playerName]); - const visibleShots = useMemo(() => [...playerShots].reverse(), [playerShots]); + const profileShots = useMemo(() => filterShotsByProfile(shots, profileId), [shots, profileId]); + const visibleShots = useMemo(() => [...profileShots].reverse(), [profileShots]); const validatedCount = useMemo( - () => playerShots.filter((shot) => entries[shot.timestamp]?.comparatorSpeed).length, - [entries, playerShots] + () => profileShots.filter((shot) => entries[shot.timestamp]?.comparatorSpeed).length, + [entries, profileShots] ); const handleExport = () => { const stamp = new Date().toISOString().replace(/[:.]/g, '-'); - downloadCsv(`openflight-validation-${stamp}.csv`, buildValidationCsv(playerShots, entries)); + downloadCsv(`openflight-validation-${stamp}.csv`, buildValidationCsv(profileShots, entries)); }; const handleDelete = (timestamp: string) => { @@ -154,13 +155,13 @@ export function ShotsPanel({ shots, playerName, clubLabel, onDeleteShot, onRepla socketService.uploadCloud()} > {cloudUploadState === 'running' ? t('shots.uploading') : t('shots.uploadCloud')} - + {t('shots.exportCsv')} @@ -181,7 +182,7 @@ export function ShotsPanel({ shots, playerName, clubLabel, onDeleteShot, onRepla /> ); - if (playerShots.length === 0) { + if (profileShots.length === 0) { return (
{header} @@ -198,7 +199,7 @@ export function ShotsPanel({ shots, playerName, clubLabel, onDeleteShot, onRepla {header}
{t('shots.colShot')} - {t('shots.colPlayer')} + {t('shots.colProfile')} {t('shots.colBall')} {t('shots.colClub')} {t('shots.colLaunch')} @@ -218,7 +219,7 @@ export function ShotsPanel({ shots, playerName, clubLabel, onDeleteShot, onRepla onClickCapture={dragScroll.onClickCapture} > {visibleShots.map((shot, index) => { - const shotNumber = playerShots.length - index; + const shotNumber = profileShots.length - index; const entry = entries[shot.timestamp] ?? getEmptyValidationEntry(); const isOpen = expanded === shot.timestamp; const [ball, club, launch, spin, carry] = rowValues(shot, unitSystem); @@ -233,9 +234,9 @@ export function ShotsPanel({ shots, playerName, clubLabel, onDeleteShot, onRepla onClick={() => setExpanded(isOpen ? null : shot.timestamp)} > {shotNumber} - - {shot.player_name ?? 'Player 1'} - {shot.training_implement_label ?? shot.club} + + {profileName} + {shot.training_implement_label ?? shot.club} {ball} {club} diff --git a/ui/src/components/panel/StatsPanel.test.tsx b/ui/src/components/panel/StatsPanel.test.tsx index ae904e17a..a278eaa12 100644 --- a/ui/src/components/panel/StatsPanel.test.tsx +++ b/ui/src/components/panel/StatsPanel.test.tsx @@ -20,7 +20,8 @@ function makeShot(overrides: Partial = {}): Shot { estimated_carry_yards: 200, carry_range: [195, 205], club: 'driver', - player_name: 'James', + profile_id: 'james', + profile_name: 'James', timestamp: '2026-08-19T10:00:00Z', peak_magnitude: 100, launch_angle_vertical: 13, @@ -42,7 +43,15 @@ function makeShot(overrides: Partial = {}): Shot { const render = (shots: Shot[], activeClub = 'driver', headerAction?: ReactNode) => text( - renderToString() + renderToString( + + ) ); describe('StatsPanel', () => { @@ -152,10 +161,10 @@ describe('StatsPanel', () => { expect(activeChip).toBe('All (1)'); }); - it('computes averages and club chips from the current player only', () => { + it('computes averages and club chips from the current profile only', () => { const html = render([ makeShot({ ball_speed_mph: 90, timestamp: 'a' }), - makeShot({ player_name: 'Alex', ball_speed_mph: 150, club: '7-iron', timestamp: 'b' }), + makeShot({ profile_id: 'alex', profile_name: 'Alex', ball_speed_mph: 150, club: '7-iron', timestamp: 'b' }), ]); expect(html).toContain('All (1)'); @@ -167,8 +176,8 @@ describe('StatsPanel', () => { expect(html).not.toContain('metric-card__value">120.0<'); }); - it('shows the empty state when only other players have shots', () => { - const html = render([makeShot({ player_name: 'Alex' })]); + it('shows the empty state when only other profiles have shots', () => { + const html = render([makeShot({ profile_id: 'alex', profile_name: 'Alex' })]); expect(html).toContain('No shots yet'); expect(html).not.toContain('stats-panel__grid'); diff --git a/ui/src/components/panel/StatsPanel.tsx b/ui/src/components/panel/StatsPanel.tsx index 55cffa340..457196cd1 100644 --- a/ui/src/components/panel/StatsPanel.tsx +++ b/ui/src/components/panel/StatsPanel.tsx @@ -3,7 +3,7 @@ import type { Shot } from '../../types/shot'; import { computeStats, computeSwingSpeedStats, - filterShotsByPlayer, + filterShotsByProfile, getUniqueClubs, isSwingSpeedShot, } from '../../types/shot'; @@ -17,7 +17,8 @@ import { PanelHeader } from './PanelHeader'; interface StatsPanelProps { shots: Shot[]; activeClub: string; - playerName: string; + profileId: string; + profileName: string; /** Pinned header control, e.g. Clear session. */ headerAction?: ReactNode; } @@ -34,20 +35,20 @@ interface StatTile { * filter chips above the tiles. Six tiles for a ball-strike session (3x2 as * drawn), four for a swing-speed one. */ -export function StatsPanel({ shots, activeClub, playerName, headerAction }: StatsPanelProps) { +export function StatsPanel({ shots, activeClub, profileId, profileName, headerAction }: StatsPanelProps) { const { t } = useI18n(); - const playerShots = useMemo(() => filterShotsByPlayer(shots, playerName), [shots, playerName]); - const hasShotsForActiveClub = playerShots.some((shot) => shot.club === activeClub); + const profileShots = useMemo(() => filterShotsByProfile(shots, profileId), [shots, profileId]); + const hasShotsForActiveClub = profileShots.some((shot) => shot.club === activeClub); const [selectedClub, setSelectedClub] = useState(hasShotsForActiveClub ? activeClub : null); const [prevActiveClub, setPrevActiveClub] = useState(activeClub); - const [prevPlayerName, setPrevPlayerName] = useState(playerName); + const [prevProfileId, setPrevProfileId] = useState(profileId); const chipRef = useRef(null); const chipScroll = useDragScroll(chipRef, 'x'); // Update state during render when the prop changes, rather than in an effect. - if (activeClub !== prevActiveClub || playerName !== prevPlayerName) { + if (activeClub !== prevActiveClub || profileId !== prevProfileId) { setPrevActiveClub(activeClub); - setPrevPlayerName(playerName); + setPrevProfileId(profileId); setSelectedClub(hasShotsForActiveClub ? activeClub : null); } @@ -55,18 +56,18 @@ export function StatsPanel({ shots, activeClub, playerName, headerAction }: Stat const speedUnit = getSpeedUnit(unitSystem); const distanceUnit = getDistanceUnit(unitSystem); - const availableClubs = useMemo(() => getUniqueClubs(playerShots), [playerShots]); + const availableClubs = useMemo(() => getUniqueClubs(profileShots), [profileShots]); const clubCounts = useMemo(() => { const counts: Record = {}; - for (const shot of playerShots) { + for (const shot of profileShots) { counts[shot.club] = (counts[shot.club] ?? 0) + 1; } return counts; - }, [playerShots]); + }, [profileShots]); const filteredShots = useMemo( - () => (selectedClub === null ? playerShots : playerShots.filter((shot) => shot.club === selectedClub)), - [playerShots, selectedClub] + () => (selectedClub === null ? profileShots : profileShots.filter((shot) => shot.club === selectedClub)), + [profileShots, selectedClub] ); const stats = useMemo(() => computeStats(filteredShots), [filteredShots]); @@ -140,7 +141,7 @@ export function StatsPanel({ shots, activeClub, playerName, headerAction }: Stat aria-pressed={selectedClub === null} onClick={() => setSelectedClub(null)} > - {t('stats.all', { count: playerShots.length })} + {t('stats.all', { count: profileShots.length })}
- +
- {playerShots.length > 0 ? clubFilters : null} - {playerShots.length === 0 ? ( + {profileShots.length > 0 ? clubFilters : null} + {profileShots.length === 0 ? (
{t('stats.noShots')} {t('stats.noShotsDetail')} diff --git a/ui/src/components/panel/index.ts b/ui/src/components/panel/index.ts index e1fd4fc9e..db7f05a53 100644 --- a/ui/src/components/panel/index.ts +++ b/ui/src/components/panel/index.ts @@ -5,8 +5,8 @@ export { MenuSheet } from './MenuSheet'; export { PickerOverlay } from './PickerOverlay'; export { clubSections, trainingImplementSections, type PickerOption, type PickerSection } from './pickerSections'; export { LivePanel } from './LivePanel'; -export { PlayersPanel } from './PlayersPanel'; -export { AddPlayerDialog } from './AddPlayerDialog'; +export { ProfilesPanel } from './ProfilesPanel'; +export { ProfileNameDialog } from './ProfileNameDialog'; export { ClearSessionDialog } from './ClearSessionDialog'; export { SimulateBubble } from './SimulateBubble'; export { StatsPanel } from './StatsPanel'; diff --git a/ui/src/components/panel/liveMetrics.ts b/ui/src/components/panel/liveMetrics.ts index bcb3cc248..603f777dc 100644 --- a/ui/src/components/panel/liveMetrics.ts +++ b/ui/src/components/panel/liveMetrics.ts @@ -214,7 +214,7 @@ function buildSwingSpeedMetrics(shot: Shot, stats: SwingSpeedStats, unitSystem: label: t('metric.best'), value: formatSpeed(stats.best_speed_mph, unitSystem, 1), unit: speedUnit, - subtext: t('metric.playerImplement'), + subtext: t('metric.profileImplement'), }, { id: 'swing_avg', diff --git a/ui/src/components/panel/panel.css b/ui/src/components/panel/panel.css index 7f63ba196..b6bffa10b 100644 --- a/ui/src/components/panel/panel.css +++ b/ui/src/components/panel/panel.css @@ -736,9 +736,9 @@ letter-spacing: -0.03em; } -/* ---------------------------------------------------------- players panel -- */ +/* --------------------------------------------------------- profiles panel -- */ -.players-panel__grid { +.profiles-panel__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-content: start; @@ -752,16 +752,16 @@ -webkit-user-select: none; } -.players-panel__card-wrap { +.profiles-panel__card-wrap { position: relative; content-visibility: auto; contain-intrinsic-size: 0 132px; } -.players-panel__card { +.profiles-panel__card { width: 100%; min-height: 132px; - padding: 22px 20px 18px; + padding: 22px 96px 18px 20px; display: flex; flex-direction: column; justify-content: space-between; @@ -777,12 +777,12 @@ touch-action: none; } -.players-panel__card--selected { +.profiles-panel__card--selected { border-color: var(--color-accent); box-shadow: inset 0 0 0 1px var(--color-accent); } -.players-panel__name { +.profiles-panel__name { font-weight: 700; font-size: clamp(1.25rem, 4vh, 1.75rem); letter-spacing: -0.03em; @@ -792,7 +792,7 @@ max-width: 100%; } -.players-panel__count { +.profiles-panel__count { font-weight: 600; font-size: 0.6875rem; letter-spacing: 0.14em; @@ -800,10 +800,17 @@ color: var(--color-text); } -.players-panel__remove { +.profiles-panel__actions { position: absolute; top: 8px; right: 8px; + display: flex; + align-items: center; + justify-content: flex-end; +} + +.profiles-panel__rename, +.profiles-panel__remove { width: 44px; height: 44px; display: flex; @@ -817,11 +824,24 @@ touch-action: none; } -.players-panel__remove:hover, -.players-panel__remove:active { +.profiles-panel__rename:hover, +.profiles-panel__rename:active { + color: var(--color-text); +} + +.profiles-panel__remove:hover, +.profiles-panel__remove:active { color: var(--color-danger); } +.profiles-panel__skeleton { + grid-column: 1 / -1; + min-height: 132px; + border-radius: var(--radius); + background: var(--color-surface); + border: 1px solid var(--color-border); +} + /* ----------------------------------------------------------- shots panel -- */ .panel.shots-panel { @@ -892,14 +912,14 @@ color: var(--color-text); } -.shots-panel__player { +.shots-panel__profile { display: flex; flex-direction: column; gap: 2px; min-width: 0; } -.shots-panel__player-name { +.shots-panel__profile-name { font-weight: 600; font-size: 0.6875rem; color: var(--color-text); @@ -908,7 +928,7 @@ white-space: nowrap; } -.shots-panel__player-club { +.shots-panel__profile-club { font-weight: 500; font-size: 0.5625rem; letter-spacing: 0.14em; @@ -1373,40 +1393,27 @@ padding: 0 16px; } -/* ------------------------------------------------------- add player modal -- */ +/* ----------------------------------------------------- profile name modal -- */ -.add-player-modal { +.profile-name-modal { position: absolute; inset: 0; z-index: 40; display: flex; - align-items: center; - justify-content: center; -} - -.add-player-modal__scrim { - position: absolute; - inset: 0; - border: none; - padding: 0; - background: var(--color-scrim); - cursor: pointer; + flex-direction: column; + background: var(--color-bg); } -.add-player-modal__dialog { - position: relative; - z-index: 1; - width: min(420px, calc(100% - 48px)); +.profile-name-modal__header { display: flex; - flex-direction: column; - gap: 16px; - padding: 22px 24px; - background: var(--color-bg); - border: 0px; - border-radius: var(--radius); + align-items: center; + justify-content: space-between; + padding: 16px 26px 12px; + border-bottom: 1px solid var(--color-border); + flex-shrink: 0; } -.add-player-modal__title { +.profile-name-modal__title { font-weight: 700; font-size: 0.8125rem; letter-spacing: 0.2em; @@ -1414,19 +1421,32 @@ color: var(--color-text); } -.add-player-modal__input { +.profile-name-modal__close { + width: 44px; + height: 44px; + border: 1px solid var(--color-border); + border-radius: var(--radius); + background: transparent; + color: var(--color-text); + font-family: inherit; + font-size: 1.0625rem; + cursor: pointer; +} + +.profile-name-modal__input { display: block; box-sizing: border-box; - width: 100%; - height: 44px; - margin: 0; + flex-shrink: 0; + width: calc(100% - 36px); + height: 48px; + margin: 12px 18px 0; padding: 0 14px; border: 1px solid var(--color-border); border-radius: var(--radius); background: transparent; color: var(--color-text); font-family: inherit; - font-size: 1rem; + font-size: 1.25rem; font-weight: 500; letter-spacing: 0; text-transform: none; @@ -1435,20 +1455,111 @@ box-shadow: none; } -.add-player-modal__input::placeholder { +.profile-name-modal__input::placeholder { color: var(--color-text-faint); } -.add-player-modal__input:focus, -.add-player-modal__input:focus-visible { +.profile-name-modal__input:focus, +.profile-name-modal__input:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; border-color: var(--color-border); } -.add-player-modal__actions { +.profile-name-modal__keyboard { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: 6px; + padding: 12px 12px 8px; +} + +.profile-name-modal__key-row { + flex: 1; + min-height: 0; + display: flex; + gap: 6px; + justify-content: center; +} + +.profile-name-modal__key { + flex: 1; + min-width: 0; + min-height: 0; + padding: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius); + background: var(--color-surface); + color: var(--color-text); + font-family: inherit; + font-weight: 700; + font-size: clamp(0.875rem, 3.2vh, 1.25rem); + cursor: pointer; +} + +.profile-name-modal__key--wide { + flex: 4; +} + +.profile-name-modal__key--mod { + flex: 1.4; + font-size: clamp(0.75rem, 2.6vh, 1rem); + letter-spacing: 0.04em; +} + +.profile-name-modal__key--active { + background: var(--color-accent-block); + border-color: var(--color-accent-block); + color: var(--color-accent-fg); +} + +.profile-name-modal__actions { display: flex; + flex-shrink: 0; gap: 10px; + padding: 8px 18px 18px; +} + +.clear-session-modal { + position: absolute; + inset: 0; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; +} + +.clear-session-modal__scrim { + position: absolute; + inset: 0; + border: 0; + padding: 0; + margin: 0; + background: color-mix(in srgb, var(--color-bg) 70%, transparent); + cursor: pointer; +} + +.clear-session-modal__dialog { + position: relative; + z-index: 1; + box-sizing: border-box; + width: min(28rem, calc(100% - 48px)); + max-height: calc(100% - 32px); + padding: 1.5rem 1.25rem 1.25rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius); + background: var(--color-surface); +} + +.clear-session-modal__title { + font-weight: 700; + font-size: 1.125rem; + line-height: 1.25; + color: var(--color-text); } .clear-session-dialog__detail { @@ -1459,7 +1570,20 @@ color: var(--color-text-muted); } -.add-player-modal__actions .panel-action { +.clear-session-modal__actions { + display: flex; + flex-shrink: 0; + gap: 10px; + margin-top: 0.25rem; +} + +.clear-session-modal__actions .panel-action { + flex: 1; + min-width: 0; + justify-content: center; +} + +.profile-name-modal__actions .panel-action { flex: 1; min-width: 0; justify-content: center; @@ -1523,14 +1647,14 @@ padding-inline: 18px; } - .players-panel__grid { + .profiles-panel__grid { padding-inline: 18px; gap: 10px; } - .players-panel__card { + .profiles-panel__card { min-height: 112px; - padding: 16px 16px 14px; + padding: 16px 96px 14px 16px; } .shots-panel__row-main { @@ -1562,4 +1686,37 @@ .picker-overlay__option { font-size: clamp(0.8125rem, calc(var(--picker-tile-h) * 0.2), 1.0625rem); } + + .profile-name-modal__header { + padding: 10px 18px 8px; + } + + .profile-name-modal__close { + width: var(--panel-control-height, 32px); + height: var(--panel-control-height, 32px); + } + + .profile-name-modal__input { + height: 44px; + margin: 8px 18px 0; + font-size: 1.125rem; + } + + .profile-name-modal__keyboard { + gap: 5px; + padding: 8px 10px 6px; + } + + .profile-name-modal__key-row { + gap: 5px; + } + + .profile-name-modal__actions { + padding: 6px 18px 12px; + } + + .clear-session-modal__dialog { + width: min(28rem, calc(100% - 32px)); + padding: 1.25rem 1rem 1rem; + } } diff --git a/ui/src/components/panel/views.ts b/ui/src/components/panel/views.ts index 62c5e3608..fb7bd56a8 100644 --- a/ui/src/components/panel/views.ts +++ b/ui/src/components/panel/views.ts @@ -1,4 +1,4 @@ -export type PanelView = 'live' | 'players' | 'stats' | 'shots' | 'camera' | 'debug'; +export type PanelView = 'live' | 'profiles' | 'stats' | 'shots' | 'camera' | 'debug'; /** * Footer tabs, in order. Design doc 6a uses text-only tabs, so no icons here. @@ -8,6 +8,6 @@ export const PANEL_VIEWS: ReadonlyArray<{ id: PanelView; label: string }> = [ { id: 'stats', label: 'Stats' }, { id: 'shots', label: 'Shots' }, { id: 'camera', label: 'Camera' }, - { id: 'players', label: 'Players' }, + { id: 'profiles', label: 'Profiles' }, { id: 'debug', label: 'Debug' }, ]; diff --git a/ui/src/i18n/en.ts b/ui/src/i18n/en.ts index 596e736c1..129fb71da 100644 --- a/ui/src/i18n/en.ts +++ b/ui/src/i18n/en.ts @@ -1,6 +1,6 @@ export const en = { 'nav.live': 'Live', - 'nav.players': 'Players', + 'nav.profiles': 'Profiles', 'nav.stats': 'Stats', 'nav.shots': 'Shots', 'nav.camera': 'Camera', @@ -64,7 +64,7 @@ export const en = { 'metric.average': 'Average', 'metric.swings': 'Swings', 'metric.implement': 'Implement', - 'metric.playerImplement': 'player + implement', + 'metric.profileImplement': 'profile + implement', 'metric.swingsCount': '{count} swings', 'metric.readingsCount': '{count} readings', 'metric.trigger': '{speed} {unit} trigger', @@ -92,7 +92,7 @@ export const en = { 'shots.uploadCloud': 'Upload cloud', 'shots.exportCsv': 'Export CSV', 'shots.colShot': 'Shot', - 'shots.colPlayer': 'Player', + 'shots.colProfile': 'Profile', 'shots.colBall': 'Ball', 'shots.colClub': 'Club', 'shots.colLaunch': 'Launch', @@ -107,10 +107,17 @@ export const en = { 'shots.notes': 'Notes', 'shots.notesPlaceholder': 'notes…', - 'players.rosterAria': 'Players', - 'players.shots': '{count} shots', - 'players.shot': '{count} shot', - 'players.namePlaceholder': 'Name', + 'profiles.rosterAria': 'Profiles', + 'profiles.shots': '{count} shots', + 'profiles.shot': '{count} shot', + 'profiles.namePlaceholder': 'Name', + 'profiles.closeDialog': 'Close', + 'keyboard.aria': 'Keyboard', + 'keyboard.backspace': 'Backspace', + 'keyboard.space': 'Space', + 'keyboard.shift': 'Shift', + 'keyboard.letters': 'Letters', + 'keyboard.numbers': 'Numbers', 'camera.notConnected': 'Camera not connected', 'camera.detectionOff': 'Ball detection off', @@ -135,10 +142,11 @@ export const en = { 'menu.close': 'Close menu', 'menu.title': 'Menu', - 'menu.player': 'Player', - 'menu.addPlayer': 'Add player', + 'menu.addProfile': 'Add profile', + 'menu.renameProfile': 'Rename profile', + 'menu.renameProfileNamed': 'Rename {name}', + 'menu.removeProfile': 'Remove {name}', 'menu.add': 'Add', - 'menu.removePlayer': 'Remove {name}', 'menu.units': 'Units', 'menu.displayUnits': 'Display units', 'menu.theme': 'Theme', @@ -161,7 +169,7 @@ export const en = { 'app.changeClub': 'Change club', 'app.clearSession': 'Clear session', 'clearSession.confirm': "Clear {name}'s session?", - 'clearSession.detail': "This removes {name}'s shots. Other players are kept.", + 'clearSession.detail': "This removes {name}'s shots. Other profiles are kept.", 'clearSession.close': 'Close clear session', 'app.stopRecording': 'Stop recording', 'app.record': 'Record', diff --git a/ui/src/i18n/es.ts b/ui/src/i18n/es.ts index 1e7ac06ea..e081be016 100644 --- a/ui/src/i18n/es.ts +++ b/ui/src/i18n/es.ts @@ -2,7 +2,7 @@ import type { Messages } from './en'; export const es: Messages = { 'nav.live': 'En vivo', - 'nav.players': 'Jugadores', + 'nav.profiles': 'Perfiles', 'nav.stats': 'Estadísticas', 'nav.shots': 'Golpes', 'nav.camera': 'Cámara', @@ -66,7 +66,7 @@ export const es: Messages = { 'metric.average': 'Media', 'metric.swings': 'Swings', 'metric.implement': 'Implemento', - 'metric.playerImplement': 'jugador + implemento', + 'metric.profileImplement': 'perfil + implemento', 'metric.swingsCount': '{count} swings', 'metric.readingsCount': '{count} lecturas', 'metric.trigger': 'umbral {speed} {unit}', @@ -94,7 +94,7 @@ export const es: Messages = { 'shots.uploadCloud': 'Subir a la nube', 'shots.exportCsv': 'Exportar CSV', 'shots.colShot': 'Golpe', - 'shots.colPlayer': 'Jugador', + 'shots.colProfile': 'Perfil', 'shots.colBall': 'Bola', 'shots.colClub': 'Palo', 'shots.colLaunch': 'Salida', @@ -109,10 +109,17 @@ export const es: Messages = { 'shots.notes': 'Notas', 'shots.notesPlaceholder': 'notas…', - 'players.rosterAria': 'Jugadores', - 'players.shots': '{count} golpes', - 'players.shot': '{count} golpe', - 'players.namePlaceholder': 'Nombre', + 'profiles.rosterAria': 'Perfiles', + 'profiles.shots': '{count} golpes', + 'profiles.shot': '{count} golpe', + 'profiles.namePlaceholder': 'Nombre', + 'profiles.closeDialog': 'Cerrar', + 'keyboard.aria': 'Teclado', + 'keyboard.backspace': 'Borrar', + 'keyboard.space': 'Espacio', + 'keyboard.shift': 'Mayúsculas', + 'keyboard.letters': 'Letras', + 'keyboard.numbers': 'Números', 'camera.notConnected': 'Cámara no conectada', 'camera.detectionOff': 'Detección de bola desactivada', @@ -137,10 +144,11 @@ export const es: Messages = { 'menu.close': 'Cerrar menú', 'menu.title': 'Menú', - 'menu.player': 'Jugador', - 'menu.addPlayer': 'Añadir jugador', + 'menu.addProfile': 'Añadir perfil', + 'menu.renameProfile': 'Renombrar perfil', + 'menu.renameProfileNamed': 'Renombrar {name}', + 'menu.removeProfile': 'Eliminar {name}', 'menu.add': 'Añadir', - 'menu.removePlayer': 'Quitar {name}', 'menu.units': 'Unidades', 'menu.displayUnits': 'Unidades de pantalla', 'menu.theme': 'Tema', @@ -163,7 +171,7 @@ export const es: Messages = { 'app.changeClub': 'Cambiar palo', 'app.clearSession': 'Borrar sesión', 'clearSession.confirm': '¿Borrar la sesión de {name}?', - 'clearSession.detail': 'Esto elimina los golpes de {name}. Se conservan los de los demás jugadores.', + 'clearSession.detail': 'Esto elimina los golpes de {name}. Los demás perfiles se conservan.', 'clearSession.close': 'Cerrar borrar sesión', 'app.stopRecording': 'Parar grabación', 'app.record': 'Grabar', diff --git a/ui/src/i18n/fr.ts b/ui/src/i18n/fr.ts index 4737647de..fae5bf911 100644 --- a/ui/src/i18n/fr.ts +++ b/ui/src/i18n/fr.ts @@ -2,7 +2,7 @@ import type { Messages } from './en'; export const fr: Messages = { 'nav.live': 'Direct', - 'nav.players': 'Joueurs', + 'nav.profiles': 'Profils', 'nav.stats': 'Stats', 'nav.shots': 'Coups', 'nav.camera': 'Caméra', @@ -66,7 +66,7 @@ export const fr: Messages = { 'metric.average': 'Moyenne', 'metric.swings': 'Swings', 'metric.implement': 'Outil', - 'metric.playerImplement': 'joueur + outil', + 'metric.profileImplement': 'profil + outil', 'metric.swingsCount': '{count} swings', 'metric.readingsCount': '{count} lectures', 'metric.trigger': 'seuil {speed} {unit}', @@ -94,7 +94,7 @@ export const fr: Messages = { 'shots.uploadCloud': 'Envoyer au cloud', 'shots.exportCsv': 'Exporter CSV', 'shots.colShot': 'Coup', - 'shots.colPlayer': 'Joueur', + 'shots.colProfile': 'Profil', 'shots.colBall': 'Balle', 'shots.colClub': 'Club', 'shots.colLaunch': 'Lancement', @@ -109,10 +109,17 @@ export const fr: Messages = { 'shots.notes': 'Notes', 'shots.notesPlaceholder': 'notes…', - 'players.rosterAria': 'Joueurs', - 'players.shots': '{count} coups', - 'players.shot': '{count} coup', - 'players.namePlaceholder': 'Nom', + 'profiles.rosterAria': 'Profils', + 'profiles.shots': '{count} coups', + 'profiles.shot': '{count} coup', + 'profiles.namePlaceholder': 'Nom', + 'profiles.closeDialog': 'Fermer', + 'keyboard.aria': 'Clavier', + 'keyboard.backspace': 'Retour arrière', + 'keyboard.space': 'Espace', + 'keyboard.shift': 'Majuscule', + 'keyboard.letters': 'Lettres', + 'keyboard.numbers': 'Chiffres', 'camera.notConnected': 'Caméra non connectée', 'camera.detectionOff': 'Détection de balle désactivée', @@ -137,10 +144,11 @@ export const fr: Messages = { 'menu.close': 'Fermer le menu', 'menu.title': 'Menu', - 'menu.player': 'Joueur', - 'menu.addPlayer': 'Ajouter un joueur', + 'menu.addProfile': 'Ajouter un profil', + 'menu.renameProfile': 'Renommer le profil', + 'menu.renameProfileNamed': 'Renommer {name}', + 'menu.removeProfile': 'Supprimer {name}', 'menu.add': 'Ajouter', - 'menu.removePlayer': 'Retirer {name}', 'menu.units': 'Unités', 'menu.displayUnits': 'Unités d’affichage', 'menu.theme': 'Thème', @@ -163,7 +171,7 @@ export const fr: Messages = { 'app.changeClub': 'Changer de club', 'app.clearSession': 'Effacer la session', 'clearSession.confirm': 'Effacer la session de {name} ?', - 'clearSession.detail': 'Les coups de {name} seront supprimés. Les autres joueurs sont conservés.', + 'clearSession.detail': 'Cela supprime les coups de {name}. Les autres profils sont conservés.', 'clearSession.close': 'Fermer l’effacement de session', 'app.stopRecording': 'Arrêter l’enregistrement', 'app.record': 'Enregistrer', diff --git a/ui/src/i18n/i18n.test.ts b/ui/src/i18n/i18n.test.ts index bc01bd2c3..9d9965f57 100644 --- a/ui/src/i18n/i18n.test.ts +++ b/ui/src/i18n/i18n.test.ts @@ -21,11 +21,11 @@ describe('i18n catalogs', () => { }); it('interpolates placeholders in the active locale', () => { - expect(t('players.shots', { count: '3' })).toBe('3 shots'); + expect(t('profiles.shots', { count: '3' })).toBe('3 shots'); setActiveLocale('es'); expect(t('nav.live')).toBe('En vivo'); - expect(t('players.shots', { count: '3' })).toBe('3 golpes'); + expect(t('profiles.shots', { count: '3' })).toBe('3 golpes'); }); it('falls back to English when a locale id is unknown', () => { diff --git a/ui/src/i18n/pt.ts b/ui/src/i18n/pt.ts index b80ed0d85..e3208b366 100644 --- a/ui/src/i18n/pt.ts +++ b/ui/src/i18n/pt.ts @@ -2,7 +2,7 @@ import type { Messages } from './en'; export const pt: Messages = { 'nav.live': 'Ao vivo', - 'nav.players': 'Jogadores', + 'nav.profiles': 'Perfis', 'nav.stats': 'Estatísticas', 'nav.shots': 'Tacadas', 'nav.camera': 'Câmera', @@ -66,7 +66,7 @@ export const pt: Messages = { 'metric.average': 'Média', 'metric.swings': 'Swings', 'metric.implement': 'Implemento', - 'metric.playerImplement': 'jogador + implemento', + 'metric.profileImplement': 'perfil + implemento', 'metric.swingsCount': '{count} swings', 'metric.readingsCount': '{count} leituras', 'metric.trigger': 'limite {speed} {unit}', @@ -94,7 +94,7 @@ export const pt: Messages = { 'shots.uploadCloud': 'Enviar para a nuvem', 'shots.exportCsv': 'Exportar CSV', 'shots.colShot': 'Tacada', - 'shots.colPlayer': 'Jogador', + 'shots.colProfile': 'Perfil', 'shots.colBall': 'Bola', 'shots.colClub': 'Taco', 'shots.colLaunch': 'Saída', @@ -109,10 +109,17 @@ export const pt: Messages = { 'shots.notes': 'Notas', 'shots.notesPlaceholder': 'notas…', - 'players.rosterAria': 'Jogadores', - 'players.shots': '{count} tacadas', - 'players.shot': '{count} tacada', - 'players.namePlaceholder': 'Nome', + 'profiles.rosterAria': 'Perfis', + 'profiles.shots': '{count} tacadas', + 'profiles.shot': '{count} tacada', + 'profiles.namePlaceholder': 'Nome', + 'profiles.closeDialog': 'Fechar', + 'keyboard.aria': 'Teclado', + 'keyboard.backspace': 'Apagar', + 'keyboard.space': 'Espaço', + 'keyboard.shift': 'Shift', + 'keyboard.letters': 'Letras', + 'keyboard.numbers': 'Números', 'camera.notConnected': 'Câmera não conectada', 'camera.detectionOff': 'Detecção de bola desligada', @@ -137,10 +144,11 @@ export const pt: Messages = { 'menu.close': 'Fechar menu', 'menu.title': 'Menu', - 'menu.player': 'Jogador', - 'menu.addPlayer': 'Adicionar jogador', + 'menu.addProfile': 'Adicionar perfil', + 'menu.renameProfile': 'Renomear perfil', + 'menu.renameProfileNamed': 'Renomear {name}', + 'menu.removeProfile': 'Remover {name}', 'menu.add': 'Adicionar', - 'menu.removePlayer': 'Remover {name}', 'menu.units': 'Unidades', 'menu.displayUnits': 'Unidades de exibição', 'menu.theme': 'Tema', @@ -163,7 +171,7 @@ export const pt: Messages = { 'app.changeClub': 'Trocar taco', 'app.clearSession': 'Limpar sessão', 'clearSession.confirm': 'Limpar a sessão de {name}?', - 'clearSession.detail': 'Isso remove as tacadas de {name}. Os outros jogadores são mantidos.', + 'clearSession.detail': 'Isto remove as tacadas de {name}. Os outros perfis são mantidos.', 'clearSession.close': 'Fechar limpar sessão', 'app.stopRecording': 'Parar gravação', 'app.record': 'Gravar', diff --git a/ui/src/services/playerSocketSync.test.ts b/ui/src/services/playerSocketSync.test.ts deleted file mode 100644 index 09bd9e7f4..000000000 --- a/ui/src/services/playerSocketSync.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import { useSystemStore } from '../stores/useSystemStore'; -import { ingestSocketPlayerName, shouldEchoSelectionToServer } from './playerSocketSync'; - -describe('player socket sync', () => { - beforeEach(() => { - useSystemStore.setState({ serverPlayerName: null }); - }); - - it('does not adopt player_name from session_state snapshots', () => { - ingestSocketPlayerName('session_state', 'Player 1'); - - expect(useSystemStore.getState().serverPlayerName).toBeNull(); - }); - - it('adopts player_name from player_changed', () => { - ingestSocketPlayerName('player_changed', 'James'); - - expect(useSystemStore.getState().serverPlayerName).toBe('James'); - }); - - it('does not ping-pong when a stale snapshot arrives after a local push', () => { - const selected = 'James'; - const emitted: string[] = []; - - if (shouldEchoSelectionToServer('became-connected')) { - emitted.push(selected); - } - - ingestSocketPlayerName('session_state', 'Player 1'); - const afterSnapshot = useSystemStore.getState().serverPlayerName; - if (afterSnapshot && afterSnapshot !== selected && shouldEchoSelectionToServer('selection-changed')) { - emitted.push(afterSnapshot); - } - - ingestSocketPlayerName('player_changed', selected); - - expect(useSystemStore.getState().serverPlayerName).toBe('James'); - expect(emitted).toEqual(['James']); - }); - - it('only pushes the local player when the socket becomes connected', () => { - expect(shouldEchoSelectionToServer('became-connected')).toBe(true); - expect(shouldEchoSelectionToServer('selection-changed')).toBe(false); - }); -}); diff --git a/ui/src/services/playerSocketSync.ts b/ui/src/services/playerSocketSync.ts deleted file mode 100644 index d23bc6ab6..000000000 --- a/ui/src/services/playerSocketSync.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { useSystemStore } from '../stores/useSystemStore'; - -export type PlayerSocketEvent = 'session_state' | 'player_changed'; -export type PlayerEchoTrigger = 'became-connected' | 'selection-changed'; - -/** - * session_state.player_name is a connect/reload snapshot and can race with - * set_player. Only player_changed is a live selection update. - */ -export function ingestSocketPlayerName(source: PlayerSocketEvent, playerName: string | undefined): void { - if (playerName === undefined || source === 'session_state') return; - useSystemStore.getState().setServerPlayerName(playerName); -} - -/** Push localStorage player on connect. User clicks already emit set_player. */ -export function shouldEchoSelectionToServer(trigger: PlayerEchoTrigger): boolean { - return trigger === 'became-connected'; -} diff --git a/ui/src/services/sessionClear.test.ts b/ui/src/services/sessionClear.test.ts index f4b430e7b..28e3a9505 100644 --- a/ui/src/services/sessionClear.test.ts +++ b/ui/src/services/sessionClear.test.ts @@ -30,21 +30,21 @@ function makeShot(overrides: Partial = {}): Shot { } describe('remainingShotsAfterClear', () => { - const james = makeShot({ player_name: 'James', timestamp: 'j' }); - const alex = makeShot({ player_name: 'Alex', timestamp: 'a' }); + const james = makeShot({ profile_id: 'aaa', timestamp: 'j' }); + const alex = makeShot({ profile_id: 'bbb', timestamp: 'a' }); it('prefers the remaining shot list from the server', () => { - expect(remainingShotsAfterClear([james, alex], { player_name: 'James', shots: [alex] })).toEqual([alex]); - }); - - it('drops one player when the server only names who was cleared', () => { - expect(remainingShotsAfterClear([james, alex], { player_name: 'james' }).map((shot) => shot.timestamp)).toEqual([ - 'a', - ]); + expect(remainingShotsAfterClear([james, alex], { profile_id: 'aaa', shots: [alex] })).toEqual([alex]); }); it('clears everything when given a legacy empty payload', () => { expect(remainingShotsAfterClear([james, alex])).toEqual([]); expect(remainingShotsAfterClear([james, alex], null)).toEqual([]); }); + + it('falls back to dropping one profile by id', () => { + const current = [{ profile_id: 'aaa' } as Shot, { profile_id: 'bbb' } as Shot]; + + expect(remainingShotsAfterClear(current, { profile_id: 'aaa' })).toEqual([current[1]]); + }); }); diff --git a/ui/src/services/sessionClear.ts b/ui/src/services/sessionClear.ts index e9349b3f8..1b4052b25 100644 --- a/ui/src/services/sessionClear.ts +++ b/ui/src/services/sessionClear.ts @@ -1,18 +1,18 @@ import type { Shot } from '../types/shot'; -import { excludeShotsByPlayer } from '../types/shot'; +import { excludeShotsByProfile } from '../types/shot'; export interface SessionClearedPayload { - player_name?: string; + profile_id?: string; shots?: Shot[]; } -/** Remaining shots after a clear. Prefer the server list; otherwise drop one player. */ +/** Remaining shots after a clear. Prefer the server list; otherwise drop one profile. */ export function remainingShotsAfterClear(currentShots: Shot[], payload?: SessionClearedPayload | null): Shot[] { if (payload?.shots) { return payload.shots; } - if (payload?.player_name) { - return excludeShotsByPlayer(currentShots, payload.player_name); + if (payload?.profile_id) { + return excludeShotsByProfile(currentShots, payload.profile_id); } return []; } diff --git a/ui/src/services/sessionClubSync.ts b/ui/src/services/sessionClubSync.ts index b5cca85e0..1bc69a22c 100644 --- a/ui/src/services/sessionClubSync.ts +++ b/ui/src/services/sessionClubSync.ts @@ -2,8 +2,8 @@ import { useSystemStore } from '../stores/useSystemStore'; /** * Adopt the active club from a connect/reload snapshot. - * Unlike player_name, the UI does not echo club on connect, so restoring from - * session_state cannot ping-pong with set_club. + * Unlike the active profile, the UI does not echo club on connect, so restoring + * from session_state cannot ping-pong with set_club. */ export function ingestSessionClub(club: string | undefined): void { if (!club) return; diff --git a/ui/src/services/socketService.ts b/ui/src/services/socketService.ts index b0a409103..5af762fc7 100644 --- a/ui/src/services/socketService.ts +++ b/ui/src/services/socketService.ts @@ -14,9 +14,10 @@ import type { DebugReading, RadarConfig, DebugShotLog, SimShotInfo, SimStatus } import type { PowerStatus } from '../types/power'; import { getServerOrigin } from '../utils/serverOrigin'; import { handleShotMessage, handleShotUpdate, type ShotMessage, type ShotUpdateMessage } from './handleShotMessage'; -import { ingestSocketPlayerName } from './playerSocketSync'; import { ingestSessionClub } from './sessionClubSync'; import { remainingShotsAfterClear } from './sessionClear'; +import { useProfileStore } from '../stores/useProfileStore'; +import type { ProfilesSnapshot } from '../types/profile'; const SOCKET_URL = getServerOrigin(); @@ -51,6 +52,7 @@ class SocketService { this.socket?.emit('get_trigger_status'); this.socket?.emit('get_radar_config'); this.socket?.emit('get_camera_capture_settings'); + this.socket?.emit('get_profiles'); }); this.socket.on('disconnect', () => { @@ -106,8 +108,8 @@ class SocketService { ingestSessionClub(data.club); }); - this.socket.on('player_changed', (data: { player_name: string }) => { - ingestSocketPlayerName('player_changed', data.player_name); + this.socket.on('profiles', (data: ProfilesSnapshot) => { + useProfileStore.getState().applySnapshot(data); }); this.socket.on( @@ -120,7 +122,6 @@ class SocketService { camera_enabled?: boolean; camera_streaming?: boolean; ball_detected?: boolean; - player_name?: string; } ) => { console.log('Session state received:', data); @@ -134,7 +135,6 @@ class SocketService { if (data.debug_mode !== undefined) { systemStore.setDebugMode(data.debug_mode); } - ingestSocketPlayerName('session_state', data.player_name); ingestSessionClub(data.club); // Update camera status from session state @@ -187,7 +187,7 @@ class SocketService { }); }); - this.socket.on('session_cleared', (data?: { player_name?: string; shots?: Shot[] }) => { + this.socket.on('session_cleared', (data?: { profile_id?: string; shots?: Shot[] }) => { const remaining = remainingShotsAfterClear(useShotStore.getState().shots, data); if (remaining.length === 0) { useShotStore.getState().clearShots(); @@ -227,8 +227,24 @@ class SocketService { }; } - clearSession(playerName: string) { - this.socket?.emit('clear_session', { player_name: playerName }); + clearSession(profileId: string) { + this.socket?.emit('clear_session', { profile_id: profileId }); + } + + setActiveProfile(profileId: string) { + this.socket?.emit('set_active_profile', { profile_id: profileId }); + } + + addProfile(name: string) { + this.socket?.emit('add_profile', { name }); + } + + renameProfile(profileId: string, name: string) { + this.socket?.emit('rename_profile', { profile_id: profileId, name }); + } + + removeProfile(profileId: string) { + this.socket?.emit('remove_profile', { profile_id: profileId }); } uploadCloud() { @@ -244,10 +260,6 @@ class SocketService { this.socket?.emit('set_training_implement', { implement }); } - setPlayer(playerName: string) { - this.socket?.emit('set_player', { player_name: playerName }); - } - simulateShot() { this.socket?.emit('simulate_shot'); } diff --git a/ui/src/stores/useHeroMetricStore.ts b/ui/src/stores/useHeroMetricStore.ts index 9644f11af..0480ecdca 100644 --- a/ui/src/stores/useHeroMetricStore.ts +++ b/ui/src/stores/useHeroMetricStore.ts @@ -4,7 +4,7 @@ const STORAGE_KEY = 'openflight.hero-metric'; /** * Which Live metric is selected: yellow title, top-left of the table, and the - * value that stays pinned in the table's top-left slot. Persisted so a player who + * value that stays pinned in the table's top-left slot. Persisted so someone who * cares about club speed keeps that choice across restarts. * * Stored as a plain metric id; an id that no longer exists is handled by diff --git a/ui/src/stores/usePlayerStore.test.ts b/ui/src/stores/usePlayerStore.test.ts deleted file mode 100644 index 361a4c6be..000000000 --- a/ui/src/stores/usePlayerStore.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -function installBrowser(players: string[] = ['James', 'Alex'], selected = 'James') { - const store: Record = { - 'openflight-players': JSON.stringify(players), - 'openflight-selected-player': selected, - }; - const localStorage = { - getItem: (key: string) => store[key] ?? null, - setItem: (key: string, value: string) => { - store[key] = value; - }, - }; - vi.stubGlobal('localStorage', localStorage); - vi.stubGlobal('window', { localStorage }); - return store; -} - -describe('usePlayerStore', () => { - beforeEach(() => { - vi.resetModules(); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it('refuses to remove the active player', async () => { - installBrowser(); - const { usePlayerStore } = await import('./usePlayerStore'); - - usePlayerStore.getState().removePlayer('James'); - - expect(usePlayerStore.getState().players).toEqual(['James', 'Alex']); - expect(usePlayerStore.getState().selectedPlayer).toBe('James'); - }); - - it('removes an inactive player', async () => { - installBrowser(); - const { usePlayerStore } = await import('./usePlayerStore'); - - usePlayerStore.getState().removePlayer('Alex'); - - expect(usePlayerStore.getState().players).toEqual(['James']); - expect(usePlayerStore.getState().selectedPlayer).toBe('James'); - }); -}); diff --git a/ui/src/stores/usePlayerStore.ts b/ui/src/stores/usePlayerStore.ts deleted file mode 100644 index 19ce13926..000000000 --- a/ui/src/stores/usePlayerStore.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { create } from 'zustand'; - -const PLAYERS_STORAGE_KEY = 'openflight-players'; -const SELECTED_PLAYER_STORAGE_KEY = 'openflight-selected-player'; -const DEFAULT_PLAYER = 'Player 1'; - -function cleanPlayerName(name: string): string { - return name.trim().slice(0, 40); -} - -function loadPlayers(): string[] { - if (typeof window === 'undefined') return [DEFAULT_PLAYER]; - - try { - const raw = window.localStorage.getItem(PLAYERS_STORAGE_KEY); - const parsed = raw ? JSON.parse(raw) : null; - if (Array.isArray(parsed)) { - const players = parsed.map((name) => cleanPlayerName(String(name))).filter(Boolean); - return Array.from(new Set(players)).slice(0, 12); - } - } catch { - // Ignore broken localStorage data and fall back to the default. - } - return [DEFAULT_PLAYER]; -} - -function savePlayers(players: string[]) { - if (typeof window === 'undefined') return; - try { - window.localStorage.setItem(PLAYERS_STORAGE_KEY, JSON.stringify(players)); - } catch { - // Ignore storage failures; the picker still works for the current session. - } -} - -function saveSelectedPlayer(playerName: string) { - if (typeof window === 'undefined') return; - try { - window.localStorage.setItem(SELECTED_PLAYER_STORAGE_KEY, playerName); - } catch { - // Ignore storage failures; the picker still works for the current session. - } -} - -function loadSelectedPlayer(): string { - if (typeof window === 'undefined') return ''; - try { - return cleanPlayerName(window.localStorage.getItem(SELECTED_PLAYER_STORAGE_KEY) ?? ''); - } catch { - return ''; - } -} - -interface PlayerState { - players: string[]; - selectedPlayer: string; - addPlayer: (name: string) => string; - removePlayer: (name: string) => void; - selectPlayer: (name: string) => void; -} - -const initialPlayers = loadPlayers(); -const savedSelected = loadSelectedPlayer(); -const initialSelected = initialPlayers.includes(savedSelected) ? savedSelected : (initialPlayers[0] ?? DEFAULT_PLAYER); - -export const usePlayerStore = create((set, get) => ({ - players: initialPlayers.length ? initialPlayers : [DEFAULT_PLAYER], - selectedPlayer: initialSelected, - addPlayer: (name) => { - const playerName = cleanPlayerName(name) || DEFAULT_PLAYER; - const nextPlayers = Array.from(new Set([...get().players, playerName])).slice(0, 12); - savePlayers(nextPlayers); - saveSelectedPlayer(playerName); - set({ players: nextPlayers, selectedPlayer: playerName }); - return playerName; - }, - removePlayer: (name) => { - const current = get(); - if (current.selectedPlayer === name) return; - const remaining = current.players.filter((player) => player !== name); - const nextPlayers = remaining.length ? remaining : [DEFAULT_PLAYER]; - savePlayers(nextPlayers); - saveSelectedPlayer(current.selectedPlayer); - set({ players: nextPlayers, selectedPlayer: current.selectedPlayer }); - }, - selectPlayer: (name) => { - const playerName = cleanPlayerName(name) || DEFAULT_PLAYER; - const nextPlayers = get().players.includes(playerName) - ? get().players - : [...get().players, playerName].slice(0, 12); - savePlayers(nextPlayers); - saveSelectedPlayer(playerName); - set({ players: nextPlayers, selectedPlayer: playerName }); - }, -})); diff --git a/ui/src/stores/useProfileStore.test.ts b/ui/src/stores/useProfileStore.test.ts new file mode 100644 index 000000000..3eae40bf4 --- /dev/null +++ b/ui/src/stores/useProfileStore.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { useProfileStore } from './useProfileStore'; +import type { Profile } from '../types/profile'; + +const profile = (id: string, name: string): Profile => ({ + id, + name, + created_at: '2026-08-27T10:00:00Z', + settings: {}, +}); + +describe('useProfileStore', () => { + beforeEach(() => { + useProfileStore.setState({ profiles: [], activeProfileId: '', loaded: false }); + }); + + it('starts empty and unloaded', () => { + const state = useProfileStore.getState(); + + expect(state.profiles).toEqual([]); + expect(state.activeProfileId).toBe(''); + expect(state.loaded).toBe(false); + }); + + it('applies a snapshot and marks itself loaded', () => { + useProfileStore.getState().applySnapshot({ + profiles: [profile('aaa', 'Home'), profile('bbb', 'Range')], + active_profile_id: 'bbb', + }); + + const state = useProfileStore.getState(); + expect(state.profiles.map((entry) => entry.name)).toEqual(['Home', 'Range']); + expect(state.activeProfileId).toBe('bbb'); + expect(state.loaded).toBe(true); + }); + + it('replaces state wholesale rather than merging', () => { + useProfileStore.getState().applySnapshot({ + profiles: [profile('aaa', 'Home'), profile('bbb', 'Range')], + active_profile_id: 'aaa', + }); + + useProfileStore.getState().applySnapshot({ + profiles: [profile('ccc', 'Course')], + active_profile_id: 'ccc', + }); + + expect(useProfileStore.getState().profiles.map((entry) => entry.id)).toEqual(['ccc']); + }); + + it('ignores a malformed snapshot instead of blanking the roster', () => { + useProfileStore.getState().applySnapshot({ + profiles: [profile('aaa', 'Home')], + active_profile_id: 'aaa', + }); + + useProfileStore.getState().applySnapshot({ profiles: undefined, active_profile_id: '' } as never); + + expect(useProfileStore.getState().profiles.map((entry) => entry.id)).toEqual(['aaa']); + }); +}); diff --git a/ui/src/stores/useProfileStore.ts b/ui/src/stores/useProfileStore.ts new file mode 100644 index 000000000..fff96897b --- /dev/null +++ b/ui/src/stores/useProfileStore.ts @@ -0,0 +1,32 @@ +import { create } from 'zustand'; +import type { Profile, ProfilesSnapshot } from '../types/profile'; + +/** + * A mirror of the server's roster, not a source of truth. + * + * The server owns profiles.json and broadcasts one authoritative `profiles` + * snapshot after every mutation, so there is nothing to persist here and + * nothing to reconcile. Deliberately no localStorage: a second copy of the + * selection is what used to race with the connect-time snapshot. + */ +interface ProfileState { + profiles: Profile[]; + activeProfileId: string; + /** False until the first snapshot arrives; the UI shows a skeleton meanwhile. */ + loaded: boolean; + applySnapshot: (snapshot: ProfilesSnapshot) => void; +} + +export const useProfileStore = create((set) => ({ + profiles: [], + activeProfileId: '', + loaded: false, + applySnapshot: (snapshot) => { + if (!snapshot || !Array.isArray(snapshot.profiles)) return; + set({ + profiles: snapshot.profiles, + activeProfileId: snapshot.active_profile_id ?? '', + loaded: true, + }); + }, +})); diff --git a/ui/src/stores/useSystemStore.ts b/ui/src/stores/useSystemStore.ts index 4c9599adc..21d17ffc4 100644 --- a/ui/src/stores/useSystemStore.ts +++ b/ui/src/stores/useSystemStore.ts @@ -11,7 +11,6 @@ interface SystemState { simStatuses: Record; latestSimShots: Record; serverClub: string | null; - serverPlayerName: string | null; powerStatus: PowerStatus | null; setConnected: (connected: boolean) => void; setMockMode: (mockMode: boolean) => void; @@ -20,7 +19,6 @@ interface SystemState { setSimStatus: (status: SimStatus) => void; setLatestSimShot: (shot: SimShotInfo) => void; setServerClub: (club: string | null) => void; - setServerPlayerName: (playerName: string | null) => void; setPowerStatus: (status: PowerStatus) => void; } @@ -33,7 +31,6 @@ export const useSystemStore = create((set) => ({ simStatuses: {}, latestSimShots: {}, serverClub: null, - serverPlayerName: null, powerStatus: null, setConnected: (connected) => set({ connected }), setMockMode: (mockMode) => set({ mockMode }), @@ -48,6 +45,5 @@ export const useSystemStore = create((set) => ({ latestSimShots: { ...state.latestSimShots, [shot.target]: shot }, })), setServerClub: (serverClub) => set({ serverClub }), - setServerPlayerName: (serverPlayerName) => set({ serverPlayerName }), setPowerStatus: (status) => set({ powerStatus: status }), })); diff --git a/ui/src/types/profile.ts b/ui/src/types/profile.ts new file mode 100644 index 000000000..199114766 --- /dev/null +++ b/ui/src/types/profile.ts @@ -0,0 +1,14 @@ +/** One named context shots are attributed to: a person or a place. */ +export interface Profile { + id: string; + name: string; + created_at: string; + /** Open bag the server round-trips untouched; later features claim keys here. */ + settings: Record; +} + +/** The server's authoritative roster + selection, sent as one event. */ +export interface ProfilesSnapshot { + profiles: Profile[]; + active_profile_id: string; +} diff --git a/ui/src/types/shot.test.ts b/ui/src/types/shot.test.ts index 96da9b97a..c28ebd732 100644 --- a/ui/src/types/shot.test.ts +++ b/ui/src/types/shot.test.ts @@ -1,60 +1,48 @@ import { describe, expect, it } from 'vitest'; import type { Shot } from './shot'; -import { filterShotsByPlayer, excludeShotsByPlayer } from './shot'; - -function makeShot(overrides: Partial = {}): Shot { - return { - ball_speed_mph: 90, - club_speed_mph: 67, - smash_factor: 1.34, - estimated_carry_yards: 200, - carry_range: [195, 205], - club: 'driver', - timestamp: 'a', - peak_magnitude: 100, - launch_angle_vertical: 13, - launch_angle_horizontal: 0, - launch_angle_confidence: 0.8, - angle_source: 'radar', - club_angle_deg: null, - club_path_deg: null, - spin_axis_deg: null, - spin_rpm: 2600, - spin_confidence: 0.9, - spin_quality: 'high', - spin_source: 'measured', - spin_method: null, - carry_spin_adjusted: null, - ...overrides, - }; -} - -describe('filterShotsByPlayer', () => { - it('keeps shots whose player matches, ignoring case and padding', () => { - const shots = [ - makeShot({ player_name: 'James', timestamp: 'a' }), - makeShot({ player_name: 'james ', timestamp: 'b' }), - makeShot({ player_name: 'Alex', timestamp: 'c' }), - ]; - - expect(filterShotsByPlayer(shots, ' JAMES').map((shot) => shot.timestamp)).toEqual(['a', 'b']); +import { filterShotsByProfile, excludeShotsByProfile } from './shot'; + +describe('filterShotsByProfile', () => { + const shotWith = (profileId: string | undefined): Shot => ({ profile_id: profileId, ball_speed_mph: 100 }) as Shot; + + it('keeps only shots stamped with the given profile id', () => { + const shots = [shotWith('aaa'), shotWith('bbb'), shotWith('aaa')]; + + expect(filterShotsByProfile(shots, 'aaa')).toHaveLength(2); }); - it('treats a missing player name as Player 1', () => { - const shots = [makeShot({ timestamp: 'a' }), makeShot({ player_name: 'James', timestamp: 'b' })]; + it('matches exactly, without folding case', () => { + const shots = [shotWith('AAA'), shotWith('aaa')]; - expect(filterShotsByPlayer(shots, 'Player 1').map((shot) => shot.timestamp)).toEqual(['a']); + expect(filterShotsByProfile(shots, 'aaa')).toEqual([shots[1]]); + }); + + it('excludes unstamped shots from every profile', () => { + const shots = [shotWith(undefined), shotWith('')]; + + expect(filterShotsByProfile(shots, 'aaa')).toEqual([]); + expect(filterShotsByProfile(shots, 'bbb')).toEqual([]); + }); + + it('returns nothing for a blank profile id', () => { + const shots = [shotWith('aaa'), shotWith(undefined)]; + + expect(filterShotsByProfile(shots, '')).toEqual([]); }); }); -describe('excludeShotsByPlayer', () => { - it('drops matching shots and keeps everyone else', () => { - const shots = [ - makeShot({ player_name: 'James', timestamp: 'a' }), - makeShot({ player_name: 'Alex', timestamp: 'b' }), - makeShot({ player_name: 'james', timestamp: 'c' }), - ]; +describe('excludeShotsByProfile', () => { + const shotWith = (profileId: string | undefined): Shot => ({ profile_id: profileId, ball_speed_mph: 100 }) as Shot; + + it('drops only the given profile and keeps unstamped shots', () => { + const shots = [shotWith('aaa'), shotWith('bbb'), shotWith(undefined)]; + + expect(excludeShotsByProfile(shots, 'aaa')).toEqual([shots[1], shots[2]]); + }); + + it('excludes nothing for a blank profile id', () => { + const shots = [shotWith('aaa'), shotWith('bbb')]; - expect(excludeShotsByPlayer(shots, 'James').map((shot) => shot.timestamp)).toEqual(['b']); + expect(excludeShotsByProfile(shots, '')).toEqual(shots); }); }); diff --git a/ui/src/types/shot.ts b/ui/src/types/shot.ts index 6a22e8a02..ee2ef41ea 100644 --- a/ui/src/types/shot.ts +++ b/ui/src/types/shot.ts @@ -18,7 +18,8 @@ export interface Shot { estimated_carry_yards: number; carry_range: [number, number]; club: string; - player_name?: string; + profile_id?: string; + profile_name?: string; timestamp: string; impact_timestamp?: number | null; peak_magnitude: number | null; @@ -143,7 +144,7 @@ export interface SwingSpeedStats { } export interface SwingSpeedStatsFilter { - playerName?: string | null; + profileId?: string | null; trainingImplement?: string | null; club?: string | null; } @@ -156,18 +157,14 @@ export function getSwingSpeedMph(shot: Shot): number { return shot.club_speed_mph ?? shot.ball_speed_mph; } -function normalizePlayerName(playerName: string | null | undefined): string { - return (playerName?.trim() || 'Player 1').toLowerCase(); +export function filterShotsByProfile(shots: Shot[], profileId: string): Shot[] { + if (!profileId) return []; + return shots.filter((shot) => shot.profile_id === profileId); } -export function filterShotsByPlayer(shots: Shot[], playerName: string): Shot[] { - const normalized = normalizePlayerName(playerName); - return shots.filter((shot) => normalizePlayerName(shot.player_name) === normalized); -} - -export function excludeShotsByPlayer(shots: Shot[], playerName: string): Shot[] { - const normalized = normalizePlayerName(playerName); - return shots.filter((shot) => normalizePlayerName(shot.player_name) !== normalized); +export function excludeShotsByProfile(shots: Shot[], profileId: string): Shot[] { + if (!profileId) return shots; + return shots.filter((shot) => shot.profile_id !== profileId); } function normalizeToken(value: string | null | undefined): string { @@ -175,7 +172,7 @@ function normalizeToken(value: string | null | undefined): string { } export function filterSwingSpeedShots(shots: Shot[], filter: SwingSpeedStatsFilter = {}): Shot[] { - const scoped = filter.playerName ? filterShotsByPlayer(shots, filter.playerName) : shots; + const scoped = filter.profileId ? filterShotsByProfile(shots, filter.profileId) : shots; const trainingImplement = normalizeToken(filter.trainingImplement); const club = normalizeToken(filter.club); diff --git a/ui/src/types/socket.ts b/ui/src/types/socket.ts index 3a9d4fa30..29f52114b 100644 --- a/ui/src/types/socket.ts +++ b/ui/src/types/socket.ts @@ -32,7 +32,8 @@ export interface SwingSpeedEvent { reading_count: number; trigger_speed_mph: number; peak_magnitude: number | null; - player_name?: string; + profile_id?: string; + profile_name?: string; unit: string; mode: 'swing-speed'; } diff --git a/ui/src/utils/validationCsv.ts b/ui/src/utils/validationCsv.ts index aa0f5bf8c..99e5fb919 100644 --- a/ui/src/utils/validationCsv.ts +++ b/ui/src/utils/validationCsv.ts @@ -23,7 +23,7 @@ export function buildValidationCsv(shots: Shot[], entries: Record { await expect(page.locator('.panel-footer__count')).toHaveCount(0); await expect(page.getByRole('button', { name: 'Shots' })).toContainText('2'); - await page.getByRole('button', { name: 'Players' }).click(); - await expect(page.getByRole('region', { name: 'Players' })).toBeVisible(); - await expect(page.locator('.panel-header').getByRole('button', { name: 'Add player' })).toBeVisible(); + await page.getByRole('button', { name: 'Profiles' }).click(); + await expect(page.getByRole('region', { name: 'Profiles' })).toBeVisible(); + await expect(page.locator('.panel-header').getByRole('button', { name: 'Add profile' })).toBeVisible(); await expect(page.locator('.panel-footer__units')).toHaveCount(0); await expect(page.getByRole('button', { name: 'Simulate shot' })).toHaveCount(0); await expect(page.getByRole('button', { name: 'Change club' })).toHaveCount(0); @@ -193,24 +201,62 @@ test('switches between primary navigation views', async ({ page }) => { await expect(page.locator('.panel-footer__count')).toHaveCount(0); }); -test('selecting a player opens Live and does not offer delete on the active player', async ({ page }) => { +test('selecting a profile opens Live and does not offer delete on the active profile', async ({ page }) => { await gotoApp(page); await dismissPicker(page); - await page.getByRole('button', { name: 'Players' }).click(); - await page.getByRole('button', { name: 'Add player' }).click(); + await page.getByRole('button', { name: 'Profiles' }).click(); + await page.getByRole('button', { name: 'Add profile' }).click(); await page.getByPlaceholder('Name').fill('Alex'); - await page.getByRole('dialog', { name: 'Add player' }).getByRole('button', { name: 'Add player' }).click(); + await page.getByRole('dialog', { name: 'Add profile' }).getByRole('button', { name: 'Add profile' }).click(); - await expect(page.getByLabel('Remove Player 1')).toBeVisible(); + await expect(page.getByLabel('Remove Profile 1')).toBeVisible(); await expect(page.getByLabel('Remove Alex')).toHaveCount(0); - await page.locator('.players-panel__card').filter({ hasText: 'Player 1' }).click(); + await page.locator('.profiles-panel__card').filter({ hasText: 'Profile 1' }).click(); await expect(page.locator('.panel-header__title')).toHaveText('Live'); - await expect(page.locator('.panel-header__subtitle')).toHaveText('Player 1'); + await expect(page.locator('.panel-header__subtitle')).toHaveText('Profile 1'); }); -test('confirms before clearing and only removes that player, then returns to Live', async ({ page }) => { +test('does not allow deleting a profile that still has shots', async ({ page }) => { + await gotoApp(page); + await dismissPicker(page); + + await page.getByRole('button', { name: 'Profiles' }).click(); + await page.getByRole('button', { name: 'Add profile' }).click(); + await page.getByPlaceholder('Name').fill('Alex'); + await page.getByRole('dialog', { name: 'Add profile' }).getByRole('button', { name: 'Add profile' }).click(); + + await withControlSocket(async (socket) => { + await simulateShot(socket); + }); + + await page.locator('.profiles-panel__card').filter({ hasText: 'Profile 1' }).click(); + await page.getByRole('button', { name: 'Profiles' }).click(); + + await expect(page.getByLabel('Remove Alex')).toHaveCount(0); + + await withControlSocket(async (socket) => { + const snapshotPromise = waitForEvent<{ profiles: Array<{ id: string; name: string }> }>(socket, 'profiles'); + socket.emit('get_profiles'); + const { profiles } = await snapshotPromise; + const alex = profiles.find((profile) => profile.name === 'Alex'); + expect(alex).toBeTruthy(); + + const afterPromise = waitForEvent<{ profiles: Array<{ name: string }> }>(socket, 'profiles'); + socket.emit('remove_profile', { profile_id: alex!.id }); + const after = await afterPromise; + expect(after.profiles.map((profile) => profile.name)).toContain('Alex'); + }); + + await expect(page.locator('.profiles-panel__card').filter({ hasText: 'Alex' })).toBeVisible(); + await page.locator('.profiles-panel__card').filter({ hasText: 'Alex' }).click(); + await page.getByRole('button', { name: 'Shots' }).click(); + await expect(page.locator('.shots-panel__row')).toHaveCount(1); + await expect(page.locator('.shots-panel__profile-name')).toHaveText('Alex'); +}); + +test('confirms before clearing and only removes that profile, then returns to Live', async ({ page }) => { await withControlSocket(async (socket) => { await simulateShot(socket); }); @@ -218,10 +264,10 @@ test('confirms before clearing and only removes that player, then returns to Liv await gotoApp(page); await dismissPicker(page); - await page.getByRole('button', { name: 'Players' }).click(); - await page.getByRole('button', { name: 'Add player' }).click(); + await page.getByRole('button', { name: 'Profiles' }).click(); + await page.getByRole('button', { name: 'Add profile' }).click(); await page.getByPlaceholder('Name').fill('Alex'); - await page.getByRole('dialog', { name: 'Add player' }).getByRole('button', { name: 'Add player' }).click(); + await page.getByRole('dialog', { name: 'Add profile' }).getByRole('button', { name: 'Add profile' }).click(); await expect(page.getByLabel('Remove Alex')).toHaveCount(0); await withControlSocket(async (socket) => { @@ -233,23 +279,176 @@ test('confirms before clearing and only removes that player, then returns to Liv const dialog = page.getByRole('dialog', { name: "Clear Alex's session?" }); await expect(dialog).toBeVisible(); - await expect(dialog).toContainText("This removes Alex's shots. Other players are kept."); + await expect(dialog).toContainText("This removes Alex's shots. Other profiles are kept."); await dialog.getByRole('button', { name: 'Cancel' }).click(); await expect(dialog).toHaveCount(0); await expect(page.locator('.panel-header__title')).toHaveText('Stats'); await page.locator('.panel-header').getByRole('button', { name: 'Clear session' }).click(); - await page.getByRole('dialog', { name: "Clear Alex's session?" }).getByRole('button', { name: 'Clear session' }).click(); + await page + .getByRole('dialog', { name: "Clear Alex's session?" }) + .getByRole('button', { name: 'Clear session' }) + .click(); await expect(page.locator('.panel-header__title')).toHaveText('Live'); await expect(page.locator('.panel-header__subtitle')).toHaveText('Alex'); await expect(page.getByText('Ready', { exact: true })).toBeVisible(); - await page.getByRole('button', { name: 'Players' }).click(); - await page.locator('.players-panel__card').filter({ hasText: 'Player 1' }).click(); + await page.getByRole('button', { name: 'Profiles' }).click(); + await page.locator('.profiles-panel__card').filter({ hasText: 'Profile 1' }).click(); + await page.getByRole('button', { name: 'Shots' }).click(); + await expect(page.locator('.shots-panel__row')).toHaveCount(1); +}); + +test('clear-session confirmation is a centered overlay at 800×480', async ({ page }) => { + await page.setViewportSize({ width: 800, height: 480 }); + + await withControlSocket(async (socket) => { + await simulateShot(socket); + }); + + await gotoApp(page); + await dismissPicker(page); + + await page.getByRole('button', { name: 'Stats' }).click(); + await page.locator('.panel-header').getByRole('button', { name: 'Clear session' }).click(); + + const dialog = page.getByRole('dialog', { name: "Clear Profile 1's session?" }); + await expect(dialog).toBeVisible(); + + const layout = await page.evaluate(() => { + const modal = document.querySelector('.clear-session-modal'); + const scrim = document.querySelector('.clear-session-modal__scrim'); + const box = document.querySelector('.clear-session-modal__dialog'); + if (!(modal instanceof HTMLElement) || !(scrim instanceof HTMLElement) || !(box instanceof HTMLElement)) { + return null; + } + + const modalStyle = getComputedStyle(modal); + const scrimStyle = getComputedStyle(scrim); + const modalRect = modal.getBoundingClientRect(); + const scrimRect = scrim.getBoundingClientRect(); + const dialogRect = box.getBoundingClientRect(); + const dialogCenterX = (dialogRect.left + dialogRect.right) / 2; + const dialogCenterY = (dialogRect.top + dialogRect.bottom) / 2; + + return { + modalPosition: modalStyle.position, + modalCoversViewport: + Math.abs(modalRect.width - window.innerWidth) < 4 && Math.abs(modalRect.height - window.innerHeight) < 4, + scrimPosition: scrimStyle.position, + scrimCoversModal: + Math.abs(scrimRect.width - modalRect.width) < 4 && Math.abs(scrimRect.height - modalRect.height) < 4, + dialogCentered: + Math.abs(dialogCenterX - window.innerWidth / 2) < 48 && Math.abs(dialogCenterY - window.innerHeight / 2) < 48, + }; + }); + + expect(layout).not.toBeNull(); + expect(layout?.modalPosition).toMatch(/^(absolute|fixed)$/); + expect(layout?.modalCoversViewport).toBe(true); + expect(layout?.scrimPosition).toMatch(/^(absolute|fixed)$/); + expect(layout?.scrimCoversModal).toBe(true); + expect(layout?.dialogCentered).toBe(true); + + await page.locator('.clear-session-modal__scrim').click({ position: { x: 8, y: 8 } }); + await expect(dialog).toHaveCount(0); + await expect(page.locator('.panel-header__title')).toHaveText('Stats'); + + await page.getByRole('button', { name: 'Shots' }).click(); + await expect(page.locator('.shots-panel__row')).toHaveCount(1); +}); + +test('clicking the rename control opens the rename dialog and renames the profile', async ({ page }) => { + await gotoApp(page); + await dismissPicker(page); + + await page.getByRole('button', { name: 'Profiles' }).click(); + await page.getByRole('button', { name: 'Add profile' }).click(); + await page.getByRole('textbox').fill('Rnage'); + await page.getByRole('button', { name: 'Add profile' }).last().click(); + + await page.getByLabel('Rename Rnage').click(); + const dialog = page.getByRole('dialog', { name: 'Rename profile' }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole('textbox')).toHaveValue('Rnage'); + + await dialog.getByRole('textbox').fill('Range'); + await dialog.getByRole('button', { name: 'Rename profile' }).click(); + + await expect(dialog).toHaveCount(0); + await expect(page.locator('.profiles-panel__card').filter({ hasText: 'Range' })).toBeVisible(); + await expect(page.locator('.profiles-panel__card').filter({ hasText: 'Rnage' })).toHaveCount(0); +}); + +test('types a new profile name with the on-screen keyboard', async ({ page }) => { + await page.setViewportSize({ width: 800, height: 400 }); + await gotoApp(page); + await dismissPicker(page); + + await page.getByRole('button', { name: 'Profiles' }).click(); + await page.getByRole('button', { name: 'Add profile' }).click(); + + const dialog = page.getByRole('dialog', { name: 'Add profile' }); + await expect(dialog.getByRole('group', { name: 'Keyboard' })).toBeVisible(); + await expect(dialog.getByRole('button', { name: 'Q', exact: true })).toBeInViewport(); + await expect(dialog.getByRole('button', { name: 'Add profile', exact: true })).toBeInViewport(); + await dialog.getByRole('button', { name: 'A', exact: true }).click(); + await dialog.getByRole('button', { name: 'L', exact: true }).click(); + await dialog.getByRole('button', { name: 'E', exact: true }).click(); + await dialog.getByRole('button', { name: 'X', exact: true }).click(); + await expect(dialog.getByRole('textbox')).toHaveValue('Alex'); + + await dialog.getByRole('button', { name: 'Add profile', exact: true }).click(); + await expect(page.locator('.profiles-panel__card').filter({ hasText: 'Alex' })).toBeVisible(); +}); + +test('pressing Enter in the name dialog confirms the rename', async ({ page }) => { + await gotoApp(page); + await dismissPicker(page); + + await page.getByRole('button', { name: 'Profiles' }).click(); + await page.getByRole('button', { name: 'Add profile' }).click(); + await page.getByRole('textbox').fill('Rnage'); + await page.getByRole('button', { name: 'Add profile' }).last().click(); + + await page.getByLabel('Rename Rnage').click(); + await page.getByRole('textbox').fill('Range'); + await page.getByRole('textbox').press('Enter'); + + await expect(page.getByRole('dialog', { name: 'Rename profile' })).toHaveCount(0); + await expect(page.locator('.profiles-panel__card').filter({ hasText: 'Range' })).toBeVisible(); +}); + +test('renaming a profile keeps its shots', async ({ page }) => { + await gotoApp(page); + await dismissPicker(page); + + await page.getByRole('button', { name: 'Profiles' }).click(); + await page.getByRole('button', { name: 'Add profile' }).click(); + await page.getByRole('textbox').fill('Rnage'); + await page.getByRole('button', { name: 'Add profile' }).last().click(); + + // The server makes the new profile active as soon as it's created. + await withControlSocket(async (socket) => { + await simulateShot(socket); + }); + + await page.getByLabel('Rename Rnage').click(); + await page.getByRole('textbox').fill('Range'); + await page.getByRole('button', { name: 'Rename profile' }).last().click(); + + // The header subtitle also renders the active profile's name, so scope to + // the roster card to avoid an ambiguous match. + const renamedCard = page.locator('.profiles-panel__card').filter({ hasText: 'Range' }); + await expect(renamedCard).toBeVisible(); + await expect(page.locator('.profiles-panel__card').filter({ hasText: 'Rnage' })).toHaveCount(0); + + await renamedCard.click(); await page.getByRole('button', { name: 'Shots' }).click(); await expect(page.locator('.shots-panel__row')).toHaveCount(1); + await expect(page.locator('.shots-panel__profile-name')).toHaveText('Range'); }); test('scrolls the shots list by dragging on a row', async ({ page }) => { diff --git a/ui/tests/e2e/helpers.ts b/ui/tests/e2e/helpers.ts index 766d1e698..f28251226 100644 --- a/ui/tests/e2e/helpers.ts +++ b/ui/tests/e2e/helpers.ts @@ -63,26 +63,44 @@ export async function waitForEvent(socket: Socket, event: string, timeoutMs = }); } +/** Roster + shot state the mock server hands back from a `profiles` snapshot. */ +interface ProfilesSnapshot { + profiles: Array<{ id: string; name: string }>; + active_profile_id: string; +} + +/** + * Resets the shared session between tests: clears every profile's shots, then + * switches back to the seeded default profile and removes every other profile + * (the backend keeps state across connections, so profiles added by one test + * would otherwise leak into the next and collide on name). Removal is refused + * while a profile still has session rows, so shots must be cleared first. + */ export async function resetSession(socket: Socket) { - const statePromise = waitForEvent<{ shots?: Array<{ player_name?: string }> }>(socket, 'session_state'); - socket.emit('get_session'); - const state = await statePromise; - const names = [ - ...new Set((state.shots ?? []).map((shot) => shot.player_name?.trim() || 'Player 1')), - ]; - if (names.length === 0) { + const snapshotPromise = waitForEvent(socket, 'profiles'); + socket.emit('get_profiles'); + const { profiles } = await snapshotPromise; + + const defaultProfile = profiles.find((profile) => profile.name === 'Profile 1') ?? profiles[0]; + if (!defaultProfile) return; + + for (const profile of profiles) { const cleared = waitForEvent(socket, 'session_cleared'); - socket.emit('clear_session'); + socket.emit('clear_session', { profile_id: profile.id }); await cleared; - return; } - for (const playerName of names) { - const changed = waitForEvent(socket, 'player_changed'); - socket.emit('set_player', { player_name: playerName }); - await changed; - const cleared = waitForEvent(socket, 'session_cleared'); - socket.emit('clear_session', { player_name: playerName }); - await cleared; + + if (profiles.length > 1) { + const activated = waitForEvent(socket, 'profiles'); + socket.emit('set_active_profile', { profile_id: defaultProfile.id }); + await activated; + + for (const profile of profiles) { + if (profile.id === defaultProfile.id) continue; + const removed = waitForEvent(socket, 'profiles'); + socket.emit('remove_profile', { profile_id: profile.id }); + await removed; + } } } diff --git a/ui/tests/e2e/isolateProfilesPath.ts b/ui/tests/e2e/isolateProfilesPath.ts new file mode 100644 index 000000000..c9dba1691 --- /dev/null +++ b/ui/tests/e2e/isolateProfilesPath.ts @@ -0,0 +1,11 @@ +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +export const PROFILES_PATH_ENV = 'OPENFLIGHT_PROFILES_PATH'; + +/** Unique roster file for one E2E backend process (shared webServer, keyed by pid). */ +export function uniqueE2eProfilesPath(workerId: string | number = process.pid): string { + return join(mkdtempSync(join(tmpdir(), `openflight-e2e-w${workerId}-`)), 'profiles.json'); +} + diff --git a/ui/tests/isolateProfilesPath.test.ts b/ui/tests/isolateProfilesPath.test.ts new file mode 100644 index 000000000..1bd223734 --- /dev/null +++ b/ui/tests/isolateProfilesPath.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import config from '../playwright.config'; +import { PROFILES_PATH_ENV } from './e2e/isolateProfilesPath'; + +interface WebServerConfig { + command: string; + env?: Record; + reuseExistingServer?: boolean; + url: string; +} + +function webServers(): WebServerConfig[] { + return config.webServer as WebServerConfig[]; +} + +describe('E2E profile isolation', () => { + it('starts a fresh isolated backend instead of reusing a live OpenFlight server', () => { + const backend = webServers().find((server) => server.url === 'http://127.0.0.1:8080'); + + expect(backend).toMatchObject({ + reuseExistingServer: false, + }); + expect(backend?.env?.[PROFILES_PATH_ENV]).toContain('openflight-e2e-w'); + }); +}); diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 8cf29526e..03ce96569 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -14,7 +14,7 @@ export default defineConfig({ }, }, test: { - include: ['src/**/*.test.{ts,tsx}'], + include: ['src/**/*.test.{ts,tsx}', 'tests/**/*.test.{ts,tsx}'], exclude: ['tests/e2e/**'], }, });